diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d2f5708 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +* +!wordpress_check.py +!LICENSE +!LICENSING.md +!integrations/ +!integrations/__init__.py +!integrations/worker/ +!integrations/worker/*.py +!deployment/ +!deployment/Dockerfile diff --git a/.gitattributes b/.gitattributes index 129eaa8..30ad131 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,5 @@ *.md text eol=lf *.yml text eol=lf *.txt text eol=lf +# Unified patch context includes a leading space before source indentation. +deployment/*.patch -whitespace diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9c7f633..6e56072 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,3 +31,40 @@ jobs: run: python -m unittest -v - name: Check CLI help run: python wordpress_check.py --help + + wordpress: + name: WordPress queue and authorization + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Start isolated WordPress fixture + run: docker compose -p php2shell-ci -f deployment/compose.test.yaml up -d --wait + - name: Install disposable WordPress + run: docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-bootstrap.php + - name: Test queue, confirmation and receipts + run: docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/integrations/wordpress/tests/integration.php + - name: Test Python to WordPress over HTTP with synthetic scan responses + run: | + fixture=$(docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-http-fixture.php setup) + token=$(printf '%s' "$fixture" | jq -r .token) + docker run --rm --network php2shell-ci_fixture -v "$PWD:/app:ro" -w /app -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/app -e CT_FIXTURE_TOKEN="$token" python:3.12-slim-bookworm@sha256:6a0e06f5ba0b373d051aabd3fffb9b873f27f9a2a3a14bcbe37dee6f0e3bbc70 python deployment/test-http-worker.py + docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-http-fixture.php cleanup + - name: Test worker under Linux container privileges + run: >- + docker run --rm --network none --read-only --cap-drop ALL --cap-add SETUID --cap-add SETGID --cap-add KILL + --security-opt no-new-privileges --pids-limit 32 --memory 384m + --tmpfs /tmp:rw,noexec,nosuid,size=32m,mode=1777 + -v "$PWD:/app:ro" -w /app -e PYTHONDONTWRITEBYTECODE=1 + python:3.12-slim-bookworm@sha256:6a0e06f5ba0b373d051aabd3fffb9b873f27f9a2a3a14bcbe37dee6f0e3bbc70 + python -m unittest -v test_scan_worker + - name: Build and check packaged isolation + run: | + docker build --build-arg CHECKER_REVISION="$GITHUB_SHA" -f deployment/Dockerfile -t ct-worker-fixture . + docker run --rm --network none --read-only --cap-drop ALL --cap-add SETUID --cap-add SETGID --cap-add KILL --security-opt no-new-privileges --tmpfs /tmp:rw,noexec,nosuid,size=16m,mode=1777 -v "$PWD/deployment/test-runtime-image.py:/smoke.py:ro" -e PYTHONPATH=/app --entrypoint python ct-worker-fixture /smoke.py + - name: Stop disposable fixture + if: always() + run: docker compose -p php2shell-ci -f deployment/compose.test.yaml down --volumes diff --git a/.gitignore b/.gitignore index 1ad5edd..9d5bfd0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ results*.json websites.txt .DS_Store Thumbs.db +.work/ +artifacts/ +deployment/private/ diff --git a/AGENTS.md b/AGENTS.md index c9fb60d..1b05857 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,11 @@ Read [README.md](README.md) for setup and usage, - `skills/php2shell-check/SKILL.md`: reusable instructions for agents using the checker; `agents/openai.yaml` inside that skill provides discovery metadata. - `CLAUDE.md`: points Claude to these shared instructions; keep policy here. +- `integrations/worker/`: restricted network adapter and authenticated queue + controller; the public CLI remains independent of this integration. +- `integrations/wordpress/`: WordPress MU plugin and database integration tests. +- `deployment/`: isolated worker container, disposable WordPress fixture, and + reviewed CompleteTech site patch. Read `docs/WORDPRESS.md` before deployment. ## Implementation constraints diff --git a/README.md b/README.md index 645b3d9..44ef6f7 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ shared development instructions; [CLAUDE.md](CLAUDE.md) points Claude to them. - [Full reference](docs/REFERENCE.md): every CLI option, output field, and request rule - [Assessment methodology](docs/PHP2SHELL.md): how each version is classified +- [WordPress free-scan integration](docs/WORDPRESS.md): signed requests, confirmation, + private job queue, restricted worker, deployment and recovery - [Contributing](CONTRIBUTING.md) ยท [Security policy](SECURITY.md) ```sh diff --git a/deployment/Dockerfile b/deployment/Dockerfile new file mode 100644 index 0000000..3ce838c --- /dev/null +++ b/deployment/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim-bookworm@sha256:6a0e06f5ba0b373d051aabd3fffb9b873f27f9a2a3a14bcbe37dee6f0e3bbc70 +WORKDIR /app +COPY wordpress_check.py LICENSE LICENSING.md /app/ +COPY integrations/__init__.py /app/integrations/__init__.py +COPY integrations/worker /app/integrations/worker +ARG CHECKER_REVISION +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 CT_SCAN_REVISION=${CHECKER_REVISION} +RUN test -n "$CHECKER_REVISION" && mkdir -p /var/lib/ct-scan /run/ct-scan && chmod 700 /var/lib/ct-scan /run/ct-scan +# The controller reads the root-only API key; scanner children drop to 65534. +USER 0:0 +ENTRYPOINT ["python", "-m", "integrations.worker.service"] diff --git a/deployment/complete-tech-site-manifest.json b/deployment/complete-tech-site-manifest.json new file mode 100644 index 0000000..e849901 --- /dev/null +++ b/deployment/complete-tech-site-manifest.json @@ -0,0 +1,18 @@ +{ + "theme/spintech-child/functions.php": { + "before_normalized_sha256": "911f1612ff0292d4bfce13c52b8b7fe0c27e630498c13b32352f8010d91b629a", + "after_sha256": "aaec0b66a94a89a3ed85475bab9a22325e52162213e58a2b2d3238f2e842f751" + }, + "mu-plugins/ct-promo-terms.php": { + "before_normalized_sha256": "9f5df66eb176e61263e76ea1a3d24ac191350885cc60b2ad7e17dd53530927c3", + "after_sha256": "a1e2e25202a1857399b3d358382c7e31a4b209777dd0b25031001dc2674debfb" + }, + "theme/spintech-child/templates/template-service-brief.php": { + "before_normalized_sha256": "ad4eec2bd5e973b54f0f2a06155c96d0f4432d0c4dfd829a3145e3b8ca11073f", + "after_sha256": "9ce71eadaed23d34e547539df3cb46d920e00caa0313e5a32ed1f58b0bf3f7ea" + }, + "content/php2shell-scan-terms.html": { + "before_normalized_sha256": "20ea5d6c61f65a79bba0bc18b248fc1ecef78a1c7572f4da3328e1a5083c3dce", + "after_sha256": "4def089ebb32c318a3ccab18d8eb432f0cb8e28cd309554efee907f183653ee3" + } +} diff --git a/deployment/complete-tech-site.patch b/deployment/complete-tech-site.patch new file mode 100644 index 0000000..ee833ee --- /dev/null +++ b/deployment/complete-tech-site.patch @@ -0,0 +1,137 @@ +--- a/theme/spintech-child/functions.php ++++ b/theme/spintech-child/functions.php +@@ -509,6 +509,16 @@ + exit; + } + ++ // Canonical scope is signed and persisted before asynchronous work. ++ if ( function_exists( 'ct_scan_normalize_target' ) ) { ++ $target = ct_scan_normalize_target( wp_unslash( $_POST['ct_website'] ?? '' ) ); ++ if ( is_wp_error( $target ) ) { ++ wp_safe_redirect( $fail ); ++ exit; ++ } ++ $website = $target; ++ } ++ + $body = "Promotion: {$promo['id']}\n" + . "Referred by: {$referred_by}\n" + . "Name: {$name}\n" +@@ -528,6 +538,21 @@ + } + update_post_meta( $request_id, '_ct_ref', $referred_by ); + ++ if ( function_exists( 'ct_scan_on_request_ready' ) ) { ++ $fields = array( 'target' => $website, 'email' => $email, 'name' => $name, 'referrer' => $referred_by ); ++ $saved = true; ++ foreach ( array( 'target', 'email', 'name' ) as $field ) { ++ $key = '_ct_scan_' . $field; ++ update_post_meta( $request_id, $key, wp_slash( $fields[ $field ] ) ); ++ $saved = $saved && get_post_meta( $request_id, $key, true ) === $fields[ $field ]; ++ } ++ if ( $saved && get_post_meta( $request_id, '_ct_ref', true ) === $referred_by ) { ++ do_action( 'ct_promo_request_ready', $request_id, $fields ); ++ } else { ++ update_post_meta( $request_id, '_ct_scan_enqueue_error', 'request_metadata_not_saved' ); ++ } ++ } ++ + ct_promo_send_notification( + $request_id, + get_option( 'admin_email' ), +--- a/mu-plugins/ct-promo-terms.php ++++ b/mu-plugins/ct-promo-terms.php +@@ -2,7 +2,7 @@ + /** + * Plugin Name: CompleteTech Promo Terms + * Description: Requires acceptance of the PHP2Shell scan terms on the promo form, records the acceptance, and attaches a filled-in copy of the terms to each request and its notification email. +- * Version: 1.0.0 ++ * Version: 1.1.0 + */ + + defined( 'ABSPATH' ) || exit; +@@ -11,7 +11,7 @@ + function ct_promo_terms() { + return array( + 'page_id' => 658, +- 'version' => '2026-10-21', ++ 'version' => '2026-10-21-r2', + 'title' => 'Free PHP2Shell Scan: Authorization and Promotion Terms', + 'path' => '/services/website-security/php2shell-scan-terms/', + ); +@@ -119,11 +119,19 @@ + return; + } + foreach ( array( 'version', 'url', 'sha256', 'accepted_at', 'ip', 'remote_addr', 'user_agent' ) as $key ) { +- update_post_meta( $post_id, '_ct_terms_' . $key, $acceptance[ $key ] ); ++ update_post_meta( $post_id, '_ct_terms_' . $key, wp_slash( $acceptance[ $key ] ) ); + } + $contract = ct_promo_terms_contract( $post_id, $post->post_content, $acceptance ); + $GLOBALS['ct_promo_acceptance']['contract'] = $contract; + update_post_meta( $post_id, '_ct_terms_contract', wp_slash( $contract ) ); ++ // Queue eligibility requires a complete, durably saved acceptance record. ++ $terms_saved = get_post_meta( $post_id, '_ct_terms_contract', true ) === $contract; ++ foreach ( array( 'version', 'url', 'sha256', 'accepted_at', 'ip', 'remote_addr', 'user_agent' ) as $key ) { ++ $terms_saved = $terms_saved && get_post_meta( $post_id, '_ct_terms_' . $key, true ) === $acceptance[ $key ]; ++ } ++ if ( $terms_saved ) { ++ update_post_meta( $post_id, '_ct_terms_saved', '1' ); ++ } + wp_update_post( wp_slash( array( + 'ID' => $post_id, + 'post_content' => $post->post_content . "\n\n" . $contract, +--- a/theme/spintech-child/templates/template-service-brief.php ++++ b/theme/spintech-child/templates/template-service-brief.php +@@ -161,10 +161,10 @@ + Limited offer · through Nov 20, 2026 +

