Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
14 changes: 14 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ jobs:
- name: Check CLI help
run: python wordpress_check.py --help

wordpress-standalone:
name: WordPress lifecycle unit checks
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Test report retrieval, trusted closure and fail-closed retention
run: >-
docker run --rm --network none -v "$PWD:/work:ro" -w /work
php:8.3-cli@sha256:aafe21201943a8a6e497ddfb471e2ce68ada76adede38c7d61fede8918b24319
sh -c 'set -e; php -d zend.assertions=1 integrations/wordpress/standalone-tests/report-access.php; php -d zend.assertions=1 integrations/wordpress/standalone-tests/retention.php integrations/wordpress/ct-scan.php; php -d zend.assertions=1 integrations/wordpress/standalone-tests/tombstones.php integrations/wordpress/ct-scan.php; php -d zend.assertions=1 integrations/wordpress/standalone-tests/manual-lifecycle.php'

wordpress:
name: WordPress queue and authorization
runs-on: ubuntu-latest
Expand Down
29 changes: 27 additions & 2 deletions deployment/test-http-fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
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');
$fields = array('target' => 'https://http-fixture.example.com/', 'email' => 'fixture@example.com', 'name' => 'HTTP Fixture', 'legal_name' => 'HTTP Fixture LLC', 'signer_role' => 'Owner', 'signature' => '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]); }
Expand All @@ -43,7 +43,32 @@
}
if ($strict) {
// Strict policy: the acceptance is bound to the exact terms and Target Summary the signer was shown.
$summary = ct_scan_target_summary($fields['target'], $fields['name'], $fields['email']);
$summary = ct_scan_target_summary($fields['target'], $fields['legal_name'], $fields['name'], $fields['signer_role'], $fields['email']);
$statement = 'I authorize the described fixture scope.';
$authority = 'Client legal name: ' . $fields['legal_name'] . "\n"
. 'Signer full name: ' . $fields['name'] . "\n"
. 'Signer role (stated): ' . $fields['signer_role'] . "\n"
. 'Typed signature: ' . $fields['signature'] . "\n";
$authority_hash = hash('sha256', $authority);
$evidence = "Terms version: 2026-10-21-r2\n"
. 'Terms SHA-256: ' . hash('sha256', $snapshot) . "\n"
. 'Target Summary SHA-256: ' . hash('sha256', $summary) . "\n"
. 'Signer evidence SHA-256: ' . $authority_hash . "\n"
. 'Acceptance statement SHA-256: ' . hash('sha256', $statement) . "\n"
. 'Accepted at (UTC): ' . get_post_meta($id, '_ct_terms_accepted_at', true) . "\n";
$evidence_hash = hash('sha256', $evidence);
$contract = 'Client legal name: ' . $fields['legal_name'] . "\n"
. 'Client contact: ' . $fields['name'] . "\n"
. 'Signer role (stated): ' . $fields['signer_role'] . "\n"
. 'Typed signature: ' . $fields['signature'] . "\n"
. 'Client email: ' . $fields['email'] . "\n"
. 'Website (scan scope): ' . $fields['target'] . "\nReferred by: direct\n"
. 'Signer evidence SHA-256: ' . $authority_hash . "\n"
. 'Acceptance evidence SHA-256: ' . $evidence_hash . "\nTERMS AS ACCEPTED\n" . $snapshot;
foreach (array('client_legal_name' => $fields['legal_name'], 'signer_name' => $fields['name'], 'signer_role' => $fields['signer_role'], 'typed_signature' => $fields['signature'], 'authority_sha256' => $authority_hash, 'evidence_sha256' => $evidence_hash, 'statement' => $statement, 'contract' => $contract) as $key => $value) {
update_post_meta($id, '_ct_terms_' . $key, wp_slash($value));
}
wp_update_post(wp_slash(array('ID' => $id, 'post_content' => $contract)));
foreach (array('binding' => '1', 'snapshot' => $snapshot) as $key => $value) { update_post_meta($id, '_ct_terms_' . $key, wp_slash($value)); }
update_post_meta($id, '_ct_terms_shown_digest', hash('sha256', $snapshot));
update_post_meta($id, '_ct_summary_text', wp_slash($summary));
Expand Down
77 changes: 65 additions & 12 deletions docs/WORDPRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ carried. The site recorder writes these post meta keys:

The Summary must be exactly the canonical text from `ct_scan_target_summary()`
(LF terminated, in this order, nothing before, between or after): the website,
`Client contact`, `Client email`, `Profile`, a `Limits` line (this website only,
`Client legal name`, `Signer`, `Signer role`, `Client email`, `Profile`, a `Limits` line (this website only,
HTTP 80 / HTTPS 443, at most N requests, one at a time, at most one per second,
10 seconds each, from the profile's code-defined limits) and an `Authorization`
line (60 days after acceptance, never after the published window end, or when
Expand Down Expand Up @@ -481,7 +481,9 @@ table (existing rows default to `r2-header-v1` with empty counters); schema 4
adds `summary_sha256`, `bound_required` (the job's frozen evidence requirement;
existing rows default to `0`), `accepted_copy_status` and `accepted_copy_at`; schema 7
adds the closure, retention, hold and identity columns and the tombstones table
(and re-checks `control_admitted`). Upgrades
(and re-checks `control_admitted`). Schema 8 adds one-time report capability,
notification status and explicit closure source/actor/reason fields. Legacy
maintenance timestamps do not become trusted closure events during upgrade. Upgrades
run on `init` and the worker API refuses requests until they complete.

The healthcheck requires a successful authenticated exchange in the last ten
Expand Down Expand Up @@ -521,15 +523,17 @@ Retention follows the owner's decisions of 2026-10-22, implemented in
maintenance (which runs on worker polls that hand out no job and on the admin
queue page):

- **Closure is explicit.** The first time a job is seen in a terminal state
- **Closure is a separate trusted event.** A terminal scanner status
(`completed`, `failed`, `failed_unknown`, `cancelled`, `expired`, `blocked`)
it is stamped `closed_at`; an accepted request that was never confirmed or
never showed target control closes when it expires. A job that can still run
(including one in `awaiting_control`, with a control check pending or leased)
is never closed or deleted for age, however old its dates say it is; if one is ever
reopened, its closure stamp is cleared.
records that execution stopped. It does not close the request. An authenticated
administrator may explicitly finalize a terminal no-report request with its
exact saved status as the reason and a positive actor ID. A successful one-time
report POST records the delivery event, actor `0`, and the same server timestamp
for access, delivery, and final closure. Missing fields, old maintenance-only
`closed_at`, and ambiguous pre-v8 report-mail state stay pending and are kept.
- **Findings** (`result_json`, manual remainder notes): cleared twelve months
(366 days, so a leap day cannot make it early) after closure. The signed
(366 days, so a leap day cannot make it early) after trusted report retrieval,
or an authenticated no-report final close. The signed
evidence stays.
- **Signed authorization evidence** (the job row and its linked private request,
in whatever post status it is in): deleted at `retain_until`, set at closure
Expand Down Expand Up @@ -623,9 +627,8 @@ queue page):
stored `retain_until` and the date required under the current configuration):
a live row whose retention deletion wrote its tombstone and then failed
part-way cannot be told apart from a restored one, and a period raised since
must protect it. (A row without a derivable deadline cannot be such a live row,
because retention only deletes rows whose dates are all valid; the tombstone
then decides.) The identity comparison between the tombstone and the request's
must protect it. A row without a trustworthy derivable deadline is kept; a
tombstone alone cannot establish the restored row's current retention deadline. The identity comparison between the tombstone and the request's
post meta is binary, so differing table collations (for example
`utf8mb4_unicode_ci` post meta beside a `utf8mb4_unicode_520_ci` tombstones
table) cannot make it fail. A retention query that fails is never read as
Expand Down Expand Up @@ -655,3 +658,53 @@ objects and clones (issue 11), unsigned caller leads (there is no lead record in
this plugin; the current form saves nothing without a signature), and deletion
from mail systems. Authentication replay records and bounded controller logs
have shorter operational lifetimes.

## Protected report retrieval and manual lifecycle

Report notification mail contains a generic single-use access link, not the
findings. Only a SHA-256 hash of the random 256-bit capability is stored. A GET
or link preview renders a local POST form and neither consumes the capability
nor reveals findings. The POST rereads authorization, exact-target control,
recipient, accepted terms, result and closure state under the queue lock. It
serves escaped findings only after one compare-and-set records successful
redemption. Failure, expiration, revocation, changed acceptance, and replay
serve no findings. Responses deny caching, indexing, referrers and external
resources. A successful response is a server-observed retrieval, not proof
that a person read the report.

Findings remain for 366 days after trusted report retrieval, or 366 days after
an explicit administrator final close where no report was delivered. Signed
evidence remains six years from trusted closure for new terms; r2 and earlier
retain their three-year acceptance-based policy. Missing trusted event evidence
keeps data. Do not infer closure from `finished_at` or a terminal status.

Sites that support manual closure without a queue job must serialize that local
write with enqueue using `ct_scan_lock_acquire()` and
`ct_scan_manual_closure_ready()`. The readiness API verifies that the caller's
current database connection owns the lock, schema 8 is installed, and a
successful query proves no job exists. A `_ct_archive_manual_lifecycle` marker
blocks enqueue unless the owning archive plugin's verifier explicitly validates
an open state. Marker reads use a direct database query under the lock and pass the exact
single raw value to the archive verifier, bypassing whole-post metadata caches. This optional host integration never authorizes target traffic.

The schema upgrade and report route require a protected backup, demonstrated
isolated restore, exact candidate reviews and normal deployment verification.
Keep the queue disabled until customer-specific authorization and all existing
worker, egress, cancellation and delivery prerequisites are verified.

The edit-screen final-close metabox links to the private queue; it never nests a
second form inside WordPress's post editor. Its actual projected database row
includes every field required by the trusted-event validator. For an offline
browser regression, run the standalone report fixture with
`CT_SCAN_METABOX_DOM_FIXTURE` set to a new private HTML path, record the current `/proc/self/ns/net` value in `CT_SCAN_DOM_PARENT_NETNS`,
then invoke `standalone-tests/metabox-dom.mjs` under `bwrap --unshare-net` with that HTML,
an already-installed Playwright module path and Chromium executable. The check
intercepts ordinary post saving and requires one original post action/nonce,
intact form ownership and zero network requests; it sends no submission.

Findings purge uses bounded fresh-row validation under the queue lock, then a
binary compare-and-set on event, findings and hold fields. Malformed calendar
dates, noncanonical case, damaged holds and changed rows remain unchanged.
Revocation invalidates access through its durable revoked state while retaining
the non-secret hash/expiry proof that notification contained a link rather than
findings, so an authenticated no-report final close remains possible.
Loading
Loading