From 394618422cd86fb63b238f80526e5f88c4266153 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 21:10:14 -0500 Subject: [PATCH 01/14] fix(wordpress): bind signer evidence and explicit report lifecycle for #24 --- .github/workflows/tests.yml | 14 + docs/WORDPRESS.md | 60 +- integrations/wordpress/ct-scan.php | 977 +++++++++++++++--- .../standalone-tests/manual-lifecycle.php | 77 ++ .../standalone-tests/report-access.php | 486 +++++++++ .../wordpress/standalone-tests/retention.php | 189 ++++ .../wordpress/standalone-tests/tombstones.php | 224 ++++ .../wordpress/tests/evidence-strict.php | 44 +- integrations/wordpress/tests/evidence.php | 6 +- integrations/wordpress/tests/integration.php | 21 +- .../wordpress/tests/retention-years.php | 4 +- integrations/wordpress/tests/retention.php | 86 +- integrations/wordpress/tests/support.php | 47 +- 13 files changed, 2056 insertions(+), 179 deletions(-) create mode 100644 integrations/wordpress/standalone-tests/manual-lifecycle.php create mode 100644 integrations/wordpress/standalone-tests/report-access.php create mode 100644 integrations/wordpress/standalone-tests/retention.php create mode 100644 integrations/wordpress/standalone-tests/tombstones.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 409ada3..220f3fc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index f843d5c..7a1d7ab 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -655,3 +658,36 @@ 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. diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 3811f8e..e193983 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -17,9 +17,8 @@ } if ( ! defined( 'CT_SCAN_SCHEMA_VERSION' ) ) { - // 7: the retention/hold/tombstone columns (introduced as 6) together with `control_admitted`, so an - // install that already recorded 6 before `control_admitted` existed re-runs the upgrade. - define( 'CT_SCAN_SCHEMA_VERSION', '7' ); + // 7 combined retention/hold/tombstone columns with `control_admitted`; 8 adds one-time report capabilities and report closure metadata. + define( 'CT_SCAN_SCHEMA_VERSION', '8' ); } /** @@ -164,12 +163,28 @@ function ct_scan_policy_declared( $config ) { } /** - * The canonical Target Summary a signer is shown and signs: the exact website, the client, the profile, - * the limits of that profile and the authorization period. Deterministic from the request fields and the - * profile's code-defined limits, so the form, the recorder and this plugin render byte-identical text. - * Returns '' for an unknown profile. + * The canonical signer-aware Target Summary for strict terms versions. It is deterministic from the + * accepted request fields and the code-defined profile limits. Returns '' for an unknown profile. */ -function ct_scan_target_summary( $target, $name, $email, $profile = null ) { +function ct_scan_target_summary( $target, $legal_name, $signer_name, $signer_role, $email, $profile = null ) { + $profile = null === $profile ? ct_scan_current_profile() : $profile; + $limits = ct_scan_profile_limits( $profile ); + if ( ! $limits || ! is_string( $target ) || ! is_string( $legal_name ) || ! is_string( $signer_name ) || ! is_string( $signer_role ) || ! is_string( $email ) + || '' === $legal_name || '' === $signer_name || '' === $signer_role || '' === $email ) { + return ''; + } + return 'Website (scan scope): ' . $target . "\n" + . 'Client legal name: ' . $legal_name . "\n" + . 'Signer: ' . $signer_name . "\n" + . 'Signer role: ' . $signer_role . "\n" + . 'Client email: ' . $email . "\n" + . 'Profile: ' . $profile . "\n" + . 'Limits: this website only (HTTP 80 / HTTPS 443); at most ' . (int) $limits['http_requests'] . ' requests, one at a time, at most one per second, ' . (int) ( $limits['timeout_ms'] / 1000 ) . " seconds each\n" + . "Authorization: expires 60 days after acceptance (never after the published window end), or when the request closes\n"; +} + +/** The frozen r2-and-earlier Target Summary, used only for explicitly grandfathered legacy acceptances. */ +function ct_scan_legacy_target_summary( $target, $name, $email, $profile = null ) { $profile = null === $profile ? ct_scan_current_profile() : $profile; $limits = ct_scan_profile_limits( $profile ); if ( ! $limits || ! is_string( $target ) || ! is_string( $name ) || ! is_string( $email ) ) { @@ -252,7 +267,13 @@ function ct_scan_maybe_install_schema() { manual_remainder_completed_at datetime NULL, report_notification_status varchar(16) NOT NULL DEFAULT 'not_sent', report_notification_at datetime NULL, + report_access_token_hash char(64) NULL, + report_access_expires_at datetime NULL, + report_access_used_at datetime NULL, report_delivered_at datetime NULL, + closure_source varchar(32) NULL, + closed_by bigint(20) unsigned NULL, + closure_reason varchar(64) NULL, revoked_at datetime NULL, profile_id varchar(32) NOT NULL DEFAULT 'r2-header-v1', http_admitted smallint(5) unsigned NOT NULL DEFAULT 0, @@ -320,7 +341,7 @@ function ct_scan_maybe_install_schema() { ) {$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', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_page_hash', 'control_requested_method', 'control_attempts', 'control_last_attempt_ms', 'control_leased_ms', 'control_admitted', 'control_lease_hash', 'control_lease_until', 'control_method', 'control_verified_at', 'control_valid_until', 'control_evidence', 'closed_at', 'retain_until', 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at', 'hold_released_by', 'hold_released_at', 'identity', 'retention_checked_at' ), + $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_notification_status', 'report_notification_at', 'report_delivered_at', 'closure_source', 'closed_by', 'closure_reason', 'report_access_token_hash', 'report_access_expires_at', 'report_access_used_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_page_hash', 'control_requested_method', 'control_attempts', 'control_last_attempt_ms', 'control_leased_ms', 'control_admitted', 'control_lease_hash', 'control_lease_until', 'control_method', 'control_verified_at', 'control_valid_until', 'control_evidence', 'closed_at', 'retain_until', 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at', 'hold_released_by', 'hold_released_at', 'identity', 'retention_checked_at' ), $nonces => array( 'nonce_hash', 'expires_at' ), $tombstones => array( 'tombstone_id', 'job_id', 'request_id', 'scope_mac', 'created_at', 'deleted_at', 'reason', 'identity' ), ); @@ -443,14 +464,14 @@ function ct_scan_meta_string( $post_id, $key ) { function ct_scan_check_bound_evidence( $request_id, $terms_hash, $identity, $required, $profile ) { // get_post_meta( ..., true ) silently returns the first of several rows, so an evidence key that // holds more than one value is ambiguous: refuse rather than pick one. - foreach ( array( '_ct_terms_binding', '_ct_terms_snapshot', '_ct_terms_sha256', '_ct_terms_shown_digest', '_ct_summary_text', '_ct_summary_sha256', '_ct_summary_shown_digest' ) as $key ) { + foreach ( array( '_ct_terms_binding', '_ct_terms_snapshot', '_ct_terms_sha256', '_ct_terms_shown_digest', '_ct_summary_text', '_ct_summary_sha256', '_ct_summary_shown_digest', '_ct_terms_client_legal_name', '_ct_terms_signer_name', '_ct_terms_signer_role', '_ct_terms_typed_signature', '_ct_terms_authority_sha256', '_ct_terms_evidence_sha256', '_ct_terms_statement' ) as $key ) { if ( count( (array) get_post_meta( $request_id, $key, false ) ) > 1 ) { return new WP_Error( 'ct_scan_evidence_duplicate', 'The saved acceptance holds more than one value for a piece of evidence.' ); } } $marker = ct_scan_meta_string( $request_id, '_ct_terms_binding' ); $partial = false; - foreach ( array( '_ct_terms_snapshot', '_ct_terms_shown_digest', '_ct_summary_text', '_ct_summary_sha256', '_ct_summary_shown_digest' ) as $key ) { + foreach ( array( '_ct_terms_snapshot', '_ct_terms_shown_digest', '_ct_summary_text', '_ct_summary_sha256', '_ct_summary_shown_digest', '_ct_terms_client_legal_name', '_ct_terms_signer_name', '_ct_terms_signer_role', '_ct_terms_typed_signature', '_ct_terms_authority_sha256', '_ct_terms_evidence_sha256', '_ct_terms_statement' ) as $key ) { if ( '' !== ct_scan_meta_string( $request_id, $key ) ) { $partial = true; } @@ -485,15 +506,95 @@ function ct_scan_check_bound_evidence( $request_id, $terms_hash, $identity, $req // The Target Summary must be exactly the canonical text of the authorized request (website, client, // profile, limits, authorization period): nothing before, between or after, so no decoy or appended // instruction can ride along, and the limits the signer saw are the limits the job runs under. - $expected = ct_scan_target_summary( $identity['target'], $identity['name'], $identity['email'], $profile ); + if ( ! empty( $identity['strict_authority'] ) ) { + $expected = ct_scan_target_summary( $identity['target'], $identity['legal_name'], $identity['signer_name'], $identity['signer_role'], $identity['email'], $profile ); + } else { + $expected = ct_scan_legacy_target_summary( $identity['target'], $identity['name'], $identity['email'], $profile ); + } if ( '' === $expected || ! hash_equals( $expected, $summary ) ) { return new WP_Error( 'ct_scan_binding_mismatch', 'The retained Target Summary is not the canonical summary of the authorized request.' ); } return array( 'bound' => true, 'summary_sha256' => $summary_hash, 'required' => (bool) $required ); } +/** Validate the versioned client/signer evidence independently from the display form. */ +function ct_scan_read_signer_evidence( $request_id, $terms_version, $terms_hash, $summary_hash, $accepted_raw, $contact_name, $contract, $required ) { + if ( ! $required ) { + return array( 'strict' => false, 'legal_name' => '', 'signer_name' => '', 'signer_role' => '', 'authority_sha256' => '' ); + } + $keys = array( + 'legal_name' => '_ct_terms_client_legal_name', + 'signer_name' => '_ct_terms_signer_name', + 'signer_role' => '_ct_terms_signer_role', + 'signature' => '_ct_terms_typed_signature', + ); + $authority = array(); + foreach ( $keys as $field => $meta_key ) { + $values = (array) get_post_meta( $request_id, $meta_key, false ); + $value = ct_scan_meta_string( $request_id, $meta_key ); + $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value, 'UTF-8' ) : strlen( $value ); + if ( 1 !== count( $values ) || '' === $value || trim( $value ) !== $value || $length > 120 + || preg_match( '/[\x00-\x1f\x7f<>]/', $value ) || sanitize_text_field( $value ) !== $value ) { + return new WP_Error( 'ct_scan_signer_evidence_missing', 'The saved client and signer evidence is incomplete or ambiguous.' ); + } + $authority[ $field ] = $value; + } + if ( ! hash_equals( $contact_name, $authority['signer_name'] ) || ! hash_equals( $authority['signer_name'], $authority['signature'] ) ) { + return new WP_Error( 'ct_scan_signer_evidence_mismatch', 'The saved signer does not match the signed request contact.' ); + } + $authority_record = 'Client legal name: ' . $authority['legal_name'] . "\n" + . 'Signer full name: ' . $authority['signer_name'] . "\n" + . 'Signer role (stated): ' . $authority['signer_role'] . "\n" + . 'Typed signature: ' . $authority['signature'] . "\n"; + $authority_hash = hash( 'sha256', $authority_record ); + $statement = ct_scan_meta_string( $request_id, '_ct_terms_statement' ); + $accepted_at = ct_scan_utc_parse( $accepted_raw ); + if ( ! preg_match( '/\A[a-f0-9]{64}\z/', (string) $summary_hash ) || '' === $statement || ! $accepted_at || $accepted_at->format( 'Y-m-d\\TH:i:s\\Z' ) !== $accepted_raw ) { + return new WP_Error( 'ct_scan_signer_evidence_mismatch', 'The signed acceptance statement or time is missing or invalid.' ); + } + $evidence_record = 'Terms version: ' . $terms_version . "\n" + . 'Terms SHA-256: ' . $terms_hash . "\n" + . 'Target Summary SHA-256: ' . $summary_hash . "\n" + . 'Signer evidence SHA-256: ' . $authority_hash . "\n" + . 'Acceptance statement SHA-256: ' . hash( 'sha256', $statement ) . "\n" + . 'Accepted at (UTC): ' . $accepted_raw . "\n"; + $evidence_hash = hash( 'sha256', $evidence_record ); + if ( ! hash_equals( $authority_hash, strtolower( ct_scan_meta_string( $request_id, '_ct_terms_authority_sha256' ) ) ) + || ! hash_equals( $evidence_hash, strtolower( ct_scan_meta_string( $request_id, '_ct_terms_evidence_sha256' ) ) ) ) { + return new WP_Error( 'ct_scan_signer_evidence_mismatch', 'The saved signer and acceptance digests do not match their source values.' ); + } + $header_parts = explode( "\nTERMS AS ACCEPTED\n", $contract, 2 ); + if ( 2 !== count( $header_parts ) ) { + return new WP_Error( 'ct_scan_signer_contract_mismatch', 'The filled-in agreement has no unambiguous accepted-terms boundary.' ); + } + $contract_header = $header_parts[0]; + $contract_lines = array( + 'Client legal name' => $authority['legal_name'], + 'Client contact' => $authority['signer_name'], + 'Signer role (stated)' => $authority['signer_role'], + 'Typed signature' => $authority['signature'], + ); + foreach ( $contract_lines as $label => $expected ) { + if ( 1 !== preg_match_all( '/^' . preg_quote( $label, '/' ) . ':[ \\t]*(.*)$/m', $contract_header, $matches ) || trim( $matches[1][0] ) !== $expected ) { + return new WP_Error( 'ct_scan_signer_contract_mismatch', 'The filled-in agreement does not match the saved client and signer fields.' ); + } + } + foreach ( array( 'Signer evidence SHA-256' => $authority_hash, 'Acceptance evidence SHA-256' => $evidence_hash ) as $label => $expected ) { + if ( 1 !== preg_match_all( '/^' . preg_quote( $label, '/' ) . ':[ \\t]*([a-f0-9]{64})$/m', $contract_header, $matches ) || ! hash_equals( $expected, $matches[1][0] ) ) { + return new WP_Error( 'ct_scan_signer_contract_mismatch', 'The filled-in agreement omits or changes its signed evidence digest.' ); + } + } + return array( + 'strict' => true, + 'legal_name' => $authority['legal_name'], + 'signer_name' => $authority['signer_name'], + 'signer_role' => $authority['signer_role'], + 'authority_sha256'=> $authority_hash, + ); +} + /** 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 = '', $frozen = null ) { +function ct_scan_validate_saved_request( $request_id, $fields, $require_current_terms = true, $frozen_expiry = '', $frozen = null, $allow_expired = false ) { // Authorization is judged on what the database holds now, never on an object-cache copy. clean_post_cache( (int) $request_id ); $config = ct_scan_config(); @@ -565,7 +666,32 @@ function ct_scan_validate_saved_request( $request_id, $fields, $require_current_ // configuration change can neither relax nor tighten an authorization already given. $bound_required = is_array( $frozen ) ? (bool) $frozen['bound_required'] : 'strict' === ct_scan_terms_policy( $terms_version ); $profile = is_array( $frozen ) ? (string) $frozen['profile'] : ct_scan_current_profile(); - $bound = ct_scan_check_bound_evidence( $request_id, $terms_hash, array( 'target' => $target, 'email' => $email, 'name' => $name ), $bound_required, $profile ); + $signer = ct_scan_read_signer_evidence( $request_id, $terms_version, $terms_hash, ct_scan_meta_string( $request_id, '_ct_summary_sha256' ), $accepted_raw, $name, $contract, $bound_required ); + if ( is_wp_error( $signer ) ) { + return $signer; + } + if ( $signer['strict'] ) { + $expected_lines = array_merge( $expected_lines, array( + 'Client legal name' => $signer['legal_name'], + 'Signer role (stated)' => $signer['signer_role'], + 'Typed signature' => $signer['signer_name'], + ) ); + foreach ( $expected_lines as $label => $expected ) { + if ( 1 !== preg_match_all( '/^' . preg_quote( $label, '/' ) . ':[ \\t]*(.*)$/m', $contract, $matches ) || trim( $matches[1][0] ) !== $expected ) { + return new WP_Error( 'ct_scan_contract_mismatch', 'The saved acceptance copy does not match the authorized request fields.' ); + } + } + } + $summary_identity = array( + 'target' => $target, + 'email' => $email, + 'name' => $name, + 'strict_authority' => $signer['strict'], + 'legal_name' => $signer['legal_name'], + 'signer_name' => $signer['signer_name'], + 'signer_role' => $signer['signer_role'], + ); + $bound = ct_scan_check_bound_evidence( $request_id, $terms_hash, $summary_identity, $bound_required, $profile ); if ( is_wp_error( $bound ) ) { return $bound; } @@ -591,7 +717,7 @@ function ct_scan_validate_saved_request( $request_id, $fields, $require_current_ return new WP_Error( 'ct_scan_expiry_invalid', 'The saved authorization expiry exceeds its accepted window.' ); } } - if ( $expires <= new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) ) { + if ( ! $allow_expired && $expires <= new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) ) { return new WP_Error( 'ct_scan_authorization_expired', 'The scan authorization window has expired.' ); } return array( @@ -600,6 +726,10 @@ function ct_scan_validate_saved_request( $request_id, $fields, $require_current_ 'email' => $email, 'name' => $name, 'referrer' => $ref, + 'legal_name' => $signer['legal_name'], + 'signer_name' => $signer['signer_name'], + 'signer_role' => $signer['signer_role'], + 'authority_sha256' => $signer['authority_sha256'], 'terms_version' => $terms_version, 'terms_hash' => $terms_hash, 'terms_accepted_at' => $accepted_at->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ), @@ -626,6 +756,52 @@ function ct_scan_lock_release() { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) ); } +/** + * Queue-disabled manual closure may proceed only while its caller owns the same queue lock and a + * successful current-schema read proves that this request has never entered the scan queue. + */ +function ct_scan_manual_closure_ready( $request_id ) { + global $wpdb; + if ( ( ! is_int( $request_id ) && ! ( is_string( $request_id ) && ctype_digit( $request_id ) ) ) + || (int) $request_id <= 0 || (string) (int) $request_id !== (string) $request_id + || get_option( 'ct_scan_schema_version' ) !== CT_SCAN_SCHEMA_VERSION ) { + return false; + } + $name = 'ctscan_' . substr( hash( 'sha256', DB_NAME . '|' . $wpdb->prefix ), 0, 24 ); + $wpdb->last_error = ''; + $owns_lock = $wpdb->get_var( $wpdb->prepare( 'SELECT IS_USED_LOCK(%s) = CONNECTION_ID()', $name ) ); + if ( '' !== (string) $wpdb->last_error || '1' !== (string) $owns_lock ) { + return false; + } + $wpdb->last_error = ''; + $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_jobs_table() . ' WHERE request_id = %d LIMIT 1', (int) $request_id ) ); + return '' === (string) $wpdb->last_error && null === $row; +} + +/** A manual final-close marker is fail-closed, including when its owning archive plugin is unavailable. */ +function ct_scan_manual_queue_blocked( $request_id ) { + global $wpdb; + // The caller holds the queue lock. Read the database directly: another process may + // repopulate a persistent whole-post metadata cache after an invalidation. + $wpdb->last_error = ''; + $markers = $wpdb->get_col( $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s", + $request_id, + '_ct_archive_manual_lifecycle' + ) ); + if ( '' !== (string) $wpdb->last_error || ! is_array( $markers ) ) { + return true; + } + if ( array() === $markers ) { + return false; + } + if ( 1 !== count( $markers ) || ! is_string( $markers[0] ) || ! function_exists( 'ct_archive_manual_queue_blocked' ) ) { + return true; + } + // The archive plugin strictly decodes and verifies this exact raw marker and its binding. + return false !== ct_archive_manual_queue_blocked( $request_id, $markers[0] ); +} + function ct_scan_mark_request_status( $request_id, $status, $detail = '' ) { update_post_meta( $request_id, '_ct_scan_status', sanitize_key( $status ) ); if ( '' !== $detail ) { @@ -666,6 +842,9 @@ function ct_scan_on_request_ready( $request_id, $fields ) { return ct_scan_safe_notice( $request_id, 'queue_busy' ); } try { + if ( ct_scan_manual_queue_blocked( $request_id ) ) { + return ct_scan_safe_notice( $request_id, 'manual_final_close' ); + } $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' ); @@ -875,7 +1054,7 @@ function ct_scan_maintenance_locked() { } -/** Statuses from which a job never runs again; reaching one is the request's closure. */ +/** Statuses from which a job never runs again; final request closure requires a separate trusted event. */ function ct_scan_terminal_statuses() { return array( 'completed', 'failed', 'failed_unknown', 'cancelled', 'expired', 'blocked' ); } @@ -905,6 +1084,69 @@ function ct_scan_valid_datetime( $value ) { return true; } +/** + * A final close is trusted only when its source, actor, reason and UTC timestamp form one of the two + * server-written events. Missing/null fields mean pending; terminal status and `finished_at` are not closure. + */ +function ct_scan_closure_metadata_trusted( $row ) { + if ( ! is_object( $row ) ) { + return false; + } + foreach ( array( 'status', 'created_at', 'terms_accepted_at', 'closed_at', 'closure_source', 'closed_by', 'closure_reason', 'report_delivered_at', 'report_access_used_at' ) as $field ) { + if ( ! property_exists( $row, $field ) ) { + return false; + } + } + if ( ! in_array( $row->status, ct_scan_terminal_statuses(), true ) + || ! ct_scan_valid_datetime( $row->created_at ) + || ! ct_scan_valid_datetime( $row->terms_accepted_at ) + || ! ct_scan_valid_datetime( $row->closed_at ) + || $row->closed_at < $row->created_at + || $row->closed_at < $row->terms_accepted_at ) { + return false; + } + $latest_trusted = gmdate( 'Y-m-d H:i:s', time() + ct_scan_clock_skew_limit_seconds() ); + if ( $row->closed_at > $latest_trusted ) { + return false; + } + if ( ! is_string( $row->closure_reason ) || '' === $row->closure_reason ) { + return false; + } + $actor_is_integer = is_int( $row->closed_by ) || ( is_string( $row->closed_by ) && ctype_digit( $row->closed_by ) ); + if ( ! $actor_is_integer ) { + return false; + } + if ( 'report_delivery' === $row->closure_source ) { + return 0 === (int) $row->closed_by + && '0' === (string) $row->closed_by + && 'report_delivered' === $row->closure_reason + && ct_scan_valid_datetime( $row->report_delivered_at ) + && ct_scan_valid_datetime( $row->report_access_used_at ) + && $row->report_access_used_at === $row->report_delivered_at + && $row->report_delivered_at === $row->closed_at; + } + if ( 'admin_final_close' === $row->closure_source ) { + return (int) $row->closed_by > 0 + && null === $row->report_delivered_at + && null === $row->report_access_used_at + && $row->closure_reason === $row->status; + } + return false; +} + +/** Findings and any served report remain until their trusted delivery or final-close timestamp plus 366 days. */ +function ct_scan_findings_retain_until( $row ) { + if ( ! ct_scan_closure_metadata_trusted( $row ) ) { + return false; + } + $base = 'report_delivery' === $row->closure_source ? $row->report_delivered_at : $row->closed_at; + try { + return ( new DateTimeImmutable( $base, new DateTimeZone( 'UTC' ) ) )->modify( '+366 days' )->format( 'Y-m-d H:i:s' ); + } catch ( Throwable $error ) { + return false; + } +} + /** The longest retention period honoured, so every derived date stays a valid (pre-2200) date. */ function ct_scan_max_retention_years() { return 100; @@ -946,6 +1188,9 @@ function ct_scan_retain_until( $row ) { $years = ct_scan_clamp_years( $config['legacy_retention_years'], 3 ); $base = $row->terms_accepted_at; } else { + if ( ! ct_scan_closure_metadata_trusted( $row ) ) { + return false; + } $years = ct_scan_clamp_years( $config['evidence_retention_years'], 6 ); $base = $row->closed_at; } @@ -1027,14 +1272,27 @@ function ct_scan_deletion_decision( $row, $now ) { return array( 'keep', null ); } } - // A record cannot have closed before it was created; if it appears to, a date is wrong. if ( $row->closed_at < $row->created_at ) { return array( 'keep', null ); } + if ( ct_scan_is_legacy_version( $row->terms_version ) ) { + if ( ! ct_scan_valid_datetime( $row->terms_accepted_at ) ) { + return array( 'keep', null ); + } + } elseif ( ! ct_scan_closure_metadata_trusted( $row ) ) { + return array( 'keep', null ); + } $required = ct_scan_required_retain_until( $row ); if ( false === $required ) { return array( 'keep', null ); } + $has_findings = ! empty( $row->result_json ) || ! empty( $row->manual_remainder_notes ); + if ( $has_findings ) { + $findings_until = ct_scan_findings_retain_until( $row ); + if ( false === $findings_until || $findings_until > $now ) { + return array( 'keep', null ); + } + } if ( $row->retain_until < $required ) { return array( 'extend', $required ); } @@ -1054,21 +1312,29 @@ function ct_scan_deletion_allowed( $row, $now ) { * required under today's configuration (ct_scan_required_retain_until()). A longer period configured after a * failed deletion therefore protects that row exactly as it protects any other. * - * When that date cannot be derived (no valid closure for a current-version acceptance, a damaged creation - * date), the row cannot be such a live row: retention deleted only rows whose dates were all valid, and a - * failed deletion leaves those dates as they were. The row is then a restored copy from before its closure - * (or a damaged one) and the tombstone, which records that this record was already deleted, decides. + * When that date cannot be derived (no valid closure for a current-version acceptance, or a damaged + * creation date), keep the row. A tombstone alone cannot establish a trustworthy current retention + * deadline for a restored or damaged copy. */ function ct_scan_tombstone_deadline_passed( $row, $now ) { $required = ct_scan_required_retain_until( $row ); if ( false === $required ) { - return true; + return false; } $deadline = $required; if ( isset( $row->retain_until ) && ct_scan_valid_datetime( $row->retain_until ) && $row->retain_until > $deadline ) { $deadline = $row->retain_until; } - return $deadline <= $now; + if ( $deadline > $now ) { + return false; + } + if ( ! empty( $row->result_json ) || ! empty( $row->manual_remainder_notes ) ) { + $findings_until = ct_scan_findings_retain_until( $row ); + if ( false === $findings_until || $findings_until > $now ) { + return false; + } + } + return true; } /** @@ -1127,8 +1393,12 @@ function ct_scan_delete_job_records( $row, $reason, $tombstone_identity = null ) global $wpdb; $table = ct_scan_jobs_table(); clean_post_cache( (int) $row->request_id ); - $post = get_post( (int) $row->request_id ); - $ours = $post && 'ct_promo_request' === $post->post_type && (int) $post->ID === (int) $row->request_id; + $wpdb->last_error = ''; + $post = $wpdb->get_row( $wpdb->prepare( "SELECT ID, post_type FROM {$wpdb->posts} WHERE ID = %d LIMIT 1", (int) $row->request_id ) ); + if ( ct_scan_retention_query_failed( 'delete_post_read' ) ) { + return false; + } + $ours = is_object( $post ) && 'ct_promo_request' === $post->post_type && (int) $post->ID === (int) $row->request_id; if ( $ours ) { $post_identity = ct_scan_meta_string( (int) $post->ID, '_ct_scan_identity' ); if ( null !== $tombstone_identity ) { @@ -1304,9 +1574,9 @@ function ct_scan_retention_steps( $now ) { if ( ! $sane ) { return; } - // 1. Closure is explicit: the first time a job is seen terminal it is stamped. - $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET closed_at = COALESCE(finished_at, %s) WHERE closed_at IS NULL AND status IN ({$terminal})", $now ) ); - ct_scan_retention_query_failed( 'closure' ); // only stamps dates; the steps below re-read what they act on + // 1. A terminal status records when scanner work stopped, not when the request was finally closed. + // Only the authenticated admin-close action or a successful one-time report redemption may write + // trusted closure metadata. Legacy `closed_at` values remain untrusted without that v8 evidence. // A row judged doubtful is re-examined at most daily, so doubtful rows can never starve the rest. $recheck = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-1 day' )->format( 'Y-m-d H:i:s' ); // 1b. Jobs that predate the opaque identity get one (and so does their request) so a restored request @@ -1342,7 +1612,7 @@ function ct_scan_retention_steps( $now ) { } // 2. The evidence deletion date is set at closure; later it may only be extended (step 4), never shortened. // An untrustworthy base leaves it NULL (kept). - $unset = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY job_id ASC LIMIT 200", $recheck ) ); + $unset = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY job_id ASC LIMIT 200", $recheck ) ); foreach ( ct_scan_retention_query_failed( 'retain_until' ) ? array() : (array) $unset as $row ) { $when = ct_scan_retain_until( $row ); if ( false !== $when ) { @@ -1351,11 +1621,28 @@ function ct_scan_retention_steps( $now ) { $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); } } - // 3. Findings: twelve months after closure (366 days, so a leap day cannot make it early), unless held. + // 3. Findings and any report: 366 days after confirmed report delivery, or 366 days after an + // authenticated final close when no report was delivered. Terminal status/finished_at and legacy + // maintenance-only `closed_at` values are not a retention anchor. $findings_cutoff = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-366 days' )->format( 'Y-m-d H:i:s' ); $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->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 closed_at IS NOT NULL AND closed_at >= '2020-01-01 00:00:00' AND closed_at < %s AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s))", + "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL + WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) + AND status IN ({$terminal}) + AND created_at >= '2020-01-01 00:00:00' + AND terms_accepted_at >= '2020-01-01 00:00:00' + AND closed_at >= '2020-01-01 00:00:00' + AND closed_at >= created_at + AND closed_at >= terms_accepted_at + AND closed_at <= %s + AND closed_at < %s + AND ( + (closure_source = 'report_delivery' AND closed_by = 0 AND closure_reason = 'report_delivered' AND report_delivered_at = closed_at AND report_access_used_at = report_delivered_at) + OR (closure_source = 'admin_final_close' AND closed_by > 0 AND closure_reason = status AND report_delivered_at IS NULL AND report_access_used_at IS NULL) + ) + AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s))", + $now, $findings_cutoff, $grace_edge ) ); @@ -1415,9 +1702,10 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu // paged by key from where the previous run stopped, wrapping around once, so rows that are skipped or // whose deletion keeps failing can never starve later restored rows. $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); - $cursor = max( 0, (int) get_option( 'ct_scan_tombstone_cursor', 0 ) ); - $after_job = $cursor; - $after_id = 0; + // Tombstone IDs are globally unique. Persisting that cursor avoids resetting to id 0 when several + // tombstones share one job_id and a run stops between pages. + $cursor = max( 0, (int) get_option( 'ct_scan_tombstone_id_cursor', 0 ) ); + $after_id = $cursor; $upper = PHP_INT_MAX; $wrapped = 0 === $cursor; $finished = false; @@ -1427,12 +1715,10 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu for ( $page = 0; $page < 40 && ! $finished; $page++ ) { $ghosts = $wpdb->get_results( $wpdb->prepare( 'SELECT j.job_id, t.tombstone_id, t.scope_mac, t.identity FROM ' . $table . ' j INNER JOIN ' . $stones . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.created_at = j.created_at' - . ' WHERE (j.job_id > %d OR (j.job_id = %d AND t.tombstone_id > %d)) AND j.job_id <= %d' + . ' WHERE t.tombstone_id > %d AND t.tombstone_id <= %d' . " AND (j.hold_reason IS NULL OR (j.hold_until >= '2020-01-01 00:00:00' AND j.hold_until <= %s))" . " AND j.status NOT IN ('leased','running') AND NOT (j.status = 'awaiting_control' AND COALESCE(j.control_state, '') = 'checking')" - . ' ORDER BY j.job_id ASC, t.tombstone_id ASC LIMIT 50', - $after_job, - $after_job, + . ' ORDER BY t.tombstone_id ASC LIMIT 50', $after_id, $upper, $grace_edge @@ -1446,10 +1732,9 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu break; } // Reached the end: start again from the beginning, up to where this run started. - $wrapped = true; - $after_job = 0; - $after_id = 0; - $upper = $cursor; + $wrapped = true; + $after_id = 0; + $upper = $cursor; continue; } foreach ( (array) $ghosts as $ghost ) { @@ -1459,10 +1744,9 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu } if ( microtime( true ) > $deadline || ! ct_scan_lock_acquire() ) { // Out of time, or the queue is busy: the next run continues from here. - update_option( 'ct_scan_tombstone_cursor', (string) $after_job, false ); + update_option( 'ct_scan_tombstone_id_cursor', (string) $after_id, false ); return; } - $after_job = (int) $ghost->job_id; $after_id = (int) $ghost->tombstone_id; try { $row = ct_scan_row( (int) $ghost->job_id ); @@ -1482,7 +1766,7 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu } } } - update_option( 'ct_scan_tombstone_cursor', $finished ? '0' : (string) $after_job, false ); + update_option( 'ct_scan_tombstone_id_cursor', $finished ? '0' : (string) $after_id, false ); // A restored request post whose job row was not restored: matched in SQL on the opaque identity // (never on recyclable numeric ids alone) AND on the job id the post names, and only where no job row // currently uses that request or job id, so a live record can never be reached. Re-checked under the @@ -3088,7 +3372,7 @@ function ct_scan_admin_revoke( $job_id ) { 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, 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none', 'control_lease_hash' => null, 'control_lease_until' => null ); + $update = array( 'revoked_at' => $now, 'confirmation_token_hash' => null, 'lease_hash' => null, 'lease_until' => null, 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none', 'control_lease_hash' => null, 'control_lease_until' => null, 'report_access_token_hash' => null, 'report_access_expires_at' => null ); if ( ! ct_scan_terminal_status( $row->status ) ) { $update['status'] = 'cancelled'; $update['error_code'] = 'operator_revoked'; @@ -3121,9 +3405,18 @@ function ct_scan_admin_save_manual_remainder( $job_id, $notes ) { $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 ) + if ( ! $row ) { + return new WP_Error( 'manual_review_unavailable', 'This job cannot receive manual review.' ); + } + foreach ( array( 'closure_source', 'closed_by', 'closure_reason', 'report_delivered_at', 'report_access_used_at' ) as $field ) { + if ( ! property_exists( $row, $field ) ) { + return new WP_Error( 'manual_review_schema_missing', 'The report and closure state could not be verified.' ); + } + } + if ( ! ct_scan_terminal_status( $row->status ) || ! empty( $row->revoked_at ) || ! empty( $row->report_delivered_at ) || ! empty( $row->report_access_used_at ) + || null !== $row->closure_source || null !== $row->closed_by || null !== $row->closure_reason || ! 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.' ); + return new WP_Error( 'manual_review_not_ready', 'Manual completion is available only for a terminal request with no recorded closure, revocation, or report 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 ) { @@ -3136,75 +3429,356 @@ function ct_scan_admin_save_manual_remainder( $job_id, $notes ) { 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.' ); +/** Return whether a job still matches its saved private request and is eligible for a report. */ +function ct_scan_validate_report_job( $row, $token = '', $require_capability = false, $for_send = false ) { + if ( ! is_object( $row ) || ! isset( $row->job_id, $row->request_id, $row->contact_email, $row->terms_version, $row->terms_hash ) + || ! property_exists( $row, 'report_access_token_hash' ) || ! property_exists( $row, 'report_access_expires_at' ) || ! property_exists( $row, 'report_access_used_at' ) + || ! property_exists( $row, 'closed_at' ) || ! property_exists( $row, 'closure_source' ) || ! property_exists( $row, 'closed_by' ) || ! property_exists( $row, 'closure_reason' ) || ! property_exists( $row, 'report_delivered_at' ) ) { + return new WP_Error( 'report_schema_missing', 'The saved report access state could not be verified.' ); + } + $allowed_notification_states = $for_send ? array( 'not_sent', 'failed' ) : array( 'accepted', 'unknown' ); + if ( ! empty( $row->revoked_at ) || ! empty( $row->report_delivered_at ) + || ! in_array( $row->report_notification_status, $allowed_notification_states, true ) ) { + return new WP_Error( 'report_access_unavailable', 'This report is no longer available.' ); + } + // A prior closure reason/source is authoritative. Only legacy rows with no closure metadata may + // receive the report-delivery closure stamp below, replacing the old maintenance-only timestamp. + if ( null !== $row->closure_source || null !== $row->closed_by || null !== $row->closure_reason ) { + return new WP_Error( 'report_closure_unavailable', 'The report closure state cannot be replaced.' ); + } + 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', 'The reviewed report is not ready.' ); + } + if ( empty( $row->confirmed_at ) ) { + return new WP_Error( 'report_recipient_unconfirmed', 'The report recipient did not confirm the request.' ); + } + clean_post_cache( (int) $row->request_id ); + $post = get_post( (int) $row->request_id ); + if ( ! $post || 'ct_promo_request' !== $post->post_type || 'private' !== $post->post_status + || (string) (int) $row->job_id !== ct_scan_meta_string( (int) $row->request_id, '_ct_scan_job_id' ) ) { + return new WP_Error( 'report_request_mismatch', 'The original request and job no longer match.' ); + } + 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 + || strtolower( ct_scan_meta_string( (int) $row->request_id, '_ct_terms_sha256' ) ) !== $row->terms_hash + || ! preg_match( '/\A[a-f0-9]{64}\z/', (string) $row->terms_hash ) + || ct_scan_meta_string( (int) $row->request_id, '_ct_scan_authorization_expires_at' ) !== $row->authorization_expires_at ) { + return new WP_Error( 'report_contact_changed', 'The original authorized recipient or acceptance could not be verified.' ); + } + // Scan authorization expires for target traffic, not for delivery of a prepared report to the + // already-confirmed recipient. Re-read the immutable saved acceptance without applying that + // scan-only expiry gate, and compare its complete accepted tuple with the job row. + $fields = array( + 'target' => (string) $row->target_origin, + 'email' => (string) $row->contact_email, + 'name' => (string) $row->contact_name, + 'referrer' => (string) $row->referrer, + ); + $accepted = ct_scan_validate_saved_request( + (int) $row->request_id, + $fields, + false, + (string) $row->authorization_expires_at, + array( 'bound_required' => ! empty( $row->bound_required ), 'profile' => (string) $row->profile_id ), + true + ); + if ( is_wp_error( $accepted ) + || ! hash_equals( (string) $row->scope_hash, hash( 'sha256', (string) ( $accepted['target'] ?? '' ) ) ) + || ! hash_equals( (string) $row->terms_hash, (string) ( $accepted['terms_hash'] ?? '' ) ) + || $row->terms_version !== ( $accepted['terms_version'] ?? null ) + || $row->terms_accepted_at !== ( $accepted['terms_accepted_at'] ?? null ) + || $row->authorization_expires_at !== ( $accepted['authorization_expires'] ?? null ) + || ( null === ( $accepted['summary_sha256'] ?? null ) + ? ! empty( $row->summary_sha256 ) + : ( ! is_string( $row->summary_sha256 ) || ! hash_equals( $row->summary_sha256, $accepted['summary_sha256'] ) ) ) ) { + return new WP_Error( 'report_acceptance_mismatch', 'The saved acceptance no longer matches the report job.' ); + } + // A missing control_required value is deliberately treated as required by ct_scan_control_required(). + if ( ct_scan_control_required( $row ) ) { + if ( empty( $row->control_verified_at ) || ! ct_scan_valid_datetime( (string) $row->control_verified_at ) + || ! in_array( $row->control_method ?? null, array( 'dns_txt', 'https_file', 'manual_review' ), true ) + || ! ct_scan_valid_datetime( (string) ( $row->control_valid_until ?? '' ) ) + || $row->control_valid_until !== $row->authorization_expires_at + || strtotime( $row->control_verified_at . ' UTC' ) > strtotime( $row->control_valid_until . ' UTC' ) ) { + return new WP_Error( 'report_target_unverified', 'Control of the exact target was not verified for the authorized scan window.' ); + } + } + if ( $require_capability ) { + if ( ! is_string( $token ) || ! preg_match( '/\A[a-f0-9]{64}\z/', $token ) + || ! is_string( $row->report_access_token_hash ) || ! preg_match( '/\A[a-f0-9]{64}\z/', $row->report_access_token_hash ) + || ! hash_equals( $row->report_access_token_hash, hash( 'sha256', $token ) ) + || ! empty( $row->report_access_used_at ) || ! ct_scan_valid_datetime( (string) $row->report_access_expires_at ) + || strtotime( $row->report_access_expires_at . ' UTC' ) <= time() ) { + return new WP_Error( 'report_capability_invalid', 'The report capability is invalid, expired, or already used.' ); + } + } + $body = ct_scan_build_report_body( $row ); + return $body; +} + +function ct_scan_report_access_page_markup( $job_id, $token ) { + return 'Reviewed website report

Your reviewed website report is ready

Continue to view the report. Opening this page has not used the access link.

'; +} + +function ct_scan_report_document_markup( $body ) { + return 'Reviewed website report

Reviewed website report

' . esc_html( $body ) . '
'; +} + +function ct_scan_report_unavailable_markup() { + return 'Website report

Website report

This report link is invalid, expired, already used, or no longer authorized.

'; +} + +/** Build report responses without output so the single-use transition is easy to exercise with synthetic state. */ +function ct_scan_report_access_response( $method, $job_id, $token ) { + $method = strtoupper( (string) $method ); + if ( ! in_array( $method, array( 'GET', 'POST' ), true ) ) { + return array( 'status' => 405, 'body' => ct_scan_report_unavailable_markup() ); + } + $job_id = absint( $job_id ); + if ( ! $job_id || ! is_string( $token ) || ! preg_match( '/\A[a-f0-9]{64}\z/', $token ) ) { + return array( 'status' => 404, 'body' => ct_scan_report_unavailable_markup() ); + } + if ( 'GET' === $method ) { + $row = ct_scan_row( $job_id ); + $validated = ct_scan_validate_report_job( $row, $token, true ); + if ( is_wp_error( $validated ) ) { + return array( 'status' => 410, 'body' => ct_scan_report_unavailable_markup() ); + } + return array( 'status' => 200, 'body' => ct_scan_report_access_page_markup( $job_id, $token ) ); + } + if ( ! ct_scan_lock_acquire( 3 ) ) { + return array( 'status' => 503, 'body' => ct_scan_report_unavailable_markup() ); } 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.' ); + $body = ct_scan_validate_report_job( $row, $token, true ); + if ( is_wp_error( $body ) ) { + return array( 'status' => 410, 'body' => ct_scan_report_unavailable_markup() ); + } + $now = ct_scan_utc_now(); + if ( ! ct_scan_valid_datetime( $now ) || ! ct_scan_valid_datetime( $row->created_at ) || ! ct_scan_valid_datetime( $row->terms_accepted_at ) + || $now < $row->created_at || $now < $row->terms_accepted_at || ! ct_scan_clock_is_sane( $now ) ) { + return array( 'status' => 503, 'body' => ct_scan_report_unavailable_markup() ); + } + $data = array( + 'report_access_used_at' => $now, + 'report_delivered_at' => $now, + 'report_access_token_hash' => null, + 'report_access_expires_at' => null, + 'closed_at' => $now, + 'closure_source' => 'report_delivery', + 'closed_by' => 0, + 'closure_reason' => 'report_delivered', + ); + $where = array( + 'job_id' => (int) $row->job_id, + 'request_id' => (int) $row->request_id, + 'target_origin' => $row->target_origin, + 'scope_hash' => $row->scope_hash, + 'contact_email' => $row->contact_email, + 'contact_name' => $row->contact_name, + 'referrer' => $row->referrer, + 'terms_version' => $row->terms_version, + 'terms_hash' => $row->terms_hash, + 'terms_accepted_at' => $row->terms_accepted_at, + 'authorization_expires_at' => $row->authorization_expires_at, + 'status' => $row->status, + 'confirmed_at' => $row->confirmed_at, + 'control_required' => ct_scan_control_required( $row ) ? 1 : 0, + 'control_method' => $row->control_method ?? null, + 'control_verified_at' => $row->control_verified_at ?? null, + 'control_valid_until' => $row->control_valid_until ?? null, + 'manual_remainder_status' => $row->manual_remainder_status, + 'manual_remainder_notes' => $row->manual_remainder_notes, + 'result_json' => $row->result_json ?? null, + 'report_notification_status' => $row->report_notification_status, + 'report_access_token_hash' => $row->report_access_token_hash, + 'report_access_expires_at' => $row->report_access_expires_at, + 'report_access_used_at' => null, + 'report_delivered_at' => null, + 'closed_at' => $row->closed_at, + 'closure_source' => null, + 'closed_by' => null, + 'closure_reason' => null, + 'revoked_at' => null, + ); + $updated = $wpdb->update( $table, $data, $where ); + if ( false === $updated ) { + return array( 'status' => 503, 'body' => ct_scan_report_unavailable_markup() ); } - 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 ( 1 !== $updated ) { + return array( 'status' => 410, 'body' => ct_scan_report_unavailable_markup() ); } - // Detailed findings go only to a recipient who confirmed the request, whatever the terminal state. - if ( empty( $row->confirmed_at ) ) { - return new WP_Error( 'report_recipient_unconfirmed', 'The recipient never confirmed this request. Detailed findings cannot be sent.' ); + try { + update_post_meta( (int) $row->request_id, '_ct_scan_report_delivered_at', $now ); + } catch ( Throwable $error ) { + // The jobs table is the authoritative delivery record. A mirror failure must not suppress a served report. } - // ...and, when the job required it, only for a target whose control was established. - if ( ct_scan_control_required( $row ) && empty( $row->control_verified_at ) ) { - return new WP_Error( 'report_target_unverified', 'Control of the target was never established. Detailed findings cannot be sent.' ); + return array( 'status' => 200, 'body' => ct_scan_report_document_markup( $body ) ); + } finally { + ct_scan_lock_release(); + } +} + +function ct_scan_report_access_headers() { + return array( + 'Content-Type' => 'text/html; charset=UTF-8', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0, private', + 'Pragma' => 'no-cache', + 'Expires' => 'Wed, 11 Jan 1984 05:00:00 GMT', + 'Referrer-Policy' => 'no-referrer', + 'X-Robots-Tag' => 'noindex, nofollow, noarchive, nosnippet', + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'DENY', + 'Content-Security-Policy'=> "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'", + ); +} + +/** Is there trustworthy evidence that no report body was delivered, so an admin may close without a report? */ +function ct_scan_no_report_close_eligible( $row ) { + if ( ! is_object( $row ) ) { + return false; + } + foreach ( array( 'status', 'report_notification_status', 'report_access_token_hash', 'report_access_expires_at', 'report_access_used_at', 'report_delivered_at', 'closure_source', 'closed_by', 'closure_reason' ) as $field ) { + if ( ! property_exists( $row, $field ) ) { + return false; } - 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 - // The job stores the recorded terms hash lowercased (as the accepted-copy path compares it). - || strtolower( 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.' ); + } + if ( ! in_array( $row->status, ct_scan_terminal_statuses(), true ) + || null !== $row->closure_source || null !== $row->closed_by || null !== $row->closure_reason + || null !== $row->report_delivered_at || null !== $row->report_access_used_at ) { + return false; + } + $has_unredeemed_link = is_string( $row->report_access_token_hash ) + && 1 === preg_match( '/\A[a-f0-9]{64}\z/', $row->report_access_token_hash ) + && is_string( $row->report_access_expires_at ) + && ct_scan_valid_datetime( $row->report_access_expires_at ) + && in_array( $row->report_notification_status, array( 'accepted', 'unknown', 'failed' ), true ); + $never_sent = 'not_sent' === $row->report_notification_status + && null === $row->report_access_token_hash + && null === $row->report_access_expires_at; + return $has_unredeemed_link || $never_sent; +} + +function ct_scan_report_access_handler() { + $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : ''; + $source = 'POST' === $method ? $_POST : $_GET; + $job_id = isset( $source['job_id'] ) && is_scalar( $source['job_id'] ) ? absint( wp_unslash( $source['job_id'] ) ) : 0; + $token = isset( $source['key'] ) && is_string( $source['key'] ) ? strtolower( wp_unslash( $source['key'] ) ) : ''; + $response = ct_scan_report_access_response( $method, $job_id, $token ); + $status = (int) $response['status']; + if ( function_exists( 'status_header' ) ) { + status_header( $status ); + } else { + http_response_code( $status ); + } + if ( function_exists( 'nocache_headers' ) ) { + nocache_headers(); + } + foreach ( ct_scan_report_access_headers() as $name => $value ) { + header( $name . ': ' . $value ); + } + echo $response['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- response markup escapes the report body and capability fields. + exit; +} +add_action( 'admin_post_nopriv_ct_scan_report_access', 'ct_scan_report_access_handler' ); +add_action( 'admin_post_ct_scan_report_access', 'ct_scan_report_access_handler' ); + +/** Record the operator's final review of a terminal job; terminal status alone never closes a request. */ +function ct_scan_admin_close( $job_id, $reason ) { + global $wpdb; + $user = wp_get_current_user(); + $user_id = ( is_object( $user ) && isset( $user->ID ) && is_numeric( $user->ID ) ) ? (int) $user->ID : 0; + if ( ! $user || ! $user->exists() || $user_id < 1 || ! current_user_can( 'manage_options' ) ) { + return new WP_Error( 'close_forbidden', 'Only an authenticated administrator can record final closure.' ); + } + $job_id = absint( $job_id ); + if ( ! $job_id || ! is_string( $reason ) || ! in_array( $reason, ct_scan_terminal_statuses(), true ) ) { + return new WP_Error( 'close_invalid', 'Select a valid terminal status as the closure reason.' ); + } + if ( ! ct_scan_lock_acquire( 3 ) ) { + return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); + } + try { + $row = ct_scan_row( $job_id ); + if ( ! $row ) { + return new WP_Error( 'close_unavailable', 'This job no longer exists.' ); } - $body = ct_scan_build_report_body( $row ); - if ( is_wp_error( $body ) ) { - return $body; + foreach ( array( 'status', 'created_at', 'terms_accepted_at', 'closed_at', 'closure_source', 'closed_by', 'closure_reason', 'report_delivered_at', 'report_notification_status', 'report_access_token_hash', 'report_access_expires_at', 'report_access_used_at' ) as $field ) { + if ( ! property_exists( $row, $field ) ) { + return new WP_Error( 'close_schema_missing', 'The final-close state could not be verified.' ); + } + } + if ( ! in_array( $row->status, ct_scan_terminal_statuses(), true ) || $reason !== $row->status ) { + return new WP_Error( 'close_reason_mismatch', 'The reason must match the job’s saved terminal status.' ); + } + if ( null !== $row->closure_source || null !== $row->closed_by || null !== $row->closure_reason || null !== $row->report_delivered_at || null !== $row->report_access_used_at ) { + return new WP_Error( 'close_already_recorded', 'This job already has closure or report-delivery metadata.' ); + } + if ( ! ct_scan_no_report_close_eligible( $row ) ) { + // Pre-v8 `accepted`/`unknown` mail may have contained the report itself; without the v8 + // single-use capability fields, its delivery outcome cannot be truthfully called no-report. + return new WP_Error( 'close_report_ambiguous', 'The earlier report-delivery state cannot be verified; leave this request pending for review.' ); } $now = ct_scan_utc_now(); + if ( ! ct_scan_valid_datetime( $now ) || ! ct_scan_valid_datetime( $row->created_at ) || ! ct_scan_valid_datetime( $row->terms_accepted_at ) + || $now < $row->created_at || $now < $row->terms_accepted_at || ! ct_scan_clock_is_sane( $now ) ) { + return new WP_Error( 'close_clock_untrusted', 'The server and database clocks or saved acceptance dates could not be verified; nothing was closed.' ); + } $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' ) + ct_scan_jobs_table(), + array( + 'closed_at' => $now, + 'closure_source' => 'admin_final_close', + 'closed_by' => $user_id, + 'closure_reason' => $reason, + 'report_access_token_hash' => null, + 'report_access_expires_at' => null, + 'report_access_used_at' => null, + 'report_delivered_at' => null, + ), + array( + 'job_id' => (int) $row->job_id, + 'status' => $row->status, + 'created_at' => $row->created_at, + 'terms_accepted_at' => $row->terms_accepted_at, + 'closed_at' => $row->closed_at, + 'closure_source' => null, + 'closed_by' => null, + 'closure_reason' => null, + 'report_delivered_at' => null, + 'report_notification_status'=> $row->report_notification_status, + 'report_access_token_hash' => $row->report_access_token_hash, + 'report_access_expires_at' => $row->report_access_expires_at, + 'report_access_used_at' => null, + ), + array( '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s' ), + array( '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' ) ); + if ( false === $updated ) { + return new WP_Error( 'close_write_failed', 'Final closure could not be durably recorded.' ); + } 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.' ); + return new WP_Error( 'close_changed', 'This job changed while final closure was being recorded; reload and review it again.' ); } - 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'; - } + update_post_meta( (int) $row->request_id, '_ct_scan_closed_at', $now ); + update_post_meta( (int) $row->request_id, '_ct_scan_closure_source', 'admin_final_close' ); + update_post_meta( (int) $row->request_id, '_ct_scan_closed_by', $user_id ); + update_post_meta( (int) $row->request_id, '_ct_scan_closure_reason', $reason ); } 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.' ); + // The jobs table is authoritative. A mirror failure cannot roll back the authenticated closure. } - update_post_meta( (int) $row->request_id, '_ct_scan_report_notification_status', $status ); - } - finally { + } 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.' ); + return true; } -function ct_scan_admin_record_delivery( $job_id ) { +function ct_scan_admin_send_report( $job_id ) { if ( ! ct_scan_lock_acquire() ) { return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); } @@ -3212,25 +3786,79 @@ function ct_scan_admin_record_delivery( $job_id ) { $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.' ); + 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.' ); + } + $body = ct_scan_validate_report_job( $row, '', false, true ); + if ( is_wp_error( $body ) ) { + return $body; + } + if ( ! property_exists( $row, 'report_access_token_hash' ) || ! property_exists( $row, 'report_access_expires_at' ) || ! property_exists( $row, 'report_access_used_at' ) ) { + return new WP_Error( 'report_schema_missing', 'The saved report access state could not be verified.' ); } + $token = bin2hex( random_bytes( 32 ) ); $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' ) ); + // The scan window controls target traffic. A report link gets its own short lifetime and + // remains deliverable after scan authorization expires. + $expiry = time() + ( 7 * 24 * 60 * 60 ); + $access_expires = gmdate( 'Y-m-d H:i:s', $expiry ); + $updated = $wpdb->update( + $table, + array( + 'report_notification_status' => 'unknown', + 'report_notification_at' => $now, + 'report_access_token_hash' => hash( 'sha256', $token ), + 'report_access_expires_at' => $access_expires, + 'report_access_used_at' => null, + ), + array( + 'job_id' => (int) $row->job_id, + 'request_id' => (int) $row->request_id, + 'status' => $row->status, + 'report_notification_status' => $row->report_notification_status, + 'report_delivered_at' => null, + 'revoked_at' => null, + 'report_access_used_at' => null, + ), + array( '%s', '%s', '%s', '%s', '%s' ), + array( '%d', '%d', '%s', '%s', '%s', '%s', '%s' ) + ); if ( 1 !== $updated ) { - return new WP_Error( 'report_delivery_write_failed', 'The delivery confirmation could not be durably saved.' ); + return new WP_Error( 'report_state_write_failed', 'The one-time send state could not be durably recorded. No email was sent.' ); } - $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','awaiting_control','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_notification_status', 'unknown' ); + $url = add_query_arg( + array( 'action' => 'ct_scan_report_access', 'job_id' => (int) $row->job_id, 'key' => $token ), + admin_url( 'admin-post.php' ) + ); + $mail_body = "A reviewed report for a request you confirmed is ready.\n\nView your report using this one-time link, which expires within seven days:\n" . $url . "\n\nIf you did not make this request, you can ignore this email.\n"; + $status = 'unknown'; + try { + if ( wp_mail( $row->contact_email, 'Your reviewed website report is ready', $mail_body ) ) { + $status = 'accepted'; + } + } catch ( Throwable $error ) { + $status = 'unknown'; } - 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' ); + if ( 'accepted' === $status ) { + $updated = $wpdb->update( + $table, + array( 'report_notification_status' => 'accepted', 'report_notification_at' => ct_scan_utc_now() ), + array( 'job_id' => (int) $row->job_id, 'report_notification_status' => 'unknown', '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 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 true; + 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_action_handler() { @@ -3239,7 +3867,7 @@ function ct_scan_admin_action_handler() { } $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', 'authority_review', 'set_hold', 'release_hold' ), true ) ) { + if ( ! $job_id || ! in_array( $action, array( 'revoke', 'manual_complete', 'send_report', 'authority_review', 'set_hold', 'release_hold', 'final_close' ), true ) ) { wp_die( esc_html__( 'The requested action is invalid.', 'ct-scan' ), '', array( 'response' => 400 ) ); } check_admin_referer( 'ct_scan_admin_' . $action . '_' . $job_id ); @@ -3247,10 +3875,10 @@ function ct_scan_admin_action_handler() { 'revoke' => 'ct_scan_revoke_confirm', 'manual_complete' => 'ct_scan_review_confirm', 'send_report' => 'ct_scan_send_confirm', - 'record_delivery' => 'ct_scan_delivery_confirm', 'authority_review' => 'ct_scan_authority_confirm', 'set_hold' => 'ct_scan_hold_confirm', 'release_hold' => 'ct_scan_hold_release_confirm', + 'final_close' => 'ct_scan_final_close_confirm', ); $confirm_key = $confirmations[ $action ]; if ( ! isset( $_POST[ $confirm_key ] ) || ! is_string( $_POST[ $confirm_key ] ) || '1' !== wp_unslash( $_POST[ $confirm_key ] ) ) { @@ -3268,8 +3896,8 @@ function ct_scan_admin_action_handler() { $result = ct_scan_admin_release_hold( $job_id, isset( $_POST['ct_scan_hold_identity'] ) && is_string( $_POST['ct_scan_hold_identity'] ) ? sanitize_key( wp_unslash( $_POST['ct_scan_hold_identity'] ) ) : '' ); } elseif ( 'authority_review' === $action ) { $result = ct_scan_admin_record_authority_review( $job_id, isset( $_POST['ct_scan_authority_note'] ) && is_string( $_POST['ct_scan_authority_note'] ) ? wp_unslash( $_POST['ct_scan_authority_note'] ) : '' ); - } else { - $result = ct_scan_admin_record_delivery( $job_id ); + } elseif ( 'final_close' === $action ) { + $result = ct_scan_admin_close( $job_id, isset( $_POST['ct_scan_close_reason'] ) && is_string( $_POST['ct_scan_close_reason'] ) ? sanitize_key( wp_unslash( $_POST['ct_scan_close_reason'] ) ) : '' ); } $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' ) ); @@ -3283,6 +3911,52 @@ function ct_scan_admin_form( $job_id, $operation, $label, $extra = '' ) { return '
' . $nonce . $extra . '
'; } +/** Offer final close only from an authenticated admin edit screen for a terminal job with pending metadata. */ +function ct_scan_admin_add_final_close_metabox() { + if ( ! current_user_can( 'manage_options' ) || ! function_exists( 'add_meta_box' ) ) { + return; + } + add_meta_box( 'ct_scan_final_close', 'Scan request final closure', 'ct_scan_admin_render_final_close_metabox', 'ct_promo_request', 'side', 'high' ); +} +add_action( 'add_meta_boxes_ct_promo_request', 'ct_scan_admin_add_final_close_metabox' ); + +function ct_scan_admin_render_final_close_metabox( $post ) { + if ( ! current_user_can( 'manage_options' ) || ! is_object( $post ) || empty( $post->ID ) ) { + return; + } + global $wpdb; + $wpdb->last_error = ''; + $row = $wpdb->get_row( $wpdb->prepare( + 'SELECT job_id,status,closed_at,closure_source,closed_by,closure_reason,report_delivered_at,report_notification_status,report_access_token_hash,report_access_expires_at,report_access_used_at FROM ' . ct_scan_jobs_table() . ' WHERE request_id = %d LIMIT 1', + (int) $post->ID + ) ); + if ( '' !== (string) $wpdb->last_error || ! is_object( $row ) || ! in_array( $row->status, ct_scan_terminal_statuses(), true ) ) { + return; + } + foreach ( array( 'closed_at', 'closure_source', 'closed_by', 'closure_reason', 'report_delivered_at', 'report_notification_status', 'report_access_token_hash', 'report_access_expires_at', 'report_access_used_at' ) as $field ) { + if ( ! property_exists( $row, $field ) ) { + return; + } + } + echo '

Report transport: ' . esc_html( sanitize_key( (string) $row->report_notification_status ) ) . '. Report delivered: ' . ( null !== $row->report_delivered_at ? esc_html( $row->report_delivered_at . ' UTC' ) : 'not confirmed' ) . '.

'; + if ( ct_scan_closure_metadata_trusted( $row ) ) { + echo '

Final closure: ' . esc_html( $row->closure_source . ' at ' . $row->closed_at . ' UTC, actor ' . (int) $row->closed_by . ', reason ' . $row->closure_reason ) . '.

'; + return; + } + if ( null !== $row->closure_source || null !== $row->closed_by || null !== $row->closure_reason || null !== $row->report_delivered_at || null !== $row->report_access_used_at ) { + echo '

Closure metadata is present but does not pass validation; leave the request pending for review.

'; + return; + } + if ( ! ct_scan_no_report_close_eligible( $row ) ) { + echo '

The older report-delivery state is ambiguous. Leave this request pending until the delivery history is reconciled.

'; + return; + } + $reason = (string) $row->status; + $extra = ''; + echo '

Terminal status does not close the request. Final closure records the no-report retention anchor; use it only after confirming that no report was delivered.

'; + echo ct_scan_admin_form( (int) $row->job_id, 'final_close', 'Record final closure', $extra ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper escapes form fields and the reason above is escaped. +} + /** One line summarizing a recorded manual authority review, for the admin queue. */ function ct_scan_control_evidence_line( $row ) { $evidence = json_decode( (string) $row->control_evidence, true ); @@ -3324,6 +3998,7 @@ function ct_scan_admin_result_details( $row ) { return $html . ''; } +/** Private report/retention controls. Only authenticated administrators can see this queue. */ function ct_scan_admin_page() { if ( ! current_user_can( 'manage_options' ) ) { return; @@ -3332,14 +4007,15 @@ function ct_scan_admin_page() { 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'] ) ) { - $notice = sanitize_key( wp_unslash( $_GET['ct_scan_notice'] ) ); + $notice = sanitize_key( wp_unslash( $_GET['ct_scan_notice'] ) ); $messages = array( - 'hold_active' => 'Not changed: this job already has a preservation hold. A second hold cannot replace or shorten it; release the existing hold first (the records stay protected through the grace period), then place the new one.', - 'hold_changed' => 'Not changed: the hold was modified since this page was loaded (for example, released and replaced by another administrator). Reload the queue to see the current hold, then try again.', + 'close_report_ambiguous' => 'Final close was not recorded: an earlier report-delivery state is ambiguous and needs reconciliation.', + 'close_already_recorded' => 'Final close was not recorded: closure or report delivery is already recorded.', + 'close_changed' => 'Final close was not recorded: the saved job changed while you reviewed it. Reload the queue and check the current state.', + 'hold_active' => 'Not changed: this job already has a preservation hold. A second hold cannot replace or shorten it; release the existing hold first, then place the new one.', ); echo '

' . esc_html( $messages[ $notice ] ?? $notice ) . '

'; } @@ -3347,31 +4023,60 @@ function ct_scan_admin_page() { echo '

No screening jobs are stored.

'; return; } - echo ''; + echo '
Job / requestAuthorized scope and termsExecutionConfirmation / reportManual remainder and actions
'; 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' ) . "\nTarget control: " . ( $row->control_required ? ( $row->control_verified_at ? sanitize_key( (string) $row->control_method ) . ' at ' . $row->control_verified_at . ' UTC' : 'required, not shown (' . (int) $row->control_attempts . ' of 3 checks used)' ) : 'not required' ) . "\nClosed: " . ( $row->closed_at ? $row->closed_at . ' UTC' : '—' ) . "\nEvidence deletion date: " . ( $row->retain_until ? $row->retain_until . ' UTC' : ( $row->closed_at ? 'unknown (kept)' : '—' ) ) . "\nError: " . ( $row->error_code ? sanitize_key( $row->error_code ) : '—' ) . ( 'manual_review' === $row->control_method ? "\nAuthority review: " . ct_scan_control_evidence_line( $row ) : '' ) . "\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 ''; + $trusted_close = ct_scan_closure_metadata_trusted( $row ); + $closure = $trusted_close + ? $row->closure_source . ' at ' . $row->closed_at . ' UTC (actor ' . (int) $row->closed_by . ', reason ' . sanitize_key( $row->closure_reason ) . ')' + : 'pending; terminal status or legacy timestamp is not final closure'; + $retain = ct_scan_valid_datetime( (string) $row->retain_until ) ? $row->retain_until . ' UTC' : 'pending / kept'; + $exec = 'Status: ' . sanitize_key( (string) $row->status ) + . "\nAttempts: " . (int) $row->attempts + . "\nOutcome: " . ( $row->outcome ? sanitize_key( (string) $row->outcome ) : 'pending' ) + . "\nTarget control: " . ( ct_scan_control_required( $row ) ? ( $row->control_verified_at ? sanitize_key( (string) $row->control_method ) . ' at ' . $row->control_verified_at . ' UTC' : 'required, not shown (' . (int) $row->control_attempts . ' of 3 checks used)' ) : 'not required' ) + . "\nFinal close: " . $closure + . "\nSigned-evidence deletion date: " . $retain + . "\nError: " . ( $row->error_code ? sanitize_key( (string) $row->error_code ) : '—' ) + . ( 'manual_review' === $row->control_method ? "\nAuthority review: " . ct_scan_control_evidence_line( $row ) : '' ) + . "\nChecker revision: " . ( $row->checker_revision ? substr( (string) $row->checker_revision, 0, 12 ) . '…' : '—' ); + $delivery = $trusted_close && 'report_delivery' === $row->closure_source ? $row->report_delivered_at . ' UTC' : 'not confirmed'; + $link_state = is_string( $row->report_access_token_hash ) && 1 === preg_match( '/\A[a-f0-9]{64}\z/', $row->report_access_token_hash ) && ct_scan_valid_datetime( (string) $row->report_access_expires_at ) + ? 'one-time link issued; expires ' . $row->report_access_expires_at . ' UTC' + : 'no active one-time link'; + $mail = 'Confirmation email: ' . sanitize_key( (string) $row->confirmation_email_status ) + . "\nReport transport: " . sanitize_key( (string) $row->report_notification_status ) + . "\nReport delivery: " . $delivery + . "\nReport access: " . $link_state + . "\nRevoked: " . ( $row->revoked_at ? $row->revoked_at . ' UTC' : 'no' ); + echo ''; + echo ''; echo ''; } @@ -3406,11 +4110,20 @@ function ct_scan_add_request_meta_box() { add_action( 'add_meta_boxes_ct_promo_request', 'ct_scan_add_request_meta_box' ); function ct_scan_request_meta_box( $post ) { + if ( ! is_object( $post ) || empty( $post->ID ) ) { + return; + } $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

'; + $close_state = ct_scan_closure_metadata_trusted( $row ) + ? $row->closure_source . ' at ' . $row->closed_at . ' UTC' + : 'pending / unverified'; + $delivery = ct_scan_closure_metadata_trusted( $row ) && 'report_delivery' === $row->closure_source + ? $row->report_delivered_at . ' UTC' + : 'not confirmed'; + echo '

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

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

Terms: ' . esc_html( (string) $row->terms_version ) . ' / ' . esc_html( substr( (string) $row->terms_hash, 0, 16 ) ) . '…

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

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

Report delivery: ' . esc_html( $delivery ) . '

Final close: ' . esc_html( $close_state ) . '

Open private queue

'; } diff --git a/integrations/wordpress/standalone-tests/manual-lifecycle.php b/integrations/wordpress/standalone-tests/manual-lifecycle.php new file mode 100644 index 0000000..912ef2b --- /dev/null +++ b/integrations/wordpress/standalone-tests/manual-lifecycle.php @@ -0,0 +1,77 @@ +code = $code; } } +function add_action( ...$args ) {} +function add_filter( ...$args ) {} +function get_option( $key ) { return $GLOBALS['schema'] ?? '8'; } +function wp_cache_delete( $id, $group ) { ++$GLOBALS['cache_deletes']; unset( $GLOBALS['cached_markers'] ); } +function get_post_meta( $id, $key, $single = true ) { + if ( array_key_exists( 'cached_markers', $GLOBALS ) ) { return $GLOBALS['cached_markers']; } + if ( ! empty( $GLOBALS['meta_error'] ) ) { $GLOBALS['wpdb']->last_error = 'synthetic failure'; } + return $GLOBALS['markers'] ?? array(); +} +class ManualFixtureDb { + public $prefix = 'wp_'; + public $postmeta = 'wp_postmeta'; + public $last_error = ''; + public $owns = false; + public $row = null; + public $query_error = false; + public $lock_error = false; + public $reads = 0; + function prepare( $query, ...$args ) { return $query; } + function get_var( $query ) { if ( $this->lock_error ) { $this->last_error = 'synthetic failure'; } return $this->owns ? 1 : 0; } + function get_col( $query ) { if ( ! empty( $GLOBALS['meta_error'] ) ) { $this->last_error = 'synthetic failure'; } return $GLOBALS['markers'] ?? array(); } + function get_row( $query ) { ++$this->reads; if ( $this->query_error ) { $this->last_error = 'synthetic failure'; } return $this->row; } +} +$GLOBALS['cache_deletes'] = 0; +$GLOBALS['wpdb'] = new ManualFixtureDb(); +require dirname( __DIR__ ) . '/ct-scan.php'; +$count = 0; +function check_manual( $ok, $message ) { global $count; ++$count; if ( ! $ok ) { throw new RuntimeException( $message ); } } +$db = $GLOBALS['wpdb']; +check_manual( false === ct_scan_manual_closure_ready( 805 ), 'Manual close accepted without owning the queue lock.' ); +check_manual( 0 === $db->reads, 'Manual close queried queue before proving lock ownership.' ); +$db->owns = true; +check_manual( true === ct_scan_manual_closure_ready( 805 ), 'Successful locked empty-queue read was refused.' ); +foreach ( array( 0, -805, '0805', '805x', 805.0, null, true, array() ) as $bad ) { + check_manual( false === ct_scan_manual_closure_ready( $bad ), 'Noncanonical request identity was accepted.' ); +} +$GLOBALS['schema'] = '7'; +check_manual( false === ct_scan_manual_closure_ready( 805 ), 'Manual close accepted stale schema.' ); +$GLOBALS['schema'] = '8'; +$db->row = (object) array( 'job_id' => 1 ); +check_manual( false === ct_scan_manual_closure_ready( 805 ), 'Manual close accepted an existing queued request.' ); +$db->row = null; $db->query_error = true; +check_manual( false === ct_scan_manual_closure_ready( 805 ), 'Failed queue read was mistaken for no job.' ); +$db->query_error = false; $db->lock_error = true; +check_manual( false === ct_scan_manual_closure_ready( 805 ), 'Failed lock ownership read was accepted.' ); +$db->lock_error = false; +check_manual( false === ct_scan_manual_queue_blocked( 805 ), 'Absent marker blocked enqueue.' ); +$GLOBALS['meta_error'] = true; +check_manual( true === ct_scan_manual_queue_blocked( 805 ), 'Metadata read failure allowed enqueue.' ); +$GLOBALS['meta_error'] = false; $GLOBALS['markers'] = array( '' ); +$GLOBALS['cached_markers'] = array(); +check_manual( true === ct_scan_manual_queue_blocked( 805 ), 'A cached absent marker permitted enqueue after another request closed.' ); +check_manual( array_key_exists( 'cached_markers', $GLOBALS ), 'Direct marker read unexpectedly depended on the metadata cache.' ); +check_manual( true === ct_scan_manual_queue_blocked( 805 ), 'Present malformed marker allowed enqueue without its verifier.' ); +// Conditional declaration intentionally follows the missing-plugin regression. +if ( ! function_exists( 'ct_archive_manual_queue_blocked' ) ) { + function ct_archive_manual_queue_blocked( $id, $fresh_raw ) { $GLOBALS['passed_raw'] = $fresh_raw; return $GLOBALS['verdict']; } +} +foreach ( array( true, null, 0, '', 'false', array() ) as $bad_verdict ) { + $GLOBALS['verdict'] = $bad_verdict; + check_manual( true === ct_scan_manual_queue_blocked( 805 ), 'An untrusted verifier result permitted enqueue.' ); +} +$GLOBALS['verdict'] = false; +$GLOBALS['markers'] = array( 'fresh serialized marker' ); +check_manual( false === ct_scan_manual_queue_blocked( 805 ), 'An explicitly validated reopened request was blocked.' ); +check_manual( 'fresh serialized marker' === $GLOBALS['passed_raw'], 'Verifier did not receive the exact fresh database marker.' ); +$GLOBALS['markers'] = array( 'one', 'two' ); +check_manual( true === ct_scan_manual_queue_blocked( 805 ), 'Duplicate lifecycle markers permitted enqueue.' ); +$GLOBALS['markers'] = array( array() ); +check_manual( true === ct_scan_manual_queue_blocked( 805 ), 'Nonstring raw database marker permitted enqueue.' ); +echo $count . " manual lifecycle guards passed.\n"; diff --git a/integrations/wordpress/standalone-tests/report-access.php b/integrations/wordpress/standalone-tests/report-access.php new file mode 100644 index 0000000..6ad9c9a --- /dev/null +++ b/integrations/wordpress/standalone-tests/report-access.php @@ -0,0 +1,486 @@ +code = $code; $this->message = $message; } + function get_error_code() { return $this->code; } + function get_error_message() { return $this->message; } +} + +class FakeScanUser { + public $ID; + function __construct( $id = 0 ) { $this->ID = $id; } + function exists() { return $this->ID > 0; } +} + +class FakeScanDb { + public $prefix = 'wp_'; + public $last_error = ''; + public $row; + public $locked = false; + public $fail_next_update = false; + public $zero_next_update = false; + public $during_redemption = null; + public $updates = array(); + function prepare( $query, ...$args ) { return $query; } + function get_var( $query ) { + if ( false !== strpos( $query, 'GET_LOCK' ) ) { + if ( $this->locked ) { return 0; } + $this->locked = true; + return 1; + } + if ( false !== strpos( $query, 'RELEASE_LOCK' ) ) { + $this->locked = false; + return 1; + } + if ( false !== strpos( $query, 'SELECT UTC_TIMESTAMP()' ) ) { + return gmdate( 'Y-m-d H:i:s' ); + } + if ( false !== strpos( $query, 'SELECT MAX(' ) ) { + return null; + } + return null; + } + function get_row( $query ) { + if ( ! $this->row || false === strpos( $query, 'SELECT * FROM wp_ct_scan_jobs' ) ) { return null; } + return clone $this->row; + } + function update( $table, $data, $where, $format = null, $where_format = null ) { + $this->updates[] = array( $data, $where ); + if ( isset( $data['report_delivered_at'] ) && null !== $data['report_delivered_at'] + && isset( $data['report_access_used_at'] ) && null !== $data['report_access_used_at'] && $this->during_redemption ) { + $callback = $this->during_redemption; + $this->during_redemption = null; + $callback(); + } + if ( $this->fail_next_update ) { + $this->fail_next_update = false; + return false; + } + if ( $this->zero_next_update ) { + $this->zero_next_update = false; + return 0; + } + foreach ( $where as $key => $expected ) { + $actual = $this->row->$key ?? null; + if ( null === $expected ? null !== $actual : ( null === $actual || (string) $actual !== (string) $expected ) ) { + return 0; + } + } + foreach ( $data as $key => $value ) { $this->row->$key = $value; } + return 1; + } +} + +$GLOBALS['actions'] = array(); +$GLOBALS['meta'] = array(); +$GLOBALS['posts'] = array(); +$GLOBALS['mail_sends'] = array(); +$GLOBALS['mail_result'] = false; +$GLOBALS['current_user'] = new FakeScanUser( 0 ); +$GLOBALS['current_user_can_manage'] = false; +$GLOBALS['wpdb'] = new FakeScanDb(); +$GLOBALS['submenu_pages'] = array(); + +function add_action( $name, $callback, $priority = 10, $accepted_args = 1 ) { $GLOBALS['actions'][ $name ][] = $callback; } +function add_filter( $name, $callback, $priority = 10, $accepted_args = 1 ) {} +function add_submenu_page( ...$args ) { $GLOBALS['submenu_pages'][] = $args; } +function is_wp_error( $value ) { return $value instanceof WP_Error; } +function absint( $value ) { return abs( (int) $value ); } +function wp_parse_url( $url, $component = -1 ) { return parse_url( $url, $component ); } +function wp_strip_all_tags( $value ) { return strip_tags( (string) $value ); } +function wp_json_encode( $value, $flags = 0 ) { return json_encode( $value, $flags ); } +function wp_unslash( $value ) { return is_string( $value ) ? stripslashes( $value ) : $value; } +function clean_post_cache( $id ) {} +function get_post( $id ) { return $GLOBALS['posts'][ $id ] ?? null; } +function get_post_meta( $id, $key, $single = true ) { + if ( ! $single ) { return array_key_exists( $key, $GLOBALS['meta'][ $id ] ?? array() ) ? array( $GLOBALS['meta'][ $id ][ $key ] ) : array(); } + return $GLOBALS['meta'][ $id ][ $key ] ?? ''; +} +function update_post_meta( $id, $key, $value ) { $GLOBALS['meta'][ $id ][ $key ] = $value; return true; } +function delete_post_meta( $id, $key ) { unset( $GLOBALS['meta'][ $id ][ $key ] ); return true; } +function wp_get_current_user() { return $GLOBALS['current_user']; } +function current_user_can( $capability ) { return 'manage_options' === $capability && ! empty( $GLOBALS['current_user_can_manage'] ); } +function get_current_user_id() { return (int) $GLOBALS['current_user']->ID; } +function is_user_logged_in() { return $GLOBALS['current_user']->exists(); } +function sanitize_email( $value ) { return filter_var( trim( (string) $value ), FILTER_SANITIZE_EMAIL ); } +function is_email( $value ) { return is_string( $value ) && false !== filter_var( $value, FILTER_VALIDATE_EMAIL ); } +function sanitize_text_field( $value ) { return trim( strip_tags( (string) $value ) ); } +function sanitize_textarea_field( $value ) { return trim( strip_tags( (string) $value ) ); } +function sanitize_title( $value ) { return strtolower( trim( preg_replace( '/[^a-z0-9-]+/i', '-', (string) $value ), '-' ) ); } +function sanitize_key( $value ) { return preg_replace( '/[^a-z0-9_-]/', '', strtolower( (string) $value ) ); } +function esc_html( $value ) { return htmlspecialchars( (string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); } +function esc_attr( $value ) { return esc_html( $value ); } +function esc_url( $value ) { return htmlspecialchars( (string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); } +function admin_url( $path = '' ) { return 'https://complete.tech/wp-admin/' . ltrim( $path, '/' ); } +function add_query_arg( $args, $url ) { return $url . ( false === strpos( $url, '?' ) ? '?' : '&' ) . http_build_query( $args, '', '&', PHP_QUERY_RFC3986 ); } +function wp_mail( $to, $subject, $body, $headers = array() ) { + $GLOBALS['mail_sends'][] = array( 'to' => $to, 'subject' => $subject, 'body' => $body, 'headers' => $headers ); + return $GLOBALS['mail_result']; +} + +require dirname( __DIR__ ) . '/ct-scan.php'; + +ct_scan_register_admin_menu(); +check( + isset( $GLOBALS['submenu_pages'][0][4], $GLOBALS['submenu_pages'][0][5] ) + && 'ct-scan-queue' === $GLOBALS['submenu_pages'][0][4] + && 'ct_scan_admin_page' === $GLOBALS['submenu_pages'][0][5] + && in_array( 'ct_scan_register_admin_menu', $GLOBALS['actions']['admin_menu'] ?? array(), true ), + 'the authenticated admin menu registers the report, delivery-status, and final-close queue page' +); + +function check( $condition, $message ) { + if ( ! $condition ) { + fwrite( STDERR, "FAIL: {$message}\n" ); + exit( 1 ); + } + echo "PASS: {$message}\n"; +} + +function fixture_reset( $notification_status = 'not_sent' ) { + $job_id = 417; + $request_id = 902; + $accepted_epoch = time() - 3600; + $accepted_iso = gmdate( 'Y-m-d\TH:i:s\Z', $accepted_epoch ); + $accepted_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch ); + $expiry = gmdate( 'Y-m-d H:i:s', time() + 20 * 86400 ); + $terms_hash = hash( 'sha256', 'synthetic accepted terms' ); + $contract = "Client contact: Fixture Reviewer\nClient email: fixture@example.net\nWebsite (scan scope): https://fixture.example.net/\nReferred by: direct\n"; + $GLOBALS['posts'][ $request_id ] = (object) array( + 'post_type' => 'ct_promo_request', + 'post_status' => 'private', + 'post_content' => "Synthetic request\n" . $contract, + ); + $GLOBALS['meta'][ $request_id ] = array( + '_ct_scan_job_id' => (string) $job_id, + '_ct_scan_target' => 'https://fixture.example.net/', + '_ct_scan_email' => 'fixture@example.net', + '_ct_scan_name' => 'Fixture Reviewer', + '_ct_scan_authorization_expires_at' => $expiry, + '_ct_ref' => 'direct', + '_ct_terms_saved' => '1', + '_ct_terms_version' => '2026-10-21-r2', + '_ct_terms_url' => 'https://complete.tech/fixture-terms/', + '_ct_terms_sha256' => $terms_hash, + '_ct_terms_accepted_at' => $accepted_iso, + '_ct_terms_contract' => $contract, + ); + $GLOBALS['wpdb']->row = (object) array( + 'job_id' => $job_id, + 'request_id' => $request_id, + 'target_origin' => 'https://fixture.example.net/', + 'scope_hash' => hash( 'sha256', 'https://fixture.example.net/' ), + 'terms_version' => '2026-10-21-r2', + 'terms_hash' => $terms_hash, + 'terms_accepted_at' => $accepted_sql, + 'created_at' => $accepted_sql, + 'finished_at' => gmdate( 'Y-m-d H:i:s', time() - 1800 ), + 'authorization_expires_at' => $expiry, + 'contact_email' => 'fixture@example.net', + 'contact_name' => 'Fixture Reviewer', + 'referrer' => 'direct', + 'status' => 'failed', + 'confirmed_at' => $accepted_sql, + 'revoked_at' => null, + 'report_delivered_at' => null, + 'report_notification_status' => $notification_status, + 'report_notification_at' => null, + 'report_access_token_hash' => null, + 'report_access_expires_at' => null, + 'report_access_used_at' => null, + 'closed_at' => gmdate( 'Y-m-d H:i:s', time() - 1800 ), // Legacy maintenance-only terminal timestamp. + 'closure_source' => null, + 'closed_by' => null, + 'closure_reason' => null, + 'manual_remainder_status' => 'complete', + 'manual_remainder_notes' => 'Synthetic private finding marker 9f7e; comparison 2 < 3 & reviewed.', + 'error_code' => 'fixture_failure', + 'profile_id' => 'r2-header-v1', + 'bound_required' => 0, + 'summary_sha256' => null, + 'control_required' => 1, + 'control_state' => 'none', + 'control_method' => 'dns_txt', + 'control_verified_at' => gmdate( 'Y-m-d H:i:s', time() - 600 ), + 'control_valid_until' => $expiry, + ); + $GLOBALS['wpdb']->locked = false; + $GLOBALS['wpdb']->fail_next_update = false; + $GLOBALS['wpdb']->zero_next_update = false; + $GLOBALS['wpdb']->during_redemption = null; + $GLOBALS['mail_sends'] = array(); + $GLOBALS['mail_result'] = false; + $GLOBALS['current_user'] = new FakeScanUser( 0 ); + $GLOBALS['current_user_can_manage'] = false; + return array( $job_id, $request_id ); +} + +function send_fixture_link( $mail_result = false ) { + list( $job_id ) = fixture_reset(); + $GLOBALS['mail_result'] = $mail_result; + $result = ct_scan_admin_send_report( $job_id ); + check( 'unknown' === $GLOBALS['wpdb']->row->report_notification_status || 'accepted' === $GLOBALS['wpdb']->row->report_notification_status, 'report mail state is persisted' ); + check( 1 === count( $GLOBALS['mail_sends'] ), 'one generic report-link email was attempted' ); + return array( $job_id, $GLOBALS['mail_sends'][0] ); +} + +list( $job_id, $request_id ) = fixture_reset(); +$pending_closed_at = $GLOBALS['wpdb']->row->closed_at; +check( + 'failed' === $GLOBALS['wpdb']->row->status + && null === $GLOBALS['wpdb']->row->closure_source + && null === $GLOBALS['wpdb']->row->closed_by + && null === $GLOBALS['wpdb']->row->closure_reason + && $pending_closed_at === $GLOBALS['wpdb']->row->closed_at, + 'a terminal scan and legacy maintenance timestamp remain pending without explicit closure metadata' +); +$GLOBALS['wpdb']->fail_next_update = true; +$failed_send = ct_scan_admin_send_report( $job_id ); +check( is_wp_error( $failed_send ) && 'report_state_write_failed' === $failed_send->get_error_code(), 'send state write failure is reported' ); +check( 0 === count( $GLOBALS['mail_sends'] ), 'no email is sent when the capability state cannot be stored' ); + +list( $job_id, $mail ) = send_fixture_link( false ); +check( is_wp_error( ct_scan_admin_send_report( $job_id ) ), 'unknown mail outcome is not retried' ); +check( 1 === count( $GLOBALS['mail_sends'] ), 'unknown mail outcome sends at most once' ); +check( 'fixture@example.net' === $mail['to'] && 'Your reviewed website report is ready' === $mail['subject'], 'report notification goes to the confirmed recipient with a generic subject' ); +check( false === strpos( $mail['body'], 'fixture.example.net' ) && false === strpos( $mail['body'], 'Fixture Reviewer' ) && false === strpos( $mail['body'], '9f7e' ), 'email contains no target, recipient name, or report findings' ); +check( 1 === preg_match( '#https://complete\.tech/wp-admin/admin-post\.php\?[^\s]+#', $mail['body'], $url_match ), 'email contains only a report access URL' ); +$query = array(); +parse_str( (string) parse_url( $url_match[0], PHP_URL_QUERY ), $query ); +$token = $query['key'] ?? ''; +check( 64 === strlen( $token ) && 1 === preg_match( '/\A[a-f0-9]{64}\z/', $token ), 'email capability has 256 bits of URL-safe entropy' ); +check( array( 'action', 'job_id', 'key' ) === array_keys( $query ), 'URL contains only the route, object id, and opaque capability' ); +check( hash( 'sha256', $token ) === $GLOBALS['wpdb']->row->report_access_token_hash, 'only a one-way hash of the email capability is stored' ); + +$get = ct_scan_report_access_response( 'GET', $job_id, $token ); +check( 200 === $get['status'] && false !== strpos( $get['body'], 'View report' ), 'GET renders an explicit POST form' ); +check( false === strpos( $get['body'], '9f7e' ) && false === strpos( $get['body'], '2 < 3' ), 'GET and link previews never reveal report content' ); +$closed_at_before_get = $GLOBALS['wpdb']->row->closed_at; +check( + empty( $GLOBALS['wpdb']->row->report_access_used_at ) + && empty( $GLOBALS['wpdb']->row->report_delivered_at ) + && null === $GLOBALS['wpdb']->row->closure_source + && null === $GLOBALS['wpdb']->row->closed_by + && null === $GLOBALS['wpdb']->row->closure_reason + && $closed_at_before_get === $GLOBALS['wpdb']->row->closed_at, + 'GET does not consume the capability, serve the report, or create trusted closure metadata' +); +$headers = ct_scan_report_access_headers(); +check( false !== strpos( $headers['Cache-Control'], 'no-store' ) && 'no-referrer' === $headers['Referrer-Policy'] && false !== strpos( $headers['X-Robots-Tag'], 'noindex' ) && 'nosniff' === $headers['X-Content-Type-Options'], 'access responses disable caching, referrers, indexing, and MIME sniffing' ); +check( false !== strpos( $headers['Content-Security-Policy'], "default-src 'none'" ) && false !== strpos( $headers['Content-Security-Policy'], "form-action 'self'" ), 'access page blocks external resources and limits form submission to this site' ); +check( 1 !== preg_match( '/<(?:script|iframe|img|link|style)\b|\bsrc\s*=/i', $get['body'] ), 'the access page loads no scripts, stylesheets, frames, or external assets' ); + +$GLOBALS['wpdb']->fail_next_update = true; +$write_failed = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 503 === $write_failed['status'] && false === strpos( $write_failed['body'], '9f7e' ), 'redemption write failure serves no report' ); +check( $token !== '' && hash( 'sha256', $token ) === $GLOBALS['wpdb']->row->report_access_token_hash && empty( $GLOBALS['wpdb']->row->report_delivered_at ), 'failed redemption leaves the capability unconsumed and report unserved' ); + +$GLOBALS['wpdb']->zero_next_update = true; +$cas_lost = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 410 === $cas_lost['status'] && false === strpos( $cas_lost['body'], '9f7e' ), 'a lost compare-and-set race fails closed without serving the report' ); +check( hash( 'sha256', $token ) === $GLOBALS['wpdb']->row->report_access_token_hash && empty( $GLOBALS['wpdb']->row->report_delivered_at ), 'a compare-and-set miss leaves the capability unserved' ); + +$GLOBALS['meta'][ $request_id ]['_ct_scan_email'] = 'changed@example.net'; +$recipient_changed = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 410 === $recipient_changed['status'] && false === strpos( $recipient_changed['body'], '9f7e' ), 'a changed saved recipient cannot redeem the capability' ); +$GLOBALS['meta'][ $request_id ]['_ct_scan_email'] = 'fixture@example.net'; +$GLOBALS['meta'][ $request_id ]['_ct_terms_version'] = 'changed-version'; +$terms_version_changed = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 410 === $terms_version_changed['status'], 'a changed saved terms version cannot redeem the capability' ); +$GLOBALS['meta'][ $request_id ]['_ct_terms_version'] = '2026-10-21-r2'; +$GLOBALS['meta'][ $request_id ]['_ct_terms_sha256'] = str_repeat( 'b', 64 ); +$terms_hash_changed = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 410 === $terms_hash_changed['status'], 'a changed saved terms hash cannot redeem the capability' ); +$GLOBALS['meta'][ $request_id ]['_ct_terms_sha256'] = $GLOBALS['wpdb']->row->terms_hash; + +$GLOBALS['wpdb']->row->confirmed_at = null; +$unconfirmed = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 410 === $unconfirmed['status'], 'an unconfirmed recipient cannot redeem the capability' ); +$GLOBALS['wpdb']->row->confirmed_at = $GLOBALS['wpdb']->row->terms_accepted_at; + +$GLOBALS['wpdb']->row->control_valid_until = gmdate( 'Y-m-d H:i:s', time() - 1 ); +$control_expired = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 410 === $control_expired['status'], 'expired exact-target control blocks report access' ); +$GLOBALS['wpdb']->row->control_valid_until = $GLOBALS['wpdb']->row->authorization_expires_at; + +$GLOBALS['wpdb']->row->report_access_expires_at = gmdate( 'Y-m-d H:i:s', time() - 1 ); +$expired = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 410 === $expired['status'] && empty( $GLOBALS['wpdb']->row->report_delivered_at ), 'expired capability cannot serve the report' ); +$GLOBALS['wpdb']->row->report_access_expires_at = gmdate( 'Y-m-d H:i:s', time() + 3600 ); + +$malformed = ct_scan_report_access_response( 'POST', $job_id, 'not-a-capability' ); +check( 404 === $malformed['status'] && false === strpos( $malformed['body'], '9f7e' ), 'malformed capability is rejected without report disclosure' ); + +$GLOBALS['wpdb']->row->control_required = 0; +unset( $GLOBALS['wpdb']->row->control_required ); // missing field is interpreted as required +$GLOBALS['wpdb']->row->control_verified_at = ''; +$missing_control = ct_scan_report_access_response( 'GET', $job_id, $token ); +check( 410 === $missing_control['status'], 'a missing control-required field fails closed when proof is absent' ); +$GLOBALS['wpdb']->row->control_required = 1; +$GLOBALS['wpdb']->row->control_verified_at = gmdate( 'Y-m-d H:i:s', time() - 600 ); + +$race = null; +$GLOBALS['wpdb']->during_redemption = function () use ( $job_id, $token, &$race ) { + $race = ct_scan_report_access_response( 'POST', $job_id, $token ); +}; +$served = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 200 === $served['status'] && 503 === $race['status'], 'the queue lock serializes simultaneous capability POSTs' ); +check( ! empty( $GLOBALS['wpdb']->row->report_access_used_at ) && ! empty( $GLOBALS['wpdb']->row->report_delivered_at ), 'successful POST atomically consumes the capability and marks the report served' ); +check( $GLOBALS['wpdb']->row->report_delivered_at === $GLOBALS['wpdb']->row->closed_at && 'report_delivery' === $GLOBALS['wpdb']->row->closure_source && 0 === $GLOBALS['wpdb']->row->closed_by && 'report_delivered' === $GLOBALS['wpdb']->row->closure_reason, 'successful POST anchors closure to the same server time and trusted report-delivery source' ); +check( false !== strpos( $served['body'], 'Synthetic private finding marker 9f7e' ) && false !== strpos( $served['body'], '2 < 3 & reviewed' ) && false === strpos( $served['body'], 'row->request_id ]['_ct_scan_job_id'] = '999'; +$request_mismatch = ct_scan_report_access_response( 'GET', $job_id, $token ); +check( 410 === $request_mismatch['status'], 'capability use requires the exact saved request-to-job linkage' ); + +list( $job_id ) = send_fixture_link( true ); +$token = ''; +foreach ( $GLOBALS['mail_sends'] as $sent ) { + if ( preg_match( '#key=([a-f0-9]{64})#', $sent['body'], $match ) ) { $token = $match[1]; } +} +$revoked = ct_scan_admin_revoke( $job_id ); +check( true === $revoked && ! empty( $GLOBALS['wpdb']->row->revoked_at ) && null === $GLOBALS['wpdb']->row->report_access_token_hash, 'authorization revocation clears the outstanding report capability' ); +$after_revoke = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 410 === $after_revoke['status'] && false === strpos( $after_revoke['body'], '9f7e' ), 'revoked authorization blocks report access' ); + +list( $job_id, $mail ) = send_fixture_link( true ); +preg_match( '#key=([a-f0-9]{64})#', $mail['body'], $match ); +$token = $match[1] ?? ''; +$GLOBALS['wpdb']->row->closure_source = 'other_closure'; +$existing_closure = ct_scan_report_access_response( 'POST', $job_id, $token ); +check( 410 === $existing_closure['status'] && empty( $GLOBALS['wpdb']->row->report_delivered_at ), 'existing closure metadata blocks replacement and report serving' ); + +list( $job_id, $request_id ) = fixture_reset(); +$GLOBALS['wpdb']->row->status = 'completed'; +$GLOBALS['wpdb']->row->result_json = ''; +$invalid_report = ct_scan_admin_send_report( $job_id ); +check( is_wp_error( $invalid_report ) && 'report_result_missing' === $invalid_report->get_error_code(), 'report schema/readiness validation still blocks sending an incomplete result' ); +check( 0 === count( $GLOBALS['mail_sends'] ), 'invalid report content is never emailed' ); + +list( $job_id ) = fixture_reset(); +$pending_timestamp = $GLOBALS['wpdb']->row->closed_at; +$pending = ct_scan_admin_close( $job_id, 'failed' ); +check( is_wp_error( $pending ), 'a terminal row cannot be finalized without an authenticated administrator' ); +check( + $pending_timestamp === $GLOBALS['wpdb']->row->closed_at + && null === $GLOBALS['wpdb']->row->closure_source + && null === $GLOBALS['wpdb']->row->closed_by + && null === $GLOBALS['wpdb']->row->closure_reason, + 'missing actor and closure metadata leave the row pending without rewriting its legacy timestamp' +); + +$GLOBALS['current_user'] = new FakeScanUser( 41 ); +$GLOBALS['current_user_can_manage'] = false; +$unauthorized = ct_scan_admin_close( $job_id, 'failed' ); +check( is_wp_error( $unauthorized ) && null === $GLOBALS['wpdb']->row->closure_source, 'a positive user without manage_options cannot finalize the request' ); + +$GLOBALS['current_user_can_manage'] = true; +$GLOBALS['current_user'] = new FakeScanUser( 0 ); +$missing_actor = ct_scan_admin_close( $job_id, 'failed' ); +check( is_wp_error( $missing_actor ) && null === $GLOBALS['wpdb']->row->closed_by, 'manage_options without a positive authenticated user ID cannot finalize the request' ); + +$GLOBALS['current_user'] = new FakeScanUser( 41 ); +$missing_delivery_column = clone $GLOBALS['wpdb']->row; +unset( $missing_delivery_column->report_delivered_at ); +check( + ! ct_scan_no_report_close_eligible( $missing_delivery_column ), + 'a missing report-delivery column is not treated as an explicit SQL NULL or no-report proof' +); +$invalid_reason = ct_scan_admin_close( $job_id, 'closed_without_report' ); +$mismatched_reason = ct_scan_admin_close( $job_id, 'completed' ); +check( is_wp_error( $invalid_reason ) && is_wp_error( $mismatched_reason ), 'final closure rejects unrecognized reasons and reasons that do not match the terminal status' ); +check( null === $GLOBALS['wpdb']->row->closure_source && null === $GLOBALS['wpdb']->row->closed_by && null === $GLOBALS['wpdb']->row->closure_reason, 'invalid final-close attempts persist no closure metadata' ); + +$old_timestamp = $GLOBALS['wpdb']->row->closed_at; +$closed = ct_scan_admin_close( $job_id, 'failed' ); +check( true === $closed, 'an authenticated administrator can explicitly close a terminal request with its allowlisted reason' ); +check( + 'admin_final_close' === $GLOBALS['wpdb']->row->closure_source + && 41 === (int) $GLOBALS['wpdb']->row->closed_by + && 'failed' === $GLOBALS['wpdb']->row->closure_reason + && null === $GLOBALS['wpdb']->row->report_delivered_at + && null === $GLOBALS['wpdb']->row->report_access_used_at, + 'admin final close stores its source, positive actor, exact reason, and undelivered state' +); +$close_write = end( $GLOBALS['wpdb']->updates ); +check( + isset( $close_write[0] ) + && array_key_exists( 'report_delivered_at', $close_write[0] ) + && null === $close_write[0]['report_delivered_at'] + && array_key_exists( 'report_access_used_at', $close_write[0] ) + && null === $close_write[0]['report_access_used_at'], + 'the final-close database write explicitly stores SQL NULL for both delivery timestamps' +); +$closed_epoch = strtotime( $GLOBALS['wpdb']->row->closed_at . ' UTC' ); +check( + $GLOBALS['wpdb']->row->closed_at !== $old_timestamp + && is_int( $closed_epoch ) + && $closed_epoch >= strtotime( $GLOBALS['wpdb']->row->terms_accepted_at . ' UTC' ) + && abs( time() - $closed_epoch ) <= 10, + 'final close replaces the legacy timestamp with a trustworthy server time after acceptance' +); +$closed_at = $GLOBALS['wpdb']->row->closed_at; +$duplicate_close = ct_scan_admin_close( $job_id, 'failed' ); +check( is_wp_error( $duplicate_close ) && $closed_at === $GLOBALS['wpdb']->row->closed_at, 'a repeated final-close action cannot replace trusted closure evidence' ); +$edit_after_close = ct_scan_admin_save_manual_remainder( $job_id, 'Attempted post-close edit.' ); +check( is_wp_error( $edit_after_close ) && 'admin_final_close' === $GLOBALS['wpdb']->row->closure_source, 'a final-closed request cannot have its reviewed findings rewritten' ); + +list( $job_id ) = fixture_reset( 'accepted' ); +$GLOBALS['current_user'] = new FakeScanUser( 58 ); +$GLOBALS['current_user_can_manage'] = true; +$legacy_mail_ambiguous = ct_scan_admin_close( $job_id, 'failed' ); +check( is_wp_error( $legacy_mail_ambiguous ) && 'close_report_ambiguous' === $legacy_mail_ambiguous->get_error_code() && null === $GLOBALS['wpdb']->row->closure_source, 'a pre-v8 accepted full-report email without a single-use-link record cannot be mislabeled as no-report closure' ); + +list( $job_id ) = fixture_reset( 'accepted' ); +$GLOBALS['current_user'] = new FakeScanUser( 59 ); +$GLOBALS['current_user_can_manage'] = true; +$GLOBALS['wpdb']->row->report_access_token_hash = str_repeat( 'a', 64 ); +$GLOBALS['wpdb']->row->report_access_expires_at = gmdate( 'Y-m-d H:i:s', time() + 3600 ); +$unredeemed_link_close = ct_scan_admin_close( $job_id, 'failed' ); +check( + true === $unredeemed_link_close + && 'admin_final_close' === $GLOBALS['wpdb']->row->closure_source + && null === $GLOBALS['wpdb']->row->report_access_token_hash + && null === $GLOBALS['wpdb']->row->report_access_expires_at + && ct_scan_closure_metadata_trusted( $GLOBALS['wpdb']->row ), + 'an unused report link can be invalidated by an authenticated no-report close, and the persisted event has trusted v8 actor/source fields' +); + +foreach ( array( 'completed', 'failed', 'failed_unknown', 'cancelled', 'expired', 'blocked' ) as $terminal_reason ) { + list( $job_id ) = fixture_reset(); + $GLOBALS['wpdb']->row->status = $terminal_reason; + $GLOBALS['current_user'] = new FakeScanUser( 53 ); + $GLOBALS['current_user_can_manage'] = true; + $terminal_closed = ct_scan_admin_close( $job_id, $terminal_reason ); + check( + true === $terminal_closed + && 'admin_final_close' === $GLOBALS['wpdb']->row->closure_source + && 53 === (int) $GLOBALS['wpdb']->row->closed_by + && $terminal_reason === $GLOBALS['wpdb']->row->closure_reason, + 'admin final close accepts and records terminal reason ' . $terminal_reason + ); +} + +list( $job_id ) = fixture_reset(); +$GLOBALS['current_user'] = new FakeScanUser( 61 ); +$GLOBALS['current_user_can_manage'] = true; +$GLOBALS['wpdb']->row->status = 'running'; +$nonterminal_close = ct_scan_admin_close( $job_id, 'failed' ); +check( is_wp_error( $nonterminal_close ) && null === $GLOBALS['wpdb']->row->closure_source, 'a running request cannot be finalized by an administrator' ); + +echo "Report access checks passed.\n"; diff --git a/integrations/wordpress/standalone-tests/retention.php b/integrations/wordpress/standalone-tests/retention.php new file mode 100644 index 0000000..619b029 --- /dev/null +++ b/integrations/wordpress/standalone-tests/retention.php @@ -0,0 +1,189 @@ +modify( '+' . (int) $days . ' days' )->format( 'Y-m-d H:i:s' ); +} + +function retention_plus_years( $value, $years ) { + return ( new DateTimeImmutable( $value, new DateTimeZone( 'UTC' ) ) )->modify( '+' . (int) $years . ' years' )->format( 'Y-m-d H:i:s' ); +} + +$accepted = '2024-01-14 12:30:00'; +$closed = '2024-01-15 12:30:00'; +$delivery = '2025-04-01 09:00:00'; + +$admin_closed = (object) array( + 'terms_version' => '2026-11-13-r3', + 'terms_accepted_at' => $accepted, + 'created_at' => $accepted, + 'status' => 'completed', + 'closed_at' => $closed, + 'closure_source' => 'admin_final_close', + 'closed_by' => 42, + 'closure_reason' => 'completed', + 'report_delivered_at'=> null, + 'report_access_used_at' => null, +); + +$report_delivered = (object) array( + 'terms_version' => '2026-11-13-r3', + 'terms_accepted_at' => $accepted, + 'created_at' => $accepted, + 'status' => 'completed', + 'closed_at' => $delivery, + 'closure_source' => 'report_delivery', + 'closed_by' => 0, + 'closure_reason' => 'report_delivered', + 'report_delivered_at'=> $delivery, + 'report_access_used_at' => $delivery, +); + +retention_assert( + retention_plus_days( $delivery, 366 ) === ct_scan_findings_retain_until( $report_delivered ), + 'Findings/reports were not retained for 366 days after the confirmed report-delivery timestamp.' +); + +retention_assert( + retention_plus_days( $closed, 366 ) === ct_scan_findings_retain_until( $admin_closed ), + 'Undelivered findings/reports were not retained for 366 days after explicit final close.' +); + +retention_assert( + retention_plus_years( $closed, 6 ) === ct_scan_retain_until( $admin_closed ), + 'Future-version signed evidence was not retained for six years after trusted final close.' +); + +$legacy = (object) array( + 'terms_version' => '2026-10-21-r2', + 'terms_accepted_at' => $accepted, + 'created_at' => $accepted, + 'status' => 'completed', + 'closed_at' => null, + 'closure_source' => null, + 'closed_by' => null, + 'closure_reason' => null, + 'report_delivered_at' => null, + 'report_access_used_at' => null, +); + +retention_assert( + retention_plus_years( $accepted, 3 ) === ct_scan_retain_until( $legacy ), + 'r2-or-earlier signed evidence was not retained for three years from acceptance.' +); + +// Terminal status and finished_at are evidence that work stopped, not an explicit final closure. +$terminal_but_open = (object) array( + 'terms_version' => '2026-11-13-r3', + 'terms_accepted_at' => $accepted, + 'created_at' => $accepted, + 'status' => 'completed', + 'finished_at' => $closed, + 'closed_at' => null, + 'closure_source' => null, + 'closed_by' => null, + 'closure_reason' => null, + 'report_delivered_at'=> null, + 'report_access_used_at' => null, +); + +retention_assert( false === ct_scan_retain_until( $terminal_but_open ), 'A terminal status/finished_at was treated as explicit final close.' ); +retention_assert( false === ct_scan_findings_retain_until( $terminal_but_open ), 'Findings got a deadline before explicit final close or report delivery.' ); +$legacy_terminal = (object) array_merge( + (array) $terminal_but_open, + array( + 'closed_at' => $closed, // a pre-v8 maintenance timestamp + 'retain_until' => '2025-01-15 12:30:00', + 'identity' => str_repeat( 'a', 32 ), + 'result_json' => '{"finding":"synthetic"}', + 'manual_remainder_notes' => 'synthetic finding', + 'hold_reason' => null, + 'hold_until' => null, + ) +); +retention_assert( + array( 'keep', null ) === ct_scan_deletion_decision( $legacy_terminal, '2026-01-16 00:00:00' ), + 'A legacy maintenance timestamp allowed a current-version job and findings to be deleted without a trusted closure event.' +); + +foreach ( array( + (object) array_merge( (array) $admin_closed, array( 'closure_source' => null, 'closed_by' => null, 'closure_reason' => null ) ), + (object) array_merge( (array) $admin_closed, array( 'closed_by' => 0 ) ), + (object) array_merge( (array) $admin_closed, array( 'closure_reason' => 'other' ) ), + (object) array_merge( (array) $admin_closed, array( 'closure_reason' => 'failed' ) ), + (object) array_merge( (array) $admin_closed, array( 'closed_at' => '2024-01-13 12:30:00' ) ), + (object) array_merge( (array) $admin_closed, array( 'closed_at' => gmdate( 'Y-m-d H:i:s', time() + 3600 ) ) ), + (object) array_merge( (array) $report_delivered, array( 'closed_by' => null ) ), + (object) array_merge( (array) $report_delivered, array( 'report_delivered_at' => null ) ), + (object) array_merge( (array) $report_delivered, array( 'report_delivered_at' => '2025-03-31 09:00:00' ) ), + (object) array_merge( (array) $report_delivered, array( 'report_access_used_at' => '2025-03-31 09:00:00' ) ), + (object) array_merge( (array) $report_delivered, array( 'closure_reason' => 'completed' ) ), + (object) array_diff_key( (array) $admin_closed, array( 'report_delivered_at' => true ) ), + (object) array_diff_key( (array) $report_delivered, array( 'report_access_used_at' => true ) ), +) as $invalid ) { + retention_assert( false === ct_scan_retain_until( $invalid ), 'Untrusted closure metadata was accepted for evidence retention.' ); + retention_assert( false === ct_scan_findings_retain_until( $invalid ), 'Untrusted closure/delivery metadata was accepted for findings retention.' ); +} + +// Legacy evidence still requires a valid non-backwards closure before deletion, even without findings. +$legacy_due = (object) array_merge( (array) $legacy, array( + 'terms_accepted_at' => '2021-01-01 00:00:00', + 'created_at' => '2021-01-01 00:00:00', + 'closed_at' => '2021-01-02 00:00:00', + 'retain_until' => '2024-01-01 00:00:00', + 'identity' => str_repeat( 'b', 32 ), + 'hold_reason' => null, + 'hold_until' => null, + 'result_json' => null, + 'manual_remainder_notes' => null, +) ); +retention_assert( 'delete' === ct_scan_deletion_decision( $legacy_due, '2026-01-16 00:00:00' )[0], 'A valid due legacy evidence-only row was not eligible.' ); +foreach ( array( null, '2020-12-31 23:59:59', 'invalid' ) as $invalid_close ) { + $damaged = (object) array_merge( (array) $legacy_due, array( 'closed_at' => $invalid_close ) ); + retention_assert( array( 'keep', null ) === ct_scan_deletion_decision( $damaged, '2026-01-16 00:00:00' ), 'Legacy evidence was deleted despite a missing, invalid, or backwards closure.' ); +} + +$plugin_source = file_get_contents( $argv[1] ); +retention_assert( is_string( $plugin_source ), 'The scan source could not be read for the no-auto-close regression check.' ); +retention_assert( + false === stripos( $plugin_source, 'closed_at = COALESCE(finished_at' ), + 'The retention source still derives request closure from terminal finished_at.' +); +foreach ( array( + "closure_source = 'report_delivery'", + "closed_by = 0", + "report_delivered_at = closed_at", + "report_access_used_at = report_delivered_at", + "closure_source = 'admin_final_close'", + "closed_by > 0", + 'closure_reason = status', + 'report_delivered_at IS NULL', + 'report_access_used_at IS NULL', +) as $required_purge_guard ) { + retention_assert( false !== strpos( $plugin_source, $required_purge_guard ), 'The findings purge no longer guards on trusted event field: ' . $required_purge_guard ); +} + +echo "Scan findings and evidence retention checks passed.\n"; diff --git a/integrations/wordpress/standalone-tests/tombstones.php b/integrations/wordpress/standalone-tests/tombstones.php new file mode 100644 index 0000000..5dfc4de --- /dev/null +++ b/integrations/wordpress/standalone-tests/tombstones.php @@ -0,0 +1,224 @@ +last_args = $args; + return (string) $query; + } + + public function get_results( $query ) { + $this->last_error = ''; + if ( false !== strpos( $query, 'SELECT j.job_id, t.tombstone_id' ) ) { + $lower = 0; + $upper = PHP_INT_MAX; + if ( false !== strpos( $query, 'j.job_id > %d' ) && count( $this->last_args ) >= 4 ) { + // Legacy composite cursor: (job_id, tombstone_id, upper job_id). + $lower = (int) $this->last_args[2]; + $upper = (int) $this->last_args[3]; + } elseif ( count( $this->last_args ) >= 2 ) { + // Global tombstone_id cursor: (lower tombstone_id, upper tombstone_id). + $lower = (int) $this->last_args[0]; + $upper = (int) $this->last_args[1]; + } + $matches = array_values( array_filter( $this->stones, static function ( $stone ) use ( $lower, $upper ) { + return (int) $stone->tombstone_id > $lower && (int) $stone->tombstone_id <= $upper; + } ) ); + usort( $matches, static function ( $a, $b ) { + return (int) $a->tombstone_id <=> (int) $b->tombstone_id; + } ); + $matches = array_slice( $matches, 0, 50 ); + $this->tombstone_queries[] = array( + 'lower' => $lower, + 'upper' => $upper, + 'ids' => array_map( static function ( $stone ) { return (int) $stone->tombstone_id; }, $matches ), + ); + return $matches; + } + if ( false !== strpos( $query, 'SELECT t.tombstone_id' ) ) { + return array(); + } + return array(); + } + + public function get_row( $query ) { + $this->last_error = ''; + if ( false !== strpos( $query, $this->posts ) ) { + return $this->read_post(); + } + if ( false !== strpos( $query, 'contact_email,contact_name' ) ) { + return (object) array( 'contact_email' => '', 'contact_name' => '' ); + } + // The tombstone-row pagination case deliberately uses no restored/live job row. + return null; + } + + public function read_post() { + $this->last_error = ''; + if ( 'error' === $this->post_read_mode ) { + $this->last_error = 'synthetic WordPress post-read failure'; + return null; + } + if ( 'missing' === $this->post_read_mode ) { + return null; + } + return (object) array( 'ID' => 900, 'post_type' => 'ct_promo_request' ); + } + + public function get_var( $query ) { + if ( false !== strpos( $query, 'GET_LOCK' ) ) { + ++$this->lock_attempts; + return $this->lock_fail_on_attempt === $this->lock_attempts ? '0' : '1'; + } + if ( false !== strpos( $query, 'RELEASE_LOCK' ) ) { + return '1'; + } + return null; + } + + public function query( $query ) { + if ( false !== strpos( $query, 'INSERT IGNORE INTO' ) && false !== strpos( $query, 'ct_scan_tombstones' ) ) { + ++$this->tombstone_inserts; + } + return 1; + } + + public function update( $table, $data, $where, $data_format = null, $where_format = null ) { + if ( false !== strpos( $table, 'ct_scan_jobs' ) && isset( $data['contact_email'], $data['contact_name'] ) ) { + ++$this->scrub_updates; + } + return 1; + } + + public function delete( $table, $where, $where_format = null ) { + if ( false !== strpos( $table, 'ct_scan_jobs' ) ) { + ++$this->job_deletes; + } + return 1; + } +} + +function add_action( ...$args ) {} +function add_filter( ...$args ) {} +function is_wp_error( $value ) { return $value instanceof WP_Error; } +function get_option( $key, $default = false ) { return $GLOBALS['test_options'][ $key ] ?? $default; } +function update_option( $key, $value, $autoload = null ) { $GLOBALS['test_options'][ $key ] = $value; return true; } +function delete_option( $key ) { unset( $GLOBALS['test_options'][ $key ] ); return true; } +function wp_salt( $scheme = 'auth' ) { return 'synthetic-tombstone-salt'; } +function absint( $value ) { return abs( (int) $value ); } +function sanitize_key( $value ) { return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $value ) ); } +function clean_post_cache( $post_id ) {} +function get_post( $post_id ) { return $GLOBALS['wpdb']->read_post(); } +function get_post_meta( $post_id, $key = '', $single = false ) { return $single ? '' : array(); } +function wp_delete_post( $post_id, $force_delete = false ) { ++$GLOBALS['wpdb']->post_deletes; return true; } + +$GLOBALS['test_options'] = array(); +$GLOBALS['wpdb'] = new FakeTombstoneDb(); +require $argv[1]; + +function tombstone_assert( $condition, $message ) { + if ( ! $condition ) { + throw new RuntimeException( $message ); + } +} + +function tombstone_cursor_max_id() { + $max = 0; + foreach ( $GLOBALS['test_options'] as $key => $value ) { + if ( false === strpos( (string) $key, 'tombstone' ) || false === strpos( (string) $key, 'cursor' ) ) { + continue; + } + $encoded = is_scalar( $value ) ? (string) $value : json_encode( $value ); + if ( is_string( $encoded ) && preg_match_all( '/\d+/', $encoded, $matches ) ) { + foreach ( $matches[0] as $number ) { + $number = (int) $number; + if ( $number >= 100 && $number < 1000000 ) { + $max = max( $max, $number ); + } + } + } + } + return $max; +} + +// Use one job id with many distinct tombstone ids to catch a persisted job-only cursor. +for ( $id = 101; $id <= 160; ++$id ) { + $GLOBALS['wpdb']->stones[] = (object) array( + 'job_id' => 7, + 'tombstone_id' => $id, + 'scope_mac' => str_repeat( 'a', 64 ), + 'identity' => str_repeat( 'b', 32 ), + ); +} +$GLOBALS['wpdb']->lock_fail_on_attempt = 7; +$remaining = 100; +ct_scan_apply_tombstones( gmdate( 'Y-m-d H:i:s' ), microtime( true ) + 30, $remaining ); +$saved_tombstone_id = tombstone_cursor_max_id(); +tombstone_assert( $saved_tombstone_id >= 101 && $saved_tombstone_id <= 106, 'A pause did not persist the last processed tombstone_id for a shared job_id.' ); + +// The next invocation must query after the saved tombstone_id. The row whose lock failed is retried first. +$GLOBALS['wpdb']->lock_attempts = 0; +$GLOBALS['wpdb']->lock_fail_on_attempt = 1; +$query_count_before = count( $GLOBALS['wpdb']->tombstone_queries ); +ct_scan_apply_tombstones( gmdate( 'Y-m-d H:i:s' ), microtime( true ) + 30, $remaining ); +$next_queries = array_slice( $GLOBALS['wpdb']->tombstone_queries, $query_count_before ); +tombstone_assert( ! empty( $next_queries ), 'The resumed retention pass did not query tombstones.' ); +tombstone_assert( $next_queries[0]['lower'] === $saved_tombstone_id, 'The resumed query did not use the persisted tombstone_id cursor.' ); +tombstone_assert( isset( $next_queries[0]['ids'][0] ) && $next_queries[0]['ids'][0] === $saved_tombstone_id + 1, 'The resumed query restarted at an earlier tombstone_id instead of advancing.' ); + +// A database error while reading the linked request post is not the same as a successful missing-post read. +$GLOBALS['wpdb'] = new FakeTombstoneDb(); +$GLOBALS['wpdb']->post_read_mode = 'error'; +$job = (object) array( + 'job_id' => 41, + 'request_id' => 900, + 'created_at' => '2024-01-01 00:00:00', + 'scope_hash' => hash( 'sha256', 'synthetic.example' ), + 'identity' => str_repeat( 'c', 32 ), + 'contact_email' => 'private@example.invalid', + 'contact_name' => 'Synthetic Client', +); +tombstone_assert( false === ct_scan_delete_job_records( $job, 'retention' ), 'A failed post read did not stop the deletion attempt.' ); +tombstone_assert( + 0 === $GLOBALS['wpdb']->tombstone_inserts + && 0 === $GLOBALS['wpdb']->scrub_updates + && 0 === $GLOBALS['wpdb']->job_deletes + && 0 === $GLOBALS['wpdb']->post_deletes, + 'A failed post read wrote a tombstone, scrubbed data, or deleted the job.' +); + +// A successful read that finds no post remains a distinct, permitted absence case. +$GLOBALS['wpdb'] = new FakeTombstoneDb(); +$GLOBALS['wpdb']->post_read_mode = 'missing'; +tombstone_assert( true === ct_scan_delete_job_records( $job, 'retention' ), 'A successfully-read absent post prevented the job retention cleanup.' ); +tombstone_assert( + 1 === $GLOBALS['wpdb']->tombstone_inserts + && 1 === $GLOBALS['wpdb']->scrub_updates + && 1 === $GLOBALS['wpdb']->job_deletes + && 0 === $GLOBALS['wpdb']->post_deletes, + 'The successful missing-post case did not delete only the job after recording its tombstone.' +); + +echo "Scan tombstone regressions passed.\n"; diff --git a/integrations/wordpress/tests/evidence-strict.php b/integrations/wordpress/tests/evidence-strict.php index 2b481ac..ab4d0f4 100644 --- a/integrations/wordpress/tests/evidence-strict.php +++ b/integrations/wordpress/tests/evidence-strict.php @@ -27,9 +27,44 @@ list( $unbound_id, $unbound_fields ) = ct_test_request( 'strict-unbound.example.net' ); $request_ids[] = $unbound_id; $refused = ct_scan_on_request_ready( $unbound_id, $unbound_fields ); -ct_test_assert( is_wp_error( $refused ) && 'ct_scan_binding_required' === get_post_meta( $unbound_id, '_ct_scan_status_detail', true ), 'An unbound acceptance for a bound-only version was queued.' ); +ct_test_assert( is_wp_error( $refused ) && 'ct_scan_signer_evidence_missing' === get_post_meta( $unbound_id, '_ct_scan_status_detail', true ), 'An unbound acceptance for a bound-only version was queued.' ); ct_test_assert( 0 === (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$jobs} WHERE request_id = %d", $unbound_id ) ), 'A job exists for an unbound acceptance of a bound-only version.' ); +// Retained signer identity and its statement/digests are mandatory independently of the terms snapshot. +foreach ( array( + 'missing_role' => array( '_ct_terms_signer_role', null ), + 'wrong_signature' => array( '_ct_terms_typed_signature', 'Someone Else' ), + 'changed_client' => array( '_ct_terms_client_legal_name', 'Different Client LLC' ), + 'changed_statement' => array( '_ct_terms_statement', 'Changed statement' ), + 'changed_authority_digest' => array( '_ct_terms_authority_sha256', str_repeat( '0', 64 ) ), + 'changed_acceptance_digest' => array( '_ct_terms_evidence_sha256', str_repeat( '0', 64 ) ), + 'missing_binding' => array( '_ct_terms_binding', null ), +) as $case => $mutation ) { + list( $bad_id, $bad_fields ) = ct_test_request( 'strict-signer-' . str_replace( '_', '-', $case ) . '.example.net' ); + $request_ids[] = $bad_id; + ct_test_bind( $bad_id, $bad_fields ); + if ( null === $mutation[1] ) { + delete_post_meta( $bad_id, $mutation[0] ); + } else { + update_post_meta( $bad_id, $mutation[0], wp_slash( $mutation[1] ) ); + } + $before_mail = count( $mail ); + $bad_event = ct_scan_on_request_ready( $bad_id, $bad_fields ); + ct_test_assert( is_wp_error( $bad_event ) && count( $mail ) === $before_mail, "Invalid signer evidence queued or sent confirmation ($case)." ); + ct_test_assert( 0 === (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$jobs} WHERE request_id = %d", $bad_id ) ), "Invalid signer evidence persisted a job ($case)." ); + if ( 'missing_binding' === $case ) { + ct_test_assert( 'ct_scan_binding_required' === get_post_meta( $bad_id, '_ct_scan_status_detail', true ), 'Complete signer evidence bypassed missing terms binding.' ); + } +} + +// Duplicate signer metadata must never select the first value as if it were unambiguous. +list( $duplicate_id, $duplicate_fields ) = ct_test_request( 'strict-signer-duplicate.example.net' ); +$request_ids[] = $duplicate_id; +ct_test_bind( $duplicate_id, $duplicate_fields ); +add_post_meta( $duplicate_id, '_ct_terms_signer_role', 'Owner' ); +$duplicate_event = ct_scan_on_request_ready( $duplicate_id, $duplicate_fields ); +ct_test_assert( is_wp_error( $duplicate_event ) && 0 === (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$jobs} WHERE request_id = %d", $duplicate_id ) ), 'Duplicate signer evidence was accepted.' ); + // A bound acceptance is queued; the mailbox confirmation then sends the accepted copy to the verified contact only. list( $request_id, $fields ) = ct_test_request( 'strict-bound.example.net', 'verified.customer@example.com' ); $request_ids[] = $request_id; @@ -47,6 +82,7 @@ $to = (array) $copy['to']; ct_test_assert( array( 'verified.customer@example.com' ) === array_values( $to ), 'The accepted copy went to someone other than the verified contact.' ); ct_test_assert( false !== strpos( $copy['message'], $meta['_ct_terms_sha256'] ) && false !== strpos( $copy['message'], $meta['_ct_summary_sha256'] ) && false !== strpos( $copy['message'], 'Website (scan scope): https://strict-bound.example.net/' ) && false !== strpos( $copy['message'], $meta['_ct_summary_text'] ), 'The accepted copy omits the hashes or the Target Summary.' ); +ct_test_assert( false !== strpos( $copy['message'], 'Client legal name: Fixture Client LLC' ) && false !== strpos( $copy['message'], 'Signer: Fixture Reviewer' ) && false !== strpos( $copy['message'], 'Signer role: Owner' ), 'The accepted copy omits the bound client or signer capacity.' ); $attachment = end( $captured_attachments ); ct_test_assert( $attachment && $attachment['bytes'] === $meta['_ct_terms_snapshot'] && hash( 'sha256', $attachment['bytes'] ) === $meta['_ct_terms_sha256'] && 0 === strpos( $attachment['name'], 'accepted-terms-' . $event['job_id'] . '-2026-10-21-r2-' ), 'The attached terms are not the exact retained bytes.' ); ct_test_assert( 'accepted' === ct_scan_row( $event['job_id'] )->accepted_copy_status && 'queued' === ct_scan_row( $event['job_id'] )->status, 'The copy changed the job state or was not recorded as transport-accepted.' ); @@ -132,9 +168,13 @@ $request_ids[] = $legacy_id; ct_test_bind( $legacy_id, $legacy_fields ); $legacy_event = ct_scan_on_request_ready( $legacy_id, $legacy_fields ); -foreach ( array( '_ct_terms_binding', '_ct_terms_snapshot', '_ct_terms_shown_digest', '_ct_summary_text', '_ct_summary_sha256', '_ct_summary_shown_digest' ) as $key ) { +foreach ( array( '_ct_terms_binding', '_ct_terms_snapshot', '_ct_terms_shown_digest', '_ct_summary_text', '_ct_summary_sha256', '_ct_summary_shown_digest', '_ct_terms_client_legal_name', '_ct_terms_signer_name', '_ct_terms_signer_role', '_ct_terms_typed_signature', '_ct_terms_authority_sha256', '_ct_terms_evidence_sha256', '_ct_terms_statement' ) as $key ) { delete_post_meta( $legacy_id, $key ); } +// A pre-binding request carried only its original contact/scope contract, with no signer-evidence header. +$legacy_contract = "Client contact: Fixture Reviewer\nClient email: " . $legacy_fields['email'] . "\nWebsite (scan scope): " . $legacy_fields['target'] . "\nReferred by: direct\nTerms version: 2026-10-21-r2\nAccepted test fixture terms."; +update_post_meta( $legacy_id, '_ct_terms_contract', wp_slash( $legacy_contract ) ); +wp_update_post( wp_slash( array( 'ID' => $legacy_id, 'post_content' => "TERMS AS ACCEPTED\n\n" . $legacy_contract ) ) ); $wpdb->update( $jobs, array( 'bound_required' => 0, 'summary_sha256' => null ), array( 'job_id' => $legacy_event['job_id'] ) ); ct_test_queue_job( $legacy_event['job_id'] ); $claim = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data()['job']; diff --git a/integrations/wordpress/tests/evidence.php b/integrations/wordpress/tests/evidence.php index 41a3eb3..4d454ae 100644 --- a/integrations/wordpress/tests/evidence.php +++ b/integrations/wordpress/tests/evidence.php @@ -145,9 +145,9 @@ function ct_test_gate_env( $env ) { } // ---- The Target Summary carries the limits and the authorization period --------------------------------------- -$canonical = ct_scan_target_summary( 'https://a.example.net/', 'Pat', 'pat@example.com' ); -ct_test_assert( 6 === substr_count( $canonical, "\n" ) && false !== strpos( $canonical, 'at most 20 requests, one at a time, at most one per second, 10 seconds each' ) && false !== strpos( $canonical, 'expires 60 days after acceptance' ), 'The canonical Target Summary omits the limits or the authorization period.' ); -ct_test_assert( '' === ct_scan_target_summary( 'https://a.example.net/', 'Pat', 'pat@example.com', 'external-web-v9' ) && '' === ct_scan_target_summary( array(), 'Pat', 'pat@example.com' ), 'A summary was produced for an unknown profile or bad input.' ); +$canonical = ct_scan_target_summary( 'https://a.example.net/', 'Fixture Client LLC', 'Pat', 'Owner', 'pat@example.com' ); +ct_test_assert( 8 === substr_count( $canonical, "\n" ) && false !== strpos( $canonical, 'at most 20 requests, one at a time, at most one per second, 10 seconds each' ) && false !== strpos( $canonical, 'expires 60 days after acceptance' ), 'The canonical Target Summary omits the limits or the authorization period.' ); +ct_test_assert( '' === ct_scan_target_summary( 'https://a.example.net/', 'Fixture Client LLC', 'Pat', 'Owner', 'pat@example.com', 'external-web-v9' ) && '' === ct_scan_target_summary( array(), 'Fixture Client LLC', 'Pat', 'Owner', 'pat@example.com' ), 'A summary was produced for an unknown profile or bad input.' ); // After a job exists, the retained evidence cannot change: any alteration ends the authorization. $running = ct_test_running_job_for_bound( 'evidence-running.example.net' ); diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index 4e999d7..ae1bac4 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -22,6 +22,19 @@ return $mail_mode; }, 10, 2 ); +/** Exercise the recipient's one-time POST; a transport result alone never proves delivery. */ +function ct_test_redeem_report( $job_id, $mail ) { + ct_test_assert( isset( $mail['message'] ) && 1 === preg_match( '/[?&]key=([a-f0-9]{64})/', $mail['message'], $match ), 'Report capability missing from the captured fixture notification.' ); + ct_test_assert( false === strpos( $mail['message'], 'PHP2Shell status:' ), 'Notification email leaked report findings.' ); + $preview = ct_scan_report_access_response( 'GET', $job_id, $match[1] ); + ct_test_assert( 200 === $preview['status'] && null === ct_scan_row( $job_id )->report_delivered_at, 'A preview consumed the report capability or falsely recorded delivery.' ); + $response = ct_scan_report_access_response( 'POST', $job_id, $match[1] ); + ct_test_assert( 200 === $response['status'], 'Explicit report redemption did not serve the reviewed report.' ); + $closed = ct_scan_row( $job_id ); + ct_test_assert( ct_scan_closure_metadata_trusted( $closed ) && 'report_delivery' === $closed->closure_source, 'Report redemption did not persist trusted closure evidence.' ); + ct_test_assert( 410 === ct_scan_report_access_response( 'POST', $job_id, $match[1] )['status'], 'A report capability was reusable.' ); +} + // 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; @@ -118,7 +131,7 @@ ct_test_assert( true === ct_scan_admin_send_report( $event['job_id'] ), 'Reviewed report send was not accepted by the isolated mail fixture.' ); ct_test_assert( 'accepted' === ct_scan_row( $event['job_id'] )->report_notification_status, 'Report transport and delivery state were conflated.' ); ct_test_assert( is_wp_error( ct_scan_admin_save_manual_remainder( $event['job_id'], 'Edited after send' ) ), 'Report details must freeze after a send attempt.' ); -ct_test_assert( true === ct_scan_admin_record_delivery( $event['job_id'] ), 'Independently confirmed delivery did not persist.' ); +ct_test_redeem_report( $event['job_id'], end( $mail_calls ) ); // An expired, never-confirmed request cannot receive detailed findings, even after manual review. list( $unconfirmed_id, $unconfirmed_fields ) = ct_test_request( 'unconfirmed-report-fixture.example.net' ); @@ -131,7 +144,7 @@ ct_test_assert( is_wp_error( $unconfirmed_send ) && 'report_recipient_unconfirmed' === $unconfirmed_send->get_error_code(), 'An unconfirmed recipient was allowed a detailed report.' ); ct_test_assert( count( $mail_calls ) === $mail_count && 'not_sent' === ct_scan_row( $unconfirmed_event['job_id'] )->report_notification_status, 'A refused unconfirmed report must not send or change send state.' ); -// Unknown mail outcomes remain one-shot; independently confirmed delivery can still be recorded. +// Unknown mail outcomes remain one-shot; explicit recipient redemption still records actual report access. list( $unknown_id, $unknown_fields ) = ct_test_request( 'unknown-mail-fixture.example.net' ); $request_ids[] = $unknown_id; $unknown_event = ct_scan_on_request_ready( $unknown_id, $unknown_fields ); @@ -155,7 +168,7 @@ $unknown_send = ct_scan_admin_send_report( $unknown_event['job_id'] ); ct_test_assert( is_wp_error( $unknown_send ) && 'unknown' === ct_scan_row( $unknown_event['job_id'] )->report_notification_status, 'Unconfirmed mail must remain unknown and must not be retried.' ); ct_test_assert( is_wp_error( ct_scan_admin_save_manual_remainder( $unknown_event['job_id'], 'Altered content' ) ), 'Report contents changed after an unknown send outcome.' ); -ct_test_assert( true === ct_scan_admin_record_delivery( $unknown_event['job_id'] ), 'Independent proof must allow recording delivery from unknown state.' ); +ct_test_redeem_report( $unknown_event['job_id'], end( $mail_calls ) ); $mail_mode = true; // The recorded terms hash is compared case-insensitively at report time (the job stores it lowercased). @@ -217,7 +230,7 @@ $request_ids[] = $retention_id; $retention_event = ct_scan_on_request_ready( $retention_id, $retention_fields ); $old_finished = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-13 months' )->format( 'Y-m-d H:i:s' ); -$wpdb->update( $jobs, array( 'status' => 'failed', 'finished_at' => $old_finished, 'terms_accepted_at' => $old_finished, 'result_json' => '{}', 'manual_remainder_notes' => 'private notes' ), array( 'job_id' => (int) $retention_event['job_id'] ) ); +$wpdb->update( $jobs, array( 'status' => 'failed', 'finished_at' => $old_finished, 'closed_at' => $old_finished, 'closure_source' => 'admin_final_close', 'closed_by' => 1, 'closure_reason' => 'failed', 'report_delivered_at' => null, 'report_access_used_at' => null, 'created_at' => $old_finished, 'terms_accepted_at' => $old_finished, 'result_json' => '{}', 'manual_remainder_notes' => 'private notes' ), array( 'job_id' => (int) $retention_event['job_id'] ) ); ct_test_maintain(); $retained = ct_scan_row( $retention_event['job_id'] ); ct_test_assert( $retained && null === $retained->result_json && null === $retained->manual_remainder_notes && get_post( $retention_id ), '12-month cleanup must clear result/manual data but retain the request evidence.' ); diff --git a/integrations/wordpress/tests/retention-years.php b/integrations/wordpress/tests/retention-years.php index 4ffee8c..9bda74d 100644 --- a/integrations/wordpress/tests/retention-years.php +++ b/integrations/wordpress/tests/retention-years.php @@ -1,6 +1,6 @@ '2027-06-01-r3', 'closed_at' => '2026-01-01 00:00:00', 'terms_accepted_at' => '2025-01-01 00:00:00' ); -$old = (object) array( 'terms_version' => '2026-10-21-r2', 'closed_at' => '2026-01-01 00:00:00', 'terms_accepted_at' => '2025-01-01 00:00:00' ); +$row = (object) array( 'terms_version' => '2027-06-01-r3', 'closed_at' => '2026-01-01 00:00:00', 'terms_accepted_at' => '2025-01-01 00:00:00', 'created_at' => '2025-01-01 00:00:00', 'status' => 'completed', 'closure_source' => 'admin_final_close', 'closed_by' => 1, 'closure_reason' => 'completed', 'report_delivered_at' => null, 'report_access_used_at' => null ); +$old = (object) array( 'terms_version' => '2026-10-21-r2', 'closed_at' => '2026-01-01 00:00:00', 'terms_accepted_at' => '2025-01-01 00:00:00', 'created_at' => '2025-01-01 00:00:00', 'status' => 'completed', 'closure_source' => 'admin_final_close', 'closed_by' => 1, 'closure_reason' => 'completed', 'report_delivered_at' => null, 'report_access_used_at' => null ); echo ct_scan_retain_until( $row ) . ' ' . ct_scan_retain_until( $old ) . "\n"; diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index 64443e8..6a1a73d 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -17,8 +17,11 @@ function ct_test_old( $modifier ) { return ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( $modifier )->format( 'Y-m-d H:i:s' ); } -/** A job in $status whose acceptance and closure are the given ages. */ -function ct_test_aged_job( $host, $status, $accepted, $closed, $version = '2026-10-21-r2' ) { +/** + * Synthetic saved final-close event at the specified age (not an automatic terminal-status close). + * Evidence-only cases omit findings; opt in explicitly to test their independent 366-day lifetime. + */ +function ct_test_aged_job( $host, $status, $accepted, $closed, $version = '2026-10-21-r2', $with_findings = false ) { global $wpdb, $request_ids; list( $request_id, $fields ) = ct_test_request( $host ); $request_ids[] = $request_id; @@ -27,8 +30,16 @@ function ct_test_aged_job( $host, $status, $accepted, $closed, $version = '2026- $update = array( 'status' => $status, 'terms_accepted_at' => ct_test_old( $accepted ), 'created_at' => ( new DateTimeImmutable( ct_test_old( $accepted ), new DateTimeZone( 'UTC' ) ) )->modify( '+30 seconds' )->format( 'Y-m-d H:i:s' ), 'terms_version' => $version, 'confirmation_token_hash' => null ); if ( null !== $closed ) { $update['finished_at'] = ct_test_old( $closed ); - $update['result_json'] = '{"fixture":true}'; - $update['manual_remainder_notes'] = 'private findings'; + $update['closed_at'] = $update['finished_at']; + $update['closure_source'] = 'admin_final_close'; + $update['closed_by'] = 1; + $update['closure_reason'] = $status; + $update['report_delivered_at'] = null; + $update['report_access_used_at'] = null; + if ( $with_findings ) { + $update['result_json'] = '{"fixture":true}'; + $update['manual_remainder_notes'] = 'private findings'; + } } $wpdb->update( ct_scan_jobs_table(), $update, array( 'job_id' => (int) $event['job_id'] ) ); return array( 'job_id' => (int) $event['job_id'], 'request_id' => $request_id ); @@ -48,9 +59,9 @@ function ct_test_exists( $job ) { $blocked = ct_test_aged_job( 'ret-blocked.example.net', 'blocked', '-2 days', '-1 day' ); ct_test_maintain(); $row = ct_scan_row( $closing['job_id'] ); -ct_test_assert( $row->closed_at === $row->finished_at && ! empty( $row->retain_until ), 'A finished job was not stamped closed with a deletion date.' ); +ct_test_assert( $row->closed_at === $row->finished_at && ! empty( $row->retain_until ), 'An explicit saved final-close event was not given its evidence deletion date.' ); ct_test_assert( null === ct_scan_row( $live['job_id'] )->closed_at && null === ct_scan_row( $live['job_id'] )->retain_until, 'A job that can still run was stamped closed.' ); -ct_test_assert( ! empty( ct_scan_row( $blocked['job_id'] )->closed_at ), 'A blocked job was not closed.' ); +ct_test_assert( ! empty( ct_scan_row( $blocked['job_id'] )->closed_at ), 'An explicitly final-closed blocked job lost its closure event.' ); ct_test_assert( ct_test_exists( $live ) && get_post( $live['request_id'] ), 'A job that can still run was deleted or closed by retention.' ); // r2: three years from acceptance. Other versions: six years after closure. @@ -67,23 +78,46 @@ function ct_test_exists( $job ) { ct_test_maintain(); ct_test_assert( ct_scan_row( $newer['job_id'] )->retain_until === $fixed, 'The deletion date was recomputed after closure.' ); -// An accepted-but-unverified request that expires is closed too. +// Expiry stops execution but does not invent a no-report final-close event. list( $unverified_id, $unverified_fields ) = ct_test_request( 'ret-unverified.example.net' ); $request_ids[] = $unverified_id; $unverified = ct_scan_on_request_ready( $unverified_id, $unverified_fields ); $wpdb->update( $jobs, array( 'confirmation_expires_at' => ct_test_old( '-1 hour' ) ), array( 'job_id' => $unverified['job_id'] ) ); ct_test_maintain(); $u = ct_scan_row( $unverified['job_id'] ); -ct_test_assert( 'expired' === $u->status && ! empty( $u->closed_at ) && ! empty( $u->retain_until ), 'An unconfirmed request that expired was not closed with a deletion date.' ); +ct_test_assert( 'expired' === $u->status && null === $u->closed_at && null === $u->retain_until, 'Expiry invented final closure or an evidence deadline.' ); +wp_set_current_user( 1 ); +ct_test_assert( true === ct_scan_admin_close( $unverified['job_id'], 'expired' ), 'An authenticated final-close action after expiry was refused.' ); +ct_test_assert( ct_scan_closure_metadata_trusted( ct_scan_row( $unverified['job_id'] ) ), 'Explicit final close after expiry lacks trusted provenance.' ); // ---- Findings: twelve months after closure; evidence is kept ------------------------------------------------ -$young = ct_test_aged_job( 'ret-young.example.net', 'completed', '-2 years', '-11 months' ); -$old = ct_test_aged_job( 'ret-old.example.net', 'completed', '-2 years', '-13 months' ); +$young = ct_test_aged_job( 'ret-young.example.net', 'completed', '-2 years', '-11 months', '2026-10-21-r2', true ); +$old = ct_test_aged_job( 'ret-old.example.net', 'completed', '-2 years', '-13 months', '2026-10-21-r2', true ); ct_test_maintain(); ct_test_assert( null !== ct_scan_row( $young['job_id'] )->result_json && null !== ct_scan_row( $young['job_id'] )->manual_remainder_notes, 'Findings were cleared before twelve months.' ); $o = ct_scan_row( $old['job_id'] ); ct_test_assert( null === $o->result_json && null === $o->manual_remainder_notes && ct_test_exists( $old ) && get_post( $old['request_id'] ), 'Findings were not cleared at twelve months, or the signed evidence went with them.' ); +// A stale pre-v8 maintenance date is not provenance. Even a stored overdue deadline and a matching +// tombstone may not erase a current-version restored row whose trusted close event is missing. +$pending = ct_test_aged_job( 'ret-pending-v8.example.net', 'completed', '-4 years', '-2 years', '2027-06-01-r3', true ); +$wpdb->update( $jobs, array( 'closure_source' => null, 'closed_by' => null, 'closure_reason' => null, 'retain_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $pending['job_id'] ) ); +$pending_row = ct_scan_row( $pending['job_id'] ); +$wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => $pending_row->job_id, 'request_id' => $pending_row->request_id, 'scope_mac' => ct_scan_tombstone_mac( $pending_row->scope_hash ), 'created_at' => $pending_row->created_at, 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => $pending_row->identity ) ); +ct_test_maintain(); +$pending_row = ct_scan_row( $pending['job_id'] ); +ct_test_assert( $pending_row && null === $pending_row->closure_source && null !== $pending_row->result_json && get_post( $pending['request_id'] ), 'A legacy timestamp/tombstone erased pending current-version evidence or findings.' ); + +// Legacy signed evidence may already be three years old while its recently delivered findings still +// require another 366 days. A delivery event has its own exact timestamp, independent of execution stop. +$recent_report = ct_test_aged_job( 'ret-recent-report.example.net', 'completed', '-4 years', '-2 years', '2026-10-21-r2', true ); +$delivery_at = ct_test_old( '-1 day' ); +$wpdb->update( $jobs, array( 'closed_at' => $delivery_at, 'closure_source' => 'report_delivery', 'closed_by' => 0, 'closure_reason' => 'report_delivered', 'report_delivered_at' => $delivery_at, 'report_access_used_at' => $delivery_at ), array( 'job_id' => $recent_report['job_id'] ) ); +ct_test_maintain(); +$recent = ct_scan_row( $recent_report['job_id'] ); +ct_test_assert( $recent && null !== $recent->result_json && get_post( $recent_report['request_id'] ), 'Old legacy evidence was deleted before recently delivered findings reached their separate deadline.' ); +ct_test_assert( ct_scan_findings_retain_until( $recent ) === ( new DateTimeImmutable( $delivery_at, new DateTimeZone( 'UTC' ) ) )->modify( '+366 days' )->format( 'Y-m-d H:i:s' ), 'Findings used the execution-stop timestamp instead of confirmed delivery.' ); + // ---- Evidence deadlines -------------------------------------------------------------------------------------- $edge_keep = ct_test_aged_job( 'ret-keep.example.net', 'failed', '-2 years -11 months', '-1 day' ); $edge_del = ct_test_aged_job( 'ret-del.example.net', 'failed', '-3 years -1 day', '-1 day' ); @@ -149,7 +183,7 @@ function ct_test_exists( $job ) { ct_test_assert( null === $wpdb->get_var( $wpdb->prepare( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $six_del['job_id'] ) ), 'A tombstone outlived the backup window.' ); // ---- Narrow preservation holds --------------------------------------------------------------------------------- -$held = ct_test_aged_job( 'ret-held.example.net', 'failed', '-3 years -1 day', '-13 months' ); +$held = ct_test_aged_job( 'ret-held.example.net', 'failed', '-3 years -1 day', '-13 months', '2026-10-21-r2', true ); wp_set_current_user( 0 ); ct_test_assert( 'hold_forbidden' === ct_scan_admin_set_hold( $held['job_id'], 'Preserving records for dispute number 123.', 30 )->get_error_code(), 'An anonymous caller placed a hold.' ); $subscriber = wp_insert_user( array( 'user_login' => 'ct_ret_sub_' . wp_generate_password( 6, false ), 'user_pass' => wp_generate_password(), 'role' => 'subscriber' ) ); @@ -185,7 +219,7 @@ function ct_test_exists( $job ) { // ---- Untrustworthy dates and doubtful rows are kept, never deleted -------------------------------------------- $zero_r2 = ct_test_aged_job( 'ret-zero-r2.example.net', 'failed', '-3 years -1 day', '-1 hour' ); $wpdb->update( $jobs, array( 'terms_accepted_at' => '0000-00-00 00:00:00', 'closed_at' => null, 'retain_until' => null ), array( 'job_id' => $zero_r2['job_id'] ) ); -$zero_new = ct_test_aged_job( 'ret-zero-new.example.net', 'cancelled', '-1 year', '-1 hour', '2027-06-01-r3' ); +$zero_new = ct_test_aged_job( 'ret-zero-new.example.net', 'cancelled', '-1 year', '-1 hour', '2027-06-01-r3', true ); $wpdb->update( $jobs, array( 'finished_at' => '0000-00-00 00:00:00', 'closed_at' => '0000-00-00 00:00:00', 'retain_until' => null ), array( 'job_id' => $zero_new['job_id'] ) ); $garbled = ct_test_aged_job( 'ret-garbled.example.net', 'failed', '-3 years -1 day', '-1 hour' ); $wpdb->update( $jobs, array( 'terms_accepted_at' => '1999-01-01 00:00:00', 'closed_at' => null, 'retain_until' => null ), array( 'job_id' => $garbled['job_id'] ) ); @@ -248,10 +282,14 @@ function ct_test_exists( $job ) { ct_test_maintain(); $ro = ct_scan_row( $reopen['job_id'] ); ct_test_assert( null === $ro->closed_at && null === $ro->retain_until, 'Reopening a job left its old closure and deletion date.' ); -// A terminal job with no finished_at is closed now, not left open. +$pending_finished = ct_test_aged_job( 'ret-pending-finished.example.net', 'failed', '-1 day', '-1 hour' ); +$wpdb->update( $jobs, array( 'closed_at' => null, 'closure_source' => null, 'closed_by' => null, 'closure_reason' => null, 'retain_until' => null ), array( 'job_id' => $pending_finished['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( null !== ct_scan_row( $pending_finished['job_id'] )->finished_at && null === ct_scan_row( $pending_finished['job_id'] )->closed_at, 'A terminal execution-stop timestamp was promoted to final closure.' ); +// A terminal job without a trusted final-close event remains pending, including without finished_at. $nofinish = ct_test_aged_job( 'ret-nofinish.example.net', 'cancelled', '-1 day', null ); ct_test_maintain(); -ct_test_assert( ! empty( ct_scan_row( $nofinish['job_id'] )->closed_at ), 'A terminal job without finished_at was never closed.' ); +ct_test_assert( null === ct_scan_row( $nofinish['job_id'] )->closed_at && null === ct_scan_row( $nofinish['job_id'] )->retain_until, 'A terminal job without trusted closure was automatically finalized.' ); // ---- Version rules and period floors ---------------------------------------------------------------------------- foreach ( array( '2026-01-01-r1' => true, '2026-10-21-r1' => true, '2026-10-21-r2' => true, '2026-10-21-r3' => false, '2026-10-22-r1' => false, '2027-06-01-r3' => false, 'weird' => false, '' => false, '2026-10-21-r2 ' => false, '2020-01-01-r9999' => false ) as $version => $legacy ) { @@ -283,7 +321,7 @@ function ct_test_exists( $job ) { ct_test_assert( 0 === count( array_filter( $batch, 'ct_test_exists' ) ), 'The remaining due jobs were not deleted on later runs.' ); // Release works; a damaged hold (purpose without expiry) preserves rather than deletes. -$rel = ct_test_aged_job( 'ret-release.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$rel = ct_test_aged_job( 'ret-release.example.net', 'failed', '-3 years -1 day', '-13 months', '2026-10-21-r2', true ); ct_scan_admin_set_hold( $rel['job_id'], 'Preserving records for dispute number 456.', 10 ); ct_test_assert( 'hold_changed' === ct_scan_admin_release_hold( $rel['job_id'] )->get_error_code() && 'hold_changed' === ct_scan_admin_release_hold( $rel['job_id'], str_repeat( 'a', 64 ) )->get_error_code() && empty( ct_scan_row( $rel['job_id'] )->hold_released_at ), 'A release without the identity of the hold shown was accepted.' ); ct_test_assert( true === ct_scan_admin_release_hold( $rel['job_id'], ct_scan_hold_identity( ct_scan_row( $rel['job_id'] ) ) ), 'Release failed.' ); @@ -374,7 +412,7 @@ function ct_test_exists( $job ) { ct_test_assert( $dup_identity === ct_scan_meta_string( $dup_id, '_ct_scan_identity' ), 'The duplicate-event path left the request without its identity.' ); // A damaged hold (zero expiry) also protects the findings. -$zero_hold = ct_test_aged_job( 'ret-zero-hold.example.net', 'completed', '-2 years', '-13 months' ); +$zero_hold = ct_test_aged_job( 'ret-zero-hold.example.net', 'completed', '-2 years', '-13 months', '2026-10-21-r2', true ); $wpdb->update( $jobs, array( 'hold_reason' => 'Preserving for a dispute (damaged expiry).', 'hold_until' => '0000-00-00 00:00:00' ), array( 'job_id' => $zero_hold['job_id'] ) ); ct_test_maintain(); ct_test_assert( null !== ct_scan_row( $zero_hold['job_id'] )->result_json, 'A hold with a zero expiry did not protect the findings.' ); @@ -514,7 +552,7 @@ function ct_test_exists( $job ) { // A deletion is refused when the record appears to have closed before it was created (every other check passes). $backwards = ct_test_aged_job( 'ret-backwards.example.net', 'failed', '-3 years -3 days', '-2 days' ); $accepted_at = ct_test_old( '-3 years -3 days' ); -$wpdb->update( $jobs, array( 'terms_accepted_at' => $accepted_at, 'created_at' => ( new DateTimeImmutable( $accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+30 seconds' )->format( 'Y-m-d H:i:s' ), 'finished_at' => ( new DateTimeImmutable( $accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+10 seconds' )->format( 'Y-m-d H:i:s' ) ), array( 'job_id' => $backwards['job_id'] ) ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => $accepted_at, 'created_at' => ( new DateTimeImmutable( $accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+30 seconds' )->format( 'Y-m-d H:i:s' ), 'finished_at' => ( new DateTimeImmutable( $accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+10 seconds' )->format( 'Y-m-d H:i:s' ), 'closed_at' => ( new DateTimeImmutable( $accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+10 seconds' )->format( 'Y-m-d H:i:s' ) ), array( 'job_id' => $backwards['job_id'] ) ); ct_test_make_due( $backwards ); ct_test_maintain(); ct_test_assert( ct_test_exists( $backwards ) && get_post( $backwards['request_id'] ), 'A record that appeared to close before it was created was deleted.' ); @@ -523,24 +561,23 @@ function ct_test_exists( $job ) { $clocked = ct_test_aged_job( 'ret-clock.example.net', 'failed', '-3 years -1 day', '-1 day' ); $future_row = ct_test_aged_job( 'ret-future-row.example.net', 'queued', '-1 day', null ); $wpdb->update( $jobs, array( 'created_at' => ct_test_old( '+10 days' ), 'authorization_expires_at' => ct_test_old( '+60 days' ) ), array( 'job_id' => $future_row['job_id'] ) ); -$clock_closing = ct_test_aged_job( 'ret-clock-closing.example.net', 'cancelled', '-1 day', null ); // no finished_at: closure would stamp "now" +$clock_closing = ct_test_aged_job( 'ret-clock-closing.example.net', 'cancelled', '-1 day', null ); // pending final-close event ct_test_maintain(); ct_test_assert( ct_test_exists( $clocked ) && get_post( $clocked['request_id'] ), 'Evidence was deleted although the clock was earlier than a stored job timestamp.' ); -ct_test_assert( null === ct_scan_row( $clock_closing['job_id'] )->closed_at && null === ct_scan_row( $clocked['job_id'] )->closed_at && null === ct_scan_row( $clocked['job_id'] )->retain_until, 'A closure or deletion date was stamped while the clock could not be trusted.' ); +ct_test_assert( null === ct_scan_row( $clock_closing['job_id'] )->closed_at && null !== ct_scan_row( $clocked['job_id'] )->closed_at && null === ct_scan_row( $clocked['job_id'] )->retain_until, 'A closure or deletion date was stamped while the clock could not be trusted.' ); ct_test_assert( ! ct_scan_clock_is_sane( ct_scan_utc_now() ) && ct_scan_clock_is_sane( ct_test_old( '+11 days' ) ), 'The clock sanity check does not compare with the newest stored timestamp.' ); $wpdb->delete( $jobs, array( 'job_id' => $future_row['job_id'] ) ); wp_delete_post( $future_row['request_id'], true ); ct_test_maintain(); ct_test_assert( ! ct_test_exists( $clocked ), 'Deletion did not resume once the clock was consistent.' ); -ct_test_assert( ! empty( ct_scan_row( $clock_closing['job_id'] )->closed_at ), 'Closure was not stamped once the clock was consistent.' ); +ct_test_assert( null === ct_scan_row( $clock_closing['job_id'] )->closed_at, 'Clock recovery invented an explicit final-close event.' ); ct_test_assert( ! ct_scan_clock_is_sane( '1999-01-01 00:00:00' ) && ! ct_scan_clock_is_sane( '2300-01-01 00:00:00' ), 'Absurd clocks were accepted.' ); /** Stamp closure and the deletion date directly, without running a pass (the job is due as soon as retention runs). */ function ct_test_make_due( $job ) { global $wpdb; $row = ct_scan_row( $job['job_id'] ); - $closed = $row->finished_at; - $row->closed_at = $closed; + $closed = $row->closed_at; $wpdb->update( ct_scan_jobs_table(), array( 'closed_at' => $closed, 'retain_until' => ct_scan_retain_until( $row ), 'retention_checked_at' => null ), array( 'job_id' => (int) $job['job_id'] ) ); } @@ -655,6 +692,11 @@ function ct_test_make_due( $job ) { // Leap days: a period that starts on 29 February ends on 1 March, never on 28 February. $leap_r2 = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-29 12:00:00', 'created_at' => '2024-02-29 12:00:30', 'closed_at' => '2024-03-02 00:00:00', 'retain_until' => '2027-03-01 12:00:00' ); $leap_new = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2027-06-01-r3', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2030-03-01 12:00:00' ); +$leap_new->closure_source = 'admin_final_close'; +$leap_new->closed_by = 1; +$leap_new->closure_reason = 'failed'; +$leap_new->report_delivered_at = null; +$leap_new->report_access_used_at = null; ct_test_assert( '2027-03-01 12:00:00' === ct_scan_retain_until( $leap_r2 ) && '2027-03-01 12:00:00' === ct_scan_required_retain_until( $leap_r2 ) && '2030-03-01 12:00:00' === ct_scan_retain_until( $leap_new ), 'A period starting on 29 February does not end on 1 March.' ); ct_test_assert( ! ct_scan_deletion_allowed( $leap_r2, '2027-02-28 23:59:59' ) && ct_scan_deletion_allowed( $leap_r2, '2027-03-01 12:00:00' ) && ! ct_scan_deletion_allowed( $leap_new, '2030-02-28 23:59:59' ) && ct_scan_deletion_allowed( $leap_new, '2030-03-01 12:00:00' ), 'A leap-day period allowed deletion early (or never).' ); $leap_r2->retain_until = '2027-02-28 12:00:00'; diff --git a/integrations/wordpress/tests/support.php b/integrations/wordpress/tests/support.php index 6d0efd6..ccb5173 100644 --- a/integrations/wordpress/tests/support.php +++ b/integrations/wordpress/tests/support.php @@ -67,7 +67,7 @@ function ct_test_request( $host, $email = 'fixture@example.com' ) { 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' ) ); + return array( (int) $id, array( 'target' => $target, 'email' => $email, 'name' => 'Fixture Reviewer', 'legal_name' => 'Fixture Client LLC', 'signer_role' => 'Owner', 'signature' => 'Fixture Reviewer', 'referrer' => 'direct' ) ); } function ct_test_rest_call( $callback, $route, $payload, $nonce = null, $timestamp = null ) { @@ -153,7 +153,10 @@ function ct_test_purge_fixtures() { /** The Target Summary text a bound acceptance would carry for a fixture request; $overrides replace single lines. */ function ct_test_summary( $fields, $overrides = array() ) { - $canonical = ct_scan_target_summary( $fields['target'], $fields['name'], $fields['email'], ct_scan_current_profile() ); + $version = ct_scan_config()['supported_terms_version']; + $canonical = 'strict' === ct_scan_terms_policy( $version ) + ? ct_scan_target_summary( $fields['target'], $fields['legal_name'] ?? 'Fixture Client LLC', $fields['name'], $fields['signer_role'] ?? 'Owner', $fields['email'], ct_scan_current_profile() ) + : ct_scan_legacy_target_summary( $fields['target'], $fields['name'], $fields['email'], ct_scan_current_profile() ); $lines = array(); foreach ( explode( "\n", rtrim( $canonical, "\n" ) ) as $line ) { list( $label, $value ) = explode( ': ', $line, 2 ); @@ -179,6 +182,46 @@ function ct_test_bind( $request_id, $fields, $mutations = array() ) { '_ct_summary_sha256' => hash( 'sha256', $summary ), '_ct_summary_shown_digest' => hash( 'sha256', $summary ), ); + if ( 'strict' === ct_scan_terms_policy( get_post_meta( $request_id, '_ct_terms_version', true ) ) ) { + $legal_name = $fields['legal_name'] ?? 'Fixture Client LLC'; + $role = $fields['signer_role'] ?? 'Owner'; + $signature = $fields['signature'] ?? $fields['name']; + $statement = 'I authorize the described fixture scope.'; + $authority = 'Client legal name: ' . $legal_name . "\n" + . 'Signer full name: ' . $fields['name'] . "\n" + . 'Signer role (stated): ' . $role . "\n" + . 'Typed signature: ' . $signature . "\n"; + $authority_hash = hash( 'sha256', $authority ); + $version = get_post_meta( $request_id, '_ct_terms_version', true ); + $accepted_raw = get_post_meta( $request_id, '_ct_terms_accepted_at', true ); + $evidence = 'Terms version: ' . $version . "\n" + . 'Terms SHA-256: ' . $meta['_ct_terms_sha256'] . "\n" + . 'Target Summary SHA-256: ' . $meta['_ct_summary_sha256'] . "\n" + . 'Signer evidence SHA-256: ' . $authority_hash . "\n" + . 'Acceptance statement SHA-256: ' . hash( 'sha256', $statement ) . "\n" + . 'Accepted at (UTC): ' . $accepted_raw . "\n"; + $evidence_hash = hash( 'sha256', $evidence ); + $contract = 'Client legal name: ' . $legal_name . "\n" + . 'Client contact: ' . $fields['name'] . "\n" + . 'Signer role (stated): ' . $role . "\n" + . 'Typed signature: ' . $signature . "\n" + . 'Client email: ' . $fields['email'] . "\n" + . 'Website (scan scope): ' . $fields['target'] . "\n" + . 'Referred by: ' . $fields['referrer'] . "\n" + . 'Signer evidence SHA-256: ' . $authority_hash . "\n" + . 'Acceptance evidence SHA-256: ' . $evidence_hash . "\nTERMS AS ACCEPTED\n" . $snapshot; + $meta += array( + '_ct_terms_client_legal_name' => $legal_name, + '_ct_terms_signer_name' => $fields['name'], + '_ct_terms_signer_role' => $role, + '_ct_terms_typed_signature' => $signature, + '_ct_terms_authority_sha256' => $authority_hash, + '_ct_terms_evidence_sha256' => $evidence_hash, + '_ct_terms_statement' => $statement, + '_ct_terms_contract' => $contract, + ); + wp_update_post( wp_slash( array( 'ID' => $request_id, 'post_content' => $contract ) ) ); + } foreach ( array_merge( $meta, $mutations['meta'] ?? array() ) as $key => $value ) { if ( null === $value ) { delete_post_meta( $request_id, $key ); From 4b933893765bc8d4abe4d21eb177c6383f986c24 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 21:12:49 -0500 Subject: [PATCH 02/14] test(wordpress): carry signer binding through HTTP control fixture --- deployment/test-http-fixture.php | 29 +++++++++++++++++-- .../wordpress/tests/evidence-strict.php | 2 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/deployment/test-http-fixture.php b/deployment/test-http-fixture.php index 09e5490..7dafab0 100644 --- a/deployment/test-http-fixture.php +++ b/deployment/test-http-fixture.php @@ -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]); } @@ -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)); diff --git a/integrations/wordpress/tests/evidence-strict.php b/integrations/wordpress/tests/evidence-strict.php index ab4d0f4..d104d19 100644 --- a/integrations/wordpress/tests/evidence-strict.php +++ b/integrations/wordpress/tests/evidence-strict.php @@ -53,7 +53,7 @@ ct_test_assert( is_wp_error( $bad_event ) && count( $mail ) === $before_mail, "Invalid signer evidence queued or sent confirmation ($case)." ); ct_test_assert( 0 === (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$jobs} WHERE request_id = %d", $bad_id ) ), "Invalid signer evidence persisted a job ($case)." ); if ( 'missing_binding' === $case ) { - ct_test_assert( 'ct_scan_binding_required' === get_post_meta( $bad_id, '_ct_scan_status_detail', true ), 'Complete signer evidence bypassed missing terms binding.' ); + ct_test_assert( 'ct_scan_binding_required' === get_post_meta( $bad_id, '_ct_scan_status_detail', true ), 'Complete signer evidence bypassed missing terms binding (actual: ' . get_post_meta( $bad_id, '_ct_scan_status_detail', true ) . ').' ); } } From 6f79676638cab5ff36ed71ef1a4d724971d79bb9 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 21:13:45 -0500 Subject: [PATCH 03/14] test(wordpress): exercise real lifecycle locks and stale marker cache --- integrations/wordpress/tests/integration.php | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index ae1bac4..74582d1 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -35,6 +35,24 @@ function ct_test_redeem_report( $job_id, $mail ) { ct_test_assert( 410 === ct_scan_report_access_response( 'POST', $job_id, $match[1] )['status'], 'A report capability was reusable.' ); } +// Manual no-queue closure and enqueue share a real database lock; cached absence is not authority. +list( $manual_id, $manual_fields ) = ct_test_request( 'manual-close-fixture.example.net' ); +$request_ids[] = $manual_id; +ct_test_assert( false === ct_scan_manual_closure_ready( $manual_id ), 'Manual closure did not require connection-owned queue lock.' ); +ct_test_assert( ct_scan_lock_acquire(), 'Manual closure fixture could not acquire the queue lock.' ); +try { + ct_test_assert( true === ct_scan_manual_closure_ready( $manual_id ), 'Successful locked empty-queue read refused manual closure readiness.' ); +} finally { + ct_scan_lock_release(); +} +ct_test_assert( array() === get_post_meta( $manual_id, '_ct_archive_manual_lifecycle', false ), 'Unexpected fixture marker before race.' ); +// Direct SQL models another request committing after this process cached the absent marker. +ct_test_assert( 1 === $wpdb->insert( $wpdb->postmeta, array( 'post_id' => $manual_id, 'meta_key' => '_ct_archive_manual_lifecycle', 'meta_value' => serialize( array( 'state' => 'closed' ) ) ) ), 'Could not persist the independent manual marker fixture.' ); +$manual_mail_before = count( $mail_calls ); +$manual_event = ct_scan_on_request_ready( $manual_id, $manual_fields ); +ct_test_assert( is_wp_error( $manual_event ) && 'manual_final_close' === get_post_meta( $manual_id, '_ct_scan_status_detail', true ), 'A cached absent marker or missing archive verifier permitted enqueue.' ); +ct_test_assert( 0 === (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$jobs} WHERE request_id = %d", $manual_id ) ) && count( $mail_calls ) === $manual_mail_before, 'A manually closed fixture created a job or confirmation email.' ); + // 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; @@ -43,6 +61,13 @@ function ct_test_redeem_report( $job_id, $mail ) { 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( ct_scan_lock_acquire(), 'Existing-job fixture could not acquire the queue lock.' ); +try { + ct_test_assert( false === ct_scan_manual_closure_ready( $request_id ), 'An existing queued request permitted no-queue manual closure.' ); +} finally { + ct_scan_lock_release(); +} + 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.' ); ct_test_assert( 1 === preg_match( '/[?&]stop=([a-f0-9]{64})/', $mail_calls[0]['message'], $stop_match ), 'Stop link missing from the captured fixture email.' ); From b96360162457d39427f706d5f119ab151030fbb5 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 21:15:04 -0500 Subject: [PATCH 04/14] test(wordpress): use valid HTML entities in strict snapshot fixture --- integrations/wordpress/tests/support.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/wordpress/tests/support.php b/integrations/wordpress/tests/support.php index ccb5173..88d7f80 100644 --- a/integrations/wordpress/tests/support.php +++ b/integrations/wordpress/tests/support.php @@ -171,7 +171,7 @@ function ct_test_summary( $fields, $overrides = array() ) { /** Turn a fixture request's acceptance into a bound one. $mutations may break any single piece. */ function ct_test_bind( $request_id, $fields, $mutations = array() ) { - $snapshot = $mutations['snapshot'] ?? "

Terms as displayed

\n

Exact bytes \xe2\x80\x94 including \"quotes\" & entities.

\n"; + $snapshot = $mutations['snapshot'] ?? "

Terms as displayed

\n

Exact bytes \xe2\x80\x94 including \"quotes\" & entities.

\n"; $summary = $mutations['summary'] ?? ct_test_summary( $fields ); $meta = array( '_ct_terms_binding' => '1', From 872c4c01f88800833763fd346c24b07934a112d8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 21:46:27 -0500 Subject: [PATCH 05/14] fix(wordpress): preserve strict retention and usable closure controls --- docs/WORDPRESS.md | 17 ++ integrations/wordpress/ct-scan.php | 155 +++++++++++++----- .../standalone-tests/metabox-dom.mjs | 32 ++++ .../standalone-tests/report-access.php | 58 ++++++- .../wordpress/standalone-tests/retention.php | 109 ++++++++++-- .../wordpress/standalone-tests/tombstones.php | 38 +++++ integrations/wordpress/tests/integration.php | 27 +++ integrations/wordpress/tests/retention.php | 112 +++++++++++++ 8 files changed, 496 insertions(+), 52 deletions(-) create mode 100644 integrations/wordpress/standalone-tests/metabox-dom.mjs diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index 7a1d7ab..abbfb8e 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -691,3 +691,20 @@ 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, 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. diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index e193983..629d954 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -1215,10 +1215,17 @@ function ct_scan_hold_grace_days() { * action) also preserves, so a damaged hold never causes a deletion. */ function ct_scan_job_on_hold( $row, $now ) { - if ( empty( $row->hold_reason ) ) { + if ( ! is_object( $row ) || ! property_exists( $row, 'hold_reason' ) || ! property_exists( $row, 'hold_until' ) ) { + return true; + } + if ( null === $row->hold_reason ) { return false; } - if ( empty( $row->hold_until ) || ! ct_scan_valid_datetime( $row->hold_until ) ) { + // SQL NULL is the only unambiguous absence. A damaged empty/'0' purpose or expiry + // must not release a hold in PHP while SQL and the administrative UI still see a record. + if ( ! is_string( $row->hold_reason ) || '' === $row->hold_reason || '0' === $row->hold_reason + || trim( $row->hold_reason ) !== $row->hold_reason || preg_match( '/[\x00-\x1f\x7f]/', $row->hold_reason ) + || ! ct_scan_valid_datetime( $row->hold_until ) ) { return true; } $grace_end = ( new DateTimeImmutable( $row->hold_until, new DateTimeZone( 'UTC' ) ) )->modify( '+' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); @@ -1317,6 +1324,19 @@ function ct_scan_deletion_allowed( $row, $now ) { * deadline for a restored or damaged copy. */ function ct_scan_tombstone_deadline_passed( $row, $now ) { + // A tombstone does not excuse invalid dates in a restored legacy row. Apply the same + // closure/deadline prerequisites as ordinary retention before deriving its legacy period. + if ( ! is_object( $row ) ) { + return false; + } + foreach ( array( 'created_at', 'closed_at', 'retain_until' ) as $field ) { + if ( ! property_exists( $row, $field ) || ! ct_scan_valid_datetime( $row->$field ) ) { + return false; + } + } + if ( $row->closed_at < $row->created_at ) { + return false; + } $required = ct_scan_required_retain_until( $row ); if ( false === $required ) { return false; @@ -1555,6 +1575,84 @@ function ct_scan_delete_if_due( $job_id, $now ) { } } +/** Clear due findings only from a freshly read, strictly trusted event under the queue lock. */ +function ct_scan_purge_findings_if_due( $job_id, $now ) { + global $wpdb; + if ( ! ct_scan_valid_datetime( $now ) ) { + return 'kept'; + } + if ( ! ct_scan_lock_acquire() ) { + return 'busy'; + } + try { + $wpdb->last_error = ''; + $row = ct_scan_row( $job_id ); + if ( ct_scan_retention_query_failed( 'findings_read' ) ) { + return 'failed'; + } + if ( ! is_object( $row ) || ! isset( $row->job_id ) || (int) $row->job_id < 1 || (int) $row->job_id !== (int) $job_id ) { + return 'kept'; + } + $fields = array( 'status', 'created_at', 'terms_accepted_at', 'closed_at', 'closure_source', 'closed_by', 'closure_reason', 'report_delivered_at', 'report_access_used_at', 'hold_reason', 'hold_until', 'result_json', 'manual_remainder_notes' ); + foreach ( $fields as $field ) { + if ( ! property_exists( $row, $field ) ) { + return 'kept'; + } + } + $until = ct_scan_findings_retain_until( $row ); + if ( false === $until || $until > $now || ct_scan_job_on_hold( $row, $now ) + || ( null === $row->result_json && null === $row->manual_remainder_notes ) ) { + return 'kept'; + } + // The advisory lock serializes supported writers. Binary predicates additionally refuse an + // intervening case-only mutation on normal case-insensitive WordPress table collations. + $where = array( 'job_id = %d' ); + $values = array( (int) $row->job_id ); + foreach ( $fields as $field ) { + if ( null === $row->$field ) { + $where[] = $field . ' IS NULL'; + } else { + $where[] = 'BINARY ' . $field . ' = BINARY %s'; + $values[] = (string) $row->$field; + } + } + $wpdb->last_error = ''; + $changed = $wpdb->query( $wpdb->prepare( + 'UPDATE ' . ct_scan_jobs_table() . ' SET result_json = NULL, manual_remainder_notes = NULL WHERE ' . implode( ' AND ', $where ), + ...$values + ) ); + if ( ct_scan_retention_query_failed( 'findings_write' ) || false === $changed ) { + return 'failed'; + } + return 1 === $changed ? 'cleared' : 'kept'; + } finally { + ct_scan_lock_release(); + } +} + +/** Bounded cursor prevents malformed or not-yet-due findings rows starving later candidates. */ +function ct_scan_purge_findings_batch( $now, $deadline ) { + global $wpdb; + $cursor = max( 0, (int) get_option( 'ct_scan_findings_cursor', 0 ) ); + $wpdb->last_error = ''; + $rows = $wpdb->get_results( $wpdb->prepare( + 'SELECT job_id FROM ' . ct_scan_jobs_table() . ' WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND job_id > %d ORDER BY job_id ASC LIMIT 100', + $cursor + ) ); + if ( ct_scan_retention_query_failed( 'findings_candidates' ) ) { + return; + } + $finished = true; + foreach ( (array) $rows as $row ) { + if ( microtime( true ) > $deadline || 'busy' === ct_scan_purge_findings_if_due( (int) $row->job_id, $now ) ) { + $finished = false; + break; + } + $cursor = (int) $row->job_id; + } + update_option( 'ct_scan_findings_cursor', $finished && count( (array) $rows ) < 100 ? '0' : (string) $cursor, false ); +} + function ct_scan_retention_steps( $now ) { global $wpdb; $table = ct_scan_jobs_table(); @@ -1621,32 +1719,10 @@ function ct_scan_retention_steps( $now ) { $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); } } - // 3. Findings and any report: 366 days after confirmed report delivery, or 366 days after an - // authenticated final close when no report was delivered. Terminal status/finished_at and legacy - // maintenance-only `closed_at` values are not a retention anchor. - $findings_cutoff = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-366 days' )->format( 'Y-m-d H:i:s' ); - $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->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 status IN ({$terminal}) - AND created_at >= '2020-01-01 00:00:00' - AND terms_accepted_at >= '2020-01-01 00:00:00' - AND closed_at >= '2020-01-01 00:00:00' - AND closed_at >= created_at - AND closed_at >= terms_accepted_at - AND closed_at <= %s - AND closed_at < %s - AND ( - (closure_source = 'report_delivery' AND closed_by = 0 AND closure_reason = 'report_delivered' AND report_delivered_at = closed_at AND report_access_used_at = report_delivered_at) - OR (closure_source = 'admin_final_close' AND closed_by > 0 AND closure_reason = status AND report_delivered_at IS NULL AND report_access_used_at IS NULL) - ) - AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s))", - $now, - $findings_cutoff, - $grace_edge - ) ); - ct_scan_retention_query_failed( 'findings' ); // a failure only keeps the findings longer + // 3. Candidate selection only locates findings. The exact PHP trusted-event validator + // decides under the queue lock, followed by a binary compare-and-set. + ct_scan_purge_findings_batch( $now, $deadline ); + $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); // 4. Evidence: delete what has outlived its retain_until (extending a date that is shorter than today's // rules require), except under a hold. One budget of deletion attempts for the whole run (steps 4 and 5). $deletions_left = ct_scan_retention_batch_limit(); @@ -3372,7 +3448,9 @@ function ct_scan_admin_revoke( $job_id ) { 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, 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none', 'control_lease_hash' => null, 'control_lease_until' => null, 'report_access_token_hash' => null, 'report_access_expires_at' => null ); + // revoked_at invalidates redemption. Retain the non-secret hash/expiry as durable proof + // that an unused v8 notification carried a link, so an admin can truthfully close without a report. + $update = array( 'revoked_at' => $now, 'confirmation_token_hash' => null, 'lease_hash' => null, 'lease_until' => null, 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none', 'control_lease_hash' => null, 'control_lease_until' => null ); if ( ! ct_scan_terminal_status( $row->status ) ) { $update['status'] = 'cancelled'; $update['error_code'] = 'operator_revoked'; @@ -3927,13 +4005,13 @@ function ct_scan_admin_render_final_close_metabox( $post ) { global $wpdb; $wpdb->last_error = ''; $row = $wpdb->get_row( $wpdb->prepare( - 'SELECT job_id,status,closed_at,closure_source,closed_by,closure_reason,report_delivered_at,report_notification_status,report_access_token_hash,report_access_expires_at,report_access_used_at FROM ' . ct_scan_jobs_table() . ' WHERE request_id = %d LIMIT 1', + 'SELECT job_id,status,created_at,terms_accepted_at,closed_at,closure_source,closed_by,closure_reason,report_delivered_at,report_notification_status,report_access_token_hash,report_access_expires_at,report_access_used_at FROM ' . ct_scan_jobs_table() . ' WHERE request_id = %d LIMIT 1', (int) $post->ID ) ); if ( '' !== (string) $wpdb->last_error || ! is_object( $row ) || ! in_array( $row->status, ct_scan_terminal_statuses(), true ) ) { return; } - foreach ( array( 'closed_at', 'closure_source', 'closed_by', 'closure_reason', 'report_delivered_at', 'report_notification_status', 'report_access_token_hash', 'report_access_expires_at', 'report_access_used_at' ) as $field ) { + foreach ( array( 'created_at', 'terms_accepted_at', 'closed_at', 'closure_source', 'closed_by', 'closure_reason', 'report_delivered_at', 'report_notification_status', 'report_access_token_hash', 'report_access_expires_at', 'report_access_used_at' ) as $field ) { if ( ! property_exists( $row, $field ) ) { return; } @@ -3951,10 +4029,11 @@ function ct_scan_admin_render_final_close_metabox( $post ) { echo '

The older report-delivery state is ambiguous. Leave this request pending until the delivery history is reconciled.

'; return; } - $reason = (string) $row->status; - $extra = ''; - echo '

Terminal status does not close the request. Final closure records the no-report retention anchor; use it only after confirming that no report was delivered.

'; - echo ct_scan_admin_form( (int) $row->job_id, 'final_close', 'Record final closure', $extra ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper escapes form fields and the reason above is escaped. + // The editor already has its own post form. Keep its controls and nonce ownership intact; + // the private queue provides the independent authenticated, nonce-protected POST form. + $url = admin_url( 'edit.php?post_type=ct_promo_request&page=ct-scan-queue' ); + echo '

Terminal status does not close the request. Review the delivery state and record an explicit no-report final closure in the private queue.

'; + echo '

Open private queue to review final closure

'; } /** One line summarizing a recorded manual authority review, for the admin queue. */ @@ -4056,16 +4135,16 @@ function ct_scan_admin_page() { echo '
Manual remainder (' . esc_html( sanitize_key( (string) $row->manual_remainder_status ) ) . ')
' . esc_html( (string) $row->manual_remainder_notes ) . '
'; } $closure_pending = null === $row->closure_source && null === $row->closed_by && null === $row->closure_reason && null === $row->report_delivered_at && null === $row->report_access_used_at; - if ( ct_scan_terminal_status( $row->status ) && $closure_pending && empty( $row->revoked_at ) ) { + if ( ct_scan_terminal_status( $row->status ) && $closure_pending ) { $notes = esc_textarea( (string) $row->manual_remainder_notes ); - if ( in_array( $row->report_notification_status, array( 'not_sent', 'failed' ), true ) ) { + if ( empty( $row->revoked_at ) && in_array( $row->report_notification_status, array( 'not_sent', 'failed' ), true ) ) { $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 ( in_array( $row->report_notification_status, array( 'not_sent', 'failed' ), true ) ) { + if ( empty( $row->revoked_at ) && in_array( $row->report_notification_status, array( 'not_sent', 'failed' ), true ) ) { echo ct_scan_admin_form( (int) $row->job_id, 'send_report', 'Email one-time report link', ' ' ); } } elseif ( 'complete' === $row->manual_remainder_status ) { diff --git a/integrations/wordpress/standalone-tests/metabox-dom.mjs b/integrations/wordpress/standalone-tests/metabox-dom.mjs new file mode 100644 index 0000000..6af5c2e --- /dev/null +++ b/integrations/wordpress/standalone-tests/metabox-dom.mjs @@ -0,0 +1,32 @@ +// Parse the actual PHP-rendered metabox inside WordPress's outer edit form. No HTTP submission. +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; +const [htmlPath, playwrightPath, chromiumPath] = process.argv.slice(2); +if (!htmlPath || !playwrightPath || !chromiumPath) throw new Error('HTML, installed Playwright module, Chromium executable required'); +if(fs.readlinkSync('/proc/self/ns/net')===fs.readlinkSync('/proc/1/ns/net')) throw new Error('Browser check requires an isolated network namespace'); +const { chromium } = await import(pathToFileURL(playwrightPath).href); +const browser = await chromium.launch({executablePath: chromiumPath, headless:true, + args:['--no-sandbox','--disable-background-networking','--disable-dev-shm-usage']}); +try { + const page=await browser.newPage(); + let requests=0; + await page.route('**/*',route=>{requests++;return route.abort();}); + await page.setContent(fs.readFileSync(htmlPath,'utf8')); + const state=await page.evaluate(()=>{ + const update=document.querySelector('#update'); + const queue=document.querySelector('#ct-final-close a[href*="ct-scan-queue"]'); + const form=update.form; + window.fixtureSubmit=null; + form.addEventListener('submit',event=>{event.preventDefault();window.fixtureSubmit=Object.fromEntries(new FormData(form));}); + return {forms:document.forms.length,updateForm:form.id,action:form.getAttribute('action'),queueHref:queue?.href, + nonces:form.querySelectorAll('[name="_wpnonce"]').length,actions:[...form.querySelectorAll('[name="action"]')].map(x=>x.value)}; + }); + if(state.forms!==1||state.updateForm!=='post'||state.nonces!==1||state.actions.join()!=='editpost' + ||state.action!=='https://complete.tech/wp-admin/post.php'||!state.queueHref?.includes('page=ct-scan-queue')) + throw new Error('Metabox corrupted editor form: '+JSON.stringify(state)); + await page.click('#update'); + const submitted=await page.evaluate(()=>window.fixtureSubmit); + if(submitted?.action!=='editpost'||submitted?._wpnonce!=='fixture-post-save-nonce'||requests) + throw new Error('Ordinary post save was diverted or network contacted'); + console.log(JSON.stringify({metabox_dom:'pass',post_save:'pass',network_requests:requests,...state})); +} finally {await browser.close();} diff --git a/integrations/wordpress/standalone-tests/report-access.php b/integrations/wordpress/standalone-tests/report-access.php index 6ad9c9a..3895ac3 100644 --- a/integrations/wordpress/standalone-tests/report-access.php +++ b/integrations/wordpress/standalone-tests/report-access.php @@ -48,8 +48,15 @@ function get_var( $query ) { return null; } function get_row( $query ) { - if ( ! $this->row || false === strpos( $query, 'SELECT * FROM wp_ct_scan_jobs' ) ) { return null; } - return clone $this->row; + if ( ! $this->row || ! preg_match( '/SELECT (.+) FROM wp_ct_scan_jobs/', $query, $match ) ) { return null; } + if ( '*' === trim( $match[1] ) ) { return clone $this->row; } + // Apply the actual SELECT projection: missing validator fields must not be supplied by the fake. + $projected = new stdClass(); + foreach ( explode( ',', $match[1] ) as $field ) { + $field = trim( $field ); + if ( property_exists( $this->row, $field ) ) { $projected->$field = $this->row->$field; } + } + return $projected; } function update( $table, $data, $where, $format = null, $where_format = null ) { $this->updates[] = array( $data, $where ); @@ -354,9 +361,17 @@ function send_fixture_link( $mail_result = false ) { if ( preg_match( '#key=([a-f0-9]{64})#', $sent['body'], $match ) ) { $token = $match[1]; } } $revoked = ct_scan_admin_revoke( $job_id ); -check( true === $revoked && ! empty( $GLOBALS['wpdb']->row->revoked_at ) && null === $GLOBALS['wpdb']->row->report_access_token_hash, 'authorization revocation clears the outstanding report capability' ); +check( true === $revoked && ! empty( $GLOBALS['wpdb']->row->revoked_at ), 'authorization revocation is durably recorded before further report access' ); $after_revoke = ct_scan_report_access_response( 'POST', $job_id, $token ); check( 410 === $after_revoke['status'] && false === strpos( $after_revoke['body'], '9f7e' ), 'revoked authorization blocks report access' ); +check( ct_scan_no_report_close_eligible( $GLOBALS['wpdb']->row ), 'revoking an unused v8 link retains non-secret proof that no findings were emailed' ); +check( is_string( $GLOBALS['wpdb']->row->report_access_token_hash ) && is_string( $GLOBALS['wpdb']->row->report_access_expires_at ), 'revocation preserves hashed v8 link provenance while denying redemption' ); +$GLOBALS['current_user'] = new FakeScanUser( 71 ); +$GLOBALS['current_user_can_manage'] = true; +$revoked_close = ct_scan_admin_close( $job_id, $GLOBALS['wpdb']->row->status ); +check( true === $revoked_close && ct_scan_closure_metadata_trusted( $GLOBALS['wpdb']->row ) && 'admin_final_close' === $GLOBALS['wpdb']->row->closure_source, 'an authenticated administrator can finalize an unused revoked v8 link without claiming report delivery' ); +check( 410 === ct_scan_report_access_response( 'POST', $job_id, $token )['status'], 'finalizing a revoked link never revives redemption' ); + list( $job_id, $mail ) = send_fixture_link( true ); preg_match( '#key=([a-f0-9]{64})#', $mail['body'], $match ); @@ -483,4 +498,41 @@ function send_fixture_link( $mail_result = false ) { $nonterminal_close = ct_scan_admin_close( $job_id, 'failed' ); check( is_wp_error( $nonterminal_close ) && null === $GLOBALS['wpdb']->row->closure_source, 'a running request cannot be finalized by an administrator' ); +function render_final_close_box( $request_id ) { + ob_start(); + ct_scan_admin_render_final_close_metabox( (object) array( 'ID' => $request_id ) ); + return ob_get_clean(); +} + +list( $job_id, $request_id ) = fixture_reset(); +$GLOBALS['current_user'] = new FakeScanUser( 72 ); +$GLOBALS['current_user_can_manage'] = true; +check( true === ct_scan_admin_close( $job_id, 'failed' ), 'metabox admin-close fixture persists a trusted final event' ); +$admin_markup = render_final_close_box( $request_id ); +check( false !== strpos( $admin_markup, 'Final closure: admin_final_close' ) && false === strpos( $admin_markup, 'does not pass validation' ), 'actual metabox projection recognizes a trusted administrator closure' ); + +list( $job_id, $mail ) = send_fixture_link( true ); +preg_match( '#key=([a-f0-9]{64})#', $mail['body'], $key_match ); +check( 200 === ct_scan_report_access_response( 'POST', $job_id, $key_match[1] )['status'], 'metabox report fixture redeems its one-time capability' ); +$GLOBALS['current_user'] = new FakeScanUser( 73 ); +$GLOBALS['current_user_can_manage'] = true; +$report_markup = render_final_close_box( $GLOBALS['wpdb']->row->request_id ); +check( false !== strpos( $report_markup, 'Final closure: report_delivery' ) && false === strpos( $report_markup, 'does not pass validation' ), 'actual metabox projection recognizes trusted report retrieval' ); +$GLOBALS['wpdb']->row->created_at = '2024-02-00 00:00:00'; +check( false !== strpos( render_final_close_box( $GLOBALS['wpdb']->row->request_id ), 'does not pass validation' ), 'metabox still warns on malformed trusted-event dates' ); + +list( $job_id, $request_id ) = fixture_reset(); +$GLOBALS['current_user'] = new FakeScanUser( 74 ); +$GLOBALS['current_user_can_manage'] = true; +$pending_markup = render_final_close_box( $request_id ); +check( false === stripos( $pending_markup, ''; + $handle = fopen( $dom_path, 'x' ); + if ( ! $handle ) { throw new RuntimeException( 'DOM fixture output could not be created.' ); } + chmod( $dom_path, 0600 ); fwrite( $handle, $html ); fclose( $handle ); +} + echo "Report access checks passed.\n"; diff --git a/integrations/wordpress/standalone-tests/retention.php b/integrations/wordpress/standalone-tests/retention.php index 619b029..b6f16bc 100644 --- a/integrations/wordpress/standalone-tests/retention.php +++ b/integrations/wordpress/standalone-tests/retention.php @@ -8,6 +8,7 @@ class WP_Error { } +function absint( $value ) { return abs( (int) $value ); } function add_action( ...$args ) {} function add_filter( ...$args ) {} function is_wp_error( $value ) { return $value instanceof WP_Error; } @@ -130,6 +131,13 @@ function retention_plus_years( $value, $years ) { ); foreach ( array( + (object) array_merge( (array) $admin_closed, array( 'closure_source' => 'ADMIN_FINAL_CLOSE' ) ), + (object) array_merge( (array) $admin_closed, array( 'closure_reason' => 'COMPLETED' ) ), + (object) array_merge( (array) $admin_closed, array( 'status' => 'COMPLETED' ) ), + (object) array_merge( (array) $report_delivered, array( 'closed_at' => '2024-02-00 00:00:00', 'report_delivered_at' => '2024-02-00 00:00:00', 'report_access_used_at' => '2024-02-00 00:00:00' ) ), + (object) array_merge( (array) $report_delivered, array( 'closed_at' => '2024-02-30 00:00:00', 'report_delivered_at' => '2024-02-30 00:00:00', 'report_access_used_at' => '2024-02-30 00:00:00' ) ), + (object) array_merge( (array) $report_delivered, array( 'closure_source' => 'REPORT_DELIVERY' ) ), + (object) array_merge( (array) $report_delivered, array( 'closure_reason' => 'REPORT_DELIVERED' ) ), (object) array_merge( (array) $admin_closed, array( 'closure_source' => null, 'closed_by' => null, 'closure_reason' => null ) ), (object) array_merge( (array) $admin_closed, array( 'closed_by' => 0 ) ), (object) array_merge( (array) $admin_closed, array( 'closure_reason' => 'other' ) ), @@ -172,18 +180,97 @@ function retention_plus_years( $value, $years ) { false === stripos( $plugin_source, 'closed_at = COALESCE(finished_at' ), 'The retention source still derives request closure from terminal finished_at.' ); +// Destructive behavior is tested through the actual fresh-read helper, not SQL source text. +class FakeFindingsDb { + public $prefix = 'wp_'; + public $last_error = ''; + public $row; + public $snapshot; + public $locked = false; + public $busy = false; + public $read_error = false; + public $write_error = false; + public $query_count = 0; + public $reads = 0; + public $before_write; + public function prepare( $query, ...$args ) { return $query; } + public function get_var( $query ) { + if ( false !== strpos( $query, 'GET_LOCK' ) ) { + if ( $this->busy || $this->locked ) { return 0; } + $this->locked = true; + return 1; + } + if ( false !== strpos( $query, 'RELEASE_LOCK' ) ) { $this->locked = false; return 1; } + return null; + } + public function get_row( $query ) { + ++$this->reads; + retention_assert( $this->locked, 'Findings were read without acquiring the queue lock.' ); + if ( $this->read_error ) { $this->last_error = 'Synthetic read failure'; return null; } + $this->snapshot = $this->row ? clone $this->row : null; + return $this->row ? clone $this->row : null; + } + public function query( $query ) { + ++$this->query_count; + retention_assert( $this->locked, 'Findings mutation escaped its queue lock.' ); + if ( $this->before_write ) { $callback = $this->before_write; $this->before_write = null; $callback( $this ); } + if ( $this->write_error ) { $this->last_error = 'Synthetic write failure'; return false; } + // Model a compare-and-set against the fresh snapshot. The hosted database suite exercises actual SQL predicates. + if ( serialize( $this->snapshot ) !== serialize( $this->row ) ) { return 0; } + $this->row->result_json = null; + $this->row->manual_remainder_notes = null; + return 1; + } +} +$purge_now = '2026-01-16 00:00:00'; +$purge_base = (object) array_merge( (array) $admin_closed, array( + 'job_id' => 31, 'hold_reason' => null, 'hold_until' => null, + 'result_json' => '{"synthetic":"finding"}', 'manual_remainder_notes' => 'Synthetic findings', +) ); foreach ( array( - "closure_source = 'report_delivery'", - "closed_by = 0", - "report_delivered_at = closed_at", - "report_access_used_at = report_delivered_at", - "closure_source = 'admin_final_close'", - "closed_by > 0", - 'closure_reason = status', - 'report_delivered_at IS NULL', - 'report_access_used_at IS NULL', -) as $required_purge_guard ) { - retention_assert( false !== strpos( $plugin_source, $required_purge_guard ), 'The findings purge no longer guards on trusted event field: ' . $required_purge_guard ); + array( 'closed_at' => '2024-02-00 00:00:00' ), + array( 'closed_at' => '2024-02-30 00:00:00' ), + array( 'created_at' => '2024-01-00 00:00:00' ), + array( 'terms_accepted_at' => '2024-01-00 00:00:00' ), + array( 'closure_source' => 'ADMIN_FINAL_CLOSE' ), + array( 'status' => 'COMPLETED' ), + array( 'closure_reason' => 'COMPLETED' ), + array( 'hold_reason' => 'Synthetic active hold', 'hold_until' => '2027-01-01 00:00:00' ), + array( 'hold_reason' => 'Synthetic damaged hold', 'hold_until' => null ), +) as $overrides ) { + $GLOBALS['wpdb'] = $db = new FakeFindingsDb(); + $db->row = (object) array_merge( (array) $purge_base, $overrides ); + retention_assert( 'kept' === ct_scan_purge_findings_if_due( 31, $purge_now ), 'Untrusted/held findings were eligible for destructive purge.' ); + retention_assert( 0 === $db->query_count && null !== $db->row->result_json && null !== $db->row->manual_remainder_notes && ! $db->locked, 'Untrusted/held findings were mutated or their queue lock leaked.' ); +} +$GLOBALS['wpdb'] = $db = new FakeFindingsDb(); +$db->row = clone $purge_base; +retention_assert( 'cleared' === ct_scan_purge_findings_if_due( 31, $purge_now ), 'A valid due event did not clear its findings.' ); +retention_assert( 1 === $db->query_count && null === $db->row->result_json && null === $db->row->manual_remainder_notes && ! $db->locked, 'Valid findings were not atomically cleared under the lock.' ); +$GLOBALS['wpdb'] = $db = new FakeFindingsDb(); +$db->row = clone $purge_base; +$db->before_write = static function ( $database ) { $database->row->hold_reason = 'Concurrent hold'; $database->row->hold_until = '2027-01-01 00:00:00'; }; +retention_assert( 'kept' === ct_scan_purge_findings_if_due( 31, $purge_now ) && null !== $db->row->result_json && null !== $db->row->manual_remainder_notes, 'A concurrently committed hold was overwritten by findings purge.' ); +$GLOBALS['wpdb'] = $db = new FakeFindingsDb(); +$db->row = clone $purge_base; +$db->busy = true; +retention_assert( 'busy' === ct_scan_purge_findings_if_due( 31, $purge_now ) && 0 === $db->reads && 0 === $db->query_count, 'An unavailable queue lock allowed read or purge.' ); +$GLOBALS['wpdb'] = $db = new FakeFindingsDb(); +$db->row = clone $purge_base; +$db->read_error = true; +retention_assert( 'failed' === ct_scan_purge_findings_if_due( 31, $purge_now ) && 0 === $db->query_count && ! $db->locked, 'A failed fresh read was treated as purge authority.' ); +$GLOBALS['wpdb'] = $db = new FakeFindingsDb(); +$db->row = clone $purge_base; +$db->write_error = true; +retention_assert( 'failed' === ct_scan_purge_findings_if_due( 31, $purge_now ) && null !== $db->row->result_json && ! $db->locked, 'A failed CAS write was reported as successful or leaked its lock.' ); +foreach ( array( null, 'invalid', '0000-00-00 00:00:00', '2020-12-31 23:59:59' ) as $invalid_close ) { + $damaged = (object) array_merge( (array) $legacy_due, array( 'closed_at' => $invalid_close ) ); + retention_assert( false === ct_scan_tombstone_deadline_passed( $damaged, $purge_now ), 'A tombstone bypassed the ordinary legacy closure invariant.' ); +} +foreach ( array( null, 'invalid', '2024-02-00 00:00:00' ) as $invalid_deadline ) { + $damaged = (object) array_merge( (array) $legacy_due, array( 'retain_until' => $invalid_deadline ) ); + retention_assert( false === ct_scan_tombstone_deadline_passed( $damaged, $purge_now ), 'A tombstone bypassed the ordinary stored-deadline invariant.' ); } +retention_assert( true === ct_scan_tombstone_deadline_passed( $legacy_due, $purge_now ), 'A valid due legacy tombstone lost its three-year acceptance policy.' ); echo "Scan findings and evidence retention checks passed.\n"; diff --git a/integrations/wordpress/standalone-tests/tombstones.php b/integrations/wordpress/standalone-tests/tombstones.php index 5dfc4de..0fab94d 100644 --- a/integrations/wordpress/standalone-tests/tombstones.php +++ b/integrations/wordpress/standalone-tests/tombstones.php @@ -23,6 +23,7 @@ class FakeTombstoneDb { public $scrub_updates = 0; public $job_deletes = 0; public $post_deletes = 0; + public $restored_row = null; public function prepare( $query, ...$args ) { $this->last_args = $args; @@ -71,6 +72,7 @@ public function get_row( $query ) { if ( false !== strpos( $query, 'contact_email,contact_name' ) ) { return (object) array( 'contact_email' => '', 'contact_name' => '' ); } + if ( false !== strpos( $query, 'SELECT * FROM wp_ct_scan_jobs' ) && $this->restored_row ) { return clone $this->restored_row; } // The tombstone-row pagination case deliberately uses no restored/live job row. return null; } @@ -221,4 +223,40 @@ function tombstone_cursor_max_id() { 'The successful missing-post case did not delete only the job after recording its tombstone.' ); +// A matched tombstone cannot bypass the date invariants required by ordinary legacy deletion. +$legacy_restored = (object) array( + 'job_id' => 41, 'request_id' => 900, 'scope_hash' => hash( 'sha256', 'https://legacy-restored.example.net/' ), + 'identity' => str_repeat( 'c', 32 ), 'contact_email' => 'fixture@example.invalid', 'contact_name' => 'Synthetic Client', + 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2021-01-01 00:00:00', + 'created_at' => '2021-01-01 00:00:00', 'closed_at' => '2021-01-02 00:00:00', + 'retain_until' => '2024-01-01 00:00:00', 'status' => 'failed', + 'hold_reason' => null, 'hold_until' => null, 'result_json' => null, 'manual_remainder_notes' => null, + 'closure_source' => null, 'closed_by' => null, 'closure_reason' => null, 'report_delivered_at' => null, 'report_access_used_at' => null, +); +foreach ( array( + 'missing-close' => array( 'closed_at' => null ), + 'invalid-close' => array( 'closed_at' => 'invalid' ), + 'zero-close' => array( 'closed_at' => '0000-00-00 00:00:00' ), + 'partial-close' => array( 'closed_at' => '2021-01-00 00:00:00' ), + 'backwards-close' => array( 'closed_at' => '2020-12-31 23:59:59' ), + 'missing-deadline' => array( 'retain_until' => null ), + 'invalid-deadline' => array( 'retain_until' => 'invalid' ), + 'partial-deadline' => array( 'retain_until' => '2024-02-00 00:00:00' ), + 'valid' => array(), +) as $case => $overrides ) { + $GLOBALS['test_options'] = array(); + $GLOBALS['wpdb'] = $database = new FakeTombstoneDb(); + $database->restored_row = (object) array_merge( (array) $legacy_restored, $overrides ); + $database->stones[] = (object) array( 'job_id' => 41, 'tombstone_id' => 501, + 'scope_mac' => ct_scan_tombstone_mac( $database->restored_row->scope_hash ), 'identity' => str_repeat( 'c', 32 ) ); + $normal = ct_scan_deletion_decision( $database->restored_row, '2026-01-16 00:00:00' ); + $budget = 100; + ct_scan_apply_tombstones( '2026-01-16 00:00:00', microtime( true ) + 30, $budget ); + if ( 'valid' === $case ) { + tombstone_assert( 'delete' === $normal[0] && 1 === $database->tombstone_inserts && 1 === $database->scrub_updates && 1 === $database->job_deletes, 'A valid due legacy row was not deleted through its matched tombstone.' ); + } else { + tombstone_assert( 'keep' === $normal[0] && 0 === $database->tombstone_inserts && 0 === $database->scrub_updates && 0 === $database->job_deletes && 0 === $database->post_deletes, "A matched tombstone bypassed the ordinary legacy date invariant ($case)." ); + } +} + echo "Scan tombstone regressions passed.\n"; diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index 74582d1..b480ee7 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -158,6 +158,33 @@ function ct_test_redeem_report( $job_id, $mail ) { ct_test_assert( is_wp_error( ct_scan_admin_save_manual_remainder( $event['job_id'], 'Edited after send' ) ), 'Report details must freeze after a send attempt.' ); ct_test_redeem_report( $event['job_id'], end( $mail_calls ) ); +// A revoked unused v8 link stays unredeemable but retains proof for authenticated no-report closure. +list( $revoked_id, $revoked_fields ) = ct_test_request( 'revoked-link-close-fixture.example.net' ); +$request_ids[] = $revoked_id; +$revoked_event = ct_scan_on_request_ready( $revoked_id, $revoked_fields ); +ct_test_queue_job( $revoked_event['job_id'] ); +$wpdb->update( $jobs, array( 'status' => 'failed', 'finished_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $revoked_event['job_id'] ) ); +ct_test_assert( true === ct_scan_admin_save_manual_remainder( $revoked_event['job_id'], 'Synthetic reviewed no-report lifecycle.' ), 'Revoked-link fixture manual review failed.' ); +ct_test_assert( true === ct_scan_admin_send_report( $revoked_event['job_id'] ), 'Revoked-link notification fixture failed.' ); +$revoked_mail = end( $mail_calls ); +ct_test_assert( 1 === preg_match( '/[?&]key=([a-f0-9]{64})/', $revoked_mail['message'], $revoked_key ), 'Revoked-link fixture capability missing.' ); +ct_test_assert( true === ct_scan_admin_revoke( $revoked_event['job_id'] ), 'Unused report link could not be revoked.' ); +$revoked_row = ct_scan_row( $revoked_event['job_id'] ); +ct_test_assert( ct_scan_no_report_close_eligible( $revoked_row ) && is_string( $revoked_row->report_access_token_hash ), 'Revocation destroyed non-secret link-only provenance.' ); +ct_test_assert( 410 === ct_scan_report_access_response( 'POST', $revoked_event['job_id'], $revoked_key[1] )['status'] && null === ct_scan_row( $revoked_event['job_id'] )->report_delivered_at, 'Revoked capability exposed findings or falsely recorded retrieval.' ); +$close_admin = wp_insert_user( array( 'user_login' => 'ct_close_' . wp_generate_password( 8, false ), 'user_pass' => wp_generate_password(), 'role' => 'administrator' ) ); +ct_test_assert( ! is_wp_error( $close_admin ), 'Synthetic closure administrator could not be created.' ); +wp_set_current_user( $close_admin ); +ct_test_assert( true === ct_scan_admin_close( $revoked_event['job_id'], 'failed' ), 'Authenticated no-report closure of an unused revoked link was refused.' ); +$closed_revoked = ct_scan_row( $revoked_event['job_id'] ); +ct_test_assert( ct_scan_closure_metadata_trusted( $closed_revoked ) && 'admin_final_close' === $closed_revoked->closure_source && (int) $close_admin === (int) $closed_revoked->closed_by, 'Revoked-link final closure lacks its trusted event.' ); +ob_start(); ct_scan_admin_render_final_close_metabox( (object) array( 'ID' => $revoked_id ) ); $closed_metabox = ob_get_clean(); +ct_test_assert( false !== strpos( $closed_metabox, 'Final closure: admin_final_close' ) && false === strpos( $closed_metabox, 'does not pass validation' ), 'Actual database metabox projection rejected the saved administrator event.' ); +ct_test_assert( 410 === ct_scan_report_access_response( 'POST', $revoked_event['job_id'], $revoked_key[1] )['status'], 'Final closure revived the revoked capability.' ); +wp_set_current_user( 0 ); +require_once ABSPATH . 'wp-admin/includes/user.php'; +wp_delete_user( $close_admin ); + // An expired, never-confirmed request cannot receive detailed findings, even after manual review. list( $unconfirmed_id, $unconfirmed_fields ) = ct_test_request( 'unconfirmed-report-fixture.example.net' ); $request_ids[] = $unconfirmed_id; diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index 6a1a73d..cbbaed4 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -978,6 +978,118 @@ function ct_test_make_due( $job ) { ct_test_maintain(); ct_test_assert( ! ct_test_exists( $partial ) && ! get_post( $partial['request_id'] ), 'The tombstone loop did not complete a failed deletion whose deadline has passed.' ); +// ---- Reviewed v8 findings purge: exact PHP event validation, even on permissive SQL modes/CI collations. +// All writes below are inside the disposable fixture guarded by support.php, never a live database. +$original_sql_mode = $wpdb->get_var( 'SELECT @@SESSION.sql_mode' ); +$fixture_modes = array_values( array_diff( explode( ',', $original_sql_mode ), array( 'NO_ZERO_DATE', 'NO_ZERO_IN_DATE' ) ) ); +$fixture_modes[] = 'ALLOW_INVALID_DATES'; +$wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode = %s', implode( ',', array_unique( $fixture_modes ) ) ) ); +try { + $closed_year = (int) gmdate( 'Y' ) - 2; + $accepted_date = ( $closed_year - 1 ) . '-01-01 00:00:00'; + $valid_close = $closed_year . '-02-01 00:00:00'; + $partial_close = $closed_year . '-02-00 00:00:00'; + $impossible_close = $closed_year . '-02-30 00:00:00'; + $event_base = array( + 'terms_version' => '2027-06-01-r3', 'status' => 'completed', 'created_at' => $accepted_date, + 'terms_accepted_at' => $accepted_date, 'closed_at' => $valid_close, + 'closure_source' => 'report_delivery', 'closed_by' => 0, 'closure_reason' => 'report_delivered', + 'report_delivered_at' => $valid_close, 'report_access_used_at' => $valid_close, + 'retain_until' => ct_test_old( '+5 years' ), 'result_json' => '{"fixture":"strict-purge"}', + 'manual_remainder_notes' => 'Synthetic strict purge findings', 'hold_reason' => null, 'hold_until' => null, + ); + $invalid_events = array( + 'partial-close' => array( 'closed_at' => $partial_close, 'report_delivered_at' => $partial_close, 'report_access_used_at' => $partial_close ), + 'impossible-close' => array( 'closed_at' => $impossible_close, 'report_delivered_at' => $impossible_close, 'report_access_used_at' => $impossible_close ), + 'partial-created' => array( 'created_at' => ( $closed_year - 1 ) . '-02-00 00:00:00' ), + 'partial-accepted' => array( 'terms_accepted_at' => ( $closed_year - 1 ) . '-02-00 00:00:00' ), + 'case-source' => array( 'closure_source' => 'REPORT_DELIVERY' ), + 'case-status' => array( 'status' => 'COMPLETED' ), + 'case-report-reason' => array( 'closure_reason' => 'REPORT_DELIVERED' ), + 'case-admin-reason' => array( 'closure_source' => 'admin_final_close', 'closed_by' => 1, 'closure_reason' => 'COMPLETED', 'report_delivered_at' => null, 'report_access_used_at' => null ), + ); + $invalid_event_jobs = array(); + foreach ( $invalid_events as $case => $overrides ) { + $fixture = ct_test_aged_job( 'ret-strict-' . $case . '.example.net', 'completed', '-4 years', '-2 years', '2027-06-01-r3', true ); + ct_test_assert( 1 === $wpdb->update( $jobs, array_merge( $event_base, $overrides ), array( 'job_id' => $fixture['job_id'] ) ), "Could not save the malformed event fixture ($case)." ); + $saved = ct_scan_row( $fixture['job_id'] ); + foreach ( $overrides as $column => $value ) { + ct_test_assert( null === $value ? null === $saved->$column : (string) $value === (string) $saved->$column, "The database normalized away the malformed event being tested ($case/$column)." ); + } + ct_test_assert( false === ct_scan_closure_metadata_trusted( $saved ), "Malformed event unexpectedly passed strict PHP validation ($case)." ); + $invalid_event_jobs[ $case ] = $fixture; + } + $case_row = $invalid_event_jobs['case-source']['job_id']; + ct_test_assert( '1' === (string) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$jobs} WHERE job_id = %d AND closure_source = 'report_delivery'", $case_row ) ), 'The actual database fixture did not exercise the case-insensitive SQL comparison that must not authorize deletion.' ); + $valid_events = array(); + foreach ( array( 'report', 'admin' ) as $kind ) { + $fixture = ct_test_aged_job( 'ret-strict-valid-' . $kind . '.example.net', 'completed', '-4 years', '-2 years', '2027-06-01-r3', true ); + $event = $event_base; + if ( 'admin' === $kind ) { $event = array_merge( $event, array( 'closure_source' => 'admin_final_close', 'closed_by' => 1, 'closure_reason' => 'completed', 'report_delivered_at' => null, 'report_access_used_at' => null ) ); } + ct_test_assert( 1 === $wpdb->update( $jobs, $event, array( 'job_id' => $fixture['job_id'] ) ), 'Could not save the valid findings purge control.' ); + ct_test_assert( ct_scan_closure_metadata_trusted( ct_scan_row( $fixture['job_id'] ) ), 'The valid purge control was not trusted before maintenance.' ); + $valid_events[] = $fixture; + } + $held_event = ct_test_aged_job( 'ret-strict-standing-hold.example.net', 'completed', '-4 years', '-2 years', '2027-06-01-r3', true ); + $wpdb->update( $jobs, array_merge( $event_base, array( 'hold_reason' => 'Synthetic standing preservation hold.', 'hold_until' => ct_test_old( '+30 days' ) ) ), array( 'job_id' => $held_event['job_id'] ) ); + $race_event = ct_test_aged_job( 'ret-strict-hold-race.example.net', 'completed', '-4 years', '-2 years', '2027-06-01-r3', true ); + $wpdb->update( $jobs, $event_base, array( 'job_id' => $race_event['job_id'] ) ); + $race_query_seen = false; + $race_hook = static function ( $query ) use ( &$race_query_seen, $race_event, $jobs, $wpdb ) { + if ( ! $race_query_seen && preg_match( '/^UPDATE\s+`?' . preg_quote( $jobs, '/' ) . '`?\s+SET\b/i', $query ) + && false !== strpos( $query, 'result_json' ) && false !== strpos( $query, 'NULL' ) + && preg_match( '/`?job_id`?\s*=\s*\x27?' . (int) $race_event['job_id'] . '(?:\x27|\s|$)/', $query ) ) { + $race_query_seen = true; + $wpdb->update( $jobs, array( 'hold_reason' => 'Synthetic concurrently committed preservation hold.', 'hold_until' => ct_test_old( '+30 days' ) ), array( 'job_id' => $race_event['job_id'] ) ); + } + return $query; + }; + add_filter( 'query', $race_hook ); + try { ct_test_maintain(); } finally { remove_filter( 'query', $race_hook ); } + foreach ( $invalid_event_jobs as $case => $fixture ) { + $row = ct_scan_row( $fixture['job_id'] ); + ct_test_assert( $row && $event_base['result_json'] === $row->result_json && $event_base['manual_remainder_notes'] === $row->manual_remainder_notes, "Malformed event lost findings in the actual database purge ($case)." ); + } + foreach ( $valid_events as $fixture ) { + $row = ct_scan_row( $fixture['job_id'] ); + ct_test_assert( $row && null === $row->result_json && null === $row->manual_remainder_notes, 'A valid due event did not clear both findings fields while preserving its evidence.' ); + } + $held_row = ct_scan_row( $held_event['job_id'] ); + $race_row = ct_scan_row( $race_event['job_id'] ); + ct_test_assert( $held_row && null !== $held_row->result_json && null !== $held_row->manual_remainder_notes, 'A standing preservation hold failed to retain findings.' ); + ct_test_assert( $race_query_seen && $race_row && null !== $race_row->result_json && null !== $race_row->manual_remainder_notes && ! empty( $race_row->hold_reason ), 'A preservation hold committed after the fresh read was overwritten by findings purge, or the race was not exercised.' ); + + // Matched legacy tombstones must honor the same valid/backwards-date prerequisites as ordinary deletion. + $legacy_tombstone_cases = array( + 'missing-close' => array( 'closed_at' => null ), + 'partial-close' => array( 'closed_at' => $partial_close ), + 'backwards-close' => array( 'closed_at' => ( $closed_year - 4 ) . '-01-01 00:00:00' ), + 'missing-deadline' => array( 'retain_until' => null ), + 'partial-deadline' => array( 'retain_until' => $partial_close ), + 'valid' => array(), + ); + $legacy_tombstone_jobs = array(); + foreach ( $legacy_tombstone_cases as $case => $overrides ) { + $fixture = ct_test_aged_job( 'ret-strict-stone-' . $case . '.example.net', 'failed', '-5 years', '-4 years' ); + $event = array_merge( array( 'created_at' => ( $closed_year - 3 ) . '-01-01 00:00:00', 'terms_accepted_at' => ( $closed_year - 3 ) . '-01-01 00:00:00', 'closed_at' => ( $closed_year - 3 ) . '-01-02 00:00:00', 'retain_until' => $closed_year . '-01-01 00:00:00', 'result_json' => null, 'manual_remainder_notes' => null ), $overrides ); + ct_test_assert( 1 === $wpdb->update( $jobs, $event, array( 'job_id' => $fixture['job_id'] ) ), "Could not save the legacy tombstone fixture ($case)." ); + $row = ct_scan_row( $fixture['job_id'] ); + ct_test_assert( 1 === $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => $row->job_id, 'request_id' => $row->request_id, 'scope_mac' => ct_scan_tombstone_mac( $row->scope_hash ), 'created_at' => $row->created_at, 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => $row->identity ) ), "Could not create the matched legacy tombstone ($case)." ); + $legacy_tombstone_jobs[ $case ] = $fixture; + } + $budget = 1000; + ct_scan_apply_tombstones( ct_scan_utc_now(), microtime( true ) + 30, $budget ); + foreach ( $legacy_tombstone_jobs as $case => $fixture ) { + if ( 'valid' === $case ) { + ct_test_assert( ! ct_test_exists( $fixture ) && ! get_post( $fixture['request_id'] ), 'A valid due legacy tombstone failed its acceptance-based three-year cleanup.' ); + } else { + ct_test_assert( ct_test_exists( $fixture ) && get_post( $fixture['request_id'] ), "A matched tombstone bypassed legacy date prerequisites ($case)." ); + } + } +} finally { + $wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode = %s', $original_sql_mode ) ); +} + foreach ( $request_ids as $id ) { $wpdb->delete( $jobs, array( 'request_id' => $id ), array( '%d' ) ); wp_delete_post( $id, true ); From f58b1b7a151ab6c1fb23e492581d61ec64468a35 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 21:53:11 -0500 Subject: [PATCH 06/14] test(wordpress): restore complete valid row for tombstone replay --- integrations/wordpress/tests/retention.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index cbbaed4..7d6da1d 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -123,6 +123,10 @@ function ct_test_exists( $job ) { $edge_del = ct_test_aged_job( 'ret-del.example.net', 'failed', '-3 years -1 day', '-1 day' ); $six_keep = ct_test_aged_job( 'ret-six-keep.example.net', 'cancelled', '-6 years -2 days', '-5 years -11 months', '2027-06-01-r3' ); $six_del = ct_test_aged_job( 'ret-six-del.example.net', 'cancelled', '-6 years -2 days', '-6 years -1 day', '2027-06-01-r3' ); +// Capture a genuine complete saved row, including its valid stamped deadline, for the restore test. +$edge_backup_row = ct_scan_row( $edge_del['job_id'] ); +$wpdb->update( $jobs, array( 'retain_until' => ct_scan_retain_until( $edge_backup_row ) ), array( 'job_id' => $edge_del['job_id'] ) ); +$edge_backup_row = (array) ct_scan_row( $edge_del['job_id'] ); ct_test_maintain(); ct_test_assert( ct_test_exists( $edge_keep ) && get_post( $edge_keep['request_id'] ), 'r2 evidence was deleted before three years.' ); ct_test_assert( ! ct_test_exists( $edge_del ) && ! get_post( $edge_del['request_id'] ), 'r2 evidence was not deleted after three years.' ); @@ -140,7 +144,8 @@ function ct_test_exists( $job ) { ct_test_assert( array() === array_intersect( $columns, array( 'contact_email', 'contact_name', 'target_origin', 'result_json', 'terms_hash', 'scope_hash' ) ), 'The tombstone table can hold personal or customer data.' ); // A restored backup brings the rows back: maintenance deletes them again. -$ghost = array( 'job_id' => $edge_del['job_id'], 'request_id' => $edge_del['request_id'], 'target_origin' => 'https://ret-del.example.net/', 'scope_hash' => $plain, 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-3 years -1 day' ), 'retention_checked_at' => ct_scan_utc_now(), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored Person', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $stone->created_at, 'finished_at' => ct_test_old( '-1 day' ), 'closed_at' => ct_test_old( '-1 day' ) ); +$ghost = $edge_backup_row; +$ghost['retention_checked_at'] = ct_scan_utc_now(); // Force the matched-tombstone path, not ordinary due-row deletion. $wpdb->insert( $jobs, $ghost ); $restored_post = wp_insert_post( array( 'import_id' => $edge_del['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); update_post_meta( $edge_del['request_id'], '_ct_scan_job_id', $edge_del['job_id'] ); From 017b6f53719ae72b2513f1eb39ec2bedb055750f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 21:55:48 -0500 Subject: [PATCH 07/14] test(wordpress): isolate held and paged tombstone controls --- integrations/wordpress/tests/retention.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index 7d6da1d..1efea21 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -155,7 +155,7 @@ function ct_test_exists( $job ) { ct_test_maintain(); ct_test_assert( ! ct_test_exists( $edge_del ) && ! get_post( $edge_del['request_id'] ), 'A restored backup resurrected deleted records.' ); // A different job that merely reuses the identifier is not touched. -$wpdb->insert( $jobs, array_merge( $ghost, array( 'created_at' => ct_test_old( '-1 day' ), 'status' => 'failed', 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'other@example.com' ) ) ); +$wpdb->insert( $jobs, array_merge( $ghost, array( 'created_at' => ct_test_old( '-3 years -2 days' ), 'status' => 'failed', 'terms_accepted_at' => ct_test_old( '-3 years -2 days' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'other@example.com' ) ) ); ct_test_maintain(); ct_test_assert( ct_test_exists( $edge_del ), 'A job that is not the deleted one matched the tombstone.' ); $wpdb->delete( $jobs, array( 'job_id' => $edge_del['job_id'] ) ); @@ -213,10 +213,13 @@ function ct_test_exists( $job ) { ct_test_assert( ! ct_test_exists( $held ) && ! get_post( $held['request_id'] ), 'An expired hold still pinned the record after its grace period.' ); // The tombstone path honours a hold too: a restored row under a hold is kept. $restore_held = ct_test_aged_job( 'ret-restore-held.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$restore_held_backup = ct_scan_row( $restore_held['job_id'] ); +$wpdb->update( $jobs, array( 'retain_until' => ct_scan_retain_until( $restore_held_backup ) ), array( 'job_id' => $restore_held['job_id'] ) ); +$restore_held_backup = (array) ct_scan_row( $restore_held['job_id'] ); ct_test_maintain(); ct_test_assert( ! ct_test_exists( $restore_held ), 'Fixture expected the job to be deleted first.' ); $rstone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $restore_held['job_id'] ) ); -$wpdb->insert( $jobs, array( 'job_id' => $restore_held['job_id'], 'request_id' => $restore_held['request_id'], 'target_origin' => 'https://ret-restore-held.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-restore-held.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'held@example.com', 'contact_name' => 'Held', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $rstone->created_at, 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ) ); +$wpdb->insert( $jobs, array_merge( $restore_held_backup, array( 'retention_checked_at' => ct_scan_utc_now(), 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ) ) ); ct_test_maintain(); ct_test_assert( ct_test_exists( $restore_held ), 'A restored job under a hold was deleted by its tombstone.' ); $wpdb->delete( $jobs, array( 'job_id' => $restore_held['job_id'] ) ); @@ -521,7 +524,8 @@ function ct_test_exists( $job ) { } // Restored rows that cannot be acted on (held, leased) never starve a later restored row in the tombstone loop. -$restored_base = array( 'target_origin' => 'https://ret-starve.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-starve.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-4 years' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => ct_test_old( '-4 years' ), 'retention_checked_at' => ct_scan_utc_now() ); +$restored_created = ct_test_old( '-4 years' ); +$restored_base = array( 'target_origin' => 'https://ret-starve.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-starve.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => $restored_created, 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $restored_created, 'closed_at' => ( new DateTimeImmutable( $restored_created, new DateTimeZone( 'UTC' ) ) )->modify( '+1 day' )->format( 'Y-m-d H:i:s' ), 'retain_until' => ( new DateTimeImmutable( $restored_created, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'retention_checked_at' => ct_scan_utc_now() ); for ( $i = 0; $i < 56; $i++ ) { $extra = 9 === $i % 10 ? array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+1 hour' ) ) : array( 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ); ct_test_assert( 1 === $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 887000100 + $i, 'request_id' => 886000100 + $i ), $extra ) ), 'Fixture insert of a restored row failed: ' . $wpdb->last_error ); From 447acc7319aba250f21d4eb8335e5c9f796dbd30 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 22:30:01 -0500 Subject: [PATCH 08/14] test(wordpress): assert restored controls meet retention prerequisites --- integrations/wordpress/tests/retention.php | 32 ++++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index 1efea21..e128882 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -118,6 +118,16 @@ function ct_test_exists( $job ) { ct_test_assert( $recent && null !== $recent->result_json && get_post( $recent_report['request_id'] ), 'Old legacy evidence was deleted before recently delivered findings reached their separate deadline.' ); ct_test_assert( ct_scan_findings_retain_until( $recent ) === ( new DateTimeImmutable( $delivery_at, new DateTimeZone( 'UTC' ) ) )->modify( '+366 days' )->format( 'Y-m-d H:i:s' ), 'Findings used the execution-stop timestamp instead of confirmed delivery.' ); +/** Positive restored controls must have valid stored dates before testing identity, holds or paging. */ +function ct_test_due_tombstone_deadline( $row, $label ) { + ct_test_assert( is_object( $row ), $label . ': restored row is missing.' ); + foreach ( array( 'created_at', 'closed_at', 'retain_until' ) as $field ) { + ct_test_assert( property_exists( $row, $field ) && ct_scan_valid_datetime( $row->$field ), $label . ': valid ' . $field . ' is required for a positive tombstone control.' ); + } + ct_test_assert( $row->closed_at >= $row->created_at, $label . ': closure precedes creation.' ); + ct_test_assert( ct_scan_tombstone_deadline_passed( $row, ct_scan_utc_now() ), $label . ': restored row is not past its current policy deadline.' ); +} + // ---- Evidence deadlines -------------------------------------------------------------------------------------- $edge_keep = ct_test_aged_job( 'ret-keep.example.net', 'failed', '-2 years -11 months', '-1 day' ); $edge_del = ct_test_aged_job( 'ret-del.example.net', 'failed', '-3 years -1 day', '-1 day' ); @@ -152,10 +162,12 @@ function ct_test_exists( $job ) { update_post_meta( $edge_del['request_id'], '_ct_scan_identity', $stone->identity ); ct_test_assert( 32 === strlen( (string) $stone->identity ), 'The tombstone did not keep the opaque identity.' ); ct_test_assert( ct_test_exists( $edge_del ) && get_post( $edge_del['request_id'] ), 'Fixture failed to restore the deleted records.' ); +ct_test_due_tombstone_deadline( ct_scan_row( $edge_del['job_id'] ), 'Complete restored backup' ); ct_test_maintain(); ct_test_assert( ! ct_test_exists( $edge_del ) && ! get_post( $edge_del['request_id'] ), 'A restored backup resurrected deleted records.' ); // A different job that merely reuses the identifier is not touched. $wpdb->insert( $jobs, array_merge( $ghost, array( 'created_at' => ct_test_old( '-3 years -2 days' ), 'status' => 'failed', 'terms_accepted_at' => ct_test_old( '-3 years -2 days' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'other@example.com' ) ) ); +ct_test_due_tombstone_deadline( ct_scan_row( $edge_del['job_id'] ), 'Different-identity control' ); ct_test_maintain(); ct_test_assert( ct_test_exists( $edge_del ), 'A job that is not the deleted one matched the tombstone.' ); $wpdb->delete( $jobs, array( 'job_id' => $edge_del['job_id'] ) ); @@ -220,6 +232,8 @@ function ct_test_exists( $job ) { ct_test_assert( ! ct_test_exists( $restore_held ), 'Fixture expected the job to be deleted first.' ); $rstone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $restore_held['job_id'] ) ); $wpdb->insert( $jobs, array_merge( $restore_held_backup, array( 'retention_checked_at' => ct_scan_utc_now(), 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ) ) ); +ct_test_due_tombstone_deadline( ct_scan_row( $restore_held['job_id'] ), 'Held restored backup' ); +ct_test_assert( ct_scan_job_on_hold( ct_scan_row( $restore_held['job_id'] ), ct_scan_utc_now() ), 'Held restored control has no effective hold.' ); ct_test_maintain(); ct_test_assert( ct_test_exists( $restore_held ), 'A restored job under a hold was deleted by its tombstone.' ); $wpdb->delete( $jobs, array( 'job_id' => $restore_held['job_id'] ) ); @@ -533,6 +547,8 @@ function ct_test_exists( $job ) { } ct_test_assert( 1 === $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 887000200, 'request_id' => 886000200 ) ) ), 'Fixture insert of a restored row failed: ' . $wpdb->last_error ); $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 887000200, 'request_id' => 886000200, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); +ct_test_due_tombstone_deadline( ct_scan_row( 887000200 ), 'Paging positive control' ); +ct_test_assert( ! ct_scan_job_on_hold( ct_scan_row( 887000200 ), ct_scan_utc_now() ) && ! ct_scan_job_holds_lease( ct_scan_row( 887000200 ) ), 'Paging positive control is held or leased.' ); ct_scan_retention_run( true ); $starve_left = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$jobs} WHERE job_id BETWEEN 887000100 AND 887000155" ); ct_test_assert( ! ct_scan_row( 887000200 ) && 56 === $starve_left, "Held or leased restored rows starved a later restored row, or were deleted ($starve_left of 56 kept)." ); @@ -749,6 +765,7 @@ function ct_test_make_due( $job ) { wp_insert_post( array( 'import_id' => $recycled['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'another record' ) ); update_post_meta( $recycled['request_id'], '_ct_scan_identity', str_repeat( 'e', 32 ) ); ct_test_assert( ct_test_exists( $recycled ) && get_post( $recycled['request_id'] ), 'Fixture failed to restore the row and recycle the post id.' ); +ct_test_due_tombstone_deadline( ct_scan_row( $recycled['job_id'] ), 'Recycled-post positive row control' ); ct_test_maintain(); ct_test_assert( ! ct_test_exists( $recycled ) && get_post( $recycled['request_id'] ), 'A restored row survived, or its tombstone deleted a post that belongs to another record.' ); wp_delete_post( $recycled['request_id'], true ); @@ -768,6 +785,7 @@ function ct_test_make_due( $job ) { foreach ( $ghost_rows as $g ) { $g['retention_checked_at'] = ct_scan_utc_now(); // examined just now: only the tombstone loop reaches these $wpdb->insert( $jobs, $g ); + ct_test_due_tombstone_deadline( ct_scan_row( $g['job_id'] ), 'Shared-budget restored control' ); } $budget_jobs = array(); for ( $i = 0; $i < 45; $i++ ) { @@ -813,14 +831,22 @@ function ct_test_make_due( $job ) { // running scan): that would free the one-at-a-time gate while its traffic may be in flight. Once the lease ends, it is. foreach ( array( 'control' => array( 'status' => 'awaiting_control', 'control_required' => 1, 'control_state' => 'checking', 'control_attempts' => 1, 'control_lease_hash' => str_repeat( 'c', 64 ), 'control_lease_until' => ct_test_old( '+2 minutes' ) ), 'scan' => array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+2 minutes' ) ) ) as $kind => $leased ) { $gone = ct_test_aged_job( "ret-restore-{$kind}.example.net", 'failed', '-3 years -1 day', '-1 day' ); + ct_test_make_due( $gone ); + $gone_backup = (array) ct_scan_row( $gone['job_id'] ); ct_test_maintain(); ct_test_assert( ! ct_test_exists( $gone ), 'Fixture expected the job to be deleted first.' ); $gstone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $gone['job_id'] ) ); - $wpdb->insert( $jobs, array_merge( array( 'job_id' => $gone['job_id'], 'request_id' => $gone['request_id'], 'target_origin' => "https://ret-restore-{$kind}.example.net/", 'scope_hash' => hash( 'sha256', "https://ret-restore-{$kind}.example.net/" ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-3 years -1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'created_at' => $gstone->created_at ), $leased ) ); - ct_test_maintain(); + $wpdb->insert( $jobs, array_merge( $gone_backup, $leased ) ); + ct_test_due_tombstone_deadline( ct_scan_row( $gone['job_id'] ), 'Leased restored control' ); + $lease_budget = ct_scan_retention_batch_limit(); + ct_scan_apply_tombstones( ct_scan_utc_now(), microtime( true ) + 3, $lease_budget ); ct_test_assert( ct_test_exists( $gone ), "A restored row holding a live {$kind} lease was deleted by its tombstone mid-lease." ); $wpdb->update( $jobs, 'control' === $kind ? array( 'control_lease_until' => ct_test_old( '-1 minute' ) ) : array( 'lease_until' => ct_test_old( '-1 minute' ) ), array( 'job_id' => $gone['job_id'] ) ); - ct_test_maintain(); + ct_test_assert( ct_scan_lock_acquire(), 'Lease-expiry fixture could not acquire the queue lock.' ); + try { ct_scan_maintenance_locked(); } finally { ct_scan_lock_release(); } + ct_test_due_tombstone_deadline( ct_scan_row( $gone['job_id'] ), 'Expired-lease restored control' ); + $lease_budget = ct_scan_retention_batch_limit(); + ct_scan_apply_tombstones( ct_scan_utc_now(), microtime( true ) + 3, $lease_budget ); ct_test_assert( ! ct_test_exists( $gone ), "A restored row was not deleted again after its {$kind} lease ended." ); } From 645b2187b66ed3f466db572862c2098e486e97ea Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 22:32:12 -0500 Subject: [PATCH 09/14] test(wordpress): retain end-to-end restored lease lifecycle coverage --- integrations/wordpress/tests/retention.php | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index e128882..e54d797 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -850,6 +850,36 @@ function ct_test_make_due( $job ) { ct_test_assert( ! ct_test_exists( $gone ), "A restored row was not deleted again after its {$kind} lease ended." ); } +// End-to-end retention also clears obsolete closure dates when a restored row can still run. +// Expiring a lease cannot invent those dates again. Keep the unknown row until complete, captured +// pre-deletion evidence is restored; only then may full maintenance delete it. The focused subsystem +// test above independently proves the live-lease guard while otherwise-valid dates are still present. +foreach ( array( 'control', 'scan' ) as $kind ) { + $full = ct_test_aged_job( "ret-restore-e2e-{$kind}.example.net", 'failed', '-3 years -1 day', '-1 day' ); + ct_test_make_due( $full ); + $full_backup = (array) ct_scan_row( $full['job_id'] ); + ct_test_maintain(); + ct_test_assert( ! ct_test_exists( $full ), 'Full-pipeline fixture was not initially deleted.' ); + $full_lease = 'control' === $kind + ? array( 'status' => 'awaiting_control', 'control_required' => 1, 'control_state' => 'checking', 'control_attempts' => 1, 'control_lease_hash' => str_repeat( 'c', 64 ), 'control_lease_until' => ct_test_old( '+2 minutes' ) ) + : array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+2 minutes' ) ); + $wpdb->insert( $jobs, array_merge( $full_backup, $full_lease ) ); + ct_test_due_tombstone_deadline( ct_scan_row( $full['job_id'] ), 'Full-pipeline initial restored control' ); + ct_test_maintain(); + $full_pending = ct_scan_row( $full['job_id'] ); + ct_test_assert( $full_pending && ct_scan_job_holds_lease( $full_pending ) && null === $full_pending->closed_at && null === $full_pending->retain_until, "Full retention deleted an active restored {$kind} row or retained an obsolete closure date." ); + $wpdb->update( $jobs, 'control' === $kind ? array( 'control_lease_until' => ct_test_old( '-1 minute' ) ) : array( 'lease_until' => ct_test_old( '-1 minute' ) ), array( 'job_id' => $full['job_id'] ) ); + ct_test_maintain(); + $full_pending = ct_scan_row( $full['job_id'] ); + ct_test_assert( $full_pending && ! ct_scan_job_holds_lease( $full_pending ) && null === $full_pending->closed_at && null === $full_pending->retain_until, "Lease expiry invented closure evidence or deleted the pending restored {$kind} row." ); + // Restore the exact actual saved row, never synthesize a historical closure from lease expiry. + $full_backup['retention_checked_at'] = ct_scan_utc_now(); + $wpdb->update( $jobs, $full_backup, array( 'job_id' => $full['job_id'] ) ); + ct_test_due_tombstone_deadline( ct_scan_row( $full['job_id'] ), 'Full-pipeline reconciled saved control' ); + ct_test_maintain(); + ct_test_assert( ! ct_test_exists( $full ), "Full retention did not delete the restored {$kind} row after its complete valid saved evidence was reconciled." ); +} + // ---- Fifth review: stale release, identity backfill ownership, restored-post paging ---------------------------- wp_set_current_user( 1 ); // A stale "Release hold" form (for hold H1) cannot end a newer hold H2 placed after H1 was released. From 8d3243b3ec65040b8f93d98254eca5d334fc022b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 22:35:04 -0500 Subject: [PATCH 10/14] test(wordpress): bind browser isolation to recorded parent namespace --- docs/WORDPRESS.md | 4 ++-- integrations/wordpress/standalone-tests/metabox-dom.mjs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index abbfb8e..82a5ec5 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -696,8 +696,8 @@ 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, then invoke -`standalone-tests/metabox-dom.mjs` under `bwrap --unshare-net` with that HTML, +`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. diff --git a/integrations/wordpress/standalone-tests/metabox-dom.mjs b/integrations/wordpress/standalone-tests/metabox-dom.mjs index 6af5c2e..0e14b51 100644 --- a/integrations/wordpress/standalone-tests/metabox-dom.mjs +++ b/integrations/wordpress/standalone-tests/metabox-dom.mjs @@ -3,7 +3,8 @@ import fs from 'node:fs'; import { pathToFileURL } from 'node:url'; const [htmlPath, playwrightPath, chromiumPath] = process.argv.slice(2); if (!htmlPath || !playwrightPath || !chromiumPath) throw new Error('HTML, installed Playwright module, Chromium executable required'); -if(fs.readlinkSync('/proc/self/ns/net')===fs.readlinkSync('/proc/1/ns/net')) throw new Error('Browser check requires an isolated network namespace'); +const parentNetns=process.env.CT_SCAN_DOM_PARENT_NETNS; +if(!/^net:\[\d+\]$/.test(parentNetns||'')||fs.readlinkSync('/proc/self/ns/net')===parentNetns) throw new Error('Browser check requires a recorded parent and isolated network namespace'); const { chromium } = await import(pathToFileURL(playwrightPath).href); const browser = await chromium.launch({executablePath: chromiumPath, headless:true, args:['--no-sandbox','--disable-background-networking','--disable-dev-shm-usage']}); From 2d55fa9de90b37e8da0c64292e0de171622857a1 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 13 Nov 2026 23:13:54 -0500 Subject: [PATCH 11/14] fix(wordpress): centralize findings confirmation and hold invariants --- integrations/wordpress/ct-scan.php | 156 +++++++++++++----- .../standalone-tests/report-access.php | 66 ++++++++ .../wordpress/standalone-tests/retention.php | 82 ++++++++- .../wordpress/standalone-tests/tombstones.php | 56 ++++++- .../wordpress/tests/evidence-strict.php | 14 ++ integrations/wordpress/tests/integration.php | 109 ++++++++++++ integrations/wordpress/tests/retention.php | 117 +++++++++++-- 7 files changed, 544 insertions(+), 56 deletions(-) diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 629d954..d96ee8f 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -134,7 +134,11 @@ function ct_scan_config() { * grandfathered. A version that cannot be parsed is NOT legacy. */ function ct_scan_is_legacy_version( $version ) { - if ( ! is_string( $version ) || ! preg_match( '/\A(\d{4}-\d{2}-\d{2})-r(\d{1,3})\z/', $version, $m ) ) { + if ( ! is_string( $version ) || ! preg_match( '/\A(\d{4}-\d{2}-\d{2})-r([1-9]\d{0,2})\z/', $version, $m ) ) { + return false; + } + $date = explode( '-', $m[1] ); + if ( ! checkdate( (int) $date[1], (int) $date[2], (int) $date[0] ) ) { return false; } return $m[1] < '2026-10-21' || ( '2026-10-21' === $m[1] && (int) $m[2] <= 2 ); @@ -1134,6 +1138,58 @@ function ct_scan_closure_metadata_trusted( $row ) { return false; } +/** + * Findings presence is a SQL NULL question, never PHP truthiness: '0' and '' are stored values. + * Missing columns or unexpected non-string data mean unknown and must preserve the complete row. + * Returns true (present), false (both explicit SQL NULL), or null (unknown schema/value). + */ +function ct_scan_findings_presence( $row ) { + if ( ! is_object( $row ) ) { + return null; + } + foreach ( array( 'result_json', 'manual_remainder_notes' ) as $field ) { + if ( ! property_exists( $row, $field ) || ( null !== $row->$field && ! is_string( $row->$field ) ) ) { + return null; + } + } + return null !== $row->result_json || null !== $row->manual_remainder_notes; +} + +/** Shared prerequisites for ordinary and restored-row deletion; legacy policy never excuses backwards dates. */ +function ct_scan_retention_dates_trusted( $row ) { + if ( ! is_object( $row ) ) { + return false; + } + foreach ( array( 'created_at', 'terms_accepted_at', 'closed_at', 'retain_until' ) as $field ) { + if ( ! property_exists( $row, $field ) || ! ct_scan_valid_datetime( $row->$field ) ) { + return false; + } + } + return $row->closed_at >= $row->created_at && $row->closed_at >= $row->terms_accepted_at; +} + +/** + * Canonical mailbox confirmation is proof only within its original acceptance/creation window. + * A prepared report may be retrieved after traffic authorization expires: the confirmation itself, + * not the current retrieval time, must have occurred by that frozen authorization deadline. + */ +function ct_scan_confirmation_metadata_trusted( $row, $now = null ) { + if ( ! is_object( $row ) ) { + return false; + } + $now = null === $now ? ct_scan_utc_now() : $now; + if ( ! ct_scan_valid_datetime( $now ) ) { + return false; + } + foreach ( array( 'confirmed_at', 'created_at', 'terms_accepted_at', 'authorization_expires_at' ) as $field ) { + if ( ! property_exists( $row, $field ) || ! ct_scan_valid_datetime( $row->$field ) ) { + return false; + } + } + return $row->confirmed_at >= $row->created_at && $row->confirmed_at >= $row->terms_accepted_at + && $row->confirmed_at <= $now && $row->confirmed_at <= $row->authorization_expires_at; +} + /** Findings and any served report remain until their trusted delivery or final-close timestamp plus 366 days. */ function ct_scan_findings_retain_until( $row ) { if ( ! ct_scan_closure_metadata_trusted( $row ) ) { @@ -1209,23 +1265,44 @@ function ct_scan_hold_grace_days() { return 3; } -/** - * Is this job under a preservation hold? An unexpired hold preserves, and so does one that expired - * less than the grace period ago; a hold with a purpose but no expiry (not creatable through the admin - * action) also preserves, so a damaged hold never causes a deletion. - */ +/** The complete nullable hold tuple; all destructive paths validate and compare the same state. */ +function ct_scan_hold_fields() { + return array( 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at', 'hold_released_by', 'hold_released_at' ); +} + +/** A database actor id is positive and canonical; NULL/zero/coercible non-ids are not evidence. */ +function ct_scan_positive_actor_id( $value ) { + return ( is_int( $value ) && $value > 0 ) + || ( is_string( $value ) && 1 === preg_match( '/\A[1-9]\d*\z/', $value ) && (string) (int) $value === $value && (int) $value > 0 ); +} + +/** Only a complete absent tuple or a trustworthy elapsed/released hold can permit cleanup. */ function ct_scan_job_on_hold( $row, $now ) { - if ( ! is_object( $row ) || ! property_exists( $row, 'hold_reason' ) || ! property_exists( $row, 'hold_until' ) ) { + if ( ! is_object( $row ) || ! ct_scan_valid_datetime( $now ) ) { return true; } - if ( null === $row->hold_reason ) { + $all_null = true; + foreach ( ct_scan_hold_fields() as $field ) { + if ( ! property_exists( $row, $field ) ) { + return true; + } + $all_null = $all_null && null === $row->$field; + } + if ( $all_null ) { return false; } - // SQL NULL is the only unambiguous absence. A damaged empty/'0' purpose or expiry - // must not release a hold in PHP while SQL and the administrative UI still see a record. + // Any partial or contradictory tuple is a damaged hold, never an absent hold. if ( ! is_string( $row->hold_reason ) || '' === $row->hold_reason || '0' === $row->hold_reason || trim( $row->hold_reason ) !== $row->hold_reason || preg_match( '/[\x00-\x1f\x7f]/', $row->hold_reason ) - || ! ct_scan_valid_datetime( $row->hold_until ) ) { + || ! ct_scan_valid_datetime( $row->hold_until ) || ! ct_scan_valid_datetime( $row->hold_set_at ) + || ! ct_scan_positive_actor_id( $row->hold_set_by ) + || $row->hold_set_at > $row->hold_until || $row->hold_set_at > $now ) { + return true; + } + $release_absent = null === $row->hold_released_by && null === $row->hold_released_at; + if ( ! $release_absent && ( ! ct_scan_positive_actor_id( $row->hold_released_by ) + || ! ct_scan_valid_datetime( $row->hold_released_at ) || $row->hold_released_at < $row->hold_set_at + || $row->hold_released_at > $now || $row->hold_until > $row->hold_released_at ) ) { return true; } $grace_end = ( new DateTimeImmutable( $row->hold_until, new DateTimeZone( 'UTC' ) ) )->modify( '+' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); @@ -1274,12 +1351,7 @@ function ct_scan_deletion_decision( $row, $now ) { if ( ! isset( $row->identity ) || ! is_string( $row->identity ) || '' === $row->identity ) { return array( 'keep', null ); } - foreach ( array( $row->created_at, $row->closed_at, $row->retain_until ) as $date ) { - if ( ! ct_scan_valid_datetime( $date ) ) { - return array( 'keep', null ); - } - } - if ( $row->closed_at < $row->created_at ) { + if ( ! ct_scan_retention_dates_trusted( $row ) ) { return array( 'keep', null ); } if ( ct_scan_is_legacy_version( $row->terms_version ) ) { @@ -1293,7 +1365,10 @@ function ct_scan_deletion_decision( $row, $now ) { if ( false === $required ) { return array( 'keep', null ); } - $has_findings = ! empty( $row->result_json ) || ! empty( $row->manual_remainder_notes ); + $has_findings = ct_scan_findings_presence( $row ); + if ( null === $has_findings ) { + return array( 'keep', null ); + } if ( $has_findings ) { $findings_until = ct_scan_findings_retain_until( $row ); if ( false === $findings_until || $findings_until > $now ) { @@ -1324,17 +1399,7 @@ function ct_scan_deletion_allowed( $row, $now ) { * deadline for a restored or damaged copy. */ function ct_scan_tombstone_deadline_passed( $row, $now ) { - // A tombstone does not excuse invalid dates in a restored legacy row. Apply the same - // closure/deadline prerequisites as ordinary retention before deriving its legacy period. - if ( ! is_object( $row ) ) { - return false; - } - foreach ( array( 'created_at', 'closed_at', 'retain_until' ) as $field ) { - if ( ! property_exists( $row, $field ) || ! ct_scan_valid_datetime( $row->$field ) ) { - return false; - } - } - if ( $row->closed_at < $row->created_at ) { + if ( ct_scan_job_on_hold( $row, $now ) || ! ct_scan_retention_dates_trusted( $row ) ) { return false; } $required = ct_scan_required_retain_until( $row ); @@ -1348,7 +1413,11 @@ function ct_scan_tombstone_deadline_passed( $row, $now ) { if ( $deadline > $now ) { return false; } - if ( ! empty( $row->result_json ) || ! empty( $row->manual_remainder_notes ) ) { + $has_findings = ct_scan_findings_presence( $row ); + if ( null === $has_findings ) { + return false; + } + if ( $has_findings ) { $findings_until = ct_scan_findings_retain_until( $row ); if ( false === $findings_until || $findings_until > $now ) { return false; @@ -1593,7 +1662,7 @@ function ct_scan_purge_findings_if_due( $job_id, $now ) { if ( ! is_object( $row ) || ! isset( $row->job_id ) || (int) $row->job_id < 1 || (int) $row->job_id !== (int) $job_id ) { return 'kept'; } - $fields = array( 'status', 'created_at', 'terms_accepted_at', 'closed_at', 'closure_source', 'closed_by', 'closure_reason', 'report_delivered_at', 'report_access_used_at', 'hold_reason', 'hold_until', 'result_json', 'manual_remainder_notes' ); + $fields = array_merge( array( 'status', 'created_at', 'terms_accepted_at', 'closed_at', 'closure_source', 'closed_by', 'closure_reason', 'report_delivered_at', 'report_access_used_at', 'result_json', 'manual_remainder_notes' ), ct_scan_hold_fields() ); foreach ( $fields as $field ) { if ( ! property_exists( $row, $field ) ) { return 'kept'; @@ -1601,7 +1670,7 @@ function ct_scan_purge_findings_if_due( $job_id, $now ) { } $until = ct_scan_findings_retain_until( $row ); if ( false === $until || $until > $now || ct_scan_job_on_hold( $row, $now ) - || ( null === $row->result_json && null === $row->manual_remainder_notes ) ) { + || true !== ct_scan_findings_presence( $row ) ) { return 'kept'; } // The advisory lock serializes supported writers. Binary predicates additionally refuse an @@ -2174,7 +2243,7 @@ function ct_scan_send_accepted_copy( $job_id ) { try { $row = ct_scan_row( $job_id ); // Same recipient rule as the report path: confirmed, and still the address the signer authorized. - if ( ! $row || empty( $row->confirmed_at ) || ! is_email( $row->contact_email ) + if ( ! $row || ! ct_scan_confirmation_metadata_trusted( $row ) || ! 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 || strtolower( ct_scan_meta_string( (int) $row->request_id, '_ct_terms_sha256' ) ) !== $row->terms_hash ) { @@ -2189,9 +2258,9 @@ function ct_scan_send_accepted_copy( $job_id ) { $claimed = $wpdb->update( $table, array( 'accepted_copy_status' => 'unknown', 'accepted_copy_at' => ct_scan_utc_now() ), - array( 'job_id' => (int) $row->job_id, 'accepted_copy_status' => 'not_sent' ), + array( 'job_id' => (int) $row->job_id, 'accepted_copy_status' => 'not_sent', 'confirmed_at' => $row->confirmed_at, 'created_at' => $row->created_at, 'terms_accepted_at' => $row->terms_accepted_at, 'authorization_expires_at' => $row->authorization_expires_at ), array( '%s', '%s' ), - array( '%d', '%s' ) + array( '%d', '%s', '%s', '%s', '%s', '%s' ) ); if ( 1 !== $claimed ) { return 'already_handled'; @@ -2782,7 +2851,7 @@ function ct_scan_admit_locked( $data, $now_ms ) { || ! hash_equals( $row->scope_hash, $data['scope_hash'] ) || strtotime( $row->lease_until . ' UTC' ) <= time() ) { return $refuse( 410, 'lease_invalid_or_expired' ); } - if ( empty( $row->confirmed_at ) ) { + if ( ! ct_scan_confirmation_metadata_trusted( $row ) ) { return $refuse( 410, 'recipient_unconfirmed' ); } if ( ! ct_scan_authorization_is_live( $row ) ) { @@ -3093,7 +3162,7 @@ function ct_scan_admit_control_locked( $data, $now_ms ) { || ! hash_equals( $row->scope_hash, $data['scope_hash'] ) || strtotime( $row->control_lease_until . ' UTC' ) <= time() ) { return $refuse( 410, 'lease_invalid_or_expired' ); } - if ( empty( $row->confirmed_at ) ) { + if ( ! ct_scan_confirmation_metadata_trusted( $row ) ) { return $refuse( 410, 'recipient_unconfirmed' ); } if ( ! ct_scan_authorization_is_live( $row ) ) { @@ -3169,7 +3238,7 @@ function ct_scan_control_claim( WP_REST_Request $request ) { if ( ! $row ) { return ct_scan_rest_response( 200, array( 'task' => null ) ); } - if ( ! ct_scan_authorization_is_live( $row ) || empty( $row->confirmed_at ) || ! in_array( $row->control_requested_method, array( 'dns_txt', 'https_file' ), true ) ) { + if ( ! ct_scan_authorization_is_live( $row ) || ! ct_scan_confirmation_metadata_trusted( $row ) || ! in_array( $row->control_requested_method, array( 'dns_txt', 'https_file' ), true ) ) { $wpdb->update( $table, array( 'status' => 'cancelled', 'error_code' => 'authorization_changed', 'finished_at' => ct_scan_utc_now(), 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none' ), array( 'job_id' => (int) $row->job_id ), null, array( '%d' ) ); ct_scan_mark_request_status( (int) $row->request_id, 'cancelled', 'authorization_changed' ); return ct_scan_rest_response( 200, array( 'task' => null ) ); @@ -3527,7 +3596,7 @@ function ct_scan_validate_report_job( $row, $token = '', $require_capability = f 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', 'The reviewed report is not ready.' ); } - if ( empty( $row->confirmed_at ) ) { + if ( ! ct_scan_confirmation_metadata_trusted( $row ) ) { return new WP_Error( 'report_recipient_unconfirmed', 'The report recipient did not confirm the request.' ); } clean_post_cache( (int) $row->request_id ); @@ -3665,6 +3734,7 @@ function ct_scan_report_access_response( $method, $job_id, $token ) { 'authorization_expires_at' => $row->authorization_expires_at, 'status' => $row->status, 'confirmed_at' => $row->confirmed_at, + 'created_at' => $row->created_at, 'control_required' => ct_scan_control_required( $row ) ? 1 : 0, 'control_method' => $row->control_method ?? null, 'control_verified_at' => $row->control_verified_at ?? null, @@ -3894,13 +3964,17 @@ function ct_scan_admin_send_report( $job_id ) { 'job_id' => (int) $row->job_id, 'request_id' => (int) $row->request_id, 'status' => $row->status, + 'confirmed_at' => $row->confirmed_at, + 'created_at' => $row->created_at, + 'terms_accepted_at' => $row->terms_accepted_at, + 'authorization_expires_at' => $row->authorization_expires_at, 'report_notification_status' => $row->report_notification_status, 'report_delivered_at' => null, 'revoked_at' => null, 'report_access_used_at' => null, ), array( '%s', '%s', '%s', '%s', '%s' ), - array( '%d', '%d', '%s', '%s', '%s', '%s', '%s' ) + array( '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%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.' ); @@ -4131,7 +4205,7 @@ function ct_scan_admin_page() { echo '
'; echo ''; echo '
Job / requestAuthorized scope and termsExecution / final closeConfirmation / 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 ) ) . '…
Job #' . (int) $row->job_id . ' / request #' . (int) $row->request_id . '
Created ' . esc_html( (string) $row->created_at ) . ' UTC
' . esc_html( (string) $row->target_origin ) . '
Terms ' . esc_html( (string) $row->terms_version ) . '
Accepted ' . esc_html( (string) $row->terms_accepted_at ) . ' UTC
Expires ' . esc_html( (string) $row->authorization_expires_at ) . ' UTC
Scope hash ' . esc_html( substr( (string) $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 ) . '
'; + echo '
Manual remainder (' . esc_html( sanitize_key( (string) $row->manual_remainder_status ) ) . ')
' . esc_html( (string) $row->manual_remainder_notes ) . '
'; } - if ( ct_scan_terminal_status( $row->status ) && empty( $row->revoked_at ) && empty( $row->report_delivered_at ) ) { + $closure_pending = null === $row->closure_source && null === $row->closed_by && null === $row->closure_reason && null === $row->report_delivered_at && null === $row->report_access_used_at; + if ( ct_scan_terminal_status( $row->status ) && $closure_pending && empty( $row->revoked_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 ); + if ( in_array( $row->report_notification_status, array( 'not_sent', 'failed' ), true ) ) { + $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( 'not_sent', 'failed' ), true ) ) { + echo ct_scan_admin_form( (int) $row->job_id, 'send_report', 'Email one-time report link', ' ' ); } + } elseif ( 'complete' === $row->manual_remainder_status ) { + echo '

' . esc_html( $preview->get_error_message() ) . '

'; } - 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 ( ct_scan_no_report_close_eligible( $row ) ) { + $reason = (string) $row->status; + $extra = ' '; + echo ct_scan_admin_form( (int) $row->job_id, 'final_close', 'Record no-report final close', $extra ); + } else { + echo '

Final close is unavailable: the earlier report-delivery state is ambiguous or needs reconciliation.

'; } } if ( 'awaiting_control' === $row->status && empty( $row->revoked_at ) ) { @@ -3379,16 +4084,15 @@ function ct_scan_admin_page() { } $on_hold = ct_scan_job_on_hold( $row, ct_scan_utc_now() ); if ( $on_hold && empty( $row->hold_released_at ) ) { - echo '

Preservation hold until ' . esc_html( $row->hold_until ) . ' UTC: ' . esc_html( $row->hold_reason ) . '

' . ct_scan_admin_form( (int) $row->job_id, 'release_hold', 'Release hold', ' ' ); + echo '

Preservation hold until ' . esc_html( (string) $row->hold_until ) . ' UTC: ' . esc_html( (string) $row->hold_reason ) . '

' . ct_scan_admin_form( (int) $row->job_id, 'release_hold', 'Release hold', ' ' ); } else { if ( $on_hold ) { - // Released, still inside the grace period: show it, and allow a new hold. - echo '

Preservation hold released ' . esc_html( $row->hold_released_at ) . ' UTC (records kept for a further ' . (int) ct_scan_hold_grace_days() . ' days): ' . esc_html( $row->hold_reason ) . '

'; + echo '

Preservation hold released ' . esc_html( (string) $row->hold_released_at ) . ' UTC (records kept for a further ' . (int) ct_scan_hold_grace_days() . ' days): ' . esc_html( (string) $row->hold_reason ) . '

'; } - echo ct_scan_admin_form( (int) $row->job_id, 'set_hold', 'Place preservation hold', '
days
' ); + echo ct_scan_admin_form( (int) $row->job_id, 'set_hold', 'Place preservation hold', '
days
' ); } if ( empty( $row->revoked_at ) && empty( $row->report_delivered_at ) ) { - echo ct_scan_admin_form( (int) $row->job_id, 'revoke', 'Revoke authorization', ' ' ); + echo ct_scan_admin_form( (int) $row->job_id, 'revoke', 'Revoke authorization', ' ' ); } echo '
Job #' . (int) $row->job_id . ' / request #' . (int) $row->request_id . '
Created ' . esc_html( (string) $row->created_at ) . ' UTC
' . esc_html( (string) $row->target_origin ) . '
Terms ' . esc_html( (string) $row->terms_version ) . '
Accepted ' . esc_html( (string) $row->terms_accepted_at ) . ' UTC
Expires ' . esc_html( (string) $row->authorization_expires_at ) . ' UTC
Scope hash ' . esc_html( substr( (string) $row->scope_hash, 0, 16 ) ) . '…
' . esc_html( $exec ) . '
' . ct_scan_admin_result_details( $row ) . '
' . esc_html( $mail ) . '
'; - if ( ! empty( $row->manual_remainder_notes ) ) { + if ( is_string( $row->manual_remainder_notes ) && '' !== $row->manual_remainder_notes ) { echo '
Manual remainder (' . esc_html( sanitize_key( (string) $row->manual_remainder_status ) ) . ')
' . esc_html( (string) $row->manual_remainder_notes ) . '
'; } $closure_pending = null === $row->closure_source && null === $row->closed_by && null === $row->closure_reason && null === $row->report_delivered_at && null === $row->report_access_used_at; diff --git a/integrations/wordpress/standalone-tests/report-access.php b/integrations/wordpress/standalone-tests/report-access.php index 3895ac3..0e538ca 100644 --- a/integrations/wordpress/standalone-tests/report-access.php +++ b/integrations/wordpress/standalone-tests/report-access.php @@ -498,6 +498,72 @@ function send_fixture_link( $mail_result = false ) { $nonterminal_close = ct_scan_admin_close( $job_id, 'failed' ); check( is_wp_error( $nonterminal_close ) && null === $GLOBALS['wpdb']->row->closure_source, 'a running request cannot be finalized by an administrator' ); +function confirmation_case_changes( $name, $row ) { + $accepted_epoch = strtotime( $row->terms_accepted_at . ' UTC' ); + switch ( $name ) { + case 'zero': return array( 'confirmed_at' => '0000-00-00 00:00:00' ); + case 'partial_zero': return array( 'confirmed_at' => '2024-02-00 00:00:00' ); + case 'invalid_calendar': return array( 'confirmed_at' => '2024-02-30 00:00:00' ); + case 'noncanonical': return array( 'confirmed_at' => '2024-02-01T00:00:00Z' ); + case 'before_acceptance': return array( 'created_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch - 60 ), 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch - 1 ) ); + case 'before_creation': return array( 'created_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch + 600 ), 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch + 300 ) ); + case 'future': return array( 'confirmed_at' => gmdate( 'Y-m-d H:i:s', time() + 3600 ) ); + case 'after_authorization': return array( 'authorization_expires_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch + 60 ), 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch + 120 ) ); + case 'missing': return array( 'confirmed_at' => null ); + } + throw new RuntimeException( 'Unknown confirmation fixture.' ); +} + +foreach ( array( 'zero', 'partial_zero', 'invalid_calendar', 'noncanonical', 'before_acceptance', 'before_creation', 'future', 'after_authorization', 'missing' ) as $case ) { + list( $job_id, $request_id ) = fixture_reset(); + foreach ( confirmation_case_changes( $case, $GLOBALS['wpdb']->row ) as $field => $value ) { $GLOBALS['wpdb']->row->$field = $value; } + $notification = ct_scan_admin_send_report( $job_id ); + check( is_wp_error( $notification ) && 'report_recipient_unconfirmed' === $notification->get_error_code() && 0 === count( $GLOBALS['mail_sends'] ), 'invalid confirmation sends no notification: ' . $case ); + + list( $job_id, $mail ) = send_fixture_link( true ); + preg_match( '#key=([a-f0-9]{64})#', $mail['body'], $key ); + $good = clone $GLOBALS['wpdb']->row; + foreach ( confirmation_case_changes( $case, $good ) as $field => $value ) { $GLOBALS['wpdb']->row->$field = $value; } + $response = ct_scan_report_access_response( 'POST', $job_id, $key[1] ); + check( 410 === $response['status'] && false === strpos( $response['body'], '9f7e' ) && null === $GLOBALS['wpdb']->row->report_access_used_at && null === $GLOBALS['wpdb']->row->report_delivered_at, 'invalid confirmation serves no findings or retrieval event: ' . $case ); + $GLOBALS['wpdb']->row = $good; + check( 200 === ct_scan_report_access_response( 'POST', $job_id, $key[1] )['status'], 'valid confirmation remains a redeemable positive control: ' . $case ); +} + +// A proof changed between validation and the one-time write cannot be used to disclose findings. +foreach ( array( 'confirmed_at', 'created_at', 'terms_accepted_at', 'authorization_expires_at' ) as $race_field ) { + list( $job_id, $mail ) = send_fixture_link( true ); + preg_match( '#key=([a-f0-9]{64})#', $mail['body'], $race_key ); + $GLOBALS['wpdb']->during_redemption = static function () use ( $race_field ) { + $GLOBALS['wpdb']->row->$race_field = gmdate( 'Y-m-d H:i:s', time() + 3600 ); + }; + $race_response = ct_scan_report_access_response( 'POST', $job_id, $race_key[1] ); + check( 410 === $race_response['status'] && false === strpos( $race_response['body'], '9f7e' ) && null === $GLOBALS['wpdb']->row->report_access_used_at && null === $GLOBALS['wpdb']->row->report_delivered_at, 'changed confirmation proof refuses the one-time retrieval write: ' . $race_field ); +} + +// Prepared report delivery may happen after target-traffic authorization expired, provided confirmation +// happened within that original window and current report/recipient/control requirements still pass. +list( $job_id, $request_id ) = fixture_reset(); +$accepted_epoch = time() - 3 * 86400; +$accepted_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch ); +$created_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 60 ); +$confirmed_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 120 ); +$expired_sql = gmdate( 'Y-m-d H:i:s', time() - 86400 ); +$GLOBALS['wpdb']->row->terms_accepted_at = $accepted_sql; +$GLOBALS['wpdb']->row->created_at = $created_sql; +$GLOBALS['wpdb']->row->confirmed_at = $confirmed_sql; +$GLOBALS['wpdb']->row->authorization_expires_at = $expired_sql; +$GLOBALS['wpdb']->row->control_required = 0; // Original grandfathered legacy header-only fixture. +$GLOBALS['meta'][$request_id]['_ct_terms_accepted_at'] = gmdate( 'Y-m-d\TH:i:s\Z', $accepted_epoch ); +$GLOBALS['meta'][$request_id]['_ct_scan_authorization_expires_at'] = $expired_sql; +$GLOBALS['mail_result'] = true; +check( true === ct_scan_admin_send_report( $job_id ), 'canonical confirmation permits a prepared report after traffic authorization expiry' ); +$expired_mail = end( $GLOBALS['mail_sends'] ); preg_match( '#key=([a-f0-9]{64})#', $expired_mail['body'], $expired_key ); +check( 200 === ct_scan_report_access_response( 'POST', $job_id, $expired_key[1] )['status'], 'prepared report remains retrievable with valid historical confirmation after traffic expiry' ); + +list( $job_id ) = fixture_reset(); +check( true === ct_scan_admin_save_manual_remainder( $job_id, '0' ) && '0' === $GLOBALS['wpdb']->row->manual_remainder_notes, 'the accepted zero-string manual notes value remains present' ); + function render_final_close_box( $request_id ) { ob_start(); ct_scan_admin_render_final_close_metabox( (object) array( 'ID' => $request_id ) ); diff --git a/integrations/wordpress/standalone-tests/retention.php b/integrations/wordpress/standalone-tests/retention.php index b6f16bc..cd21088 100644 --- a/integrations/wordpress/standalone-tests/retention.php +++ b/integrations/wordpress/standalone-tests/retention.php @@ -25,6 +25,11 @@ function retention_assert( $condition, $message ) { } } +function retention_no_hold( $overrides = array() ) { + return array_merge( array( 'hold_reason' => null, 'hold_until' => null, 'hold_set_by' => null, + 'hold_set_at' => null, 'hold_released_by' => null, 'hold_released_at' => null ), $overrides ); +} + function retention_plus_days( $value, $days ) { return ( new DateTimeImmutable( $value, new DateTimeZone( 'UTC' ) ) )->modify( '+' . (int) $days . ' days' )->format( 'Y-m-d H:i:s' ); } @@ -115,6 +120,7 @@ function retention_plus_years( $value, $years ) { retention_assert( false === ct_scan_findings_retain_until( $terminal_but_open ), 'Findings got a deadline before explicit final close or report delivery.' ); $legacy_terminal = (object) array_merge( (array) $terminal_but_open, + retention_no_hold(), array( 'closed_at' => $closed, // a pre-v8 maintenance timestamp 'retain_until' => '2025-01-15 12:30:00', @@ -157,7 +163,7 @@ function retention_plus_years( $value, $years ) { } // Legacy evidence still requires a valid non-backwards closure before deletion, even without findings. -$legacy_due = (object) array_merge( (array) $legacy, array( +$legacy_due = (object) array_merge( (array) $legacy, retention_no_hold(), array( 'terms_accepted_at' => '2021-01-01 00:00:00', 'created_at' => '2021-01-01 00:00:00', 'closed_at' => '2021-01-02 00:00:00', @@ -223,7 +229,7 @@ public function query( $query ) { } } $purge_now = '2026-01-16 00:00:00'; -$purge_base = (object) array_merge( (array) $admin_closed, array( +$purge_base = (object) array_merge( (array) $admin_closed, retention_no_hold(), array( 'job_id' => 31, 'hold_reason' => null, 'hold_until' => null, 'result_json' => '{"synthetic":"finding"}', 'manual_remainder_notes' => 'Synthetic findings', ) ); @@ -237,6 +243,8 @@ public function query( $query ) { array( 'closure_reason' => 'COMPLETED' ), array( 'hold_reason' => 'Synthetic active hold', 'hold_until' => '2027-01-01 00:00:00' ), array( 'hold_reason' => 'Synthetic damaged hold', 'hold_until' => null ), + array( 'hold_reason' => '0', 'hold_until' => null ), + array( 'hold_reason' => '', 'hold_until' => '0000-00-00 00:00:00' ), ) as $overrides ) { $GLOBALS['wpdb'] = $db = new FakeFindingsDb(); $db->row = (object) array_merge( (array) $purge_base, $overrides ); @@ -273,4 +281,74 @@ public function query( $query ) { } retention_assert( true === ct_scan_tombstone_deadline_passed( $legacy_due, $purge_now ), 'A valid due legacy tombstone lost its three-year acceptance policy.' ); +// SQL NULL state, never PHP truthiness, determines whether findings still need their own deadline. +foreach ( array( 'manual_remainder_notes', 'result_json' ) as $column ) { + foreach ( array( '0', '', 'ordinary synthetic finding' ) as $value ) { + foreach ( array( 'recent', 'missing', 'due' ) as $event ) { + $row = (object) array_merge( (array) $legacy_due, array( $column => $value, + 'closed_at' => 'due' === $event ? '2024-01-02 00:00:00' : '2026-01-15 00:00:00', + 'closure_source' => 'missing' === $event ? null : 'admin_final_close', + 'closed_by' => 'missing' === $event ? null : 42, + 'closure_reason' => 'missing' === $event ? null : 'completed', + ) ); + retention_assert( true === ct_scan_findings_presence( $row ), 'A non-NULL string was classified as absent findings.' ); + retention_assert( ( 'due' === $event ? 'delete' : 'keep' ) === ct_scan_deletion_decision( $row, $purge_now )[0], "Findings value bypassed its independent deadline ($column/$event)." ); + retention_assert( ( 'due' === $event ) === ct_scan_tombstone_deadline_passed( $row, $purge_now ), "A tombstone bypassed the independent findings deadline ($column/$event)." ); + } + } +} +retention_assert( false === ct_scan_findings_presence( $legacy_due ) && 'delete' === ct_scan_deletion_decision( $legacy_due, $purge_now )[0], 'The genuine SQL NULL/NULL legacy evidence-only control was not eligible.' ); +foreach ( array( 'result_json', 'manual_remainder_notes' ) as $column ) { + $missing = clone $legacy_due; unset( $missing->$column ); + retention_assert( null === ct_scan_findings_presence( $missing ) && 'keep' === ct_scan_deletion_decision( $missing, $purge_now )[0] && false === ct_scan_tombstone_deadline_passed( $missing, $purge_now ), 'Missing findings columns were mistaken for known NULL evidence-only data.' ); + $GLOBALS['wpdb'] = $db = new FakeFindingsDb(); $db->row = clone $purge_base; unset( $db->row->$column ); + retention_assert( 'kept' === ct_scan_purge_findings_if_due( 31, $purge_now ) && 0 === $db->query_count, 'Missing findings columns permitted purge.' ); + foreach ( array( 0, false, array(), (object) array() ) as $ambiguous ) { + $damaged = (object) array_merge( (array) $legacy_due, array( $column => $ambiguous ) ); + retention_assert( null === ct_scan_findings_presence( $damaged ) && 'keep' === ct_scan_deletion_decision( $damaged, $purge_now )[0] && false === ct_scan_tombstone_deadline_passed( $damaged, $purge_now ), 'Ambiguous non-string findings were treated as absent.' ); + $GLOBALS['wpdb'] = $db = new FakeFindingsDb(); $db->row = (object) array_merge( (array) $purge_base, array( $column => $ambiguous ) ); + retention_assert( 'kept' === ct_scan_purge_findings_if_due( 31, $purge_now ) && 0 === $db->query_count, 'Ambiguous non-string findings permitted purge.' ); + } +} + +// A hold is absent only when its complete six-column tuple is explicitly SQL NULL. +$hold_fields = array_keys( retention_no_hold() ); +$orphan_values = array( 'hold_until' => '2027-01-01 00:00:00', 'hold_set_by' => 42, + 'hold_set_at' => '2025-12-01 00:00:00', 'hold_released_by' => 42, 'hold_released_at' => '2026-01-01 00:00:00' ); +foreach ( $orphan_values as $column => $value ) { + $row = (object) array_merge( (array) $legacy_due, array( $column => $value ) ); + retention_assert( true === ct_scan_job_on_hold( $row, $purge_now ) && 'keep' === ct_scan_deletion_decision( $row, $purge_now )[0] && false === ct_scan_tombstone_deadline_passed( $row, $purge_now ), 'An orphaned hold field allowed evidence or tombstone deletion.' ); + $GLOBALS['wpdb'] = $db = new FakeFindingsDb(); + $db->row = (object) array_merge( (array) $purge_base, array( $column => $value ) ); + retention_assert( 'kept' === ct_scan_purge_findings_if_due( 31, $purge_now ) && 0 === $db->query_count && null !== $db->row->result_json, 'An orphaned hold field allowed findings purge.' ); +} +foreach ( $hold_fields as $column ) { + $row = clone $legacy_due; unset( $row->$column ); + retention_assert( true === ct_scan_job_on_hold( $row, $purge_now ) && 'keep' === ct_scan_deletion_decision( $row, $purge_now )[0] && false === ct_scan_tombstone_deadline_passed( $row, $purge_now ), 'An incomplete hold projection allowed deletion.' ); + $GLOBALS['wpdb'] = $db = new FakeFindingsDb(); $db->row = clone $purge_base; unset( $db->row->$column ); + retention_assert( 'kept' === ct_scan_purge_findings_if_due( 31, $purge_now ) && 0 === $db->query_count, 'An incomplete hold projection allowed findings purge.' ); +} +foreach ( array( + 'absent' => retention_no_hold(), + 'expired' => retention_no_hold( array( 'hold_reason' => 'Synthetic valid expired hold.', 'hold_until' => '2026-01-10 00:00:00', 'hold_set_by' => 42, 'hold_set_at' => '2026-01-01 00:00:00' ) ), + 'released' => retention_no_hold( array( 'hold_reason' => 'Synthetic valid released hold.', 'hold_until' => '2026-01-10 00:00:00', 'hold_set_by' => 42, 'hold_set_at' => '2026-01-01 00:00:00', 'hold_released_by' => 43, 'hold_released_at' => '2026-01-10 00:00:00' ) ), +) as $case => $hold ) { + $row = (object) array_merge( (array) $legacy_due, $hold ); + retention_assert( false === ct_scan_job_on_hold( $row, $purge_now ) && 'delete' === ct_scan_deletion_decision( $row, $purge_now )[0] && true === ct_scan_tombstone_deadline_passed( $row, $purge_now ), "A valid known hold control prevented due cleanup ($case)." ); + $GLOBALS['wpdb'] = $db = new FakeFindingsDb(); $db->row = (object) array_merge( (array) $purge_base, $hold ); + retention_assert( 'cleared' === ct_scan_purge_findings_if_due( 31, $purge_now ), "A valid known hold control prevented due findings purge ($case)." ); +} +$backwards_acceptance = (object) array_merge( (array) $legacy_due, array( 'terms_accepted_at' => '2021-01-01 00:04:00', 'closed_at' => '2021-01-01 00:02:00', 'retain_until' => '2024-01-01 00:04:00' ) ); +retention_assert( 'keep' === ct_scan_deletion_decision( $backwards_acceptance, $purge_now )[0] && false === ct_scan_tombstone_deadline_passed( $backwards_acceptance, $purge_now ), 'A legacy closure before acceptance was deleted.' ); +$valid_acceptance = clone $backwards_acceptance; $valid_acceptance->closed_at = '2021-01-01 00:05:00'; +retention_assert( 'delete' === ct_scan_deletion_decision( $valid_acceptance, $purge_now )[0] && true === ct_scan_tombstone_deadline_passed( $valid_acceptance, $purge_now ), 'A valid due legacy acceptance lost its three-year anchor.' ); +foreach ( array( '2024-02-30-r1', '2024-13-01-r1', '0000-01-01-r1', '2024-02-29-r0', '2024-02-29-r01' ) as $version ) { + retention_assert( false === ct_scan_is_legacy_version( $version ), 'An invalid calendar version was grandfathered into the legacy policy.' ); + $invalid_version = (object) array_merge( (array) $admin_closed, array( 'terms_version' => $version ) ); + retention_assert( retention_plus_years( $invalid_version->closed_at, 6 ) === ct_scan_retain_until( $invalid_version ), 'A malformed version did not retain the conservative trusted-close six-year policy.' ); + $invalid_version->closure_source = null; + retention_assert( false === ct_scan_retain_until( $invalid_version ), 'A malformed version permitted evidence expiry without trusted closure.' ); +} +retention_assert( true === ct_scan_is_legacy_version( '2024-02-29-r1' ), 'A valid leap-day legacy version was refused.' ); + echo "Scan findings and evidence retention checks passed.\n"; diff --git a/integrations/wordpress/standalone-tests/tombstones.php b/integrations/wordpress/standalone-tests/tombstones.php index 0fab94d..5a72b94 100644 --- a/integrations/wordpress/standalone-tests/tombstones.php +++ b/integrations/wordpress/standalone-tests/tombstones.php @@ -230,7 +230,7 @@ function tombstone_cursor_max_id() { 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2021-01-01 00:00:00', 'created_at' => '2021-01-01 00:00:00', 'closed_at' => '2021-01-02 00:00:00', 'retain_until' => '2024-01-01 00:00:00', 'status' => 'failed', - 'hold_reason' => null, 'hold_until' => null, 'result_json' => null, 'manual_remainder_notes' => null, + 'hold_reason' => null, 'hold_until' => null, 'hold_set_by' => null, 'hold_set_at' => null, 'hold_released_by' => null, 'hold_released_at' => null, 'result_json' => null, 'manual_remainder_notes' => null, 'closure_source' => null, 'closed_by' => null, 'closure_reason' => null, 'report_delivered_at' => null, 'report_access_used_at' => null, ); foreach ( array( @@ -259,4 +259,58 @@ function tombstone_cursor_max_id() { } } +function tombstone_test_complete_row( $row, $expected_delete, $label ) { + $GLOBALS['test_options'] = array(); + $GLOBALS['wpdb'] = $database = new FakeTombstoneDb(); + $database->restored_row = clone $row; + $database->stones[] = (object) array( 'job_id' => 41, 'tombstone_id' => 501, + 'scope_mac' => ct_scan_tombstone_mac( $row->scope_hash ), 'identity' => str_repeat( 'c', 32 ) ); + $normal = ct_scan_deletion_decision( $row, '2026-01-16 00:00:00' ); + tombstone_assert( ( $expected_delete ? 'delete' : 'keep' ) === $normal[0], 'Ordinary retention mismatch: ' . $label ); + $budget = 100; + ct_scan_apply_tombstones( '2026-01-16 00:00:00', microtime( true ) + 30, $budget ); + tombstone_assert( ( $expected_delete ? 1 : 0 ) === $database->job_deletes + && ( $expected_delete ? 1 : 0 ) === $database->scrub_updates + && ( $expected_delete ? 1 : 0 ) === $database->tombstone_inserts, 'Matched tombstone violated retention: ' . $label ); +} +foreach ( array( 'manual_remainder_notes', 'result_json' ) as $column ) { + foreach ( array( '0', '', 'ordinary synthetic finding' ) as $value ) { + foreach ( array( 'recent', 'missing', 'due' ) as $event ) { + $row = (object) array_merge( (array) $legacy_restored, array( $column => $value, + 'closed_at' => 'due' === $event ? '2024-01-02 00:00:00' : '2026-01-15 00:00:00', + 'closure_source' => 'missing' === $event ? null : 'admin_final_close', 'closed_by' => 'missing' === $event ? null : 42, + 'closure_reason' => 'missing' === $event ? null : 'failed', + ) ); + tombstone_test_complete_row( $row, 'due' === $event, "$column/$value/$event" ); + } + } +} +tombstone_test_complete_row( $legacy_restored, true, 'genuine NULL/NULL findings absence' ); +foreach ( array( 'manual_remainder_notes', 'result_json' ) as $column ) { + $row = clone $legacy_restored; unset( $row->$column ); + tombstone_test_complete_row( $row, false, 'missing findings column ' . $column ); + $row = clone $legacy_restored; $row->$column = 0; + tombstone_test_complete_row( $row, false, 'ambiguous numeric findings ' . $column ); +} +foreach ( array( 'hold_until' => '2027-01-01 00:00:00', 'hold_set_by' => 42, 'hold_set_at' => '2026-01-01 00:00:00', 'hold_released_by' => 42, 'hold_released_at' => '2026-01-01 00:00:00' ) as $column => $value ) { + $row = clone $legacy_restored; $row->$column = $value; + tombstone_test_complete_row( $row, false, 'orphan hold column ' . $column ); +} +foreach ( array( '0', '' ) as $purpose ) { + $row = clone $legacy_restored; $row->hold_reason = $purpose; + tombstone_test_complete_row( $row, false, 'malformed non-NULL purpose ' . json_encode( $purpose ) ); +} +foreach ( array( 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at', 'hold_released_by', 'hold_released_at' ) as $column ) { + $row = clone $legacy_restored; unset( $row->$column ); + tombstone_test_complete_row( $row, false, 'missing hold column ' . $column ); +} +foreach ( array( false, true ) as $released ) { + $row = (object) array_merge( (array) $legacy_restored, array( 'hold_reason' => 'Synthetic expired hold.', 'hold_until' => '2026-01-10 00:00:00', 'hold_set_by' => 42, 'hold_set_at' => '2026-01-01 00:00:00', 'hold_released_by' => $released ? 43 : null, 'hold_released_at' => $released ? '2026-01-10 00:00:00' : null ) ); + tombstone_test_complete_row( $row, true, $released ? 'valid released hold after grace' : 'valid expired hold after grace' ); +} +$row = (object) array_merge( (array) $legacy_restored, array( 'terms_accepted_at' => '2021-01-01 00:04:00', 'closed_at' => '2021-01-01 00:02:00', 'retain_until' => '2024-01-01 00:04:00' ) ); +tombstone_test_complete_row( $row, false, 'creation before closure before acceptance' ); +$row->closed_at = '2021-01-01 00:05:00'; +tombstone_test_complete_row( $row, true, 'closure after creation and acceptance, legacy three-year deadline' ); + echo "Scan tombstone regressions passed.\n"; diff --git a/integrations/wordpress/tests/evidence-strict.php b/integrations/wordpress/tests/evidence-strict.php index d104d19..b21512b 100644 --- a/integrations/wordpress/tests/evidence-strict.php +++ b/integrations/wordpress/tests/evidence-strict.php @@ -76,6 +76,20 @@ $unconfirmed = ct_scan_send_accepted_copy( $event['job_id'] ); ct_test_assert( is_wp_error( $unconfirmed ) && count( $mail ) === $confirmation_mails && 'not_sent' === ct_scan_row( $event['job_id'] )->accepted_copy_status, 'An accepted copy was sent before the mailbox was confirmed.' ); ct_test_queue_job( $event['job_id'] ); +// The same canonical proof guards this sensitive attachment, not just report redemption. +$confirmed_copy_row = ct_scan_row( $event['job_id'] ); +foreach ( array( + 'before_acceptance' => gmdate( 'Y-m-d H:i:s', strtotime( $confirmed_copy_row->terms_accepted_at . ' UTC' ) - 1 ), + 'future' => gmdate( 'Y-m-d H:i:s', time() + 3600 ), + 'after_authorization' => gmdate( 'Y-m-d H:i:s', strtotime( $confirmed_copy_row->authorization_expires_at . ' UTC' ) + 1 ), + 'missing' => null, +) as $proof_case => $bad_confirmation ) { + ct_test_assert( 1 === $wpdb->update( $jobs, array( 'confirmed_at' => $bad_confirmation ), array( 'job_id' => (int) $event['job_id'] ) ), 'Could not store accepted-copy confirmation fixture: ' . $proof_case ); + $copy_before = count( $mail ); + $copy_refusal = ct_scan_send_accepted_copy( $event['job_id'] ); + ct_test_assert( is_wp_error( $copy_refusal ) && 'accepted_copy_unavailable' === $copy_refusal->get_error_code() && count( $mail ) === $copy_before && 'not_sent' === ct_scan_row( $event['job_id'] )->accepted_copy_status, 'Invalid confirmation disclosed the accepted attachment or claimed its one-time state: ' . $proof_case ); +} +ct_test_assert( 1 === $wpdb->update( $jobs, array( 'confirmed_at' => $confirmed_copy_row->confirmed_at ), array( 'job_id' => (int) $event['job_id'] ) ), 'Could not restore valid accepted-copy confirmation.' ); $sent = ct_scan_send_accepted_copy( $event['job_id'] ); ct_test_assert( 'sent' === $sent && count( $mail ) === $confirmation_mails + 1, 'The accepted copy was not sent after confirmation.' ); $copy = $mail[ count( $mail ) - 1 ]; diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index b480ee7..7238624 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -196,6 +196,115 @@ function ct_test_redeem_report( $job_id, $mail ) { ct_test_assert( is_wp_error( $unconfirmed_send ) && 'report_recipient_unconfirmed' === $unconfirmed_send->get_error_code(), 'An unconfirmed recipient was allowed a detailed report.' ); ct_test_assert( count( $mail_calls ) === $mail_count && 'not_sent' === ct_scan_row( $unconfirmed_event['job_id'] )->report_notification_status, 'A refused unconfirmed report must not send or change send state.' ); +// Confirmation evidence is validated as stored by MariaDB, not by PHP truthiness. The disposable +// session deliberately permits zero and impossible dates so readback proves these are real DATETIME +// values the validator must reject. No scanner/worker traffic is needed to prepare these reports. +$confirmation_sql_mode = $wpdb->get_var( 'SELECT @@SESSION.sql_mode' ); +ct_test_assert( is_string( $confirmation_sql_mode ), 'Could not read the disposable session SQL mode.' ); +ct_test_assert( false !== $wpdb->query( "SET SESSION sql_mode = 'ALLOW_INVALID_DATES'" ), 'Could not configure the disposable invalid-date storage fixture.' ); +try { + foreach ( array( 'zero', 'partial_zero', 'invalid_calendar', 'before_acceptance', 'before_creation', 'future', 'after_authorization', 'missing' ) as $confirmation_case ) { + list( $confirmation_id, $confirmation_fields ) = ct_test_request( 'confirmation-' . str_replace( '_', '-', $confirmation_case ) . '-fixture.example.net' ); + $request_ids[] = $confirmation_id; + $confirmation_accepted_epoch = time() - 3600; + update_post_meta( $confirmation_id, '_ct_terms_accepted_at', gmdate( 'Y-m-d\TH:i:s\Z', $confirmation_accepted_epoch ) ); + $confirmation_event = ct_scan_on_request_ready( $confirmation_id, $confirmation_fields ); + ct_test_assert( is_array( $confirmation_event ), 'Confirmation matrix request was not queued: ' . $confirmation_case ); + $confirmation_job_id = (int) $confirmation_event['job_id']; + ct_test_queue_job( $confirmation_job_id ); + ct_test_assert( 1 === $wpdb->update( $jobs, array( 'status' => 'failed', 'finished_at' => ct_scan_utc_now(), 'created_at' => gmdate( 'Y-m-d H:i:s', $confirmation_accepted_epoch + 60 ), 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $confirmation_accepted_epoch + 120 ) ), array( 'job_id' => $confirmation_job_id ) ), 'Could not prepare terminal confirmation fixture: ' . $confirmation_case ); + ct_test_assert( true === ct_scan_admin_save_manual_remainder( $confirmation_job_id, 'Synthetic confirmation chronology review.' ), 'Could not prepare reviewed confirmation fixture: ' . $confirmation_case ); + $confirmation_good = ct_scan_row( $confirmation_job_id ); + ct_test_assert( ct_scan_confirmation_metadata_trusted( $confirmation_good ), 'Confirmation matrix positive prerequisite was invalid: ' . $confirmation_case ); + $confirmation_accepted_epoch = strtotime( $confirmation_good->terms_accepted_at . ' UTC' ); + switch ( $confirmation_case ) { + case 'zero': $confirmation_changes = array( 'confirmed_at' => '0000-00-00 00:00:00' ); break; + case 'partial_zero': $confirmation_changes = array( 'confirmed_at' => '2024-02-00 00:00:00' ); break; + case 'invalid_calendar': $confirmation_changes = array( 'confirmed_at' => '2024-02-30 00:00:00' ); break; + case 'before_acceptance': $confirmation_changes = array( 'created_at' => gmdate( 'Y-m-d H:i:s', $confirmation_accepted_epoch - 60 ), 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $confirmation_accepted_epoch - 1 ) ); break; + case 'before_creation': $confirmation_changes = array( 'created_at' => gmdate( 'Y-m-d H:i:s', $confirmation_accepted_epoch + 600 ), 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $confirmation_accepted_epoch + 300 ) ); break; + case 'future': $confirmation_changes = array( 'confirmed_at' => gmdate( 'Y-m-d H:i:s', time() + 3600 ) ); break; + case 'after_authorization': $confirmation_changes = array( 'authorization_expires_at' => gmdate( 'Y-m-d H:i:s', $confirmation_accepted_epoch + 60 ), 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $confirmation_accepted_epoch + 120 ) ); break; + case 'missing': $confirmation_changes = array( 'confirmed_at' => null ); break; + } + $confirmation_restore = array(); + foreach ( $confirmation_changes as $confirmation_field => $confirmation_value ) { + $confirmation_restore[ $confirmation_field ] = $confirmation_good->$confirmation_field; + } + ct_test_assert( 1 === $wpdb->update( $jobs, $confirmation_changes, array( 'job_id' => $confirmation_job_id ) ), 'Invalid confirmation fixture update did not change exactly one row: ' . $confirmation_case ); + $confirmation_bad = ct_scan_row( $confirmation_job_id ); + foreach ( $confirmation_changes as $confirmation_field => $confirmation_value ) { + ct_test_assert( $confirmation_value === $confirmation_bad->$confirmation_field, 'Invalid confirmation fixture did not survive DATETIME storage exactly: ' . $confirmation_case . '/' . $confirmation_field ); + } + update_post_meta( $confirmation_id, '_ct_scan_authorization_expires_at', $confirmation_bad->authorization_expires_at ); + if ( 'before_creation' === $confirmation_case ) { + ct_test_assert( $confirmation_bad->confirmed_at >= $confirmation_bad->terms_accepted_at && $confirmation_bad->confirmed_at <= ct_scan_utc_now() && $confirmation_bad->confirmed_at < $confirmation_bad->created_at, 'Before-creation fixture did not isolate the creation chronology boundary.' ); + } + if ( 'after_authorization' === $confirmation_case ) { + ct_test_assert( $confirmation_bad->confirmed_at >= $confirmation_bad->created_at && $confirmation_bad->confirmed_at >= $confirmation_bad->terms_accepted_at && $confirmation_bad->confirmed_at <= ct_scan_utc_now() && $confirmation_bad->confirmed_at > $confirmation_bad->authorization_expires_at, 'After-authorization fixture did not isolate the frozen deadline boundary.' ); + } + ct_test_assert( false === ct_scan_confirmation_metadata_trusted( $confirmation_bad ), 'Stored invalid confirmation passed its evidence validator: ' . $confirmation_case ); + $confirmation_mail_count = count( $mail_calls ); + $confirmation_refused = ct_scan_admin_send_report( $confirmation_job_id ); + ct_test_assert( is_wp_error( $confirmation_refused ) && 'report_recipient_unconfirmed' === $confirmation_refused->get_error_code(), 'Stored invalid confirmation was not refused before notification: ' . $confirmation_case ); + ct_test_assert( count( $mail_calls ) === $confirmation_mail_count && 'not_sent' === ct_scan_row( $confirmation_job_id )->report_notification_status, 'Invalid confirmation sent mail or altered notification state: ' . $confirmation_case ); + ct_test_assert( 1 === $wpdb->update( $jobs, $confirmation_restore, array( 'job_id' => $confirmation_job_id ) ), 'Could not restore notification positive control: ' . $confirmation_case ); + update_post_meta( $confirmation_id, '_ct_scan_authorization_expires_at', $confirmation_good->authorization_expires_at ); + ct_test_assert( true === ct_scan_admin_send_report( $confirmation_job_id ), 'Valid confirmation notification positive control failed: ' . $confirmation_case ); + $confirmation_mail = end( $mail_calls ); + ct_test_assert( 1 === preg_match( '/[?&]key=([a-f0-9]{64})/', $confirmation_mail['message'], $confirmation_key ), 'Confirmation matrix report capability missing.' ); + ct_test_assert( 1 === $wpdb->update( $jobs, $confirmation_changes, array( 'job_id' => $confirmation_job_id ) ), 'Could not persist invalid confirmation before retrieval: ' . $confirmation_case ); + update_post_meta( $confirmation_id, '_ct_scan_authorization_expires_at', $confirmation_bad->authorization_expires_at ); + $confirmation_response = ct_scan_report_access_response( 'POST', $confirmation_job_id, $confirmation_key[1] ); + $confirmation_after = ct_scan_row( $confirmation_job_id ); + ct_test_assert( 410 === $confirmation_response['status'] && false === strpos( $confirmation_response['body'], 'Synthetic confirmation chronology review.' ) && null === $confirmation_after->report_access_used_at && null === $confirmation_after->report_delivered_at, 'Invalid stored confirmation exposed findings or recorded delivery: ' . $confirmation_case ); + ct_test_assert( 1 === $wpdb->update( $jobs, $confirmation_restore, array( 'job_id' => $confirmation_job_id ) ), 'Could not restore retrieval positive control: ' . $confirmation_case ); + update_post_meta( $confirmation_id, '_ct_scan_authorization_expires_at', $confirmation_good->authorization_expires_at ); + ct_test_assert( 200 === ct_scan_report_access_response( 'POST', $confirmation_job_id, $confirmation_key[1] )['status'], 'Valid confirmation retrieval positive control failed: ' . $confirmation_case ); + } + + // DATETIME canonicalizes an ISO separator on insert: it cannot preserve that noncanonical PHP + // string. Assert the actual storage normalization and keep that valid canonical value usable; + // standalone tests separately reject noncanonical strings passed directly to the validator. + list( $normalized_id, $normalized_fields ) = ct_test_request( 'confirmation-normalized-fixture.example.net' ); + $request_ids[] = $normalized_id; + $normalized_event = ct_scan_on_request_ready( $normalized_id, $normalized_fields ); + ct_test_assert( is_array( $normalized_event ), 'Canonicalization control request did not queue.' ); + ct_test_queue_job( $normalized_event['job_id'] ); + $normalized_good = ct_scan_row( $normalized_event['job_id'] ); + $normalized_iso = str_replace( ' ', 'T', $normalized_good->confirmed_at ); + ct_test_assert( false !== $wpdb->update( $jobs, array( 'confirmed_at' => $normalized_iso ), array( 'job_id' => (int) $normalized_event['job_id'] ) ), 'DATETIME ISO separator control could not be stored.' ); + $normalized_row = ct_scan_row( $normalized_event['job_id'] ); + ct_test_assert( $normalized_good->confirmed_at === $normalized_row->confirmed_at && $normalized_iso !== $normalized_row->confirmed_at && ct_scan_confirmation_metadata_trusted( $normalized_row ), 'DATETIME did not canonicalize the ISO separator into valid confirmation evidence.' ); + $wpdb->update( $jobs, array( 'status' => 'failed', 'finished_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $normalized_event['job_id'] ) ); + ct_test_assert( true === ct_scan_admin_save_manual_remainder( $normalized_event['job_id'], 'Synthetic canonicalized datetime review.' ), 'Canonicalized control manual review failed.' ); + ct_test_assert( true === ct_scan_admin_send_report( $normalized_event['job_id'] ), 'Canonicalized DATETIME notification positive control failed.' ); + ct_test_redeem_report( $normalized_event['job_id'], end( $mail_calls ) ); +} finally { + ct_test_assert( false !== $wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode = %s', $confirmation_sql_mode ) ), 'Could not restore the disposable session SQL mode.' ); +} + +// Traffic authorization expiry does not retroactively erase valid mailbox confirmation. This is +// an original legacy no-control fixture, with confirmation inside its frozen historical window. +list( $historical_id, $historical_fields ) = ct_test_request( 'historical-confirmation-fixture.example.net' ); +$request_ids[] = $historical_id; +$historical_accepted_epoch = time() - 3 * 86400; +update_post_meta( $historical_id, '_ct_terms_accepted_at', gmdate( 'Y-m-d\TH:i:s\Z', $historical_accepted_epoch ) ); +$historical_event = ct_scan_on_request_ready( $historical_id, $historical_fields ); +ct_test_assert( is_array( $historical_event ), 'Historical confirmation fixture did not queue.' ); +$historical_expiry = gmdate( 'Y-m-d H:i:s', time() - 86400 ); +ct_test_assert( 1 === $wpdb->update( $jobs, array( + 'status' => 'failed', 'finished_at' => ct_scan_utc_now(), 'created_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 60 ), + 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 120 ), 'authorization_expires_at' => $historical_expiry, + 'confirmation_token_hash' => null, 'confirmation_expires_at' => null, +), array( 'job_id' => (int) $historical_event['job_id'] ) ), 'Historical confirmation fixture update failed.' ); +update_post_meta( $historical_id, '_ct_scan_authorization_expires_at', $historical_expiry ); +$historical_row = ct_scan_row( $historical_event['job_id'] ); +ct_test_assert( 0 === (int) $historical_row->control_required && ct_scan_confirmation_metadata_trusted( $historical_row ) && ! ct_scan_authorization_is_live( $historical_row ), 'Historical positive control did not isolate valid confirmation from expired traffic authorization.' ); +ct_test_assert( true === ct_scan_admin_save_manual_remainder( $historical_event['job_id'], 'Synthetic report prepared after traffic authorization expired.' ), 'Historical report manual review failed.' ); +ct_test_assert( true === ct_scan_admin_send_report( $historical_event['job_id'] ), 'Valid historical confirmation was refused a prepared report notification.' ); +ct_test_redeem_report( $historical_event['job_id'], end( $mail_calls ) ); + // Unknown mail outcomes remain one-shot; explicit recipient redemption still records actual report access. list( $unknown_id, $unknown_fields ) = ct_test_request( 'unknown-mail-fixture.example.net' ); $request_ids[] = $unknown_id; diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index e54d797..c60fe4e 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -217,7 +217,7 @@ function ct_test_due_tombstone_deadline( $row, $label ) { $h = ct_scan_row( $held['job_id'] ); ct_test_assert( $h && null !== $h->result_json && null !== $h->manual_remainder_notes && get_post( $held['request_id'] ), 'A held job lost records.' ); // A hold that just expired still preserves for the grace period, so nothing vanishes the minute it lapses. -$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-1 minute' ) ), array( 'job_id' => $held['job_id'] ) ); +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-1 minute' ), 'hold_set_at' => ct_test_old( '-10 days' ) ), array( 'job_id' => $held['job_id'] ) ); ct_test_maintain(); ct_test_assert( ct_test_exists( $held ) && null !== ct_scan_row( $held['job_id'] )->result_json, 'A hold that expired a minute ago released its records at once.' ); $wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-4 days' ) ), array( 'job_id' => $held['job_id'] ) ); @@ -231,7 +231,7 @@ function ct_test_due_tombstone_deadline( $row, $label ) { ct_test_maintain(); ct_test_assert( ! ct_test_exists( $restore_held ), 'Fixture expected the job to be deleted first.' ); $rstone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $restore_held['job_id'] ) ); -$wpdb->insert( $jobs, array_merge( $restore_held_backup, array( 'retention_checked_at' => ct_scan_utc_now(), 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ) ) ); +$wpdb->insert( $jobs, array_merge( $restore_held_backup, array( 'retention_checked_at' => ct_scan_utc_now(), 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ), 'hold_set_by' => 1, 'hold_set_at' => ct_test_old( '-1 day' ) ) ) ); ct_test_due_tombstone_deadline( ct_scan_row( $restore_held['job_id'] ), 'Held restored backup' ); ct_test_assert( ct_scan_job_on_hold( ct_scan_row( $restore_held['job_id'] ), ct_scan_utc_now() ), 'Held restored control has no effective hold.' ); ct_test_maintain(); @@ -355,14 +355,14 @@ function ct_test_due_tombstone_deadline( $row, $label ) { ct_test_maintain(); ct_test_assert( ct_test_exists( $rel ) && get_post( $rel['request_id'] ) && null !== ct_scan_row( $rel['job_id'] )->result_json && ct_test_exists( $damaged ), 'A released hold did not preserve the records for the grace period, or a damaged hold allowed deletion.' ); // Once the grace period after the release has passed, the job is deleted as usual. -$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-3 days -1 minute' ) ), array( 'job_id' => $rel['job_id'] ) ); +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-3 days -1 minute' ), 'hold_set_at' => ct_test_old( '-10 days' ), 'hold_released_at' => ct_test_old( '-3 days -1 minute' ) ), array( 'job_id' => $rel['job_id'] ) ); ct_test_maintain(); ct_test_assert( ! ct_test_exists( $rel ) && ! get_post( $rel['request_id'] ) && ct_test_exists( $damaged ), 'A released hold kept the records after its grace period, or a damaged hold allowed deletion.' ); // Releasing a hold that already expired keeps its own end (a release never lengthens a hold). -$lapsed = ct_test_aged_job( 'ret-lapsed.example.net', 'failed', '-1 day', '-1 hour' ); +$lapsed = ct_test_aged_job( 'ret-lapsed.example.net', 'failed', '-20 days', '-1 hour' ); ct_scan_admin_set_hold( $lapsed['job_id'], 'Preserving records for dispute number 789.', 10 ); $lapsed_until = ct_test_old( '-2 days' ); -$wpdb->update( $jobs, array( 'hold_until' => $lapsed_until ), array( 'job_id' => $lapsed['job_id'] ) ); +$wpdb->update( $jobs, array( 'hold_until' => $lapsed_until, 'hold_set_at' => ct_test_old( '-10 days' ) ), array( 'job_id' => $lapsed['job_id'] ) ); ct_test_assert( true === ct_scan_admin_release_hold( $lapsed['job_id'], ct_scan_hold_identity( ct_scan_row( $lapsed['job_id'] ) ) ) && $lapsed_until === ct_scan_row( $lapsed['job_id'] )->hold_until, 'Releasing an expired hold lengthened it.' ); wp_set_current_user( 0 ); ct_test_assert( 'hold_forbidden' === ct_scan_admin_release_hold( $damaged['job_id'] )->get_error_code() && ct_test_exists( $damaged ) && ! empty( ct_scan_row( $damaged['job_id'] )->hold_reason ), 'An anonymous caller released a hold.' ); @@ -484,7 +484,7 @@ function ct_test_due_tombstone_deadline( $row, $label ) { ct_test_maintain(); ct_test_assert( ct_test_exists( $long_hold ) && get_post( $long_hold['request_id'] ), 'A due job lost its records after a refused second hold.' ); // Inside the grace period of an expired (unreleased) hold, a new hold is refused too: the original purpose and setter stay. -$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $long_hold['job_id'] ) ); +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-1 day' ), 'hold_set_at' => ct_test_old( '-10 days' ) ), array( 'job_id' => $long_hold['job_id'] ) ); $lh = ct_scan_row( $long_hold['job_id'] ); $grace_try = ct_scan_admin_set_hold( $long_hold['job_id'], 'A different, later purpose for this job.', 30 ); ct_test_assert( is_wp_error( $grace_try ) && 'hold_active' === $grace_try->get_error_code() && $lh->hold_until === ct_scan_row( $long_hold['job_id'] )->hold_until && $lh->hold_reason === ct_scan_row( $long_hold['job_id'] )->hold_reason, 'A hold in its grace period was replaced without being released.' ); @@ -504,7 +504,7 @@ function ct_test_due_tombstone_deadline( $row, $label ) { $wpdb->delete( $jobs, array( 'job_id' => $long_hold['job_id'] ) ); // A due job without its opaque identity is kept until the backfill gives it one (never a tombstone without identity). -$no_identity = (object) array( 'status' => 'failed', 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2027-02-20 12:00:00', 'identity' => null ); +$no_identity = (object) array( 'status' => 'failed', 'hold_reason' => null, 'hold_until' => null, 'hold_set_by' => null, 'hold_set_at' => null, 'hold_released_by' => null, 'hold_released_at' => null, 'result_json' => null, 'manual_remainder_notes' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2027-02-20 12:00:00', 'identity' => null ); ct_test_assert( array( 'keep', null ) === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' ), 'A job without an identity was judged deletable.' ); $no_identity->identity = ''; ct_test_assert( array( 'keep', null ) === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' ), 'A job with an empty identity was judged deletable.' ); @@ -541,7 +541,7 @@ function ct_test_due_tombstone_deadline( $row, $label ) { $restored_created = ct_test_old( '-4 years' ); $restored_base = array( 'target_origin' => 'https://ret-starve.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-starve.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => $restored_created, 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $restored_created, 'closed_at' => ( new DateTimeImmutable( $restored_created, new DateTimeZone( 'UTC' ) ) )->modify( '+1 day' )->format( 'Y-m-d H:i:s' ), 'retain_until' => ( new DateTimeImmutable( $restored_created, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'retention_checked_at' => ct_scan_utc_now() ); for ( $i = 0; $i < 56; $i++ ) { - $extra = 9 === $i % 10 ? array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+1 hour' ) ) : array( 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ); + $extra = 9 === $i % 10 ? array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+1 hour' ) ) : array( 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ), 'hold_set_by' => 1, 'hold_set_at' => ct_test_old( '-1 day' ) ); ct_test_assert( 1 === $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 887000100 + $i, 'request_id' => 886000100 + $i ), $extra ) ), 'Fixture insert of a restored row failed: ' . $wpdb->last_error ); $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 887000100 + $i, 'request_id' => 886000100 + $i, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); } @@ -715,8 +715,8 @@ function ct_test_make_due( $job ) { ct_test_assert( ct_test_exists( $raise_future ) && ct_test_exists( $raise_legacy ) && $rf->retain_until === ct_scan_row( $raise_future['job_id'] )->retain_until && $rl->retain_until === ct_scan_row( $raise_legacy['job_id'] )->retain_until, 'An extended deletion date was shortened when the configuration went back.' ); // Leap days: a period that starts on 29 February ends on 1 March, never on 28 February. -$leap_r2 = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-29 12:00:00', 'created_at' => '2024-02-29 12:00:30', 'closed_at' => '2024-03-02 00:00:00', 'retain_until' => '2027-03-01 12:00:00' ); -$leap_new = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2027-06-01-r3', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2030-03-01 12:00:00' ); +$leap_r2 = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'hold_set_by' => null, 'hold_set_at' => null, 'hold_released_by' => null, 'hold_released_at' => null, 'result_json' => null, 'manual_remainder_notes' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-29 12:00:00', 'created_at' => '2024-02-29 12:00:30', 'closed_at' => '2024-03-02 00:00:00', 'retain_until' => '2027-03-01 12:00:00' ); +$leap_new = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'hold_set_by' => null, 'hold_set_at' => null, 'hold_released_by' => null, 'hold_released_at' => null, 'result_json' => null, 'manual_remainder_notes' => null, 'terms_version' => '2027-06-01-r3', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2030-03-01 12:00:00' ); $leap_new->closure_source = 'admin_final_close'; $leap_new->closed_by = 1; $leap_new->closure_reason = 'failed'; @@ -1096,7 +1096,7 @@ function ct_test_make_due( $job ) { $valid_events[] = $fixture; } $held_event = ct_test_aged_job( 'ret-strict-standing-hold.example.net', 'completed', '-4 years', '-2 years', '2027-06-01-r3', true ); - $wpdb->update( $jobs, array_merge( $event_base, array( 'hold_reason' => 'Synthetic standing preservation hold.', 'hold_until' => ct_test_old( '+30 days' ) ) ), array( 'job_id' => $held_event['job_id'] ) ); + $wpdb->update( $jobs, array_merge( $event_base, array( 'hold_reason' => 'Synthetic standing preservation hold.', 'hold_until' => ct_test_old( '+30 days' ), 'hold_set_by' => 1, 'hold_set_at' => ct_test_old( '-10 days' ) ) ), array( 'job_id' => $held_event['job_id'] ) ); $race_event = ct_test_aged_job( 'ret-strict-hold-race.example.net', 'completed', '-4 years', '-2 years', '2027-06-01-r3', true ); $wpdb->update( $jobs, $event_base, array( 'job_id' => $race_event['job_id'] ) ); $race_query_seen = false; @@ -1105,7 +1105,7 @@ function ct_test_make_due( $job ) { && false !== strpos( $query, 'result_json' ) && false !== strpos( $query, 'NULL' ) && preg_match( '/`?job_id`?\s*=\s*\x27?' . (int) $race_event['job_id'] . '(?:\x27|\s|$)/', $query ) ) { $race_query_seen = true; - $wpdb->update( $jobs, array( 'hold_reason' => 'Synthetic concurrently committed preservation hold.', 'hold_until' => ct_test_old( '+30 days' ) ), array( 'job_id' => $race_event['job_id'] ) ); + $wpdb->update( $jobs, array( 'hold_reason' => 'Synthetic concurrently committed preservation hold.', 'hold_until' => ct_test_old( '+30 days' ), 'hold_set_by' => 1, 'hold_set_at' => ct_scan_utc_now() ), array( 'job_id' => $race_event['job_id'] ) ); } return $query; }; @@ -1155,6 +1155,99 @@ function ct_test_make_due( $job ) { $wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode = %s', $original_sql_mode ) ); } +// ---- Findings SQL-NULL semantics and complete hold/chronology invariants across both delete paths. +$matrix_now = ct_scan_utc_now(); +$matrix_rows = array(); +$matrix_counter = 0; +function ct_test_matrix_legacy( $label, $overrides = array() ) { + global $wpdb, $jobs, $matrix_counter; + ++$matrix_counter; + $fixture = ct_test_aged_job( 'ret-null-matrix-' . $matrix_counter . '.example.net', 'failed', '-5 years', '-4 years' ); + $accepted = ct_test_old( '-5 years' ); + $event = array( 'created_at' => $accepted, 'terms_accepted_at' => $accepted, + 'closed_at' => ct_test_old( '-400 days' ), 'closure_source' => 'admin_final_close', 'closed_by' => 42, + 'closure_reason' => 'failed', 'report_delivered_at' => null, 'report_access_used_at' => null, + 'retain_until' => ( new DateTimeImmutable( $accepted, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), + 'result_json' => null, 'manual_remainder_notes' => null, 'hold_reason' => null, 'hold_until' => null, + 'hold_set_by' => null, 'hold_set_at' => null, 'hold_released_by' => null, 'hold_released_at' => null, + ); + ct_test_assert( 1 === $wpdb->update( $jobs, array_merge( $event, $overrides ), array( 'job_id' => $fixture['job_id'] ) ), 'Could not save matrix fixture: ' . $label ); + return $fixture; +} +function ct_test_matrix_tombstone( $fixture ) { + global $wpdb; + $row = ct_scan_row( $fixture['job_id'] ); + ct_test_assert( 1 === $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => $row->job_id, 'request_id' => $row->request_id, + 'scope_mac' => ct_scan_tombstone_mac( $row->scope_hash ), 'created_at' => $row->created_at, 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => $row->identity ) ), 'Could not save matrix tombstone.' ); +} +foreach ( array( 'ordinary', 'tombstone' ) as $path ) { + foreach ( array( 'manual_remainder_notes', 'result_json' ) as $column ) { + foreach ( array( '0', '', 'ordinary synthetic finding' ) as $value ) { + foreach ( array( 'recent', 'missing', 'due' ) as $event ) { + $label = "$path/$column/$event/value=" . json_encode( $value ); + $overrides = array( $column => $value, 'closed_at' => ct_test_old( 'due' === $event ? '-400 days' : '-1 day' ) ); + if ( 'missing' === $event ) { $overrides += array( 'closure_source' => null, 'closed_by' => null, 'closure_reason' => null ); } + $fixture = ct_test_matrix_legacy( $label, $overrides ); + $row = ct_scan_row( $fixture['job_id'] ); + ct_test_assert( $value === $row->$column && true === ct_scan_findings_presence( $row ), 'Non-NULL findings were normalized or misclassified: ' . $label ); + $due = 'due' === $event; + ct_test_assert( ( $due ? 'delete' : 'keep' ) === ct_scan_deletion_decision( $row, $matrix_now )[0], 'Ordinary finding deadline disagrees: ' . $label ); + if ( 'ordinary' === $path ) { + ct_test_assert( ( $due ? 'deleted' : 'kept' ) === ct_scan_delete_if_due( $fixture['job_id'], $matrix_now ), 'Actual ordinary deletion violated finding deadline: ' . $label ); + ct_test_assert( $due ? ( ! ct_test_exists( $fixture ) && ! get_post( $fixture['request_id'] ) ) : ( ct_test_exists( $fixture ) && get_post( $fixture['request_id'] ) ), 'Actual ordinary evidence/link outcome disagrees: ' . $label ); + } else { + ct_test_matrix_tombstone( $fixture ); $matrix_rows[] = array( $fixture, $due, $label ); + } + } + } + } + $empty = ct_test_matrix_legacy( "$path/known-null-findings" ); + ct_test_assert( false === ct_scan_findings_presence( ct_scan_row( $empty['job_id'] ) ), 'The genuine SQL NULL/NULL control was not classified absent.' ); + if ( 'ordinary' === $path ) { ct_test_assert( 'deleted' === ct_scan_delete_if_due( $empty['job_id'], $matrix_now ), 'Known SQL NULL/NULL legacy evidence was not eligible.' ); } + else { ct_test_matrix_tombstone( $empty ); $matrix_rows[] = array( $empty, true, "$path/known-null-findings" ); } +} +$orphan_holds = array( 'orphan-expiry' => array( 'hold_until' => ct_test_old( '+30 days' ) ), + 'orphan-setter' => array( 'hold_set_by' => 42 ), 'orphan-set-time' => array( 'hold_set_at' => ct_test_old( '-10 days' ) ), + 'orphan-release-actor' => array( 'hold_released_by' => 42 ), 'orphan-release-time' => array( 'hold_released_at' => ct_test_old( '-4 days' ) ), + 'orphan-complete-history' => array( 'hold_until' => ct_test_old( '-4 days' ), 'hold_set_by' => 42, 'hold_set_at' => ct_test_old( '-10 days' ), 'hold_released_by' => 43, 'hold_released_at' => ct_test_old( '-4 days' ) ), + 'orphan-zero-purpose' => array( 'hold_reason' => '0', 'hold_until' => null ), + 'orphan-empty-purpose' => array( 'hold_reason' => '', 'hold_until' => null ), + 'all-absent' => array(), + 'valid-expired' => array( 'hold_reason' => 'Synthetic expired hold.', 'hold_until' => ct_test_old( '-4 days' ), 'hold_set_by' => 42, 'hold_set_at' => ct_test_old( '-10 days' ) ), + 'valid-released' => array( 'hold_reason' => 'Synthetic released hold.', 'hold_until' => ct_test_old( '-4 days' ), 'hold_set_by' => 42, 'hold_set_at' => ct_test_old( '-10 days' ), 'hold_released_by' => 43, 'hold_released_at' => ct_test_old( '-4 days' ) ), +); +foreach ( $orphan_holds as $case => $hold ) { + $held = 0 === strpos( $case, 'orphan-' ); + foreach ( array( 'findings', 'ordinary', 'tombstone' ) as $path ) { + $fixture = ct_test_matrix_legacy( "$path/$case", array_merge( $hold, array( 'result_json' => '0', 'manual_remainder_notes' => '' ) ) ); + $row = ct_scan_row( $fixture['job_id'] ); + ct_test_assert( $held === ct_scan_job_on_hold( $row, $matrix_now ), 'Hold tuple classification disagrees: ' . "$path/$case" ); + if ( 'findings' === $path ) { + ct_test_assert( ( $held ? 'kept' : 'cleared' ) === ct_scan_purge_findings_if_due( $fixture['job_id'], $matrix_now ), 'Actual findings purge violated hold tuple: ' . $case ); + $row = ct_scan_row( $fixture['job_id'] ); + ct_test_assert( $held ? ( '0' === $row->result_json && '' === $row->manual_remainder_notes ) : ( null === $row->result_json && null === $row->manual_remainder_notes ), 'Findings bytes disagreed after hold tuple handling: ' . $case ); + } elseif ( 'ordinary' === $path ) { + ct_test_assert( ( $held ? 'kept' : 'deleted' ) === ct_scan_delete_if_due( $fixture['job_id'], $matrix_now ), 'Actual ordinary deletion violated hold tuple: ' . $case ); + } else { ct_test_matrix_tombstone( $fixture ); $matrix_rows[] = array( $fixture, ! $held, "$path/$case" ); } + } +} +foreach ( array( 'ordinary', 'tombstone' ) as $path ) { + foreach ( array( false, true ) as $ordered ) { + $created = ct_test_old( '-5 years' ); + $accepted = ( new DateTimeImmutable( $created, new DateTimeZone( 'UTC' ) ) )->modify( '+4 minutes' )->format( 'Y-m-d H:i:s' ); + $closed = ( new DateTimeImmutable( $created, new DateTimeZone( 'UTC' ) ) )->modify( $ordered ? '+5 minutes' : '+2 minutes' )->format( 'Y-m-d H:i:s' ); + $fixture = ct_test_matrix_legacy( "$path/acceptance-order/$ordered", array( 'created_at' => $created, 'terms_accepted_at' => $accepted, 'closed_at' => $closed, + 'retain_until' => ( new DateTimeImmutable( $accepted, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ) ) ); + if ( 'ordinary' === $path ) { ct_test_assert( ( $ordered ? 'deleted' : 'kept' ) === ct_scan_delete_if_due( $fixture['job_id'], $matrix_now ), 'Actual ordinary legacy deletion accepted closure before acceptance, or refused its valid control.' ); } + else { ct_test_matrix_tombstone( $fixture ); $matrix_rows[] = array( $fixture, $ordered, "$path/acceptance-order/$ordered" ); } + } +} +$matrix_budget = 1000; +ct_scan_apply_tombstones( $matrix_now, microtime( true ) + 30, $matrix_budget ); +foreach ( $matrix_rows as list( $fixture, $should_delete, $label ) ) { + ct_test_assert( $should_delete ? ( ! ct_test_exists( $fixture ) && ! get_post( $fixture['request_id'] ) ) : ( ct_test_exists( $fixture ) && get_post( $fixture['request_id'] ) ), 'Actual matched-tombstone outcome violated the matrix: ' . $label ); +} + foreach ( $request_ids as $id ) { $wpdb->delete( $jobs, array( 'request_id' => $id ), array( '%d' ) ); wp_delete_post( $id, true ); From a4b1a87c4de48f9d245f7a255aa5fd9f1022af43 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 14 Nov 2026 02:02:42 -0500 Subject: [PATCH 12/14] test(wordpress): verify restored deadline before applying hold --- integrations/wordpress/tests/retention.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index c60fe4e..a88f268 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -231,9 +231,10 @@ function ct_test_due_tombstone_deadline( $row, $label ) { ct_test_maintain(); ct_test_assert( ! ct_test_exists( $restore_held ), 'Fixture expected the job to be deleted first.' ); $rstone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $restore_held['job_id'] ) ); +ct_test_due_tombstone_deadline( (object) $restore_held_backup, 'Restored backup before hold' ); $wpdb->insert( $jobs, array_merge( $restore_held_backup, array( 'retention_checked_at' => ct_scan_utc_now(), 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ), 'hold_set_by' => 1, 'hold_set_at' => ct_test_old( '-1 day' ) ) ) ); -ct_test_due_tombstone_deadline( ct_scan_row( $restore_held['job_id'] ), 'Held restored backup' ); ct_test_assert( ct_scan_job_on_hold( ct_scan_row( $restore_held['job_id'] ), ct_scan_utc_now() ), 'Held restored control has no effective hold.' ); +ct_test_assert( ! ct_scan_tombstone_deadline_passed( ct_scan_row( $restore_held['job_id'] ), ct_scan_utc_now() ), 'An effective hold did not block the restored tombstone deadline.' ); ct_test_maintain(); ct_test_assert( ct_test_exists( $restore_held ), 'A restored job under a hold was deleted by its tombstone.' ); $wpdb->delete( $jobs, array( 'job_id' => $restore_held['job_id'] ) ); From a7f0e6594a96fc34127a6bafac1df07c413fd2d6 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 14 Nov 2026 02:10:29 -0500 Subject: [PATCH 13/14] fix(wordpress): bind report control proof to request chronology --- integrations/wordpress/ct-scan.php | 4 +- .../standalone-tests/report-access.php | 67 ++++++++++++---- integrations/wordpress/tests/integration.php | 77 ++++++++++++++----- 3 files changed, 112 insertions(+), 36 deletions(-) diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index d96ee8f..2603af7 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -3647,7 +3647,9 @@ function ct_scan_validate_report_job( $row, $token = '', $require_capability = f || ! in_array( $row->control_method ?? null, array( 'dns_txt', 'https_file', 'manual_review' ), true ) || ! ct_scan_valid_datetime( (string) ( $row->control_valid_until ?? '' ) ) || $row->control_valid_until !== $row->authorization_expires_at - || strtotime( $row->control_verified_at . ' UTC' ) > strtotime( $row->control_valid_until . ' UTC' ) ) { + || $row->control_verified_at < $row->created_at || $row->control_verified_at < $row->terms_accepted_at + || $row->control_verified_at < $row->confirmed_at || $row->control_verified_at > ct_scan_utc_now() + || $row->control_verified_at > $row->control_valid_until ) { return new WP_Error( 'report_target_unverified', 'Control of the exact target was not verified for the authorized scan window.' ); } } diff --git a/integrations/wordpress/standalone-tests/report-access.php b/integrations/wordpress/standalone-tests/report-access.php index 0e538ca..8f880ec 100644 --- a/integrations/wordpress/standalone-tests/report-access.php +++ b/integrations/wordpress/standalone-tests/report-access.php @@ -530,6 +530,35 @@ function confirmation_case_changes( $name, $row ) { check( 200 === ct_scan_report_access_response( 'POST', $job_id, $key[1] )['status'], 'valid confirmation remains a redeemable positive control: ' . $case ); } +// Exact-target proof must be server-observed after the saved request and confirmation, never in the future. +foreach ( array( 'before_acceptance', 'before_creation', 'before_confirmation', 'future', 'after_authorization' ) as $case ) { + list( $job_id ) = fixture_reset(); + $good = clone $GLOBALS['wpdb']->row; + if ( 'before_acceptance' === $case ) { $bad_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->terms_accepted_at . ' UTC' ) - 1 ); } + if ( 'before_creation' === $case ) { $GLOBALS['wpdb']->row->created_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->terms_accepted_at . ' UTC' ) + 60 ); $GLOBALS['wpdb']->row->confirmed_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->terms_accepted_at . ' UTC' ) + 120 ); $bad_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->terms_accepted_at . ' UTC' ) + 30 ); } + if ( 'before_confirmation' === $case ) { $GLOBALS['wpdb']->row->confirmed_at = gmdate( 'Y-m-d H:i:s', time() - 300 ); $bad_at = gmdate( 'Y-m-d H:i:s', time() - 600 ); } + if ( 'future' === $case ) { $bad_at = gmdate( 'Y-m-d H:i:s', time() + 60 ); } + if ( 'after_authorization' === $case ) { $bad_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->authorization_expires_at . ' UTC' ) + 1 ); } + $baseline = clone $GLOBALS['wpdb']->row; + $GLOBALS['wpdb']->row->control_verified_at = $bad_at; + $notification = ct_scan_admin_send_report( $job_id ); + check( is_wp_error( $notification ) && 'report_target_unverified' === $notification->get_error_code() && 0 === count( $GLOBALS['mail_sends'] ), 'invalid control chronology sends no notification: ' . $case ); + $GLOBALS['wpdb']->row = $baseline; + $GLOBALS['wpdb']->row->control_verified_at = gmdate( 'Y-m-d H:i:s', time() - 60 ); + $GLOBALS['mail_result'] = true; + check( true === ct_scan_admin_send_report( $job_id ), 'valid control chronology sends positive notification: ' . $case ); + $mail = end( $GLOBALS['mail_sends'] ); + preg_match( '#key=([a-f0-9]{64})#', $mail['body'], $key ); + $valid = clone $GLOBALS['wpdb']->row; + $GLOBALS['wpdb']->row->control_verified_at = $bad_at; + $preview = ct_scan_report_access_response( 'GET', $job_id, $key[1] ); + check( false === strpos( $preview['body'], '9f7e' ) && null === $GLOBALS['wpdb']->row->report_access_used_at && null === $GLOBALS['wpdb']->row->closure_source, 'invalid control chronology GET stays harmless: ' . $case ); + $response = ct_scan_report_access_response( 'POST', $job_id, $key[1] ); + check( 410 === $response['status'] && false === strpos( $response['body'], '9f7e' ) && null === $GLOBALS['wpdb']->row->report_access_used_at && null === $GLOBALS['wpdb']->row->closure_source, 'invalid control chronology serves no findings or closure: ' . $case ); + $GLOBALS['wpdb']->row = $valid; + check( 200 === ct_scan_report_access_response( 'POST', $job_id, $key[1] )['status'], 'valid control chronology remains redeemable: ' . $case ); +} + // A proof changed between validation and the one-time write cannot be used to disclose findings. foreach ( array( 'confirmed_at', 'created_at', 'terms_accepted_at', 'authorization_expires_at' ) as $race_field ) { list( $job_id, $mail ) = send_fixture_link( true ); @@ -543,23 +572,27 @@ function confirmation_case_changes( $name, $row ) { // Prepared report delivery may happen after target-traffic authorization expired, provided confirmation // happened within that original window and current report/recipient/control requirements still pass. -list( $job_id, $request_id ) = fixture_reset(); -$accepted_epoch = time() - 3 * 86400; -$accepted_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch ); -$created_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 60 ); -$confirmed_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 120 ); -$expired_sql = gmdate( 'Y-m-d H:i:s', time() - 86400 ); -$GLOBALS['wpdb']->row->terms_accepted_at = $accepted_sql; -$GLOBALS['wpdb']->row->created_at = $created_sql; -$GLOBALS['wpdb']->row->confirmed_at = $confirmed_sql; -$GLOBALS['wpdb']->row->authorization_expires_at = $expired_sql; -$GLOBALS['wpdb']->row->control_required = 0; // Original grandfathered legacy header-only fixture. -$GLOBALS['meta'][$request_id]['_ct_terms_accepted_at'] = gmdate( 'Y-m-d\TH:i:s\Z', $accepted_epoch ); -$GLOBALS['meta'][$request_id]['_ct_scan_authorization_expires_at'] = $expired_sql; -$GLOBALS['mail_result'] = true; -check( true === ct_scan_admin_send_report( $job_id ), 'canonical confirmation permits a prepared report after traffic authorization expiry' ); -$expired_mail = end( $GLOBALS['mail_sends'] ); preg_match( '#key=([a-f0-9]{64})#', $expired_mail['body'], $expired_key ); -check( 200 === ct_scan_report_access_response( 'POST', $job_id, $expired_key[1] )['status'], 'prepared report remains retrievable with valid historical confirmation after traffic expiry' ); +foreach ( array( false, true ) as $historical_control ) { + list( $job_id, $request_id ) = fixture_reset(); + $accepted_epoch = time() - 3 * 86400; + $accepted_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch ); + $created_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 60 ); + $confirmed_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 120 ); + $expired_sql = gmdate( 'Y-m-d H:i:s', time() - 86400 ); + $GLOBALS['wpdb']->row->terms_accepted_at = $accepted_sql; + $GLOBALS['wpdb']->row->created_at = $created_sql; + $GLOBALS['wpdb']->row->confirmed_at = $confirmed_sql; + $GLOBALS['wpdb']->row->authorization_expires_at = $expired_sql; + $GLOBALS['wpdb']->row->control_required = $historical_control ? 1 : 0; + $GLOBALS['wpdb']->row->control_verified_at = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 180 ); + $GLOBALS['wpdb']->row->control_valid_until = $expired_sql; + $GLOBALS['meta'][$request_id]['_ct_terms_accepted_at'] = gmdate( 'Y-m-d\TH:i:s\Z', $accepted_epoch ); + $GLOBALS['meta'][$request_id]['_ct_scan_authorization_expires_at'] = $expired_sql; + $GLOBALS['mail_result'] = true; + check( true === ct_scan_admin_send_report( $job_id ), 'canonical confirmation permits a prepared report after traffic authorization expiry' ); + $expired_mail = end( $GLOBALS['mail_sends'] ); preg_match( '#key=([a-f0-9]{64})#', $expired_mail['body'], $expired_key ); + check( 200 === ct_scan_report_access_response( 'POST', $job_id, $expired_key[1] )['status'], 'prepared report remains retrievable with valid historical confirmation after traffic expiry' ); +} list( $job_id ) = fixture_reset(); check( true === ct_scan_admin_save_manual_remainder( $job_id, '0' ) && '0' === $GLOBALS['wpdb']->row->manual_remainder_notes, 'the accepted zero-string manual notes value remains present' ); diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index 7238624..c3f8e34 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -284,26 +284,67 @@ function ct_test_redeem_report( $job_id, $mail ) { ct_test_assert( false !== $wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode = %s', $confirmation_sql_mode ) ), 'Could not restore the disposable session SQL mode.' ); } +// Stored target-control proof must belong to this request and have already happened. These +// canonical DATETIME values must survive MariaDB exactly, then fail before mail or retrieval. +foreach ( array( 'before_acceptance', 'before_creation', 'before_confirmation', 'future', 'after_authorization' ) as $control_case ) { + list( $control_id, $control_fields ) = ct_test_request( 'control-report-' . str_replace( '_', '-', $control_case ) . '.example.net' ); + $request_ids[] = $control_id; + $accepted_epoch = time() - 3600; + update_post_meta( $control_id, '_ct_terms_accepted_at', gmdate( 'Y-m-d\TH:i:s\Z', $accepted_epoch ) ); + $control_event = ct_scan_on_request_ready( $control_id, $control_fields ); + ct_test_assert( is_array( $control_event ), 'Control chronology fixture did not queue.' ); + $control_job = (int) $control_event['job_id']; + ct_test_queue_job( $control_job ); + $control_row = ct_scan_row( $control_job ); + $control_good_at = gmdate( 'Y-m-d H:i:s', time() - 60 ); + ct_test_assert( 1 === $wpdb->update( $jobs, array( 'status' => 'failed', 'finished_at' => ct_scan_utc_now(), 'created_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch + 60 ), 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch + 120 ), 'control_required' => 1, 'control_method' => 'dns_txt', 'control_verified_at' => $control_good_at, 'control_valid_until' => $control_row->authorization_expires_at ), array( 'job_id' => $control_job ) ), 'Control chronology fixture preparation failed.' ); + ct_test_assert( true === ct_scan_admin_save_manual_remainder( $control_job, 'Synthetic target-control chronology review.' ), 'Control chronology manual review failed.' ); + switch ( $control_case ) { + case 'before_acceptance': $control_bad_at = gmdate( 'Y-m-d H:i:s', $accepted_epoch - 1 ); break; + case 'before_creation': $control_bad_at = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 30 ); break; + case 'before_confirmation': $control_bad_at = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 90 ); break; + case 'future': $control_bad_at = gmdate( 'Y-m-d H:i:s', time() + 60 ); break; + case 'after_authorization': $control_bad_at = gmdate( 'Y-m-d H:i:s', strtotime( $control_row->authorization_expires_at . ' UTC' ) + 1 ); break; + } + ct_test_assert( 1 === $wpdb->update( $jobs, array( 'control_verified_at' => $control_bad_at ), array( 'job_id' => $control_job ) ) && $control_bad_at === ct_scan_row( $control_job )->control_verified_at, 'Invalid control chronology was not stored exactly: ' . $control_case ); + $control_mail_count = count( $mail_calls ); + $control_refused = ct_scan_admin_send_report( $control_job ); + ct_test_assert( is_wp_error( $control_refused ) && 'report_target_unverified' === $control_refused->get_error_code() && count( $mail_calls ) === $control_mail_count && 'not_sent' === ct_scan_row( $control_job )->report_notification_status, 'Invalid control chronology sent notification: ' . $control_case ); + ct_test_assert( 1 === $wpdb->update( $jobs, array( 'control_verified_at' => $control_good_at ), array( 'job_id' => $control_job ) ) && true === ct_scan_admin_send_report( $control_job ), 'Valid control notification control failed: ' . $control_case ); + $control_mail = end( $mail_calls ); + ct_test_assert( 1 === preg_match( '/[?&]key=([a-f0-9]{64})/', $control_mail['message'], $control_key ), 'Control report capability missing.' ); + ct_test_assert( 1 === $wpdb->update( $jobs, array( 'control_verified_at' => $control_bad_at ), array( 'job_id' => $control_job ) ), 'Invalid retrieval control update failed.' ); + $control_response = ct_scan_report_access_response( 'POST', $control_job, $control_key[1] ); + $control_after = ct_scan_row( $control_job ); + ct_test_assert( 410 === $control_response['status'] && false === strpos( $control_response['body'], 'Synthetic target-control chronology review.' ) && null === $control_after->report_access_used_at && null === $control_after->closure_source, 'Invalid control chronology exposed report or closure: ' . $control_case ); + ct_test_assert( 1 === $wpdb->update( $jobs, array( 'control_verified_at' => $control_good_at ), array( 'job_id' => $control_job ) ) && 200 === ct_scan_report_access_response( 'POST', $control_job, $control_key[1] )['status'], 'Valid control retrieval control failed: ' . $control_case ); +} + // Traffic authorization expiry does not retroactively erase valid mailbox confirmation. This is // an original legacy no-control fixture, with confirmation inside its frozen historical window. -list( $historical_id, $historical_fields ) = ct_test_request( 'historical-confirmation-fixture.example.net' ); -$request_ids[] = $historical_id; -$historical_accepted_epoch = time() - 3 * 86400; -update_post_meta( $historical_id, '_ct_terms_accepted_at', gmdate( 'Y-m-d\TH:i:s\Z', $historical_accepted_epoch ) ); -$historical_event = ct_scan_on_request_ready( $historical_id, $historical_fields ); -ct_test_assert( is_array( $historical_event ), 'Historical confirmation fixture did not queue.' ); -$historical_expiry = gmdate( 'Y-m-d H:i:s', time() - 86400 ); -ct_test_assert( 1 === $wpdb->update( $jobs, array( - 'status' => 'failed', 'finished_at' => ct_scan_utc_now(), 'created_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 60 ), - 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 120 ), 'authorization_expires_at' => $historical_expiry, - 'confirmation_token_hash' => null, 'confirmation_expires_at' => null, -), array( 'job_id' => (int) $historical_event['job_id'] ) ), 'Historical confirmation fixture update failed.' ); -update_post_meta( $historical_id, '_ct_scan_authorization_expires_at', $historical_expiry ); -$historical_row = ct_scan_row( $historical_event['job_id'] ); -ct_test_assert( 0 === (int) $historical_row->control_required && ct_scan_confirmation_metadata_trusted( $historical_row ) && ! ct_scan_authorization_is_live( $historical_row ), 'Historical positive control did not isolate valid confirmation from expired traffic authorization.' ); -ct_test_assert( true === ct_scan_admin_save_manual_remainder( $historical_event['job_id'], 'Synthetic report prepared after traffic authorization expired.' ), 'Historical report manual review failed.' ); -ct_test_assert( true === ct_scan_admin_send_report( $historical_event['job_id'] ), 'Valid historical confirmation was refused a prepared report notification.' ); -ct_test_redeem_report( $historical_event['job_id'], end( $mail_calls ) ); +foreach ( array( false, true ) as $historical_control ) { + list( $historical_id, $historical_fields ) = ct_test_request( 'historical-confirmation-' . (int) $historical_control . '-fixture.example.net' ); + $request_ids[] = $historical_id; + $historical_accepted_epoch = time() - 3 * 86400; + update_post_meta( $historical_id, '_ct_terms_accepted_at', gmdate( 'Y-m-d\TH:i:s\Z', $historical_accepted_epoch ) ); + $historical_event = ct_scan_on_request_ready( $historical_id, $historical_fields ); + ct_test_assert( is_array( $historical_event ), 'Historical confirmation fixture did not queue.' ); + $historical_expiry = gmdate( 'Y-m-d H:i:s', time() - 86400 ); + ct_test_assert( 1 === $wpdb->update( $jobs, array( + 'status' => 'failed', 'finished_at' => ct_scan_utc_now(), 'created_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 60 ), + 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 120 ), 'authorization_expires_at' => $historical_expiry, + 'confirmation_token_hash' => null, 'confirmation_expires_at' => null, + 'control_required' => $historical_control ? 1 : 0, 'control_method' => 'dns_txt', + 'control_verified_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 180 ), 'control_valid_until' => $historical_expiry, + ), array( 'job_id' => (int) $historical_event['job_id'] ) ), 'Historical confirmation fixture update failed.' ); + update_post_meta( $historical_id, '_ct_scan_authorization_expires_at', $historical_expiry ); + $historical_row = ct_scan_row( $historical_event['job_id'] ); + ct_test_assert( (int) $historical_control === (int) $historical_row->control_required && ct_scan_confirmation_metadata_trusted( $historical_row ) && ! ct_scan_authorization_is_live( $historical_row ), 'Historical positive control did not isolate valid confirmation from expired traffic authorization.' ); + ct_test_assert( true === ct_scan_admin_save_manual_remainder( $historical_event['job_id'], 'Synthetic report prepared after traffic authorization expired.' ), 'Historical report manual review failed.' ); + ct_test_assert( true === ct_scan_admin_send_report( $historical_event['job_id'] ), 'Valid historical confirmation was refused a prepared report notification.' ); + ct_test_redeem_report( $historical_event['job_id'], end( $mail_calls ) ); + +} // Unknown mail outcomes remain one-shot; explicit recipient redemption still records actual report access. list( $unknown_id, $unknown_fields ) = ct_test_request( 'unknown-mail-fixture.example.net' ); From 9ec956791970d2315e632dc0d7eb8457a786e09e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sat, 14 Nov 2026 02:10:37 -0500 Subject: [PATCH 14/14] style(wordpress): remove fixture trailing whitespace --- integrations/wordpress/tests/integration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index c3f8e34..c2ea4d1 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -343,7 +343,7 @@ function ct_test_redeem_report( $job_id, $mail ) { ct_test_assert( true === ct_scan_admin_save_manual_remainder( $historical_event['job_id'], 'Synthetic report prepared after traffic authorization expired.' ), 'Historical report manual review failed.' ); ct_test_assert( true === ct_scan_admin_send_report( $historical_event['job_id'] ), 'Valid historical confirmation was refused a prepared report notification.' ); ct_test_redeem_report( $historical_event['job_id'], end( $mail_calls ) ); - + } // Unknown mail outcomes remain one-shot; explicit recipient redemption still records actual report access.