Request your free PHP2Shell scan

+ +-

Thanks — your request is in. We’ll be in touch within one business day.

++

Thanks — your request is saved. Watch for a separate email to confirm authorization before scanning starts. We’ll be in touch within one business day.

+ + +- ++ + + + +@@ -174,7 +174,7 @@ + + + +- ++ + + + +--- a/content/php2shell-scan-terms.html ++++ b/content/php2shell-scan-terms.html +@@ -1,5 +1,5 @@ + +-

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.

+ + + diff --git a/deployment/compose.test.yaml b/deployment/compose.test.yaml new file mode 100644 index 0000000..6feae5a --- /dev/null +++ b/deployment/compose.test.yaml @@ -0,0 +1,45 @@ +services: + db: + image: mariadb:11.4 + environment: + MARIADB_DATABASE: wordpress + MARIADB_USER: wordpress + MARIADB_PASSWORD: fixture-only + MARIADB_ROOT_PASSWORD: fixture-root-only + tmpfs: + - /var/lib/mysql + healthcheck: + test: [CMD, healthcheck.sh, --connect, --innodb_initialized] + interval: 2s + timeout: 2s + retries: 30 + networks: [fixture] + wordpress: + image: wordpress:php8.3-apache + depends_on: + db: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: fixture-only + WORDPRESS_DB_NAME: wordpress + WORDPRESS_CONFIG_EXTRA: "define('DISABLE_WP_CRON', true);" + healthcheck: + test: [CMD, php, -r, "exit(@fsockopen('127.0.0.1', 80) ? 0 : 1);"] + interval: 2s + timeout: 2s + retries: 30 + volumes: + - wp:/var/www/html + - ../integrations/wordpress:/var/www/html/wp-content/mu-plugins:ro + - ..:/work:ro + networks: [fixture] +volumes: + wp: +networks: + fixture: + internal: true + ipam: + config: + - subnet: ${CT_TEST_SUBNET:-10.254.96.0/28} diff --git a/deployment/compose.yaml b/deployment/compose.yaml new file mode 100644 index 0000000..bd8e785 --- /dev/null +++ b/deployment/compose.yaml @@ -0,0 +1,49 @@ +services: + worker: + build: + context: .. + dockerfile: deployment/Dockerfile + args: + CHECKER_REVISION: ${CT_SCAN_REVISION:?Set the pinned source revision} + image: ct-php2shell-worker:${CT_SCAN_REVISION} + restart: unless-stopped + init: true + read_only: true + cap_drop: [ALL] + cap_add: [SETUID, SETGID, KILL] + security_opt: [no-new-privileges:true] + pids_limit: 32 + mem_limit: 384m + cpus: 0.5 + environment: + CT_SCAN_SITE_URL: https://complete.tech + CT_SCAN_SECRET_FILE: /run/ct-scan/key + CT_SCAN_SPOOL: /var/lib/ct-scan + CT_SCAN_POLL_SECONDS: "15" + volumes: + - spool:/var/lib/ct-scan + - secrets:/run/ct-scan:ro + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m,mode=1777 + networks: [worker] + healthcheck: + test: [CMD, python, -c, "import os,time; p='/var/lib/ct-scan/heartbeat.json'; raise SystemExit(0 if time.time()-os.stat(p).st_mtime < 600 else 1)"] + interval: 60s + timeout: 5s + start_period: 60s + retries: 2 + logging: + driver: json-file + options: + max-size: 1m + max-file: "3" +volumes: + spool: + secrets: + external: true + name: ct-php2shell-secrets +networks: + worker: + ipam: + config: + - subnet: ${CT_WORKER_SUBNET:-10.254.97.0/28} diff --git a/deployment/prepare_site_patch.py b/deployment/prepare_site_patch.py new file mode 100644 index 0000000..5f91f16 --- /dev/null +++ b/deployment/prepare_site_patch.py @@ -0,0 +1,126 @@ +"""Prepare a reviewable patch against an existing CompleteTech control checkout. + +Does not modify the supplied checkout or contact production. Run with --source. +""" +import argparse +import difflib +import hashlib +import json +from pathlib import Path + + +def replace_once(text, needle, replacement): + if text.count(needle) != 1: + raise ValueError("The inspected site baseline has changed; review before patching") + return text.replace(needle, replacement, 1) + + +def prepare(source, root): + stage = root / '.work' / 'site' + changes = {} + path = 'theme/spintech-child/functions.php' + old = (source / path).read_text(encoding='utf-8') + needle = '\t$body = "Promotion: {$promo[\'id\']}\\n"' + addition = '''\t// Canonical scope is signed and persisted before asynchronous work. +\tif ( function_exists( 'ct_scan_normalize_target' ) ) { +\t\t$target = ct_scan_normalize_target( wp_unslash( $_POST['ct_website'] ?? '' ) ); +\t\tif ( is_wp_error( $target ) ) { +\t\t\twp_safe_redirect( $fail ); +\t\t\texit; +\t\t} +\t\t$website = $target; +\t} + +''' + new = replace_once(old, needle, addition + needle) + needle = "\tupdate_post_meta( $request_id, '_ct_ref', $referred_by );\n" + addition = ''' +\tif ( function_exists( 'ct_scan_on_request_ready' ) ) { +\t\t$fields = array( 'target' => $website, 'email' => $email, 'name' => $name, 'referrer' => $referred_by ); +\t\t$saved = true; +\t\tforeach ( array( 'target', 'email', 'name' ) as $field ) { +\t\t\t$key = '_ct_scan_' . $field; +\t\t\tupdate_post_meta( $request_id, $key, wp_slash( $fields[ $field ] ) ); +\t\t\t$saved = $saved && get_post_meta( $request_id, $key, true ) === $fields[ $field ]; +\t\t} +\t\tif ( $saved && get_post_meta( $request_id, '_ct_ref', true ) === $referred_by ) { +\t\t\tdo_action( 'ct_promo_request_ready', $request_id, $fields ); +\t\t} else { +\t\t\tupdate_post_meta( $request_id, '_ct_scan_enqueue_error', 'request_metadata_not_saved' ); +\t\t} +\t} +''' + new = replace_once(new, needle, needle + addition) + changes[path] = old, new + + path = 'mu-plugins/ct-promo-terms.php' + old = (source / path).read_text(encoding='utf-8') + new = replace_once(old, 'Version: 1.0.0', 'Version: 1.1.0') + new = replace_once(new, "'version' => '2026-10-21'", "'version' => '2026-10-21-r2'") + new = replace_once(new, + "update_post_meta( $post_id, '_ct_terms_' . $key, $acceptance[ $key ] );", + "update_post_meta( $post_id, '_ct_terms_' . $key, wp_slash( $acceptance[ $key ] ) );") + needle = "\tupdate_post_meta( $post_id, '_ct_terms_contract', wp_slash( $contract ) );\n" + addition = '''\t// Queue eligibility requires a complete, durably saved acceptance record. +\t$terms_saved = get_post_meta( $post_id, '_ct_terms_contract', true ) === $contract; +\tforeach ( array( 'version', 'url', 'sha256', 'accepted_at', 'ip', 'remote_addr', 'user_agent' ) as $key ) { +\t\t$terms_saved = $terms_saved && get_post_meta( $post_id, '_ct_terms_' . $key, true ) === $acceptance[ $key ]; +\t} +\tif ( $terms_saved ) { +\t\tupdate_post_meta( $post_id, '_ct_terms_saved', '1' ); +\t} +''' + new = replace_once(new, needle, needle + addition) + changes[path] = old, new + + path = 'theme/spintech-child/templates/template-service-brief.php' + old = (source / path).read_text(encoding='utf-8') + new = replace_once(old, + 'Thanks — your request is in. We’ll be in touch within one business day.', + 'Thanks — your request is saved. Watch for a separate email to confirm authorization before scanning starts. We’ll be in touch within one business day.') + new = replace_once(new, + 'Please fill in your name, a valid email, and your website, then try again.', + 'Please enter your name, a valid email, and your website homepage (for example, https://example.com/), then try again.') + new = replace_once(new, 'placeholder="https://" autocomplete="url"', + 'placeholder="https://example.com/" autocomplete="url"') + changes[path] = old, new + + path = 'content/php2shell-scan-terms.html' + old = (source / path).read_text(encoding='utf-8') + new = replace_once(old, 'Version 2026-10-21', 'Version 2026-10-21-r2') + needle = '\n

2. What you authorize

' + addition = ''' +

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.

+ + +''' + new = replace_once(new, needle, addition + needle) + needle = '\n

4. What we will not do

' + addition = ''' +

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:

' . esc_html( $target ) . '

