Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
*
!wordpress_check.py
!LICENSE
!LICENSING.md
!integrations/
!integrations/__init__.py
!integrations/worker/
!integrations/worker/*.py
!deployment/
!deployment/Dockerfile
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 37 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ results*.json
websites.txt
.DS_Store
Thumbs.db
.work/
artifacts/
deployment/private/
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions deployment/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
18 changes: 18 additions & 0 deletions deployment/complete-tech-site-manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
137 changes: 137 additions & 0 deletions deployment/complete-tech-site.patch
Original file line number Diff line number Diff line change
@@ -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 @@
<span class="eyebrow service-brief-offer-head">Limited offer &middot; through Nov 20, 2026</span>
<h2>Request your free PHP2Shell scan</h2>
<?php if ( 'sent' === $promo_state ) : ?>
- <p class="service-brief-form-note" role="status">Thanks &mdash; your request is in. We&rsquo;ll be in touch within one business day.</p>
+ <p class="service-brief-form-note" role="status">Thanks &mdash; your request is saved. Watch for a separate email to confirm authorization before scanning starts. We&rsquo;ll be in touch within one business day.</p>
<?php else : ?>
<?php if ( 'error' === $promo_state ) : ?>
- <p class="service-brief-form-note is-error" role="alert">Please fill in your name, a valid email, and your website, then try again.</p>
+ <p class="service-brief-form-note is-error" role="alert">Please enter your name, a valid email, and your website homepage (for example, https://example.com/), then try again.</p>
<?php elseif ( 'terms' === $promo_state ) : ?>
<p class="service-brief-form-note is-error" role="alert">Please read and agree to the scan terms, then try again.</p>
<?php endif; ?>
@@ -174,7 +174,7 @@
<?php wp_nonce_field( 'ct_promo_request', 'ct_promo_nonce' ); ?>
<label>Name <input type="text" name="ct_name" autocomplete="name" required></label>
<label>Email <input type="email" name="ct_email" autocomplete="email" required></label>
- <label>Website <input type="url" name="ct_website" placeholder="https://" autocomplete="url" required></label>
+ <label>Website <input type="url" name="ct_website" placeholder="https://example.com/" autocomplete="url" required></label>
<label>Phone (optional) <input type="tel" name="ct_phone" autocomplete="tel"></label>
<label class="service-brief-form-hp" aria-hidden="true">Leave blank <input type="text" name="ct_hp" tabindex="-1" autocomplete="off"></label>
<?php if ( function_exists( 'ct_promo_terms' ) ) : $ct_terms = ct_promo_terms(); ?>
--- a/content/php2shell-scan-terms.html
+++ b/content/php2shell-scan-terms.html
@@ -1,5 +1,5 @@
<!-- wp:paragraph -->
-<p><strong>Version 2026-10-21</strong> &middot; Effective October 21, 2026 &middot; Offer ends November 20, 2026</p>
+<p><strong>Version 2026-10-21-r2</strong> &middot; Effective October 21, 2026 &middot; Offer ends November 20, 2026</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
@@ -12,6 +12,10 @@

<!-- wp:paragraph -->
<p>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.</p>
+<!-- /wp:paragraph -->
+
+<!-- wp:paragraph -->
+<p>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.</p>
<!-- /wp:paragraph -->

<!-- wp:heading {"level":3} -->
@@ -38,6 +42,10 @@

<!-- wp:paragraph -->
<p>Checks run at a low request rate from identified Complete Tech systems and are designed not to affect your site's availability.</p>
+<!-- /wp:paragraph -->
+
+<!-- wp:paragraph -->
+<p>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.</p>
<!-- /wp:paragraph -->

<!-- wp:heading {"level":3} -->
45 changes: 45 additions & 0 deletions deployment/compose.test.yaml
Original file line number Diff line number Diff line change
@@ -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}
49 changes: 49 additions & 0 deletions deployment/compose.yaml
Original file line number Diff line number Diff line change
@@ -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}
Loading
Loading