Version 2026-10-21 · Effective October 21, 2026 · Offer ends November 20, 2026
++
Version 2026-10-21-r2 · Effective October 21, 2026 · Offer ends November 20, 2026
+
+
+
+@@ -12,6 +12,10 @@
+
+
+
You confirm that you own the website you entered (the "Website") or are authorized by its owner to request security testing of it, and that you are at least 18 years old. Do not submit the form for a website you do not control, and do not let anyone else submit it for you. If your website is hosted by a third party, you are responsible for any notice or permission your hosting contract requires before external testing. If any of these confirmations is untrue, you agree to cover the reasonable costs and claims that result.
++
++
++
++
Before testing starts, we send a confirmation link to the email address you entered. Open the link and press the confirmation button to confirm your authority and the website scope. Merely opening the email or link does not start a scan. Callers may send you the request link, but must not sign or confirm on your behalf. Confirmation establishes control of the email address; it does not independently prove website ownership. The signed scope is the website hostname shown on the confirmation page. Other hostnames, including a www variant, require separate authorization.
+
+
+
+@@ -38,6 +42,10 @@
+
+
+
Checks run at a low request rate from identified Complete Tech systems and are designed not to affect your site's availability.
++
++
++
++
After confirmation, the automated PHP2Shell portion checks the PHP version advertised in public response headers. A potentially affected version is not proof of exploitability, and an advertised fixed version is not proof that the website is safe. Hidden, conflicting or vendor-modified versions may be inconclusive. The other checks listed above remain part of our separate review before the full report is complete.
Before testing starts, we send a confirmation link to the email address you entered. Open the link and press the confirmation button to confirm your authority and the website scope. Merely opening the email or link does not start a scan. Callers may send you the request link, but must not sign or confirm on your behalf. Confirmation establishes control of the email address; it does not independently prove website ownership. The signed scope is the website hostname shown on the confirmation page. Other hostnames, including a www variant, require separate authorization.
After confirmation, the automated PHP2Shell portion checks the PHP version advertised in public response headers. A potentially affected version is not proof of exploitability, and an advertised fixed version is not proof that the website is safe. Hidden, conflicting or vendor-modified versions may be inconclusive. The other checks listed above remain part of our separate review before the full report is complete.
+
+
+'''
+ new = replace_once(new, needle, addition + needle)
+ changes[path] = old, new
+ patch, manifest = [], {}
+ for path, (old, new) in changes.items():
+ destination = stage / path
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(new, encoding='utf-8', newline='\n')
+ patch.extend(difflib.unified_diff(old.splitlines(True), new.splitlines(True),
+ fromfile='a/' + path, tofile='b/' + path))
+ manifest[path] = {
+ 'before_normalized_sha256': hashlib.sha256(old.encode()).hexdigest(),
+ 'after_sha256': hashlib.sha256(new.encode()).hexdigest(),
+ }
+ (root / 'deployment' / 'complete-tech-site.patch').write_text(''.join(patch), encoding='utf-8', newline='\n')
+ (root / 'deployment' / 'complete-tech-site-manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8', newline='\n')
+ print('Prepared four site changes; source checkout and production unchanged.')
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--source', type=Path, required=True)
+ args = parser.parse_args()
+ prepare(args.source, Path(__file__).resolve().parents[1])
diff --git a/deployment/private.htaccess b/deployment/private.htaccess
new file mode 100644
index 0000000..b66e808
--- /dev/null
+++ b/deployment/private.htaccess
@@ -0,0 +1 @@
+Require all denied
diff --git a/deployment/test-bootstrap.php b/deployment/test-bootstrap.php
new file mode 100644
index 0000000..8e280de
--- /dev/null
+++ b/deployment/test-bootstrap.php
@@ -0,0 +1,16 @@
+ true,
+ 'worker_hmac_secret' => str_repeat('fixture-', 8),
+ 'expected_checker_revision' => str_repeat('a', 40),
+ 'authorization_end_at' => gmdate('Y-m-d\TH:i:s\Z', time() + 86400 * 30),
+ 'global_daily_limit' => 1000,
+ 'target_daily_limit' => 10,
+ ), true) . ';');
+ chmod($private . '/config.php', 0600);
+ foreach (array($private, $private . '/config.php', $private . '/.htaccess') as $path) {
+ chown($path, 'www-data');
+ chgrp($path, 'www-data');
+ }
+}
+$_SERVER['HTTP_HOST'] = 'wordpress';
+require '/var/www/html/wp-load.php';
+$state_file = '/tmp/ct-http-fixture.json';
+global $wpdb;
+if ('setup' === $mode) {
+ register_post_type('ct_promo_request', array('public' => false));
+ $fields = array('target' => 'https://http-fixture.example.com/', 'email' => 'fixture@example.com', 'name' => 'HTTP Fixture', 'referrer' => 'direct');
+ $contract = "Client contact: HTTP Fixture\nClient email: fixture@example.com\nWebsite (scan scope): {$fields['target']}\nReferred by: direct\nTerms version: 2026-10-21-r2\nSynthetic fixture only.";
+ $id = wp_insert_post(array('post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'HTTP fixture', 'post_content' => $contract));
+ foreach (array('target', 'email', 'name') as $key) { update_post_meta($id, '_ct_scan_' . $key, $fields[$key]); }
+ update_post_meta($id, '_ct_ref', 'direct');
+ foreach (array('saved' => '1', 'version' => '2026-10-21-r2', 'url' => 'http://wordpress/terms/', 'sha256' => hash('sha256', $contract), 'accepted_at' => gmdate('Y-m-d\TH:i:s\Z'), 'contract' => $contract, 'ip' => '192.0.2.1', 'remote_addr' => '192.0.2.1', 'user_agent' => 'Local HTTP fixture') as $key => $value) {
+ update_post_meta($id, '_ct_terms_' . $key, wp_slash($value));
+ }
+ $token = '';
+ add_filter('pre_wp_mail', static function ($return, $mail) use (&$token) {
+ if (preg_match('/token=([a-f0-9]{64})/', $mail['message'], $match)) { $token = $match[1]; }
+ return true;
+ }, 10, 2);
+ $result = ct_scan_on_request_ready($id, $fields);
+ if (is_wp_error($result) || !$token) { throw new RuntimeException('Fixture enqueue failed'); }
+ $state = array('request_id' => $id, 'job_id' => $result['job_id'], 'token' => $token);
+ file_put_contents($state_file, json_encode($state));
+ echo json_encode($state) . "\n";
+} else {
+ $state = json_decode(file_get_contents($state_file), true);
+ $row = ct_scan_row($state['job_id']);
+ if ('cleanup' === $mode) {
+ $wpdb->delete(ct_scan_jobs_table(), array('job_id' => $state['job_id']));
+ wp_delete_post($state['request_id'], true);
+ unlink($state_file);
+ unlink($private . '/config.php');
+ echo "HTTP fixture removed.\n";
+ } else {
+ echo json_encode(array('status' => $row->status, 'attempts' => (int)$row->attempts, 'result' => $row->result_json ? json_decode($row->result_json, true) : null)) . "\n";
+ }
+}
diff --git a/deployment/test-http-worker.py b/deployment/test-http-worker.py
new file mode 100644
index 0000000..b3278f2
--- /dev/null
+++ b/deployment/test-http-worker.py
@@ -0,0 +1,137 @@
+"""Real HTTP protocol test against the internal disposable WP fixture only.
+
+The injected transport is intentionally confined to this test. It is never
+available through production worker configuration. Run after explicit fixture
+confirmation; no website scanning takes place.
+"""
+import dataclasses
+import http.client
+import json
+import os
+import re
+import secrets
+from concurrent.futures import ThreadPoolExecutor
+from email.message import Message
+from urllib.parse import urlsplit, urlencode
+
+from integrations.worker.protocol import APIClient, APIError
+from integrations.worker.runner import SubprocessRunner
+from integrations.worker.network import RequestOrigin
+from wordpress_check import Checker
+
+
+class Response:
+ def __init__(self, response, connection):
+ self.response = response
+ self.connection = connection
+ self.code = response.status
+
+ def read(self, limit):
+ return self.response.read(limit)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ self.connection.close()
+
+
+class FixtureTransport:
+ def open(self, request, timeout):
+ route = urlsplit(request.full_url).path.removeprefix('/wp-json')
+ assert route in {'/ct-scan/v1/claim', '/ct-scan/v1/start', '/ct-scan/v1/result'}
+ connection = http.client.HTTPConnection('wordpress', 80, timeout=timeout)
+ connection.request('POST', '/?rest_route=' + route, body=request.data, headers=dict(request.header_items()))
+ response = connection.getresponse()
+ return Response(response, connection)
+
+
+class SyntheticResponse:
+ def __init__(self, url):
+ self.code = 200
+ self.headers = Message()
+ self.headers['Content-Type'] = 'text/plain' if url.endswith('/robots.txt') else 'text/html'
+ self.headers['X-Powered-By'] = 'PHP/8.3.30'
+ self.body = b'User-agent: *\nAllow: /\n' if url.endswith('/robots.txt') else b'<script>fixture</script>'
+
+ def read(self, limit):
+ return self.body[:limit]
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ pass
+
+
+class SyntheticOpener:
+ def open(self, request, timeout):
+ assert urlsplit(request.full_url).hostname == 'http-fixture.example.com'
+ return SyntheticResponse(request.full_url)
+
+
+api = APIClient(b'fixture-' * 8, transport=FixtureTransport())
+token = os.environ.get('CT_FIXTURE_TOKEN')
+if token:
+ assert re.fullmatch('[a-f0-9]{64}', token)
+ connection = http.client.HTTPConnection('wordpress', timeout=10)
+ connection.request('POST', '/?rest_route=/ct-scan/v1/claim', '{}', {'Content-Type': 'application/json'})
+ response = connection.getresponse()
+ response.read()
+ assert response.status == 401, 'Unauthenticated claim was accepted'
+ connection.close()
+ nonce = secrets.token_hex(16)
+ replay_client = APIClient(b'fixture-' * 8, transport=FixtureTransport(), nonce_factory=lambda: nonce)
+ assert replay_client.claim() is None
+ try:
+ replay_client.claim()
+ except APIError as error:
+ assert error.status == 409
+ else:
+ raise AssertionError('Replayed signed request accepted')
+ connection = http.client.HTTPConnection('wordpress', timeout=10)
+ connection.request('GET', '/wp-admin/admin-post.php?action=ct_scan_confirm&token=' + token)
+ response = connection.getresponse()
+ page = response.read().decode()
+ assert response.status == 200
+ connection.close()
+ assert 'https://http-fixture.example.com/' in page
+ assert api.claim() is None, 'GET prefetch authorized a scan'
+ fields = dict(re.findall(r'name="([^"]+)" value="([^"]*)"', page))
+ form = urlencode(fields)
+ for expected_status in (200, 410):
+ connection = http.client.HTTPConnection('wordpress', timeout=10)
+ connection.request('POST', '/wp-admin/admin-post.php', form,
+ {'Content-Type': 'application/x-www-form-urlencoded'})
+ response = connection.getresponse()
+ response.read()
+ assert response.status == expected_status, 'Confirmation replay was accepted'
+ connection.close()
+
+def concurrent_claim(_):
+ try:
+ return api.claim()
+ except APIError as error:
+ assert error.status == 503, error.slug
+ return None # Contending claim fails closed while the lock is held.
+
+
+with ThreadPoolExecutor(max_workers=2) as pool:
+ claims = [value for value in pool.map(concurrent_claim, range(2)) if value is not None]
+assert len(claims) == 1, 'Concurrent claims did not yield exactly one lease'
+claim = claims[0]
+assert claim and claim.target == 'https://http-fixture.example.com/'
+assert api.claim() is None, 'Concurrent worker obtained a second lease'
+assert api.start(claim)
+checker = Checker(delay=0)
+checker.opener = SyntheticOpener()
+result = dataclasses.asdict(checker.check(claim.target))
+scope, _ = RequestOrigin.from_target(claim.target)
+SubprocessRunner.validate_result(result, claim.target, scope)
+body = json.dumps({'id': claim.job_id, 'lease': claim.lease, 'scope_hash': claim.scope_hash,
+ 'outcome': 'completed', 'result': result, 'error': '',
+ 'checker_revision': 'a' * 40}, separators=(',', ':')).encode()
+assert api.result(body) == {'ok': True}
+assert api.result(body) == {'ok': True}, 'Identical receipt retry must be idempotent'
+assert api.claim() is None
+print('HTTP claim/start/result/receipt-retry passed; scanner used synthetic responses only.')
diff --git a/deployment/test-runtime-image.py b/deployment/test-runtime-image.py
new file mode 100644
index 0000000..ecd7b1d
--- /dev/null
+++ b/deployment/test-runtime-image.py
@@ -0,0 +1,37 @@
+"""Smoke-test the packaged worker with Docker --network none; no real target."""
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+
+from integrations.worker.runner import SubprocessRunner, _linux_preexec_limits
+from integrations.worker.service import load_secret
+
+
+assert os.geteuid() == 0
+with tempfile.TemporaryDirectory() as directory:
+ secret = Path(directory) / 'key'
+ secret.write_text('synthetic-fixture-key-' * 3)
+ secret.chmod(0o600)
+ assert load_secret(secret) == b'synthetic-fixture-key-' * 3
+ code = '''import os,sys
+assert os.getuid() == os.getgid() == 65534
+assert not os.getgroups()
+assert 'CT_SCAN_SECRET_FILE' not in os.environ
+try:
+ open(sys.argv[1]).read()
+except PermissionError:
+ print('scanner secret isolation passed')
+else:
+ raise AssertionError('scanner could read controller key')
+'''
+ subprocess.run([sys.executable, '-c', code, str(secret)], check=True,
+ env={'PATH': '/usr/local/bin:/usr/bin:/bin'},
+ preexec_fn=_linux_preexec_limits)
+
+result = SubprocessRunner().run('https://runtime-fixture.example.invalid/', timeout=15)
+assert result['status'] == 'error', result['status']
+assert result['php2shell_status'] == 'unknown'
+assert result['php2shell_exploitability'] == 'not_tested'
+print('Packaged checker ran without network access and retained unknown outcome.')
diff --git a/deployment/test-site-patch.php b/deployment/test-site-patch.php
new file mode 100644
index 0000000..f70117c
--- /dev/null
+++ b/deployment/test-site-patch.php
@@ -0,0 +1,101 @@
+ true,
+ 'worker_hmac_secret' => str_repeat('fixture-', 8),
+ 'expected_checker_revision' => str_repeat('a', 40),
+ 'authorization_end_at' => gmdate('Y-m-d\TH:i:s\Z', time() + 86400 * 30),
+ 'global_daily_limit' => 1000,
+ 'target_daily_limit' => 10,
+), true) . ';');
+define('CT_SCAN_PRIVATE_CONFIG', $config_file);
+register_shutdown_function(static function () use ($config_file) { unlink($config_file); });
+$_SERVER['HTTP_HOST'] = 'wordpress';
+$_SERVER['REMOTE_ADDR'] = '192.0.2.2';
+$_SERVER['HTTP_USER_AGENT'] = 'Local fixture';
+require '/var/www/html/wp-load.php';
+require '/work/.work/site/theme/spintech-child/functions.php';
+require '/work/.work/site/mu-plugins/ct-promo-terms.php';
+register_post_type('ct_promo_request', array('public' => false));
+if (!ct_promo()) { throw new RuntimeException('The prepared promotion has expired; refresh fixture terms first'); }
+
+function site_assert($condition, $message) {
+ if (!$condition) { throw new RuntimeException($message); }
+}
+class FixtureRedirect extends RuntimeException {}
+add_filter('wp_redirect', static function ($location) { throw new FixtureRedirect($location); });
+$mail = array();
+add_filter('pre_wp_mail', static function ($return, $atts) use (&$mail) {
+ $mail[] = $atts;
+ return true;
+}, 10, 2);
+function ct_promo_send_notification($id, $to, $subject, $body, $headers) {
+ $GLOBALS['ct_promo_mail_in_progress'] = true;
+ try { return wp_mail($to, $subject, $body, $headers); }
+ finally { $GLOBALS['ct_promo_mail_in_progress'] = false; }
+}
+$terms = array('ID' => 658, 'post_type' => 'page', 'post_status' => 'publish',
+ 'post_title' => 'Fixture scan terms', 'post_content' => file_get_contents('/work/.work/site/content/php2shell-scan-terms.html'));
+if (get_post(658)) { wp_update_post(wp_slash($terms)); }
+else { unset($terms['ID']); $terms['import_id'] = 658; wp_insert_post(wp_slash($terms)); }
+$post_data = array(
+ 'ct_promo_nonce' => wp_create_nonce('ct_promo_request'),
+ 'ct_name' => "O'Reilly Fixture", 'ct_email' => 'fixture@example.com',
+ 'ct_website' => 'https://site-fixture.example.com/', 'ct_phone' => '',
+ 'ct_ref' => 'lily', 'ct_agree' => '1', 'ct_terms_version' => ct_promo_terms()['version'],
+);
+if ('stale_terms' === $scenario) { $post_data['ct_terms_version'] = 'old'; }
+if ('missing_terms' === $scenario) { unset($post_data['ct_agree']); }
+if ('invalid_nonce' === $scenario) { $post_data['ct_promo_nonce'] = 'invalid'; }
+if ('honeypot' === $scenario) { $post_data['ct_hp'] = 'bot'; }
+if ('bad_target' === $scenario) { $post_data['ct_website'] = "https://site-fixture.example.com/\n"; }
+if ('missing_acceptance' === $scenario) {
+ $block = static function ($check, $id, $key) { return '_ct_terms_contract' === $key ? false : $check; };
+ add_filter('add_post_metadata', $block, 10, 3);
+ add_filter('update_post_metadata', $block, 10, 3);
+}
+$_POST = wp_slash($post_data);
+global $wpdb;
+$before = (int)$wpdb->get_var("SELECT MAX(ID) FROM {$wpdb->posts}");
+try { do_action('admin_post_nopriv_ct_promo_request'); throw new RuntimeException('Missing redirect'); }
+catch (FixtureRedirect $redirect) { $location = $redirect->getMessage(); }
+$ids = $wpdb->get_col($wpdb->prepare("SELECT ID FROM {$wpdb->posts} WHERE post_type='ct_promo_request' AND ID>%d", $before));
+$expects_post = in_array($scenario, array('valid', 'missing_acceptance'), true);
+site_assert(count($ids) === ($expects_post ? 1 : 0), $scenario . ': request persistence');
+if ($expects_post) {
+ $id = (int)$ids[0];
+ $post = get_post($id);
+ site_assert('private' === $post->post_status, 'Request must stay private');
+ site_assert('lily' === get_post_meta($id, '_ct_ref', true), 'Referral lost');
+ site_assert("O'Reilly Fixture" === get_post_meta($id, '_ct_scan_name', true), 'Contact damaged');
+ $row = $wpdb->get_row($wpdb->prepare('SELECT * FROM ' . ct_scan_jobs_table() . ' WHERE request_id=%d', $id));
+ if ('valid' === $scenario) {
+ site_assert($row && 'awaiting_confirmation' === $row->status, 'Missing gated job');
+ site_assert('1' === get_post_meta($id, '_ct_terms_saved', true), 'Acceptance marker missing');
+ site_assert(hash('sha256', $terms['post_content']) === $row->terms_hash, 'Wrong terms hash');
+ site_assert(count($mail) === 2, 'Confirmation and administrator notification required');
+ site_assert(false !== strpos($mail[1]['message'], 'TERMS AS ACCEPTED'), 'Admin contract attachment missing');
+ site_assert(false !== strpos($mail[1]['message'], 'Referred by: lily'), 'Admin referral missing');
+ } else {
+ site_assert(!$row, 'Incomplete acceptance queued');
+ site_assert(count($mail) === 1, 'Admin notification must survive queue rejection');
+ }
+ $wpdb->delete(ct_scan_jobs_table(), array('request_id' => $id));
+ wp_delete_post($id, true);
+} else { site_assert(count($mail) === 0, 'Invalid request sent mail'); }
+echo $scenario . ": passed\n";
diff --git a/deployment/wordpress-config.example.php b/deployment/wordpress-config.example.php
new file mode 100644
index 0000000..bc7cf24
--- /dev/null
+++ b/deployment/wordpress-config.example.php
@@ -0,0 +1,16 @@
+ false,
+ 'supported_terms_version' => '2026-10-21-r2',
+ 'expected_checker_revision' => 'REPLACE_WITH_REVIEWED_40_CHARACTER_COMMIT_SHA',
+ 'expected_rules_as_of' => '2026-10-21',
+ 'worker_hmac_secret' => is_readable(__DIR__ . '/worker.key')
+ ? trim(file_get_contents(__DIR__ . '/worker.key')) : '',
+ 'authorization_end_at' => '2026-12-20T23:59:59Z',
+ 'confirmation_ttl_hours' => 168,
+ 'global_daily_limit' => 20,
+ 'target_daily_limit' => 1,
+);
diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md
new file mode 100644
index 0000000..83771f8
--- /dev/null
+++ b/docs/WORDPRESS.md
@@ -0,0 +1,244 @@
+# WordPress free-scan integration
+
+This integration connects a saved, signed CompleteTech free-scan request to a
+single passive PHP2Shell check. It consists of the MU plugin in
+`integrations/wordpress/`, the Python controller and restricted network adapter
+in `integrations/worker/`, and the deployment files in `deployment/`.
+
+The standalone checker retains its existing behavior. Do not expose its CLI
+directly to form inputs. The integration checks the website's public homepage
+and robots policy; it does not implement the broader manual security review.
+
+```mermaid
+flowchart LR
+ A[Signed form saved] --> B[Acceptance and exact scope persisted]
+ B --> C[Confirmation email]
+ C --> D[Explicit confirmation POST]
+ D --> E[Durable queue]
+ E --> F[Authenticated worker claim and start]
+ F --> G[Restricted passive checker]
+ G --> H[Private result and manual review]
+ H --> I[Reviewed report to original contact]
+```
+
+## Safety and delivery semantics
+
+- The queue is populated only by the explicit `ct_promo_request_ready` action.
+ Editing a post or sending a notification does not start a scan. Old requests
+ are not imported. The target, original contact and accepted terms are copied
+ into the job rather than read from later edits to request text.
+- Confirmation tokens are hashed at rest, short lived, and consumed by an
+ explicit POST. GET requests, including email-security prefetches, cannot
+ confirm. Mailbox control is not independent proof of domain ownership.
+- Jobs have atomic claims. A claim that was never started can be recovered;
+ a started job with an uncertain outcome is not automatically scanned again.
+ A lost result acknowledgement retries the stored receipt, not the scan.
+- The adapter pins each connection to a validated public IP while retaining the
+ original hostname for TLS verification and SNI. All DNS answers must pass;
+ every page and robots request is checked. Proxies are disabled. Another host,
+ including a `www` variant, requires new authorization.
+- The worker only receives one normalized homepage, an opaque lease, and scope
+ identifiers. It receives no customer email, WordPress password, database
+ password or SMTP secret. The scanner subprocess drops privileges and does
+ not receive the worker authentication key.
+- `potentially_affected`, `fixed_version_reported`, and `unknown` retain their
+ original meanings. Exploitability is always `not_tested`. Unknown, blocked,
+ timed-out or malformed results never become a safety verdict.
+- Completing the automated portion does not complete the promised full review.
+ The other checks remain manual work. Report transport acceptance and confirmed
+ delivery are distinct, as are report delivery and scanner completion.
+
+## Worker protocol
+
+The three POST routes are `claim`, `start` and `result`, under
+`/wp-json/ct-scan/v1/`.
+Each request carries `X-CT-Timestamp` (Unix seconds), `X-CT-Nonce` (32 lowercase
+hex characters), and `X-CT-Signature` (HMAC-SHA256 hex). The signed string is the
+timestamp, nonce, `POST`, route without `/wp-json`, and raw-body SHA-256 joined
+with newlines. Timestamps have a five-minute tolerance; nonces are consumed
+atomically. Bodies are bounded to 64 KiB and only exact permitted fields are
+accepted. Protect the worker key as an execution credential.
+
+`claim` accepts `{}` and returns `{"job":null}` or one job containing `id`,
+`target`, `scope_hash`, `terms_hash`, `expires_at`, `lease` and `lease_until`.
+Both expiry fields are integer Unix seconds. `start` accepts `id`, `lease`
+and `scope_hash`. `result` accepts those plus `outcome`, `result`, `error` and
+`checker_revision`. A completed result has `error:""`; a failed result has
+`result:null` and a safe error category. Acknowledgements are `{"ok":true}`.
+The scan output is bounded to 32 KiB. A replayed identical receipt is accepted
+idempotently; changing its contents is a conflict.
+
+Receipt retries stop at authorization expiry or one hour after lease expiry,
+whichever comes first. Permanent rejection keeps the receipt in the private
+quarantine for operator reconciliation; it never initiates a new scan.
+Quarantine is bounded to 100 receipts and at most one year. Inspect its reason
+and the WordPress job before taking any manual action. Do not copy these
+records into public issues or logs.
+
+Before upgrading the pinned revision or rotating the key, stop accepting new
+work, drain/reconcile existing receipts and stop the controller. Do not change
+the expected revision while old receipts are awaiting acknowledgement.
+
+## Repository and site ownership
+
+The maintained integration is published in this repository. The CompleteTech
+site is a separate WordPress control/context checkout, with production files
+managed over its documented SSH alias. Read that checkout's `AGENTS.md` and
+`DOMAIN_CONTEXT.md`; do not assume a local website build/deploy pipeline.
+
+`deployment/complete-tech-site.patch` contains only the required edits to the
+existing handler, acceptance recorder, form messages and terms. It preserves
+the existing referral and administrator-mail logic. Its manifest records the
+inspected, newline-normalized before hashes and the prepared after hashes.
+Those hashes are evidence for this patch, not permission to overwrite later
+changes. If production has moved, review and rebase the patch.
+
+To prepare the patch against the inspected local site files:
+
+```sh
+python deployment/prepare_site_patch.py --source /path/to/complete.tech
+```
+
+The command writes prepared files under `.work/site/` and a patch/manifest;
+it does not change the site checkout or production. It requires exact anchors
+and refuses an unexpected baseline. Inspect the diff before using it.
+
+The new terms version is `2026-10-21-r2`. It explains the confirmation step and
+the automated/manual split without narrowing existing agreements. Update the
+terms page and `ct_promo_terms()` together. The recorder sets `_ct_terms_saved`
+only after reading back the complete acceptance. The handler stores
+`_ct_scan_target`, `_ct_scan_email`, `_ct_scan_name` and `_ct_ref`, checks those
+values, then emits the action.
+
+## Runner and scheduling
+
+The deployment uses a dedicated Docker container with Python 3.12. The base
+image is pinned by digest. The source revision is supplied as
+`CT_SCAN_REVISION`; build from that exact reviewed checkout. This is separate
+from the checker's embedded vulnerability-rule date.
+
+The controller polls WordPress every 15 seconds and performs maintenance with
+its claims, so it does not depend on visitor-triggered WP-Cron. Docker's
+`unless-stopped` policy restarts the controller after a crash or Docker restart.
+The queue lives in WordPress and pending receipts live in a private named
+volume. On Docker Desktop the host and Docker must stay running; a sleeping or
+offline workstation delays service. Move the same deployment to an always-on
+Linux Docker host if that availability is required.
+
+The container has no published ports, no Docker socket, no host source mount,
+a read-only root filesystem, memory/CPU/process limits, and only the SETUID,
+SETGID and KILL capabilities needed to drop scanner privileges and terminate a
+timed-out child running under a different UID. The controller's key and
+spool remain root-only; the scanner runs as UID/GID 65534. This is process
+isolation with a shared kernel, not a separate virtual machine.
+
+The chosen code and integration are published under this repository's license
+terms. Preserve `LICENSE`, `LICENSING.md` and copyright notices. Review the
+applicable source-availability or commercial licensing arrangements for a
+different deployment; do not assume the older MIT release applies.
+
+## Validation
+
+The Python test suite uses synthetic results, mocked DNS/transports and local
+fixtures. It must not contact external targets:
+
+```sh
+python -m unittest -v
+python wordpress_check.py --help
+git diff --check
+```
+
+For real WordPress/database integration checks, use the disposable environment:
+
+```sh
+docker compose -p php2shell-issue6-test -f deployment/compose.test.yaml up -d --wait
+docker compose -p php2shell-issue6-test -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-bootstrap.php
+docker compose -p php2shell-issue6-test -f deployment/compose.test.yaml exec -T wordpress php /work/integrations/wordpress/tests/integration.php
+```
+
+The test network is internal. Test email is intercepted in the test process.
+Only the explicitly named test project may be removed afterwards. The fixed
+test subnet can be overridden with `CT_TEST_SUBNET` after checking for overlap.
+Never use the bootstrap file with an existing WordPress installation.
+
+After preparing the site patch locally, also exercise the actual modified
+handler and acceptance recorder against this fixture:
+
+```sh
+docker compose -p php2shell-issue6-test -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-site-patch.php
+```
+
+This optional check needs the prepared `.work/site/` files, intercepts email,
+and covers saved requests, referral attribution, the attached contract,
+confirmation gating, stale/missing terms, invalid nonce, honeypot, malformed
+target and failed acceptance persistence. It uses the real promotion window;
+after that offer expires, refresh its fixture deliberately instead of enabling
+an expired offer in production.
+
+## Configuration and operator setup
+
+Production enablement is a separate operation from merging this integration.
+The default configuration is disabled, and no historical request sweep exists.
+
+1. Copy `integrations/wordpress/ct-scan.php` to the site's `wp-content/mu-plugins/`.
+ Use `deployment/wordpress-config.example.php` for
+ `wp-content/ct-scan-private/config.php`. Install `deployment/private.htaccess`
+ as that directory's `.htaccess`, make the directory mode 0700, and make its
+ configuration and key mode 0600 under the PHP process's account. Verify the
+ server actually denies HTTP access; `.htaccess` is specific to compatible
+ Apache configurations. Do not enable the plugin until this check passes.
+2. Generate a cryptographically random 32-byte-or-longer key without logging it.
+ Store its text representation in `ct-scan-private/worker.key` and in a dedicated
+ external Docker volume named `ct-php2shell-secrets`, at `/key` (root-owned,
+ mode 0600, parent mode 0700). These must contain the same bytes after trimming
+ the trailing newline. On Docker Desktop use a Linux named volume so Windows
+ bind-mount permissions cannot expose the key to the scanner child.
+3. Set `expected_checker_revision` to the reviewed 40-character Git commit and
+ keep `enabled` false. Build from a clean checkout of that commit using the
+ same value for `CT_SCAN_REVISION`. Check `git status --porcelain` first; the
+ revision string is provenance, not a substitute for a clean reviewed build.
+4. Build with `docker compose -p ct-php2shell -f deployment/compose.yaml build`,
+ then start with the same command prefix and `up -d`. The API origin is fixed
+ to `https://complete.tech`; changing sites requires a reviewed code change.
+ Check for subnet conflicts before using the default worker subnet.
+5. Apply the reviewed site patch/terms update, then set `enabled` true. The
+ runner must authenticate successfully before accepting live submissions.
+ A matching revision and key are required; mismatches fail closed.
+
+The healthcheck requires a successful authenticated exchange in the last ten
+minutes. Use `docker compose -p ct-php2shell -f deployment/compose.yaml ps` and
+bounded `logs --tail 50` for runner health, and the Promo Requests scan panel
+for queue age, authorization and report state. A healthy container does not
+mean that a particular customer's scan succeeded or that email was delivered.
+Docker restart policy restarts a stopped process, not an unhealthy process;
+investigate persistent unhealthy state instead of automatically resetting jobs.
+
+## Rollout and rollback
+
+1. Read the live site files and compare them with the inspected baseline. Keep
+ backups in an HTTP-denied directory. Preserve unrelated site changes.
+2. Install the MU plugin and its protected private configuration while disabled.
+ Keep the authentication key out of source control and logs. Verify direct
+ HTTP requests cannot retrieve any configuration or backup file.
+3. Apply the reviewed handler/terms/form patch and update the published terms
+ page in the same maintenance window. Preserve old request contracts.
+4. Provision the controller's root-only key volume, build the reviewed revision,
+ start the worker, then enable new submissions. Verify authenticated idle
+ polling, replay rejection and a controlled fixture receipt. Do not backfill
+ leads or scan prospects as a deployment test.
+5. Check queue ages and failed/unknown outcomes in Promo Requests. Check runner
+ health independently of customer-result status. Validate private result
+ access and browser confirmation behavior before considering rollout complete.
+
+To stop new work, disable the integration configuration and stop only this
+Compose project. Preserve its receipt volume and WordPress queue for
+reconciliation. Revoke/cancel affected authorization before any later restart.
+Do not delete running-job records to make the queue look healthy. To restore
+site files, restore the scoped backups; existing accepted contracts and queue
+evidence must remain available. A started job with a missing receipt needs
+operator investigation, not a blind retry.
+
+Retention follows the accepted policy: scan results twelve months, and request
+and acceptance records three years. Maintenance must preserve current work and
+must not delete unrelated WordPress content. Authentication replay records and
+bounded controller logs have shorter operational lifetimes.
diff --git a/integrations/__init__.py b/integrations/__init__.py
new file mode 100644
index 0000000..96bd109
--- /dev/null
+++ b/integrations/__init__.py
@@ -0,0 +1 @@
+"""Optional integrations for php2shell-check."""
diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php
new file mode 100644
index 0000000..76f8d3b
--- /dev/null
+++ b/integrations/wordpress/ct-scan.php
@@ -0,0 +1,1461 @@
+ false,
+ 'supported_terms_version' => CT_SCAN_DEFAULT_TERMS_VERSION,
+ 'expected_checker_revision'=> '',
+ 'expected_rules_as_of' => '2026-10-21',
+ 'worker_hmac_secret' => '',
+ 'authorization_end_at' => '2026-12-20T23:59:59Z',
+ 'confirmation_ttl_hours' => 168,
+ 'global_daily_limit' => 20,
+ 'target_daily_limit' => 1,
+ 'job_retention_days' => 365,
+ );
+
+ $path = defined( 'CT_SCAN_PRIVATE_CONFIG' )
+ ? CT_SCAN_PRIVATE_CONFIG
+ : WP_CONTENT_DIR . '/ct-scan-private/config.php';
+ if ( ! is_string( $path ) || ! is_readable( $path ) ) {
+ return $config;
+ }
+
+ try {
+ $loaded = include $path;
+ } catch ( Throwable $error ) {
+ return $config;
+ }
+ if ( ! is_array( $loaded ) ) {
+ return $config;
+ }
+ $config = array_merge( $config, $loaded );
+ $config['enabled'] = true === $config['enabled']
+ && is_string( $config['worker_hmac_secret'] )
+ && strlen( $config['worker_hmac_secret'] ) >= 32
+ && is_string( $config['expected_checker_revision'] )
+ && 1 === preg_match( '/\A[a-f0-9]{40}\z/i', $config['expected_checker_revision'] )
+ && is_string( $config['supported_terms_version'] )
+ && '' !== $config['supported_terms_version'];
+
+ return $config;
+}
+
+function ct_scan_enabled() {
+ $config = ct_scan_config();
+ return ! empty( $config['enabled'] );
+}
+
+function ct_scan_jobs_table() {
+ global $wpdb;
+ return $wpdb->prefix . 'ct_scan_jobs';
+}
+
+function ct_scan_nonces_table() {
+ global $wpdb;
+ return $wpdb->prefix . 'ct_scan_nonces';
+}
+
+/** Create plugin tables once; MU plugins do not receive activation hooks. */
+function ct_scan_maybe_install_schema() {
+ if ( get_option( 'ct_scan_schema_version' ) === CT_SCAN_SCHEMA_VERSION ) {
+ return;
+ }
+ global $wpdb;
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ $charset = $wpdb->get_charset_collate();
+ $jobs = ct_scan_jobs_table();
+ $nonces = ct_scan_nonces_table();
+ $sql = "CREATE TABLE {$jobs} (
+ job_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ request_id bigint(20) unsigned NOT NULL,
+ target_origin varchar(512) NOT NULL,
+ scope_hash char(64) NOT NULL,
+ terms_version varchar(64) NOT NULL,
+ terms_hash char(64) NOT NULL,
+ terms_accepted_at datetime NOT NULL,
+ authorization_expires_at datetime NOT NULL,
+ contact_email varchar(320) NOT NULL,
+ contact_name varchar(190) NOT NULL,
+ referrer varchar(128) NOT NULL,
+ status varchar(32) NOT NULL,
+ created_at datetime NOT NULL,
+ confirmation_token_hash char(64) NULL,
+ confirmation_expires_at datetime NULL,
+ confirmation_email_status varchar(16) NOT NULL DEFAULT 'pending',
+ confirmed_at datetime NULL,
+ lease_hash char(64) NULL,
+ lease_until datetime NULL,
+ attempts tinyint(3) unsigned NOT NULL DEFAULT 0,
+ started_at datetime NULL,
+ finished_at datetime NULL,
+ outcome varchar(16) NULL,
+ error_code varchar(64) NULL,
+ checker_revision char(40) NULL,
+ result_json longtext NULL,
+ receipt_hash char(64) NULL,
+ manual_remainder_status varchar(16) NOT NULL DEFAULT 'pending',
+ manual_remainder_notes longtext NULL,
+ manual_remainder_completed_at datetime NULL,
+ report_notification_status varchar(16) NOT NULL DEFAULT 'not_sent',
+ report_notification_at datetime NULL,
+ report_delivered_at datetime NULL,
+ revoked_at datetime NULL,
+ PRIMARY KEY (job_id),
+ UNIQUE KEY request_id (request_id),
+ KEY status_created (status,created_at),
+ KEY scope_created (scope_hash,created_at),
+ KEY authorization_expires_at (authorization_expires_at),
+ KEY confirmation_token_hash (confirmation_token_hash)
+ ) {$charset};";
+ dbDelta( $sql );
+ $sql = "CREATE TABLE {$nonces} (
+ nonce_hash char(64) NOT NULL,
+ expires_at datetime NOT NULL,
+ PRIMARY KEY (nonce_hash),
+ KEY expires_at (expires_at)
+ ) {$charset};";
+ dbDelta( $sql );
+ $required = array(
+ $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at' ),
+ $nonces => array( 'nonce_hash', 'expires_at' ),
+ );
+ foreach ( $required as $table => $columns ) {
+ $found = $wpdb->get_col( "SHOW COLUMNS FROM {$table}", 0 );
+ if ( ! is_array( $found ) || array_diff( $columns, $found ) ) {
+ return;
+ }
+ }
+ update_option( 'ct_scan_schema_version', CT_SCAN_SCHEMA_VERSION, false );
+}
+add_action( 'init', 'ct_scan_maybe_install_schema', 2 );
+
+/** Normalize a visitor-supplied URL to the exact authorized HTTP(S) origin. */
+function ct_scan_normalize_target( $url ) {
+ $bad = function ( $message ) {
+ return new WP_Error( 'ct_scan_invalid_target', $message );
+ };
+ if ( ! is_string( $url ) ) {
+ return $bad( 'The website address must be text.' );
+ }
+ if ( '' === $url || strlen( $url ) > 2048 || preg_match( '/[\x00-\x20\x7f]/', $url ) ) {
+ return $bad( 'Enter one valid public website address.' );
+ }
+ $url = trim( $url );
+ $parts = wp_parse_url( $url );
+ if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
+ return $bad( 'Include a complete http:// or https:// website address.' );
+ }
+ $scheme = strtolower( $parts['scheme'] );
+ $host = strtolower( $parts['host'] );
+ if ( ! in_array( $scheme, array( 'http', 'https' ), true )
+ || isset( $parts['user'] ) || isset( $parts['pass'] )
+ || isset( $parts['query'] ) || isset( $parts['fragment'] ) ) {
+ return $bad( 'Use an HTTP or HTTPS address without credentials, a query, or a fragment.' );
+ }
+ $port = isset( $parts['port'] ) ? (int) $parts['port'] : ( 'https' === $scheme ? 443 : 80 );
+ if ( ( 'https' === $scheme && 443 !== $port ) || ( 'http' === $scheme && 80 !== $port ) ) {
+ return $bad( 'Only the standard HTTP and HTTPS ports are allowed.' );
+ }
+ if ( isset( $parts['path'] ) && '' !== $parts['path'] && '/' !== $parts['path'] ) {
+ return $bad( 'Enter the website homepage so the authorized scope is explicit.' );
+ }
+ if ( false !== strpos( $host, '%' ) || false !== inet_pton( trim( $host, '[]' ) ) ) {
+ return $bad( 'IP address literals are not allowed.' );
+ }
+ if ( preg_match( '/\A(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+)){0,3}\z/i', $host ) ) {
+ return $bad( 'Numeric IP address forms are not allowed.' );
+ }
+ if ( strlen( $host ) > 253 || substr( $host, -1 ) === '.' || false !== strpos( $host, '..' ) ) {
+ return $bad( 'The website hostname is not valid.' );
+ }
+ $labels = explode( '.', $host );
+ if ( count( $labels ) < 2 ) {
+ return $bad( 'Use a public hostname with at least two labels.' );
+ }
+ foreach ( $labels as $label ) {
+ if ( strlen( $label ) > 63 || ! preg_match( '/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/i', $label ) ) {
+ return $bad( 'The website hostname is not valid.' );
+ }
+ }
+ $tld = end( $labels );
+ if ( strlen( $tld ) < 2 || ctype_digit( $tld ) ) {
+ return $bad( 'The website hostname is not valid.' );
+ }
+ $private_suffixes = array( 'localhost', 'local', 'internal', 'intranet', 'lan', 'home', 'corp', 'private', 'test', 'example', 'invalid', 'onion' );
+ if ( in_array( $tld, $private_suffixes, true ) || 'localhost' === $host ) {
+ return $bad( 'Local and reserved hostnames are not allowed.' );
+ }
+ return $scheme . '://' . $host . '/';
+}
+
+function ct_scan_utc_now() {
+ return gmdate( 'Y-m-d H:i:s' );
+}
+
+function ct_scan_utc_parse( $value ) {
+ if ( ! is_string( $value ) || ! preg_match( '/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/', $value ) ) {
+ return false;
+ }
+ try {
+ $date = new DateTimeImmutable( $value, new DateTimeZone( 'UTC' ) );
+ return $date->format( 'Y-m-d\TH:i:s\Z' ) === $value ? $date : false;
+ } catch ( Throwable $error ) {
+ return false;
+ }
+}
+
+function ct_scan_meta_string( $post_id, $key ) {
+ $value = get_post_meta( $post_id, $key, true );
+ return is_string( $value ) ? $value : '';
+}
+
+/** Check the saved request and acceptance snapshot before any job is created. */
+function ct_scan_validate_saved_request( $request_id, $fields, $require_current_terms = true, $frozen_expiry = '' ) {
+ $config = ct_scan_config();
+ $post = get_post( $request_id );
+ if ( ! $post || 'ct_promo_request' !== $post->post_type || 'private' !== $post->post_status ) {
+ return new WP_Error( 'ct_scan_request_missing', 'The saved private request could not be verified.' );
+ }
+ if ( ! is_array( $fields ) ) {
+ return new WP_Error( 'ct_scan_fields_invalid', 'The saved request fields could not be verified.' );
+ }
+ foreach ( array( 'target', 'email', 'name', 'referrer' ) as $key ) {
+ if ( ! isset( $fields[ $key ] ) || ! is_string( $fields[ $key ] ) ) {
+ return new WP_Error( 'ct_scan_fields_incomplete', 'The saved request fields are incomplete.' );
+ }
+ }
+ $target = ct_scan_normalize_target( $fields['target'] );
+ if ( is_wp_error( $target ) || $target !== $fields['target'] ) {
+ return new WP_Error( 'ct_scan_target_mismatch', 'The saved authorized target is not in normalized form.' );
+ }
+ $email = sanitize_email( $fields['email'] );
+ $name = sanitize_text_field( $fields['name'] );
+ $ref = sanitize_title( $fields['referrer'] );
+ if ( ! is_email( $email ) || $email !== $fields['email'] || '' === $name || $name !== $fields['name']
+ || '' === $ref || $ref !== $fields['referrer'] ) {
+ return new WP_Error( 'ct_scan_contact_mismatch', 'The saved contact or referral fields are incomplete.' );
+ }
+ foreach ( array(
+ '_ct_scan_target' => $target,
+ '_ct_scan_email' => $email,
+ '_ct_scan_name' => $name,
+ '_ct_ref' => $ref,
+ ) as $key => $expected ) {
+ if ( ct_scan_meta_string( $request_id, $key ) !== $expected ) {
+ return new WP_Error( 'ct_scan_request_readback_mismatch', 'The saved request did not match its authorization event.' );
+ }
+ }
+ if ( '1' !== ct_scan_meta_string( $request_id, '_ct_terms_saved' ) ) {
+ return new WP_Error( 'ct_scan_acceptance_incomplete', 'A complete saved terms acceptance is required.' );
+ }
+ $terms_version = ct_scan_meta_string( $request_id, '_ct_terms_version' );
+ $terms_url = ct_scan_meta_string( $request_id, '_ct_terms_url' );
+ $terms_hash = strtolower( ct_scan_meta_string( $request_id, '_ct_terms_sha256' ) );
+ $accepted_raw = ct_scan_meta_string( $request_id, '_ct_terms_accepted_at' );
+ $contract = ct_scan_meta_string( $request_id, '_ct_terms_contract' );
+ $accepted_at = ct_scan_utc_parse( $accepted_raw );
+ if ( ( $require_current_terms && $terms_version !== $config['supported_terms_version'] ) || '' === $terms_url
+ || ! preg_match( '/\A[a-f0-9]{64}\z/', $terms_hash ) || ! $accepted_at
+ || '' === trim( $contract ) ) {
+ return new WP_Error( 'ct_scan_acceptance_stale', 'The saved terms acceptance is missing or no longer current.' );
+ }
+ if ( $accepted_at->getTimestamp() > time() + 300 ) {
+ return new WP_Error( 'ct_scan_acceptance_time_invalid', 'The saved acceptance time could not be verified.' );
+ }
+ $expected_lines = array(
+ 'Client contact' => $name,
+ 'Client email' => $email,
+ 'Website (scan scope)' => $target,
+ 'Referred by' => $ref,
+ );
+ foreach ( $expected_lines as $label => $expected ) {
+ if ( ! preg_match( '/^' . preg_quote( $label, '/' ) . ':\s*(.*)$/m', $contract, $match ) || trim( $match[1] ) !== $expected ) {
+ return new WP_Error( 'ct_scan_contract_mismatch', 'The saved acceptance copy does not match the authorized request fields.' );
+ }
+ }
+ if ( false === strpos( $post->post_content, $contract ) ) {
+ return new WP_Error( 'ct_scan_contract_readback_mismatch', 'The saved acceptance copy could not be read back from the request.' );
+ }
+ if ( $require_current_terms ) {
+ $window_end = ct_scan_utc_parse( $config['authorization_end_at'] );
+ if ( ! $window_end ) {
+ return new WP_Error( 'ct_scan_config_invalid', 'The authorization end date is not configured.' );
+ }
+ $expires = $accepted_at->modify( '+60 days' );
+ if ( $window_end < $expires ) {
+ $expires = $window_end;
+ }
+ } else {
+ if ( ! preg_match( '/\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\z/', $frozen_expiry ) ) {
+ return new WP_Error( 'ct_scan_expiry_invalid', 'The saved authorization expiry could not be verified.' );
+ }
+ try {
+ $expires = new DateTimeImmutable( $frozen_expiry, new DateTimeZone( 'UTC' ) );
+ } catch ( Throwable $error ) {
+ return new WP_Error( 'ct_scan_expiry_invalid', 'The saved authorization expiry could not be verified.' );
+ }
+ if ( $expires > $accepted_at->modify( '+60 days' ) ) {
+ return new WP_Error( 'ct_scan_expiry_invalid', 'The saved authorization expiry exceeds its accepted window.' );
+ }
+ }
+ if ( $expires <= new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) ) {
+ return new WP_Error( 'ct_scan_authorization_expired', 'The scan authorization window has expired.' );
+ }
+ return array(
+ 'post' => $post,
+ 'target' => $target,
+ 'email' => $email,
+ 'name' => $name,
+ 'referrer' => $ref,
+ 'terms_version' => $terms_version,
+ 'terms_hash' => $terms_hash,
+ 'terms_accepted_at' => $accepted_at->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ),
+ 'authorization_expires' => $expires->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ),
+ );
+}
+
+/** Acquire the per-site MySQL advisory lock used to serialize state changes. */
+function ct_scan_lock_acquire() {
+ global $wpdb;
+ $name = 'ctscan_' . substr( hash( 'sha256', DB_NAME . '|' . $wpdb->prefix ), 0, 24 );
+ return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $name ) );
+}
+
+function ct_scan_lock_release() {
+ global $wpdb;
+ $name = 'ctscan_' . substr( hash( 'sha256', DB_NAME . '|' . $wpdb->prefix ), 0, 24 );
+ $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) );
+}
+
+function ct_scan_mark_request_status( $request_id, $status, $detail = '' ) {
+ update_post_meta( $request_id, '_ct_scan_status', sanitize_key( $status ) );
+ if ( '' !== $detail ) {
+ update_post_meta( $request_id, '_ct_scan_status_detail', sanitize_key( $detail ) );
+ } else {
+ delete_post_meta( $request_id, '_ct_scan_status_detail' );
+ }
+}
+
+function ct_scan_safe_notice( $request_id, $code ) {
+ ct_scan_mark_request_status( $request_id, 'not_queued', $code );
+ return new WP_Error( 'ct_scan_' . sanitize_key( $code ), 'The request was retained, but the scan was not queued.' );
+}
+
+/**
+ * Single event entry point. The theme calls this only after durable readback;
+ * the plugin repeats that readback and never infers scope from post text.
+ */
+function ct_scan_on_request_ready( $request_id, $fields ) {
+ $request_id = absint( $request_id );
+ if ( ! $request_id || ! ct_scan_enabled() ) {
+ if ( $request_id ) {
+ return ct_scan_safe_notice( $request_id, 'disabled' );
+ }
+ return new WP_Error( 'ct_scan_request_id_invalid', 'The saved request could not be identified.' );
+ }
+ $config = ct_scan_config();
+ $token = bin2hex( random_bytes( 32 ) );
+ $now = time();
+ $ttl = max( 1, min( 168, absint( $config['confirmation_ttl_hours'] ) ) );
+ $created = gmdate( 'Y-m-d H:i:s', $now );
+ $confirm_expires = gmdate( 'Y-m-d H:i:s', $now + ( $ttl * HOUR_IN_SECONDS ) );
+ $jobs = ct_scan_jobs_table();
+ global $wpdb;
+ if ( ! ct_scan_lock_acquire() ) {
+ return ct_scan_safe_notice( $request_id, 'queue_busy' );
+ }
+ try {
+ $existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE request_id = %d LIMIT 1", $request_id ) );
+ if ( $existing ) {
+ update_post_meta( $request_id, '_ct_scan_event_recorded', '1' );
+ update_post_meta( $request_id, '_ct_scan_job_id', (int) $existing->job_id );
+ update_post_meta( $request_id, '_ct_scan_authorization_expires_at', $existing->authorization_expires_at );
+ ct_scan_mark_request_status( $request_id, $existing->status, $existing->error_code ? $existing->error_code : '' );
+ return array( 'job_id' => (int) $existing->job_id, 'status' => $existing->status, 'confirmation_email_status' => $existing->confirmation_email_status, 'duplicate' => true );
+ }
+ if ( get_post_meta( $request_id, '_ct_scan_event_recorded', true ) ) {
+ return new WP_Error( 'ct_scan_duplicate_event', 'This request already has a scan event.' );
+ }
+ $accepted = ct_scan_validate_saved_request( $request_id, $fields );
+ if ( is_wp_error( $accepted ) ) {
+ return ct_scan_safe_notice( $request_id, $accepted->get_error_code() );
+ }
+ $scope_hash = hash( 'sha256', $accepted['target'] );
+ $since = gmdate( 'Y-m-d H:i:s', $now - DAY_IN_SECONDS );
+ $global_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$jobs} WHERE created_at >= %s", $since ) );
+ $target_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$jobs} WHERE scope_hash = %s AND created_at >= %s", $scope_hash, $since ) );
+ if ( $global_count >= min( 1000, max( 1, absint( $config['global_daily_limit'] ) ) ) ) {
+ return ct_scan_safe_notice( $request_id, 'global_quota' );
+ }
+ if ( $target_count >= min( 10, max( 1, absint( $config['target_daily_limit'] ) ) ) ) {
+ return ct_scan_safe_notice( $request_id, 'target_quota' );
+ }
+ $inserted = $wpdb->insert(
+ $jobs,
+ array(
+ 'request_id' => $request_id,
+ 'target_origin' => $accepted['target'],
+ 'scope_hash' => $scope_hash,
+ 'terms_version' => $accepted['terms_version'],
+ 'terms_hash' => $accepted['terms_hash'],
+ 'terms_accepted_at' => $accepted['terms_accepted_at'],
+ 'authorization_expires_at' => $accepted['authorization_expires'],
+ 'contact_email' => $accepted['email'],
+ 'contact_name' => $accepted['name'],
+ 'referrer' => $accepted['referrer'],
+ 'status' => 'awaiting_confirmation',
+ 'created_at' => $created,
+ 'confirmation_token_hash' => hash( 'sha256', $token ),
+ 'confirmation_expires_at' => $confirm_expires,
+ ),
+ array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
+ );
+ if ( 1 !== $inserted ) {
+ $existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE request_id = %d LIMIT 1", $request_id ) );
+ if ( $existing ) {
+ update_post_meta( $request_id, '_ct_scan_event_recorded', '1' );
+ update_post_meta( $request_id, '_ct_scan_job_id', (int) $existing->job_id );
+ update_post_meta( $request_id, '_ct_scan_authorization_expires_at', $existing->authorization_expires_at );
+ ct_scan_mark_request_status( $request_id, $existing->status, $existing->error_code ? $existing->error_code : '' );
+ return array( 'job_id' => (int) $existing->job_id, 'status' => $existing->status, 'confirmation_email_status' => $existing->confirmation_email_status, 'duplicate' => true );
+ }
+ return ct_scan_safe_notice( $request_id, 'queue_write_failed' );
+ }
+ $job_id = (int) $wpdb->insert_id;
+ update_post_meta( $request_id, '_ct_scan_event_recorded', '1' );
+ update_post_meta( $request_id, '_ct_scan_job_id', $job_id );
+ update_post_meta( $request_id, '_ct_scan_authorization_expires_at', $accepted['authorization_expires'] );
+ ct_scan_mark_request_status( $request_id, 'awaiting_confirmation' );
+ } finally {
+ ct_scan_lock_release();
+ }
+
+ $url = add_query_arg(
+ array( 'action' => 'ct_scan_confirm', 'token' => $token ),
+ admin_url( 'admin-post.php' )
+ );
+ $subject = 'Confirm your authorized PHP2Shell screening';
+ $message = "A request was saved to screen the website you authorized.\n\n"
+ . "To confirm and allow one passive check of " . wp_parse_url( $accepted['target'], PHP_URL_HOST ) . ", open this link and press Confirm. Opening the link alone does not authorize a scan:\n\n"
+ . $url . "\n\nThis confirmation expires in {$ttl} hours. If you did not submit this request, ignore this message. Mailbox confirmation shows control of this email address; it does not independently prove domain ownership.\n";
+ $mail_status = 'unknown';
+ $pending = $wpdb->update( $jobs, array( 'confirmation_email_status' => 'unknown' ), array( 'job_id' => $job_id, 'confirmation_email_status' => 'pending' ), array( '%s' ), array( '%d', '%s' ) );
+ if ( 1 !== $pending ) {
+ update_post_meta( $request_id, '_ct_scan_confirmation_email_status', 'unknown' );
+ return array( 'job_id' => $job_id, 'status' => 'awaiting_confirmation', 'confirmation_email_status' => 'unknown' );
+ }
+ update_post_meta( $request_id, '_ct_scan_confirmation_email_status', 'unknown' );
+ try {
+ $mail_status = wp_mail( $accepted['email'], $subject, $message ) ? 'accepted' : 'unknown';
+ } catch ( Throwable $error ) {
+ $mail_status = 'unknown';
+ }
+ $wpdb->update( $jobs, array( 'confirmation_email_status' => $mail_status ), array( 'job_id' => $job_id, 'confirmation_email_status' => 'unknown' ), array( '%s' ), array( '%d', '%s' ) );
+ update_post_meta( $request_id, '_ct_scan_confirmation_email_status', $mail_status );
+ return array( 'job_id' => $job_id, 'status' => 'awaiting_confirmation', 'confirmation_email_status' => $mail_status );
+}
+add_action( 'ct_promo_request_ready', 'ct_scan_on_request_ready', 10, 2 );
+
+function ct_scan_row( $job_id ) {
+ global $wpdb;
+ $table = ct_scan_jobs_table();
+ return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE job_id = %d", absint( $job_id ) ) );
+}
+
+function ct_scan_authorization_is_live( $row ) {
+ if ( ! is_object( $row ) || ! empty( $row->revoked_at ) || ! empty( $row->report_delivered_at )
+ || strtotime( $row->authorization_expires_at . ' UTC' ) <= time() ) {
+ return false;
+ }
+ if ( ct_scan_meta_string( (int) $row->request_id, '_ct_scan_authorization_expires_at' ) !== $row->authorization_expires_at ) {
+ return false;
+ }
+ $fields = array(
+ 'target' => $row->target_origin,
+ 'email' => $row->contact_email,
+ 'name' => $row->contact_name,
+ 'referrer' => $row->referrer,
+ );
+ $accepted = ct_scan_validate_saved_request( (int) $row->request_id, $fields, false, $row->authorization_expires_at );
+ if ( is_wp_error( $accepted ) ) {
+ return false;
+ }
+ return hash_equals( $row->scope_hash, hash( 'sha256', $accepted['target'] ) )
+ && hash_equals( $row->terms_hash, $accepted['terms_hash'] )
+ && $row->terms_version === $accepted['terms_version']
+ && $row->terms_accepted_at === $accepted['terms_accepted_at']
+ && $row->authorization_expires_at === $accepted['authorization_expires'];
+}
+
+/** Reconcile bounded leases and apply the published data-retention windows. */
+function ct_scan_maintenance_locked() {
+ global $wpdb;
+ $table = ct_scan_jobs_table();
+ $now = ct_scan_utc_now();
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$table} SET status = 'expired', error_code = 'authorization_expired', finished_at = %s, lease_hash = NULL, lease_until = NULL, confirmation_token_hash = NULL WHERE status IN ('awaiting_confirmation','queued','leased','running') AND authorization_expires_at <= %s",
+ $now,
+ $now
+ ) );
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$table} SET status = 'expired', error_code = 'confirmation_expired', finished_at = %s, confirmation_token_hash = NULL WHERE status = 'awaiting_confirmation' AND confirmation_expires_at <= %s",
+ $now,
+ $now
+ ) );
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$table} SET status = 'queued', lease_hash = NULL, lease_until = NULL WHERE status = 'leased' AND lease_until <= %s AND attempts = 0",
+ $now
+ ) );
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$table} SET status = 'failed_unknown', outcome = 'failed', error_code = 'lease_expired', finished_at = %s, lease_hash = NULL, lease_until = NULL WHERE status = 'running' AND lease_until <= %s",
+ $now,
+ $now
+ ) );
+ $queued = $wpdb->get_results( "SELECT job_id,request_id,target_origin,scope_hash,terms_version,terms_hash,terms_accepted_at,authorization_expires_at,contact_email,contact_name,referrer,revoked_at,report_delivered_at FROM {$table} WHERE status = 'queued'" );
+ foreach ( (array) $queued as $row ) {
+ if ( ! ct_scan_authorization_is_live( $row ) ) {
+ $wpdb->update( $table, array( 'status' => 'cancelled', 'error_code' => 'authorization_changed', 'finished_at' => $now, 'lease_hash' => null, 'lease_until' => null ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s', '%s', '%s', '%s' ), array( '%d' ) );
+ ct_scan_mark_request_status( (int) $row->request_id, 'cancelled', 'authorization_changed' );
+ }
+ }
+ $nonce_table = ct_scan_nonces_table();
+ $wpdb->query( $wpdb->prepare( "DELETE FROM {$nonce_table} WHERE expires_at < %s", gmdate( 'Y-m-d H:i:s', time() - 600 ) ) );
+
+ $result_cutoff = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-12 months' )->format( 'Y-m-d H:i:s' );
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND finished_at IS NOT NULL AND finished_at < %s",
+ $result_cutoff
+ ) );
+ $request_cutoff = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-3 years' )->format( 'Y-m-d H:i:s' );
+ $old_rows = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,request_id FROM {$table} WHERE terms_accepted_at < %s AND status IN ('completed','failed','failed_unknown','cancelled','expired')", $request_cutoff ) );
+ foreach ( (array) $old_rows as $row ) {
+ $post = get_post( (int) $row->request_id );
+ $personal_scrubbed = $wpdb->update(
+ $table,
+ array( 'contact_email' => '', 'contact_name' => '' ),
+ array( 'job_id' => (int) $row->job_id ),
+ array( '%s', '%s' ),
+ array( '%d' )
+ );
+ if ( false === $personal_scrubbed ) {
+ continue;
+ }
+ if ( 0 === $personal_scrubbed ) {
+ $scrubbed = $wpdb->get_row( $wpdb->prepare( "SELECT contact_email,contact_name FROM {$table} WHERE job_id = %d", (int) $row->job_id ) );
+ if ( ! $scrubbed || '' !== $scrubbed->contact_email || '' !== $scrubbed->contact_name ) {
+ continue;
+ }
+ }
+ if ( $post && 'ct_promo_request' === $post->post_type && 'private' === $post->post_status && (int) $post->ID === (int) $row->request_id ) {
+ if ( ! wp_delete_post( (int) $row->request_id, true ) ) {
+ continue;
+ }
+ }
+ $wpdb->delete( $table, array( 'job_id' => (int) $row->job_id ), array( '%d' ) );
+ }
+}
+
+function ct_scan_maintain() {
+ if ( ! ct_scan_lock_acquire() ) {
+ return false;
+ }
+ try {
+ ct_scan_maintenance_locked();
+ } finally {
+ ct_scan_lock_release();
+ }
+ return true;
+}
+
+function ct_scan_confirmation_token_from_request( $source ) {
+ if ( ! isset( $source['token'] ) || ! is_string( $source['token'] ) ) {
+ return '';
+ }
+ $token = wp_unslash( $source['token'] );
+ return preg_match( '/\A[a-f0-9]{64}\z/i', $token ) ? strtolower( $token ) : '';
+}
+
+function ct_scan_confirmation_markup( $job_id, $token, $nonce, $target, $terms_version, $accepted_at ) {
+ $action = esc_url( admin_url( 'admin-post.php' ) );
+ $accepted_label = esc_html( str_replace( ' ', 'T', $accepted_at ) . 'Z (UTC)' );
+ return 'Confirm website screening
Confirm your website screening
Confirm one passive check of this exact website origin:
Opening this page alone does not authorize a scan. This confirms control of the request email address; it does not independently prove domain ownership.