Terms version accepted: ' . esc_html( $terms_version ) . '
Acceptance recorded: ' . $accepted_label . '

Opening this page alone does not authorize a scan. This confirms control of the request email address; it does not independently prove domain ownership.

'; +} + +function ct_scan_send_confirmation_page( $message, $status = 200, $markup = '' ) { + nocache_headers(); + status_header( $status ); + header( 'Content-Type: text/html; charset=UTF-8' ); + header( 'Referrer-Policy: no-referrer' ); + header( 'X-Content-Type-Options: nosniff' ); + header( "Content-Security-Policy: default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; style-src 'unsafe-inline'" ); + if ( '' === $markup ) { + $markup = 'Website screening

Website screening

' . esc_html( $message ) . '

'; + } + echo $markup; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- markup is assembled from escaped fields above. + exit; +} + +function ct_scan_confirmation_handler() { + $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : ''; + if ( 'GET' === $method ) { + $token = ct_scan_confirmation_token_from_request( $_GET ); + if ( '' === $token ) { + ct_scan_send_confirmation_page( 'This confirmation link is invalid or expired.', 404 ); + } + $row = ct_scan_row_by_confirmation_hash( hash( 'sha256', $token ) ); + if ( ! $row || 'awaiting_confirmation' !== $row->status || strtotime( $row->confirmation_expires_at . ' UTC' ) <= time() + || ! ct_scan_authorization_is_live( $row ) ) { + ct_scan_send_confirmation_page( 'This confirmation link is invalid or expired.', 410 ); + } + $nonce = wp_create_nonce( 'ct_scan_confirm_' . (int) $row->job_id . '_' . $token ); + ct_scan_send_confirmation_page( '', 200, ct_scan_confirmation_markup( (int) $row->job_id, $token, $nonce, $row->target_origin, $row->terms_version, $row->terms_accepted_at ) ); + } + if ( 'POST' !== $method ) { + ct_scan_send_confirmation_page( 'Use the confirmation form to continue.', 405 ); + } + $token = ct_scan_confirmation_token_from_request( $_POST ); + $job_id = isset( $_POST['job_id'] ) && is_scalar( $_POST['job_id'] ) ? absint( wp_unslash( $_POST['job_id'] ) ) : 0; + if ( ! $job_id || '' === $token || ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) + || ! wp_verify_nonce( wp_unslash( $_POST['_wpnonce'] ), 'ct_scan_confirm_' . $job_id . '_' . $token ) ) { + ct_scan_send_confirmation_page( 'This confirmation form is invalid or expired.', 403 ); + } + if ( ! ct_scan_lock_acquire() ) { + ct_scan_send_confirmation_page( 'The request is temporarily busy. Please try again.', 503 ); + } + global $wpdb; + $table = ct_scan_jobs_table(); + $message = 'This confirmation link is invalid or expired.'; + $response_status = 410; + try { + $row = ct_scan_row( $job_id ); + if ( $row && 'awaiting_confirmation' === $row->status && ! empty( $row->confirmation_token_hash ) + && hash_equals( $row->confirmation_token_hash, hash( 'sha256', $token ) ) + && strtotime( $row->confirmation_expires_at . ' UTC' ) > time() && ct_scan_authorization_is_live( $row ) ) { + $updated = $wpdb->update( + $table, + array( 'status' => 'queued', 'confirmed_at' => ct_scan_utc_now(), 'confirmation_token_hash' => null, 'confirmation_expires_at' => null ), + array( 'job_id' => $job_id, 'status' => 'awaiting_confirmation', 'confirmation_token_hash' => $row->confirmation_token_hash ), + array( '%s', '%s', '%s', '%s' ), + array( '%d', '%s', '%s' ) + ); + if ( 1 === $updated ) { + ct_scan_mark_request_status( (int) $row->request_id, 'queued' ); + $message = 'Your screening is confirmed and queued. It will run once, within the authorization window.'; + $response_status = 200; + } + } + } finally { + ct_scan_lock_release(); + } + ct_scan_send_confirmation_page( $message, $response_status ); +} + +function ct_scan_row_by_confirmation_hash( $hash ) { + global $wpdb; + $table = ct_scan_jobs_table(); + return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE confirmation_token_hash = %s LIMIT 1", $hash ) ); +} +add_action( 'admin_post_nopriv_ct_scan_confirm', 'ct_scan_confirmation_handler' ); +add_action( 'admin_post_ct_scan_confirm', 'ct_scan_confirmation_handler' ); + +function ct_scan_rest_response( $status, $payload ) { + return new WP_REST_Response( $payload, (int) $status ); +} + +function ct_scan_rest_error( $status, $code ) { + return ct_scan_rest_response( $status, array( 'error' => sanitize_key( $code ) ) ); +} + +/** Authenticate and consume a unique, short-lived worker nonce. */ +function ct_scan_rest_authenticate( WP_REST_Request $request, $route ) { + if ( ! ct_scan_enabled() || get_option( 'ct_scan_schema_version' ) !== CT_SCAN_SCHEMA_VERSION ) { + return new WP_Error( 'ct_scan_disabled', 'Worker API is disabled.', array( 'status' => 503 ) ); + } + $body = $request->get_body(); + if ( ! is_string( $body ) || strlen( $body ) > 65536 ) { + return new WP_Error( 'ct_scan_body_too_large', 'Request body is too large.', array( 'status' => 413 ) ); + } + $timestamp = $request->get_header( 'x-ct-timestamp' ); + $nonce = $request->get_header( 'x-ct-nonce' ); + $signature = $request->get_header( 'x-ct-signature' ); + if ( ! is_string( $timestamp ) || ! preg_match( '/\A[0-9]{10}\z/', $timestamp ) + || abs( time() - (int) $timestamp ) > 300 + || ! is_string( $nonce ) || ! preg_match( '/\A[a-f0-9]{32}\z/i', $nonce ) + || ! is_string( $signature ) || ! preg_match( '/\A[a-f0-9]{64}\z/i', $signature ) ) { + return new WP_Error( 'ct_scan_auth_invalid', 'Worker authentication failed.', array( 'status' => 401 ) ); + } + $signing = $timestamp . "\n" . strtolower( $nonce ) . "\nPOST\n" . $route . "\n" . hash( 'sha256', $body ); + $expected = hash_hmac( 'sha256', $signing, ct_scan_config()['worker_hmac_secret'] ); + if ( ! hash_equals( $expected, strtolower( $signature ) ) ) { + return new WP_Error( 'ct_scan_auth_invalid', 'Worker authentication failed.', array( 'status' => 401 ) ); + } + global $wpdb; + $table = ct_scan_nonces_table(); + $nonce_hash = hash( 'sha256', strtolower( $nonce ) ); + $inserted = $wpdb->query( $wpdb->prepare( + "INSERT IGNORE INTO {$table} (nonce_hash, expires_at) VALUES (%s, %s)", + $nonce_hash, + gmdate( 'Y-m-d H:i:s', time() + 600 ) + ) ); + if ( 1 !== $inserted ) { + return new WP_Error( 'ct_scan_replay', 'Worker authentication failed.', array( 'status' => 409 ) ); + } + return $body; +} + +function ct_scan_decode_request_body( $body ) { + $data = json_decode( $body, true ); + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) { + return new WP_Error( 'ct_scan_json_invalid', 'Expected a JSON object.' ); + } + return $data; +} + +function ct_scan_expected_keys( $data, $keys ) { + $actual = array_keys( $data ); + sort( $actual ); + sort( $keys ); + return $actual === $keys; +} + +function ct_scan_is_list( $value ) { + return is_array( $value ) && ( array() === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ) ); +} + +function ct_scan_claim( WP_REST_Request $request ) { + $body = ct_scan_rest_authenticate( $request, '/ct-scan/v1/claim' ); + if ( is_wp_error( $body ) ) { + return ct_scan_rest_error( (int) $body->get_error_data()['status'], $body->get_error_code() ); + } + $data = ct_scan_decode_request_body( $body ); + if ( is_wp_error( $data ) || ! ct_scan_expected_keys( $data, array() ) ) { + return ct_scan_rest_error( 400, 'request_invalid' ); + } + if ( ! ct_scan_lock_acquire() ) { + return ct_scan_rest_error( 503, 'queue_busy' ); + } + global $wpdb; + $table = ct_scan_jobs_table(); + try { + ct_scan_maintenance_locked(); + $active = $wpdb->get_var( "SELECT job_id FROM {$table} WHERE status IN ('leased','running') LIMIT 1" ); + if ( $active ) { + return ct_scan_rest_response( 200, array( 'job' => null ) ); + } + $row = $wpdb->get_row( "SELECT * FROM {$table} WHERE status = 'queued' AND attempts = 0 ORDER BY created_at ASC, job_id ASC LIMIT 1" ); + if ( ! $row ) { + return ct_scan_rest_response( 200, array( 'job' => null ) ); + } + if ( ! ct_scan_authorization_is_live( $row ) ) { + $wpdb->update( $table, array( 'status' => 'cancelled', 'error_code' => 'authorization_changed', 'finished_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s', '%s' ), array( '%d' ) ); + ct_scan_mark_request_status( (int) $row->request_id, 'cancelled', 'authorization_changed' ); + return ct_scan_rest_response( 200, array( 'job' => null ) ); + } + $lease = bin2hex( random_bytes( 32 ) ); + $expiry = strtotime( $row->authorization_expires_at . ' UTC' ); + $lease_until = min( time() + 300, $expiry ); + if ( $lease_until <= time() ) { + return ct_scan_rest_response( 200, array( 'job' => null ) ); + } + $updated = $wpdb->update( + $table, + array( 'status' => 'leased', 'lease_hash' => hash( 'sha256', $lease ), 'lease_until' => gmdate( 'Y-m-d H:i:s', $lease_until ) ), + array( 'job_id' => (int) $row->job_id, 'status' => 'queued', 'attempts' => 0 ), + array( '%s', '%s', '%s' ), + array( '%d', '%s', '%d' ) + ); + if ( 1 !== $updated ) { + return ct_scan_rest_error( 503, 'claim_race' ); + } + return ct_scan_rest_response( 200, array( + 'job' => array( + 'id' => (int) $row->job_id, + 'target' => $row->target_origin, + 'scope_hash' => $row->scope_hash, + 'terms_hash' => $row->terms_hash, + 'expires_at' => (int) $expiry, + 'lease' => $lease, + 'lease_until' => (int) $lease_until, + ), + ) ); + } finally { + ct_scan_lock_release(); + } +} + +function ct_scan_callback_payload( $data ) { + if ( ! isset( $data['id'], $data['lease'], $data['scope_hash'] ) || ! is_int( $data['id'] ) || $data['id'] < 1 + || ! is_string( $data['lease'] ) || ! preg_match( '/\A[a-f0-9]{64}\z/', $data['lease'] ) + || ! is_string( $data['scope_hash'] ) || ! preg_match( '/\A[a-f0-9]{64}\z/', $data['scope_hash'] ) ) { + return false; + } + return true; +} + +function ct_scan_start( WP_REST_Request $request ) { + $body = ct_scan_rest_authenticate( $request, '/ct-scan/v1/start' ); + if ( is_wp_error( $body ) ) { + return ct_scan_rest_error( (int) $body->get_error_data()['status'], $body->get_error_code() ); + } + $data = ct_scan_decode_request_body( $body ); + if ( is_wp_error( $data ) || ! ct_scan_expected_keys( $data, array( 'id', 'lease', 'scope_hash' ) ) || ! ct_scan_callback_payload( $data ) ) { + return ct_scan_rest_error( 400, 'request_invalid' ); + } + if ( ! ct_scan_lock_acquire() ) { + return ct_scan_rest_error( 503, 'queue_busy' ); + } + global $wpdb; + $table = ct_scan_jobs_table(); + try { + ct_scan_maintenance_locked(); + $row = ct_scan_row( $data['id'] ); + if ( ! $row || 'leased' !== $row->status || empty( $row->lease_hash ) || ! hash_equals( $row->lease_hash, hash( 'sha256', $data['lease'] ) ) + || ! hash_equals( $row->scope_hash, $data['scope_hash'] ) ) { + return ct_scan_rest_error( 410, 'lease_invalid_or_expired' ); + } + if ( strtotime( $row->lease_until . ' UTC' ) <= time() || 0 !== (int) $row->attempts ) { + return ct_scan_rest_error( 410, 'lease_invalid_or_expired' ); + } + if ( ! ct_scan_authorization_is_live( $row ) ) { + $wpdb->update( $table, array( 'status' => 'cancelled', 'error_code' => 'authorization_changed', 'finished_at' => ct_scan_utc_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' ); + return ct_scan_rest_error( 410, 'authorization_revoked_or_expired' ); + } + $now = ct_scan_utc_now(); + $authorization_expiry = strtotime( $row->authorization_expires_at . ' UTC' ); + $updated = $wpdb->update( + $table, + array( 'status' => 'running', 'attempts' => 1, 'started_at' => $now, 'lease_until' => gmdate( 'Y-m-d H:i:s', min( time() + 300, $authorization_expiry ) ) ), + array( 'job_id' => (int) $row->job_id, 'status' => 'leased', 'attempts' => 0, 'lease_hash' => $row->lease_hash ), + array( '%s', '%d', '%s', '%s' ), + array( '%d', '%s', '%d', '%s' ) + ); + if ( 1 !== $updated ) { + return ct_scan_rest_error( 409, 'start_rejected' ); + } + ct_scan_mark_request_status( (int) $row->request_id, 'running' ); + return ct_scan_rest_response( 200, array( 'ok' => true ) ); + } finally { + ct_scan_lock_release(); + } +} + +function ct_scan_clean_result_line( $value, $limit = 500 ) { + return is_string( $value ) && strlen( $value ) <= $limit; +} + +function ct_scan_result_url_matches_scope( $url, $target ) { + if ( ! is_string( $url ) || '' === $url || strlen( $url ) > 4096 ) { + return false; + } + $parts = wp_parse_url( $url ); + $base = wp_parse_url( $target ); + if ( ! is_array( $parts ) || ! is_array( $base ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) + || isset( $parts['user'] ) || isset( $parts['pass'] ) ) { + return false; + } + $scheme = strtolower( $parts['scheme'] ); + $port = isset( $parts['port'] ) ? (int) $parts['port'] : ( 'https' === $scheme ? 443 : 80 ); + $base_port = 'https' === $base['scheme'] ? 443 : 80; + return in_array( $scheme, array( 'http', 'https' ), true ) + && strtolower( $parts['host'] ) === $base['host'] + && $port === ( 'https' === $scheme ? 443 : 80 ) + && ( 'https' !== $base['scheme'] || 'https' === $scheme ); +} + +/** Validate the small, single-result checker payload before persisting it. */ +function ct_scan_validate_result( $result, $row, $stored_snapshot = false ) { + if ( ! is_array( $result ) || count( $result ) > 64 ) { + return new WP_Error( 'result_invalid', 'result_invalid' ); + } + $required = array( + 'input_url', 'final_url', 'status', 'fetch_status', 'http_status', 'wordpress_version', 'php_version', 'server', 'powered_by', 'title', + 'evidence', 'asset_version_hints', 'notes', 'checked_at', 'php_version_candidates', 'php_evidence', 'php2shell_status', + 'php2shell_version_status', 'php2shell_fixed_version', 'php2shell_exploitability', 'php2shell_cves', 'php2shell_rules_as_of', + 'php2shell_sources', 'php2shell_notes', 'php2shell_recommendation', + ); + if ( ! ct_scan_expected_keys( $result, $required ) ) { + return new WP_Error( 'result_schema_mismatch', 'result_schema_mismatch' ); + } + $version_states = array( 'version_not_disclosed', 'conflicting_versions', 'unrecognized_or_partial_version', 'branch_requires_vendor_review', 'prerelease_unverified', 'vendor_build_unverified', 'below_upstream_fix', 'at_or_above_upstream_fix' ); + $config = ct_scan_config(); + if ( ! is_string( $result['input_url'] ) || $result['input_url'] !== $row->target_origin + || ! is_string( $result['final_url'] ) + || ! in_array( $result['status'], array( 'wordpress', 'likely_wordpress', 'no_evidence', 'uncertain', 'blocked', 'error' ), true ) + || ! in_array( $result['fetch_status'], array( 'ok', 'blocked', 'timeout', 'dns_error' ), true ) + || ! in_array( $result['php2shell_status'], array( 'potentially_affected', 'fixed_version_reported', 'unknown' ), true ) + || ! is_string( $result['php2shell_version_status'] ) || ! in_array( $result['php2shell_version_status'], $version_states, true ) + || 'not_tested' !== $result['php2shell_exploitability'] + || ! is_string( $result['php2shell_rules_as_of'] ) || ! preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $result['php2shell_rules_as_of'] ) + || ! checkdate( (int) substr( $result['php2shell_rules_as_of'], 5, 2 ), (int) substr( $result['php2shell_rules_as_of'], 8, 2 ), (int) substr( $result['php2shell_rules_as_of'], 0, 4 ) ) + || ( ! $stored_snapshot && $result['php2shell_rules_as_of'] !== $config['expected_rules_as_of'] ) + || ! is_string( $result['checked_at'] ) || strlen( $result['checked_at'] ) > 64 + || ! preg_match( '/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/', $result['checked_at'] ) + || ! is_string( $result['php_version'] ) || strlen( $result['php_version'] ) > 128 + || ! is_string( $result['wordpress_version'] ) || strlen( $result['wordpress_version'] ) > 128 + || ! is_string( $result['php2shell_fixed_version'] ) || strlen( $result['php2shell_fixed_version'] ) > 32 ) { + return new WP_Error( 'result_semantics_invalid', 'result_semantics_invalid' ); + } + if ( '' !== $result['final_url'] && ! ct_scan_result_url_matches_scope( $result['final_url'], $row->target_origin ) ) { + return new WP_Error( 'result_scope_mismatch', 'result_scope_mismatch' ); + } + if ( null !== $result['http_status'] && ( ! is_int( $result['http_status'] ) || $result['http_status'] < 100 || $result['http_status'] > 599 ) ) { + return new WP_Error( 'result_http_status_invalid', 'result_http_status_invalid' ); + } + foreach ( array( 'server', 'powered_by', 'title', 'php2shell_recommendation' ) as $key ) { + if ( ! ct_scan_clean_result_line( $result[ $key ], 2048 ) ) { + return new WP_Error( 'result_text_invalid', 'result_text_invalid' ); + } + } + foreach ( array( 'evidence', 'asset_version_hints', 'notes', 'php_version_candidates', 'php_evidence', 'php2shell_cves', 'php2shell_sources', 'php2shell_notes' ) as $key ) { + if ( ! is_array( $result[ $key ] ) || ! ct_scan_is_list( $result[ $key ] ) || count( $result[ $key ] ) > 100 ) { + return new WP_Error( 'result_list_invalid', 'result_list_invalid' ); + } + foreach ( $result[ $key ] as $entry ) { + $limit = in_array( $key, array( 'php_version_candidates' ), true ) ? 128 : 2048; + if ( ! ct_scan_clean_result_line( $entry, $limit ) ) { + return new WP_Error( 'result_list_item_invalid', 'result_list_item_invalid' ); + } + } + } + if ( array_values( $result['php2shell_cves'] ) !== array( 'CVE-2026-109618', 'CVE-2026-109619' ) ) { + return new WP_Error( 'result_cves_invalid', 'result_cves_invalid' ); + } + $encoded = wp_json_encode( $result ); + if ( ! is_string( $encoded ) || strlen( $encoded ) > 32768 ) { + return new WP_Error( 'result_too_large', 'result_too_large' ); + } + return $encoded; +} + +function ct_scan_canonicalize( $value ) { + if ( ! is_array( $value ) ) { + return $value; + } + if ( ct_scan_is_list( $value ) ) { + return array_map( 'ct_scan_canonicalize', $value ); + } + ksort( $value, SORT_STRING ); + foreach ( $value as $key => $item ) { + $value[ $key ] = ct_scan_canonicalize( $item ); + } + return $value; +} + +function ct_scan_result( WP_REST_Request $request ) { + $body = ct_scan_rest_authenticate( $request, '/ct-scan/v1/result' ); + if ( is_wp_error( $body ) ) { + return ct_scan_rest_error( (int) $body->get_error_data()['status'], $body->get_error_code() ); + } + $data = ct_scan_decode_request_body( $body ); + $keys = array( 'id', 'lease', 'scope_hash', 'outcome', 'result', 'error', 'checker_revision' ); + if ( is_wp_error( $data ) || ! ct_scan_expected_keys( $data, $keys ) || ! ct_scan_callback_payload( $data ) + || ! in_array( $data['outcome'], array( 'completed', 'failed' ), true ) || ! is_string( $data['checker_revision'] ) + || ! preg_match( '/\A[a-f0-9]{40}\z/', $data['checker_revision'] ) ) { + return ct_scan_rest_error( 400, 'request_invalid' ); + } + $config = ct_scan_config(); + if ( ! hash_equals( strtolower( $config['expected_checker_revision'] ), strtolower( $data['checker_revision'] ) ) ) { + return ct_scan_rest_error( 422, 'checker_revision_mismatch' ); + } + $encoded_result = null; + if ( 'completed' === $data['outcome'] ) { + if ( ! is_array( $data['result'] ) || '' !== $data['error'] ) { + return ct_scan_rest_error( 400, 'result_invalid' ); + } + } else { + if ( null !== $data['result'] || ! is_string( $data['error'] ) || ! preg_match( '/\A[a-z][a-z0-9_]{0,63}\z/', $data['error'] ) ) { + return ct_scan_rest_error( 400, 'failure_receipt_invalid' ); + } + } + $canonical = wp_json_encode( ct_scan_canonicalize( $data ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + if ( ! is_string( $canonical ) ) { + return ct_scan_rest_error( 400, 'request_invalid' ); + } + $receipt_hash = hash( 'sha256', $canonical ); + if ( ! ct_scan_lock_acquire() ) { + return ct_scan_rest_error( 503, 'queue_busy' ); + } + global $wpdb; + $table = ct_scan_jobs_table(); + try { + ct_scan_maintenance_locked(); + $row = ct_scan_row( $data['id'] ); + if ( ! $row ) { + return ct_scan_rest_error( 410, 'job_not_found' ); + } + if ( in_array( $row->status, array( 'completed', 'failed' ), true ) && ! empty( $row->receipt_hash ) ) { + return hash_equals( $row->receipt_hash, $receipt_hash ) + ? ct_scan_rest_response( 200, array( 'ok' => true ) ) + : ct_scan_rest_error( 409, 'receipt_conflict' ); + } + if ( in_array( $row->status, array( 'cancelled', 'expired', 'failed_unknown' ), true ) ) { + return ct_scan_rest_error( 410, 'job_permanently_rejected' ); + } + if ( 'running' !== $row->status || empty( $row->lease_hash ) || ! hash_equals( $row->lease_hash, hash( 'sha256', $data['lease'] ) ) + || ! hash_equals( $row->scope_hash, $data['scope_hash'] ) || strtotime( $row->lease_until . ' UTC' ) <= time() ) { + return ct_scan_rest_error( 410, 'lease_invalid_or_expired' ); + } + if ( ! ct_scan_authorization_is_live( $row ) ) { + $wpdb->update( $table, array( 'status' => 'cancelled', 'error_code' => 'authorization_changed', 'finished_at' => ct_scan_utc_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' ); + return ct_scan_rest_error( 410, 'authorization_revoked_or_expired' ); + } + if ( 'completed' === $data['outcome'] ) { + $encoded_result = ct_scan_validate_result( $data['result'], $row ); + if ( is_wp_error( $encoded_result ) ) { + return ct_scan_rest_error( 422, $encoded_result->get_error_code() ); + } + } + $state = 'completed' === $data['outcome'] ? 'completed' : 'failed'; + $updated = $wpdb->update( + $table, + array( + 'status' => $state, + 'outcome' => $data['outcome'], + 'error_code' => 'failed' === $data['outcome'] ? $data['error'] : null, + 'checker_revision' => strtolower( $data['checker_revision'] ), + 'result_json' => $encoded_result, + 'receipt_hash' => $receipt_hash, + 'finished_at' => ct_scan_utc_now(), + 'lease_hash' => null, + 'lease_until' => null, + ), + array( 'job_id' => (int) $row->job_id, 'status' => 'running', 'lease_hash' => $row->lease_hash ), + array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ), + array( '%d', '%s', '%s' ) + ); + if ( 1 !== $updated ) { + return ct_scan_rest_error( 409, 'result_rejected' ); + } + ct_scan_mark_request_status( (int) $row->request_id, $state, 'failed' === $state ? $data['error'] : '' ); + return ct_scan_rest_response( 200, array( 'ok' => true ) ); + } finally { + ct_scan_lock_release(); + } +} + +function ct_scan_register_rest_routes() { + register_rest_route( 'ct-scan/v1', '/claim', array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => 'ct_scan_claim', + 'permission_callback' => '__return_true', + ) ); + register_rest_route( 'ct-scan/v1', '/start', array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => 'ct_scan_start', + 'permission_callback' => '__return_true', + ) ); + register_rest_route( 'ct-scan/v1', '/result', array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => 'ct_scan_result', + 'permission_callback' => '__return_true', + ) ); +} +add_action( 'rest_api_init', 'ct_scan_register_rest_routes' ); + +function ct_scan_terminal_status( $status ) { + return in_array( $status, array( 'completed', 'failed', 'failed_unknown', 'cancelled', 'expired' ), true ); +} + +function ct_scan_email_safe_text( $value, $max = 2000 ) { + if ( ! is_string( $value ) ) { + return ''; + } + $value = wp_strip_all_tags( $value ); + $value = preg_replace( '/https?:\/\/[^\s<>"\']+/i', '[URL omitted]', $value ); + $value = preg_replace( '/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]+/', ' ', $value ); + $value = preg_replace( '/[ \t]+/', ' ', $value ); + $value = preg_replace( '/\n{3,}/', "\n\n", $value ); + return substr( trim( $value ), 0, $max ); +} + +function ct_scan_build_report_body( $row ) { + if ( ! is_object( $row ) || ! ct_scan_terminal_status( $row->status ) || 'complete' !== $row->manual_remainder_status || '' === trim( (string) $row->manual_remainder_notes ) ) { + return new WP_Error( 'report_not_ready', 'The scan and manual remainder are not ready for a reviewed report.' ); + } + $host = wp_parse_url( $row->target_origin, PHP_URL_HOST ); + if ( ! is_string( $host ) || '' === $host ) { + return new WP_Error( 'report_target_invalid', 'The saved target could not be verified.' ); + } + $lines = array( + 'PHP2Shell passive screening and manual review', + 'Authorized target host: ' . $host, + 'Terms version: ' . $row->terms_version, + 'Terms accepted (UTC): ' . $row->terms_accepted_at, + '', + 'PHP2Shell passive screening', + ); + if ( 'completed' === $row->status ) { + if ( ! is_string( $row->result_json ) || '' === $row->result_json ) { + return new WP_Error( 'report_result_missing', 'The stored PHP2Shell result is unavailable.' ); + } + $result = json_decode( $row->result_json, true ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + return new WP_Error( 'report_result_invalid', 'The stored PHP2Shell result could not be verified.' ); + } + $validated = ct_scan_validate_result( $result, $row, true ); + if ( is_wp_error( $validated ) ) { + return new WP_Error( 'report_result_invalid', 'The stored PHP2Shell result could not be verified.' ); + } + $labels = array( + 'potentially_affected' => 'Potentially affected: an advertised PHP version is below an upstream fix threshold; an administrator should verify the actual package and vendor backports.', + 'fixed_version_reported' => 'Fixed version reported: the public advertisement meets an upstream threshold; this does not verify the running package or prove safety.', + 'unknown' => 'Unknown: public evidence did not support a reliable version classification.', + ); + $lines[] = 'Screen status: ' . $labels[ $result['php2shell_status'] ]; + $lines[] = 'WordPress detection: ' . $result['status']; + $lines[] = 'Fetch status: ' . $result['fetch_status']; + $lines[] = 'HTTP status: ' . ( null === $result['http_status'] ? 'unavailable' : (string) $result['http_status'] ); + $lines[] = 'Advertised PHP version: ' . ( '' === $result['php_version'] ? 'not disclosed' : ct_scan_email_safe_text( $result['php_version'], 128 ) ); + $lines[] = 'Upstream fix threshold: ' . ( '' === $result['php2shell_fixed_version'] ? 'not available for this advertised version' : $result['php2shell_fixed_version'] ); + $lines[] = 'PHP2Shell rules date: ' . $result['php2shell_rules_as_of']; + $lines[] = 'Exploitability: not tested; no exploit payloads were sent.'; + $lines[] = 'Limit: this passive screen reads public robots policy, page content, and headers. It cannot verify the deployed runtime, vendor backports, or patch state.'; + $evidence = array_slice( $result['php_evidence'], 0, 10 ); + if ( $evidence ) { + $lines[] = 'Observed PHP-related header evidence:'; + foreach ( $evidence as $item ) { + $lines[] = '- ' . ct_scan_email_safe_text( $item, 500 ); + } + } + $notes = array_slice( $result['php2shell_notes'], 0, 10 ); + if ( $notes ) { + $lines[] = 'Interpretation notes:'; + foreach ( $notes as $item ) { + $lines[] = '- ' . ct_scan_email_safe_text( $item, 1000 ); + } + } + } else { + $lines[] = 'No PHP2Shell classification was produced.'; + $lines[] = 'Execution state: ' . $row->status . ( $row->error_code ? ' (' . sanitize_key( $row->error_code ) . ')' : '' ); + $lines[] = 'No rescan was started while preparing this report.'; + } + $lines[] = ''; + $lines[] = 'Other checks in the accepted service terms (manual remainder)'; + $lines[] = ct_scan_email_safe_text( $row->manual_remainder_notes, 12000 ); + $lines[] = ''; + $lines[] = 'This report is limited to the evidence above. It does not establish confirmed exploitation or a verified clean bill of health.'; + return implode( "\n", $lines ) . "\n"; +} + +function ct_scan_admin_revoke( $job_id ) { + if ( ! ct_scan_lock_acquire() ) { + return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); + } + global $wpdb; + $table = ct_scan_jobs_table(); + try { + $row = ct_scan_row( $job_id ); + if ( ! $row || ! empty( $row->report_delivered_at ) ) { + return new WP_Error( 'job_unavailable', 'This job cannot be revoked.' ); + } + $now = ct_scan_utc_now(); + $update = array( 'revoked_at' => $now, 'confirmation_token_hash' => null, 'lease_hash' => null, 'lease_until' => null ); + if ( ! ct_scan_terminal_status( $row->status ) ) { + $update['status'] = 'cancelled'; + $update['error_code'] = 'operator_revoked'; + $update['finished_at'] = $now; + } + $updated = $wpdb->update( $table, $update, array( 'job_id' => (int) $row->job_id, 'status' => $row->status, 'report_delivered_at' => null ), null, array( '%d', '%s', '%s' ) ); + if ( 1 !== $updated ) { + return new WP_Error( 'revoke_write_failed', 'The authorization state could not be durably updated. No success was recorded.' ); + } + ct_scan_mark_request_status( (int) $row->request_id, ct_scan_terminal_status( $row->status ) ? $row->status : 'cancelled', 'operator_revoked' ); + } + finally { + ct_scan_lock_release(); + } + return true; +} + +function ct_scan_admin_save_manual_remainder( $job_id, $notes ) { + if ( ! is_string( $notes ) ) { + return new WP_Error( 'manual_notes_invalid', 'Enter the completed manual review notes.' ); + } + $notes = trim( sanitize_textarea_field( wp_unslash( $notes ) ) ); + if ( '' === $notes || strlen( $notes ) > 12000 ) { + return new WP_Error( 'manual_notes_invalid', 'Enter manual review notes of 1 to 12,000 characters.' ); + } + if ( ! ct_scan_lock_acquire() ) { + return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); + } + global $wpdb; + $table = ct_scan_jobs_table(); + try { + $row = ct_scan_row( $job_id ); + if ( ! $row || ! ct_scan_terminal_status( $row->status ) || ! empty( $row->revoked_at ) || ! empty( $row->report_delivered_at ) + || ! in_array( $row->report_notification_status, array( 'not_sent', 'failed' ), true ) ) { + return new WP_Error( 'manual_review_not_ready', 'Manual completion is available only after the scan is terminal and before revocation or delivery.' ); + } + $updated = $wpdb->update( $table, array( 'manual_remainder_status' => 'complete', 'manual_remainder_notes' => $notes, 'manual_remainder_completed_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $row->job_id, 'report_notification_status' => $row->report_notification_status, 'report_delivered_at' => null, 'revoked_at' => null ), array( '%s', '%s', '%s' ), array( '%d', '%s', '%s', '%s' ) ); + if ( 1 !== $updated ) { + return new WP_Error( 'manual_review_write_failed', 'The manual review could not be durably saved.' ); + } + } + finally { + ct_scan_lock_release(); + } + return true; +} + +function ct_scan_admin_send_report( $job_id ) { + if ( ! ct_scan_lock_acquire() ) { + return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); + } + global $wpdb; + $table = ct_scan_jobs_table(); + try { + $row = ct_scan_row( $job_id ); + if ( ! $row || ! empty( $row->revoked_at ) || ! empty( $row->report_delivered_at ) + || ! in_array( $row->report_notification_status, array( 'not_sent', 'failed' ), true ) ) { + return new WP_Error( 'report_send_unavailable', 'This report cannot be sent in its current state.' ); + } + if ( ! ct_scan_terminal_status( $row->status ) || 'complete' !== $row->manual_remainder_status + || '' === trim( (string) $row->manual_remainder_notes ) ) { + return new WP_Error( 'report_not_ready', 'Complete and review the manual remainder before sending.' ); + } + if ( ! is_email( $row->contact_email ) + || ct_scan_meta_string( (int) $row->request_id, '_ct_scan_email' ) !== $row->contact_email + || ct_scan_meta_string( (int) $row->request_id, '_ct_terms_version' ) !== $row->terms_version + || ct_scan_meta_string( (int) $row->request_id, '_ct_terms_sha256' ) !== $row->terms_hash ) { + return new WP_Error( 'report_contact_changed', 'The original authorized report contact could not be verified.' ); + } + $body = ct_scan_build_report_body( $row ); + if ( is_wp_error( $body ) ) { + return $body; + } + $now = ct_scan_utc_now(); + $updated = $wpdb->update( + $table, + array( 'report_notification_status' => 'unknown', 'report_notification_at' => $now ), + array( 'job_id' => (int) $row->job_id, 'report_notification_status' => $row->report_notification_status, 'report_delivered_at' => null, 'revoked_at' => null ), + array( '%s', '%s' ), + array( '%d', '%s', '%s', '%s' ) + ); + if ( 1 !== $updated ) { + return new WP_Error( 'report_state_write_failed', 'The one-time send state could not be durably recorded. No email was sent.' ); + } + update_post_meta( (int) $row->request_id, '_ct_scan_report_notification_status', 'unknown' ); + $host = wp_parse_url( $row->target_origin, PHP_URL_HOST ); + $status = 'unknown'; + try { + if ( wp_mail( $row->contact_email, 'Reviewed website screening report for ' . $host, $body ) ) { + $status = 'accepted'; + } + } catch ( Throwable $error ) { + $status = 'unknown'; + } + $updated = $wpdb->update( $table, array( 'report_notification_status' => $status, 'report_notification_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $row->job_id, 'report_notification_status' => 'unknown', 'report_delivered_at' => null ), array( '%s', '%s' ), array( '%d', '%s', '%s' ) ); + if ( 1 !== $updated ) { + return new WP_Error( 'report_state_write_failed', 'The email outcome could not be durably recorded. Its status remains unknown; do not resend.' ); + } + update_post_meta( (int) $row->request_id, '_ct_scan_report_notification_status', $status ); + } + finally { + ct_scan_lock_release(); + } + return 'accepted' === $status ? true : new WP_Error( 'report_transport_unknown', 'The mail transport did not confirm acceptance. Check delivery logs before attempting another send.' ); +} + +function ct_scan_admin_record_delivery( $job_id ) { + if ( ! ct_scan_lock_acquire() ) { + return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); + } + global $wpdb; + $table = ct_scan_jobs_table(); + try { + $row = ct_scan_row( $job_id ); + if ( ! $row || ! in_array( $row->report_notification_status, array( 'accepted', 'unknown' ), true ) || ! empty( $row->report_delivered_at ) ) { + return new WP_Error( 'report_delivery_unverified', 'Record delivery only after independently confirming that the full report reached the saved contact.' ); + } + $now = ct_scan_utc_now(); + $updated = $wpdb->update( $table, array( 'report_delivered_at' => $now ), array( 'job_id' => (int) $row->job_id, 'report_notification_status' => $row->report_notification_status, 'report_delivered_at' => null ), array( '%s' ), array( '%d', '%s', '%s' ) ); + if ( 1 !== $updated ) { + return new WP_Error( 'report_delivery_write_failed', 'The delivery confirmation could not be durably saved.' ); + } + $cancelled = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = 'cancelled', error_code = 'report_delivered', finished_at = %s, lease_hash = NULL, lease_until = NULL, confirmation_token_hash = NULL WHERE job_id = %d AND status IN ('awaiting_confirmation','queued','leased','running')", $now, (int) $row->job_id ) ); + if ( false === $cancelled ) { + return new WP_Error( 'report_delivery_write_failed', 'Delivery was saved, but follow-up scan cancellation could not be confirmed.' ); + } + update_post_meta( (int) $row->request_id, '_ct_scan_report_delivered_at', $now ); + ct_scan_mark_request_status( (int) $row->request_id, ct_scan_terminal_status( $row->status ) ? $row->status : 'cancelled', 'report_delivered' ); + } + finally { + ct_scan_lock_release(); + } + return true; +} + +function ct_scan_admin_action_handler() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You are not allowed to manage scan requests.', 'ct-scan' ), '', array( 'response' => 403 ) ); + } + $job_id = isset( $_POST['job_id'] ) ? absint( wp_unslash( $_POST['job_id'] ) ) : 0; + $action = isset( $_POST['ct_scan_operation'] ) && is_string( $_POST['ct_scan_operation'] ) ? sanitize_key( wp_unslash( $_POST['ct_scan_operation'] ) ) : ''; + if ( ! $job_id || ! in_array( $action, array( 'revoke', 'manual_complete', 'send_report', 'record_delivery' ), true ) ) { + wp_die( esc_html__( 'The requested action is invalid.', 'ct-scan' ), '', array( 'response' => 400 ) ); + } + check_admin_referer( 'ct_scan_admin_' . $action . '_' . $job_id ); + $confirmations = array( + 'revoke' => 'ct_scan_revoke_confirm', + 'manual_complete' => 'ct_scan_review_confirm', + 'send_report' => 'ct_scan_send_confirm', + 'record_delivery' => 'ct_scan_delivery_confirm', + ); + $confirm_key = $confirmations[ $action ]; + if ( ! isset( $_POST[ $confirm_key ] ) || ! is_string( $_POST[ $confirm_key ] ) || '1' !== wp_unslash( $_POST[ $confirm_key ] ) ) { + wp_die( esc_html__( 'Confirm this action before continuing.', 'ct-scan' ), '', array( 'response' => 400 ) ); + } + if ( 'revoke' === $action ) { + $result = ct_scan_admin_revoke( $job_id ); + } elseif ( 'manual_complete' === $action ) { + $result = ct_scan_admin_save_manual_remainder( $job_id, isset( $_POST['manual_remainder_notes'] ) && is_string( $_POST['manual_remainder_notes'] ) ? $_POST['manual_remainder_notes'] : '' ); + } elseif ( 'send_report' === $action ) { + $result = ct_scan_admin_send_report( $job_id ); + } else { + $result = ct_scan_admin_record_delivery( $job_id ); + } + $notice = is_wp_error( $result ) ? $result->get_error_code() : 'updated'; + $url = add_query_arg( array( 'page' => 'ct-scan-queue', 'ct_scan_notice' => sanitize_key( $notice ) ), admin_url( 'edit.php?post_type=ct_promo_request' ) ); + wp_safe_redirect( $url ); + exit; +} +add_action( 'admin_post_ct_scan_admin_action', 'ct_scan_admin_action_handler' ); + +function ct_scan_admin_form( $job_id, $operation, $label, $extra = '' ) { + $nonce = wp_nonce_field( 'ct_scan_admin_' . $operation . '_' . $job_id, '_wpnonce', true, false ); + return '
' . $nonce . $extra . '
'; +} + +function ct_scan_admin_result_details( $row ) { + if ( ! is_object( $row ) || 'completed' !== $row->status || ! is_string( $row->result_json ) || '' === $row->result_json ) { + return ''; + } + $result = json_decode( $row->result_json, true ); + if ( JSON_ERROR_NONE !== json_last_error() || is_wp_error( ct_scan_validate_result( $result, $row, true ) ) ) { + return '

Stored result failed its integrity and schema check.

'; + } + $lines = array( + 'PHP2Shell status: ' . $result['php2shell_status'], + 'Advertised PHP version: ' . ( '' === $result['php_version'] ? 'not disclosed' : $result['php_version'] ), + 'Upstream fix threshold: ' . ( '' === $result['php2shell_fixed_version'] ? 'not available' : $result['php2shell_fixed_version'] ), + 'Rules date: ' . $result['php2shell_rules_as_of'], + 'Exploitability: not tested; no exploit payloads were sent.', + ); + $html = '
Private stored PHP2Shell evidence and summary
' . esc_html( implode( "\n", $lines ) ) . '
'; + foreach ( array( 'php_evidence' => 'Observed PHP evidence', 'php2shell_notes' => 'Assessment notes', 'php2shell_sources' => 'Assessment sources' ) as $key => $label ) { + if ( empty( $result[ $key ] ) ) { + continue; + } + $html .= '

' . esc_html( $label ) . '

'; + } + if ( '' !== $result['php2shell_recommendation'] ) { + $html .= '

Recommendation: ' . esc_html( $result['php2shell_recommendation'] ) . '

'; + } + return $html . '
'; +} + +function ct_scan_admin_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + ct_scan_maintain(); + global $wpdb; + $table = ct_scan_jobs_table(); + $rows = $wpdb->get_results( "SELECT * FROM {$table} ORDER BY created_at DESC, job_id DESC LIMIT 100" ); + $config = ct_scan_config(); + echo '

PHP2Shell screening queue

'; + echo '

Worker API: ' . ( ct_scan_enabled() ? 'enabled' : 'disabled' ) . '. This page never displays the worker secret. The screening covers public passive evidence only; broader checks remain manual.

'; + if ( isset( $_GET['ct_scan_notice'] ) && is_string( $_GET['ct_scan_notice'] ) ) { + echo '

' . esc_html( sanitize_key( wp_unslash( $_GET['ct_scan_notice'] ) ) ) . '

'; + } + if ( empty( $rows ) ) { + echo '

No screening jobs are stored.

'; + return; + } + echo ''; + foreach ( $rows as $row ) { + $request_url = admin_url( 'post.php?post=' . (int) $row->request_id . '&action=edit' ); + $host = wp_parse_url( $row->target_origin, PHP_URL_HOST ); + $exec = 'Status: ' . $row->status . "\nAttempts: " . (int) $row->attempts . "\nOutcome: " . ( $row->outcome ? $row->outcome : 'pending' ) . "\nError: " . ( $row->error_code ? sanitize_key( $row->error_code ) : 'โ€”' ) . "\nChecker revision: " . ( $row->checker_revision ? substr( $row->checker_revision, 0, 12 ) . 'โ€ฆ' : 'โ€”' ); + $mail = 'Confirmation email: ' . sanitize_key( $row->confirmation_email_status ) . "\nReport transport: " . sanitize_key( $row->report_notification_status ) . "\nDelivery confirmed: " . ( $row->report_delivered_at ? $row->report_delivered_at . ' UTC' : 'no' ) . "\nRevoked: " . ( $row->revoked_at ? $row->revoked_at . ' UTC' : 'no' ); + echo ''; + echo ''; + echo ''; + } + echo '
Job / requestAuthorized scope and termsExecutionConfirmation / reportManual remainder and actions
Job #' . (int) $row->job_id . ' / request #' . (int) $row->request_id . '
Created ' . esc_html( $row->created_at ) . ' UTC
' . esc_html( $row->target_origin ) . '
Terms ' . esc_html( $row->terms_version ) . '
Accepted ' . esc_html( $row->terms_accepted_at ) . ' UTC
Expires ' . esc_html( $row->authorization_expires_at ) . ' UTC
Scope hash ' . esc_html( substr( $row->scope_hash, 0, 16 ) ) . 'โ€ฆ
' . esc_html( $exec ) . '
' . ct_scan_admin_result_details( $row ) . '
' . esc_html( $mail ) . '
'; + if ( ! empty( $row->manual_remainder_notes ) ) { + echo '
Manual remainder (' . esc_html( sanitize_key( $row->manual_remainder_status ) ) . ')
' . esc_html( $row->manual_remainder_notes ) . '
'; + } + if ( ct_scan_terminal_status( $row->status ) && empty( $row->revoked_at ) && empty( $row->report_delivered_at ) ) { + $notes = esc_textarea( (string) $row->manual_remainder_notes ); + $extra = '

'; + echo ct_scan_admin_form( (int) $row->job_id, 'manual_complete', 'Save manual remainder', $extra ); + $preview = ct_scan_build_report_body( $row ); + if ( ! is_wp_error( $preview ) ) { + echo '
Reviewed customer report preview
' . esc_html( $preview ) . '
'; + if ( 'not_sent' === $row->report_notification_status || 'failed' === $row->report_notification_status ) { + echo ct_scan_admin_form( (int) $row->job_id, 'send_report', 'Send reviewed report to stored contact', ' ' ); + } + } + if ( in_array( $row->report_notification_status, array( 'accepted', 'unknown' ), true ) && empty( $row->report_delivered_at ) ) { + echo ct_scan_admin_form( (int) $row->job_id, 'record_delivery', 'Record confirmed delivery', ' ' ); + } + } + if ( empty( $row->revoked_at ) && empty( $row->report_delivered_at ) ) { + echo ct_scan_admin_form( (int) $row->job_id, 'revoke', 'Revoke authorization', ' ' ); + } + echo '
'; +} + +function ct_scan_register_admin_menu() { + add_submenu_page( 'edit.php?post_type=ct_promo_request', 'PHP2Shell screening queue', 'PHP2Shell queue', 'manage_options', 'ct-scan-queue', 'ct_scan_admin_page' ); +} +add_action( 'admin_menu', 'ct_scan_register_admin_menu', 30 ); + +function ct_scan_add_request_meta_box() { + add_meta_box( 'ct-scan-request-status', 'PHP2Shell screening', 'ct_scan_request_meta_box', 'ct_promo_request', 'side', 'high' ); +} +add_action( 'add_meta_boxes_ct_promo_request', 'ct_scan_add_request_meta_box' ); + +function ct_scan_request_meta_box( $post ) { + $job_id = absint( get_post_meta( $post->ID, '_ct_scan_job_id', true ) ); + $row = $job_id ? ct_scan_row( $job_id ) : null; + if ( ! $row ) { + echo '

No scan job is linked to this request.

'; + return; + } + echo '

Status: ' . esc_html( $row->status ) . '

Target: ' . esc_html( $row->target_origin ) . '

Terms: ' . esc_html( $row->terms_version ) . ' / ' . esc_html( substr( $row->terms_hash, 0, 16 ) ) . 'โ€ฆ

Confirmation email: ' . esc_html( $row->confirmation_email_status ) . '

Attempts: ' . (int) $row->attempts . ' ยท report transport: ' . esc_html( $row->report_notification_status ) . '

Open private queue

'; +} diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php new file mode 100644 index 0000000..acaa9c0 --- /dev/null +++ b/integrations/wordpress/tests/integration.php @@ -0,0 +1,278 @@ + true, + 'supported_terms_version' => '2026-10-21-r2', + 'expected_checker_revision' => str_repeat( 'a', 40 ), + 'expected_rules_as_of' => '2026-10-21', + 'worker_hmac_secret' => str_repeat( 'fixture-key-', 4 ), + 'authorization_end_at' => gmdate( 'Y-m-d\TH:i:s\Z', time() + 30 * 86400 ), + 'confirmation_ttl_hours' => 24, + 'global_daily_limit' => 1000, + 'target_daily_limit' => 100, +), true ) . ';' ); +define( 'CT_SCAN_PRIVATE_CONFIG', $private_config ); +register_shutdown_function( static function () use ( $private_config ) { @unlink( $private_config ); } ); + +$_SERVER['HTTP_HOST'] = 'wordpress'; +require '/var/www/html/wp-load.php'; +ct_scan_maybe_install_schema(); + +function ct_test_assert( $condition, $message ) { + if ( ! $condition ) { + throw new RuntimeException( $message ); + } +} + +function ct_test_request( $host, $email = 'fixture@example.com' ) { + $target = 'https://' . $host . '/'; + $contract = "Client contact: Fixture Reviewer\nClient email: {$email}\nWebsite (scan scope): {$target}\nReferred by: direct\nTerms version: 2026-10-21-r2\nAccepted test fixture terms."; + $id = wp_insert_post( array( + 'post_type' => 'ct_promo_request', + 'post_status' => 'private', + 'post_title' => 'Integration fixture request', + 'post_content' => "TERMS AS ACCEPTED\n\n" . $contract, + ), true ); + if ( is_wp_error( $id ) ) { + throw new RuntimeException( $id->get_error_message() ); + } + $meta = array( + '_ct_scan_target' => $target, + '_ct_scan_email' => $email, + '_ct_scan_name' => 'Fixture Reviewer', + '_ct_ref' => 'direct', + '_ct_terms_saved' => '1', + '_ct_terms_version' => '2026-10-21-r2', + '_ct_terms_url' => 'https://complete.tech/php2shell-scan-terms/', + '_ct_terms_sha256' => hash( 'sha256', 'Accepted test fixture terms.' ), + '_ct_terms_accepted_at' => gmdate( 'Y-m-d\TH:i:s\Z', time() - 30 ), + '_ct_terms_contract' => $contract, + ); + foreach ( $meta as $key => $value ) { + update_post_meta( $id, $key, $value ); + } + return array( (int) $id, array( 'target' => $target, 'email' => $email, 'name' => 'Fixture Reviewer', 'referrer' => 'direct' ) ); +} + +function ct_test_rest_call( $callback, $route, $payload, $nonce = null, $timestamp = null ) { + $body = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + $timestamp = null === $timestamp ? (string) time() : (string) $timestamp; + $nonce = null === $nonce ? bin2hex( random_bytes( 16 ) ) : $nonce; + $signing = $timestamp . "\n" . strtolower( $nonce ) . "\nPOST\n" . $route . "\n" . hash( 'sha256', $body ); + $request = new WP_REST_Request( 'POST', $route ); + $request->set_body( $body ); + $request->set_header( 'X-CT-Timestamp', $timestamp ); + $request->set_header( 'X-CT-Nonce', $nonce ); + $request->set_header( 'X-CT-Signature', hash_hmac( 'sha256', $signing, ct_scan_config()['worker_hmac_secret'] ) ); + return call_user_func( $callback, $request ); +} + +function ct_test_result( $target ) { + return array( + 'input_url' => $target, 'final_url' => $target, 'status' => 'wordpress', 'fetch_status' => 'ok', 'http_status' => 200, + 'wordpress_version' => '6.8', 'php_version' => '8.2.1', 'server' => 'fixture', 'powered_by' => 'PHP/8.2.1', 'title' => 'Fixture', + 'evidence' => array( 'generator present' ), 'asset_version_hints' => array(), 'notes' => array(), 'checked_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), + 'php_version_candidates' => array( '8.2.1' ), 'php_evidence' => array( 'X-Powered-By: PHP/8.2.1 ' ), + 'php2shell_status' => 'potentially_affected', 'php2shell_version_status' => 'below_upstream_fix', 'php2shell_fixed_version' => '8.2.31', + 'php2shell_exploitability' => 'not_tested', 'php2shell_cves' => array( 'CVE-2026-109618', 'CVE-2026-109619' ), + 'php2shell_rules_as_of' => '2026-10-21', 'php2shell_sources' => array( 'https://www.php.net/' ), 'php2shell_notes' => array( 'Public evidence only.' ), + 'php2shell_recommendation' => 'Verify the deployed package and vendor backports.', + ); +} + +function ct_test_queue_job( $job_id ) { + global $wpdb; + $wpdb->update( ct_scan_jobs_table(), array( 'status' => 'queued', 'confirmation_token_hash' => null, 'confirmation_expires_at' => null, 'confirmed_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $job_id ) ); +} + +// Canonical scope normalization and control-character rejection. +ct_test_assert( 'https://example.com/' === ct_scan_normalize_target( 'https://EXAMPLE.com/' ), 'Canonical origin must have lowercase host and trailing slash.' ); +foreach ( array( "https://example.com/\n", 'https://example.com/path', 'https://127.0.0.1/', 'https://user@example.com/', 'https://example.com:8443/' ) as $bad_target ) { + ct_test_assert( is_wp_error( ct_scan_normalize_target( $bad_target ) ), 'Invalid target accepted: ' . bin2hex( $bad_target ) ); +} + +global $wpdb; +$jobs = ct_scan_jobs_table(); +$old_fixture_ids = $wpdb->get_col( $wpdb->prepare( "SELECT request_id FROM {$jobs} WHERE contact_name = %s", 'Fixture Reviewer' ) ); +foreach ( (array) $old_fixture_ids as $old_id ) { + $old_post = get_post( (int) $old_id ); + if ( $old_post && 'ct_promo_request' === $old_post->post_type && 'private' === $old_post->post_status && 'Integration fixture request' === $old_post->post_title ) { + $wpdb->delete( $jobs, array( 'request_id' => (int) $old_id ), array( '%d' ) ); + wp_delete_post( (int) $old_id, true ); + } +} +$request_ids = array(); +$mail_calls = array(); +$mail_mode = true; +add_filter( 'pre_wp_mail', static function ( $pre, $atts ) use ( &$mail_calls, &$mail_mode ) { + $mail_calls[] = $atts; + return $mail_mode; +}, 10, 2 ); + +// A new accepted request creates one confirmation-gated immutable job. +list( $request_id, $fields ) = ct_test_request( 'queue-fixture.example.net' ); +$request_ids[] = $request_id; +$event = ct_scan_on_request_ready( $request_id, $fields ); +ct_test_assert( is_array( $event ) && 'awaiting_confirmation' === $event['status'], 'Accepted request was not queued for confirmation.' ); +ct_test_assert( 1 === (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$jobs} WHERE request_id=%d", $request_id ) ), 'Expected exactly one durable job.' ); +$row = ct_scan_row( $event['job_id'] ); +ct_test_assert( hash( 'sha256', $fields['target'] ) === $row->scope_hash, 'Scope hash must cover the exact canonical target.' ); +ct_test_assert( 'accepted' === $row->confirmation_email_status, 'Confirmation mail acceptance state was not recorded.' ); +ct_test_assert( false !== strpos( $mail_calls[0]['message'], 'token=' ), 'Confirmation link missing from the captured fixture email.' ); + +// Duplicate hook delivery is database-idempotent even when the event meta is absent. +delete_post_meta( $request_id, '_ct_scan_event_recorded' ); +$duplicate = ct_scan_on_request_ready( $request_id, $fields ); +ct_test_assert( ! is_wp_error( $duplicate ) && ! empty( $duplicate['duplicate'] ) && (int) $duplicate['job_id'] === (int) $event['job_id'], 'Duplicate event created a second job or changed state.' ); + +// Confirmed queue: claim, start-before-work, validated result, and exact receipt retry. +ct_test_queue_job( $event['job_id'] ); +$claim = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() ); +ct_test_assert( 200 === $claim->get_status() && is_array( $claim->get_data()['job'] ), 'Signed claim did not return a job.' ); +$claimed = $claim->get_data()['job']; +ct_test_assert( $claimed['target'] === $fields['target'] && is_int( $claimed['expires_at'] ) && is_int( $claimed['lease_until'] ), 'Claim fields or Unix timestamps are misaligned.' ); +$start_payload = array( 'id' => $claimed['id'], 'lease' => $claimed['lease'], 'scope_hash' => $claimed['scope_hash'] ); +$start = ct_test_rest_call( 'ct_scan_start', '/ct-scan/v1/start', $start_payload ); +ct_test_assert( 200 === $start->get_status() && true === $start->get_data()['ok'], 'Start callback was not acknowledged.' ); +$run = array( + 'id' => $claimed['id'], 'lease' => $claimed['lease'], 'scope_hash' => $claimed['scope_hash'], 'outcome' => 'completed', + 'result' => ct_test_result( $fields['target'] ), 'error' => '', 'checker_revision' => str_repeat( 'a', 40 ), +); +$result = ct_test_rest_call( 'ct_scan_result', '/ct-scan/v1/result', $run ); +ct_test_assert( 200 === $result->get_status() && true === $result->get_data()['ok'], 'Valid checker receipt was not acknowledged.' ); +$retry = ct_test_rest_call( 'ct_scan_result', '/ct-scan/v1/result', $run ); +ct_test_assert( 200 === $retry->get_status(), 'An exact receipt retry must be idempotent.' ); +$changed = $run; +$changed['result']['title'] = 'Different valid receipt'; +$conflict = ct_test_rest_call( 'ct_scan_result', '/ct-scan/v1/result', $changed ); +ct_test_assert( 409 === $conflict->get_status(), 'Changed receipt must conflict.' ); +$stale = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array(), null, time() - 301 ); +ct_test_assert( 401 === $stale->get_status(), 'Stale HMAC timestamp must be rejected.' ); +$replay_nonce = bin2hex( random_bytes( 16 ) ); +$replay1 = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array(), $replay_nonce ); +$replay2 = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array(), $replay_nonce ); +ct_test_assert( 200 === $replay1->get_status() && 409 === $replay2->get_status(), 'Authenticated nonce replay was not rejected.' ); + +// Reports preserve old result snapshots, escape evidence, freeze after first send, and separate acceptance from delivery. +$stored = ct_scan_row( $event['job_id'] ); +$snapshot = ct_test_result( $fields['target'] ); +$snapshot['php2shell_rules_as_of'] = '2024-02-29'; +$wpdb->update( $jobs, array( 'result_json' => wp_json_encode( $snapshot ) ), array( 'job_id' => (int) $event['job_id'] ) ); +$stored = ct_scan_row( $event['job_id'] ); +ct_test_assert( is_wp_error( ct_scan_validate_result( $snapshot, $stored ) ), 'New receipts must match current configured rules date.' ); +ct_test_assert( ! is_wp_error( ct_scan_validate_result( $snapshot, $stored, true ) ), 'Stored report snapshot must remain valid after rules-date changes.' ); +$admin_details = ct_scan_admin_result_details( $stored ); +ct_test_assert( false !== strpos( $admin_details, 'Private stored PHP2Shell evidence' ) && false === strpos( $admin_details, '