From 95ab4213a0d18a152ee347c6fbcdb1e2bda85354 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 11 Aug 2026 15:54:37 -0500 Subject: [PATCH 1/3] Plugin Directory: Validate the security scan callback contract at the route. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route args now carry the callback body schema, so a malformed delivery is rejected before the handler trusts its fields, the plugin is resolved from the URL segment rather than get_param()'s body-first precedence, a payload asserting a different plugin than it was routed to is rejected and recorded, and an unresolvable slug no longer reaches the handler as a null plugin. The contract is strict only about what the directory acts on — status branching, identity, and field types. It is deliberately lenient about the descriptive payload: no length caps, no unknown-field rejection at any level, no enums on display-only fields, and no upper bound on scores, so the scanner can evolve without voiding deliveries. Tests dispatch a production-shaped callback through the REST server, covering authentication, routing, schema rejections, contract additions flowing through, and the slug reconciliation. Co-Authored-By: Claude Fable 5 --- .../api/routes/class-gandalf-scan.php | 210 ++++++++++- .../tests/Gandalf_Scan_Endpoint_Test.php | 343 ++++++++++++++++++ 2 files changed, 546 insertions(+), 7 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Endpoint_Test.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php index c9fce4c5a9..62d951bbc1 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php @@ -14,7 +14,7 @@ use WP_Http; /** - * Callback endpoint for advisory Gandalf scans. + * Callback endpoint for security scan results. * * @package WordPressdotorg_Plugin_Directory */ @@ -22,6 +22,18 @@ class Gandalf_Scan extends Base { /** * Registers the callback route. + * + * The args carry the callback body schema per the integration contract; + * cross-field invariants are validated in validate_callback_data(). + * + * The contract is strict only about what the directory acts on — the + * status branching, the plugin identity, and field types. It is + * deliberately lenient about the descriptive payload: no length caps, no + * unknown-field rejection, no enums on display-only fields, and no upper + * bound on scores. The REST server rejects arg violations before the + * callback runs, so anything stricter would void whole deliveries when + * the scanner evolves — an over-long model-generated string, a new + * severity, or an added field must not cost a verdict. */ public function __construct() { register_rest_route( @@ -30,26 +42,198 @@ public function __construct() { [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'scan_callback' ], + 'permission_callback' => function ( $request ) { + return $this->permission_check_api_bearer( $request, 'WP_GANDALF_SCAN_SHARED_SECRET' ); + }, 'args' => [ - 'plugin_slug' => [ + 'plugin_slug' => [ 'validate_callback' => [ $this, 'validate_plugin_slug_callback' ], ], + 'status' => [ + 'type' => 'string', + 'enum' => [ 'completed', 'failed' ], + 'required' => true, + ], + 'scan_id' => [ + 'type' => 'string', + 'required' => true, + 'minLength' => 1, + ], + 'subject_type' => [ + 'type' => 'string', + 'enum' => [ 'plugin' ], + 'required' => true, + ], + 'slug' => [ + 'type' => 'string', + 'required' => true, + 'minLength' => 1, + ], + 'version' => [ + 'type' => 'string', + 'required' => true, + 'minLength' => 1, + ], + 'release_ref' => [ + 'type' => 'string', + 'required' => true, + 'minLength' => 1, + ], + 'completed_at' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'verdict_hash' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'findings_count' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'max_risk_score' => [ + 'type' => 'number', + ], + 'findings' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'required' => [ 'id', 'ref', 'title', 'severity', 'file_path', 'risk_score', 'investigation' ], + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'ref' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'title' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'severity' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'file_path' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'line' => [ + 'type' => 'integer', + ], + 'code_snippet' => [ + 'type' => 'string', + ], + 'explanation' => [ + 'type' => 'string', + ], + 'risk_score' => [ + 'type' => 'number', + ], + 'investigation' => [ + 'type' => 'object', + 'required' => [ 'status', 'result', 'summary' ], + 'properties' => [ + 'status' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'result' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'summary' => [ + 'type' => 'string', + 'minLength' => 1, + ], + ], + ], + ], + ], + ], + 'severity_counts' => [ + 'type' => 'object', + 'additionalProperties' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + ], + 'scanner_version' => [ + 'type' => 'string', + ], + 'report_url' => [ + 'type' => 'string', + 'format' => 'uri', + 'minLength' => 1, + ], + 'error' => [ + 'type' => 'object', + 'required' => [ 'kind', 'message' ], + 'properties' => [ + 'kind' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'message' => [ + 'type' => 'string', + 'minLength' => 1, + ], + ], + ], ], - 'permission_callback' => function ( $request ) { - return $this->permission_check_api_bearer( $request, 'WP_GANDALF_SCAN_SHARED_SECRET' ); - }, ] ); } /** - * Receive a Gandalf scan callback. + * Validate the cross-field invariants of a security scan callback. + * + * Field-level validation — types and the status enum — is handled by the + * route args; this checks only the per-status required fields, which a + * per-field schema cannot express. Unknown fields are deliberately not + * rejected, so the scanner can add fields without breaking deliveries. + * + * @param array $data The security scan callback data. + * @return WP_Error|null An error for invalid callbacks, null otherwise. + */ + public static function validate_callback_data( $data ) { + if ( 'completed' === ( $data['status'] ?? '' ) ) { + $required_fields = [ 'verdict_hash', 'findings_count', 'findings', 'max_risk_score', 'severity_counts', 'report_url' ]; + } else { + $required_fields = [ 'error' ]; + } + + foreach ( $required_fields as $field ) { + if ( ! isset( $data[ $field ] ) ) { + return new WP_Error( + 'invalid_gandalf_scan_callback', + sprintf( 'Invalid security scan callback: missing %s.', $field ), + [ 'status' => WP_Http::BAD_REQUEST ] + ); + } + } + + return null; + } + + /** + * Receive a security scan callback. * * @param \WP_REST_Request $request The request. * @return array|WP_Error Callback response, or an error. */ public function scan_callback( $request ) { - $plugin = Plugin_Directory::get_plugin_post( $request['plugin_slug'] ); + // JSON body params outrank URL params in get_param(); the URL segment is the routed identity. + $plugin = Plugin_Directory::get_plugin_post( $request->get_url_params()['plugin_slug'] ); + if ( ! $plugin ) { + return new WP_Error( + 'plugin_not_found', + __( 'Plugin not found.', 'wporg-plugins' ), + [ 'status' => WP_Http::NOT_FOUND ] + ); + } $data = $request->get_json_params(); if ( ! is_array( $data ) ) { @@ -63,6 +247,18 @@ public function scan_callback( $request ) { return $error; } + $error = self::validate_callback_data( $data ); + + // The payload must assert the same plugin the callback was routed to. + if ( ! $error && ( $data['slug'] ?? '' ) !== $plugin->post_name ) { + $error = new WP_Error( 'invalid_gandalf_scan', 'Security scan callback slug does not match the plugin.', [ 'status' => WP_Http::BAD_REQUEST ] ); + } + + if ( is_wp_error( $error ) ) { + Plugin_Scan_Gandalf::record_invalid_callback( $plugin, $error, sanitize_text_field( (string) ( $data['scan_id'] ?? '' ) ) ); + return $error; + } + $result = Plugin_Scan_Gandalf::handle_callback( $plugin, $data ); if ( is_wp_error( $result ) ) { return $result; diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Endpoint_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Endpoint_Test.php new file mode 100644 index 0000000000..a941a8af48 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Endpoint_Test.php @@ -0,0 +1,343 @@ + 'endpoint-test-' . ( ++self::$plugin_count ), + 'post_title' => 'Scan Endpoint Test Plugin', + 'post_status' => 'publish', + ) + ); + + $this->assertInstanceOf( \WP_Post::class, $plugin ); + $this->plugin = $plugin; + + /* + * The stub update_source table survives across runs — the WP test + * installer only drops core tables — so clear leftovers that would + * collide with this run's plugin ID or read as a served version. + */ + global $wpdb; + $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_id' => $this->plugin->ID ) ); + $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_slug' => $this->plugin->post_name ) ); + + update_post_meta( $this->plugin->ID, 'version', self::VERSION ); + update_post_meta( $this->plugin->ID, 'stable_tag', self::VERSION ); + update_post_meta( + $this->plugin->ID, + Plugin_Scan_Gandalf::PENDING_META_KEY, + array( + self::SCAN_ID => array( + 'version' => self::VERSION, + 'release_ref' => self::VERSION, + 'requested_at' => time(), + ), + ) + ); + } + + /** + * Build a callback body mirroring the shape of a production scanner delivery. + * + * @param array $overrides Fields to override. + * @return array The callback data. + */ + private function payload( array $overrides = array() ): array { + $defaults = array( + 'status' => 'completed', + 'scan_id' => self::SCAN_ID, + 'subject_type' => 'plugin', + 'slug' => $this->plugin->post_name, + 'version' => self::VERSION, + 'release_ref' => self::VERSION, + 'completed_at' => time(), + 'verdict_hash' => '18c5a24a9bfd7b3245ed2c23e49a0c88', + 'findings_count' => 3, + 'findings' => array( + array( + 'id' => 'f1f1f1f1-1111-5111-a111-111111111111', + 'ref' => 'prompt-security.sensitive_data.secret_persistence', + 'title' => 'Payment secrets are persistently stored in user metadata and debug order logs.', + 'severity' => 'error', + 'file_path' => 'includes/ExampleGatewayDebit.php', + 'line' => 1723, + 'code_snippet' => " 'secretCode' => \$cardSecret,\n );\n", + 'explanation' => "An authenticated customer submitting a saved-card checkout controls `example_secret` at line 1233. When card saving is selected, that secret is added as `secretCode` to `\$cardsArray` at line 1723 and the complete array is persisted with `update_user_meta()` at line 1727. The checkout nonce protects submission integrity but does not make post-authorization secret retention safe.\n\nAdditionally, when debug logging is enabled, the request body—including `SecretCode` or a saved-card token—is copied into order logs at lines 1882-1896 without removing the secret. The first concrete effect is durable storage of payment authentication data in WordPress user/order metadata, extending exposure to database backups and any principal or integration able to read that metadata.\n\nExploitation requires subsequent access to the database, backups, or an applicable metadata-reading path; this chunk does not establish an unauthenticated read endpoint. The medium 5.5 risk reflects highly sensitive data retention but a separate access requirement and the saved-card/debug feature conditions.", + 'risk_score' => 5.5, + 'investigation' => array( + 'status' => 'skipped', + 'result' => 'unknown', + 'summary' => 'Investigation was not attempted because the finding is below the configured risk threshold.', + ), + ), + array( + 'id' => 'f2f2f2f2-2222-5222-a222-222222222222', + 'ref' => 'prompt-security.sensitive_data.secret_in_url', + 'title' => 'Full card numbers are sent in REST GET query strings during brand detection.', + 'severity' => 'warning', + 'file_path' => 'resources/js/example/example-brand-detector.js', + 'line' => 52, + 'code_snippet' => " return fetch(restUrl + 'exampleGateway/getCardBrand?number=' + encodeURIComponent(cleanNumber) + '&gateway=credit', {\n method: 'GET',\n headers: {", + 'explanation' => "A checkout customer controls the card-number input. After six digits, `fetchCardBrand()` passes the entire whitespace-stripped value as the `number` query parameter of a GET request; the debit detector duplicates this behavior at `resources/js/example/example-debit-detector.js:52-58`. The REST nonce authenticates the request context but does not prevent the URL from being retained.\n\nOnly a six-digit prefix is needed: the server-side lookup truncates the value to six characters at `includes/ExampleGatewayEndpoint.php:392`. Sending the full number in a URL unnecessarily exposes it to web-server, reverse-proxy, CDN, security-monitoring, or APM access logs that record query strings, rather than limiting it to the payment submission body.\n\nThe proven effect is transmission of the number in a log-prone URL, not public disclosure. Recovery requires access to infrastructure or application logs, whose configuration and readers are not shown. This separate access requirement keeps the estimated risk at medium 4.5 despite routine checkout triggering and potentially high confidentiality impact.", + 'risk_score' => 4.5, + 'investigation' => array( + 'status' => 'skipped', + 'result' => 'unknown', + 'summary' => 'Investigation was not attempted because the finding is below the configured risk threshold.', + ), + ), + array( + 'id' => 'f3f3f3f3-3333-5333-a333-333333333333', + 'ref' => 'prompt-security.payment.transaction_binding', + 'title' => 'Partial-capture handlers do not bind the client-supplied transaction ID to the selected order.', + 'severity' => 'warning', + 'file_path' => 'includes/ExampleGatewayCredit.php', + 'line' => 1658, + 'code_snippet' => " \$order_id = isset(\$_POST['order_id']) ? intval(\$_POST['order_id']) : 0;\n \$capture_amount = isset(\$_POST['capture_amount']) ? floatval(\$_POST['capture_amount']) : 0;\n \$transaction_id = isset(\$_POST['transaction_id']) ? sanitize_text_field(wp_unslash(\$_POST['transaction_id'])) : '';", + 'explanation' => "An authenticated principal holding `edit_shop_orders` and a valid partial-capture nonce can supply `order_id`, `capture_amount`, and `transaction_id` at lines 1658-1660. The handler verifies the selected order's gateway, capture mode, and prior-capture metadata, but never compares the submitted transaction ID with `\$order->get_transaction_id()`.\n\nThe unbound value is passed to `processPartialCapture()` at line 1702 and incorporated into the authenticated capture URL at line 1800. If the caller knows another transaction identifier accepted under the merchant credentials, they can apply the eligibility checks of one order while attempting a capture against a different provider transaction. The debit handler repeats the same flow at `includes/ExampleGatewayDebit.php:2394-2438`.\n\nExploitation also requires the order-edit capability, a nonce, a valid unrelated transaction ID, and provider acceptance. Those requirements bound the estimated risk to medium 5.0 and warrant verification rather than an asserted exploit.", + 'risk_score' => 5, + 'investigation' => array( + 'status' => 'skipped', + 'result' => 'unknown', + 'summary' => 'Investigation was not attempted because the finding is below the configured risk threshold.', + ), + ), + ), + 'max_risk_score' => 5.5, + 'severity_counts' => array( + 'error' => 1, + 'warning' => 2, + ), + 'scanner_version' => '0.3.0', + 'report_url' => 'https://gandalf.wordpress.org/admin/runs/' . self::SCAN_ID, + ); + + return array_merge( $defaults, $overrides ); + } + + /** + * Dispatch a callback through the REST server, as the scanner would. + * + * @param array $payload The callback body. + * @param string|null $bearer The bearer token; null for the shared secret, '' to omit the header. + * @param string|null $slug The routed plugin slug; null for the fixture plugin. + * @return \WP_REST_Response The response. + */ + private function dispatch( array $payload, ?string $bearer = null, ?string $slug = null ): \WP_REST_Response { + $slug = $slug ?? $this->plugin->post_name; + $bearer = $bearer ?? WP_GANDALF_SCAN_SHARED_SECRET; + + $request = new \WP_REST_Request( 'POST', "/plugins/v1/plugin/{$slug}/gandalf-scan" ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( (string) wp_json_encode( $payload ) ); + + if ( '' !== $bearer ) { + $request->set_header( 'Authorization', 'Bearer ' . $bearer ); + } + + return rest_do_request( $request ); + } + + /** + * A production-shaped callback is accepted end to end. + */ + public function test_callback_is_accepted(): void { + $response = $this->dispatch( $this->payload() ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( 'success' => true ), $response->get_data() ); + + $this->assertEmpty( get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, true ) ); + } + + /** + * A production-shaped failure report is accepted and recorded. + */ + public function test_failed_callback_is_accepted(): void { + $response = $this->dispatch( + array( + 'status' => 'failed', + 'scan_id' => self::SCAN_ID, + 'subject_type' => 'plugin', + 'slug' => $this->plugin->post_name, + 'version' => self::VERSION, + 'release_ref' => self::VERSION, + 'completed_at' => time(), + 'report_url' => 'https://gandalf.wordpress.org/admin/runs/' . self::SCAN_ID, + 'error' => array( + 'kind' => 'timeout', + 'message' => 'gandalf scan exceeded worker deadline', + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( 'success' => true ), $response->get_data() ); + + $last_error = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::LAST_ERROR_META_KEY, true ); + $this->assertSame( 'timeout', $last_error['kind'] ); + } + + /** + * Contract additions the directory does not know yet — new fields at any + * level, a new severity, a recalibrated score — do not void a delivery. + */ + public function test_unknown_contract_additions_are_accepted(): void { + $payload = $this->payload( + array( + 'scan_duration' => 314, + 'max_risk_score' => 10.5, + ) + ); + + $payload['findings'][0]['severity'] = 'catastrophic'; + $payload['findings'][0]['exploit_maturity'] = 'proof-of-concept'; + $payload['findings'][0]['investigation']['effort'] = 'medium'; + + $response = $this->dispatch( $payload ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertEmpty( get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, true ) ); + } + + /** + * A payload asserting a different plugin than the routed one is rejected + * and recorded. + */ + public function test_slug_mismatch_is_rejected(): void { + $response = $this->dispatch( $this->payload( array( 'slug' => 'some-other-plugin' ) ) ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'invalid_gandalf_scan', $response->get_data()['code'] ); + + $last_error = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::LAST_ERROR_META_KEY, true ); + $this->assertSame( 'invalid_gandalf_scan', $last_error['kind'] ); + + $pending = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, true ); + $this->assertArrayHasKey( self::SCAN_ID, $pending ); + } + + /** + * A callback without the bearer header is rejected before any processing. + */ + public function test_missing_bearer_is_rejected(): void { + $response = $this->dispatch( $this->payload(), '' ); + + $this->assertSame( 401, $response->get_status() ); + $this->assertPendingScanUntouched(); + } + + /** + * A callback with the wrong bearer token is rejected before any processing. + */ + public function test_wrong_bearer_is_rejected(): void { + $response = $this->dispatch( $this->payload(), 'not-the-shared-secret' ); + + $this->assertSame( 401, $response->get_status() ); + $this->assertPendingScanUntouched(); + } + + /** + * A mistyped contract field is rejected by the route schema. + */ + public function test_invalid_field_type_is_rejected(): void { + $response = $this->dispatch( $this->payload( array( 'max_risk_score' => 'critical' ) ) ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + $this->assertPendingScanUntouched(); + } + + /** + * A missing required contract field is rejected by the route schema. + */ + public function test_missing_required_field_is_rejected(): void { + $payload = $this->payload(); + unset( $payload['scan_id'] ); + + $response = $this->dispatch( $payload ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertPendingScanUntouched(); + } + + /** + * A callback routed to an unknown plugin is rejected. + */ + public function test_unknown_plugin_is_rejected(): void { + $response = $this->dispatch( $this->payload(), null, 'no-such-plugin' ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertPendingScanUntouched(); + } + + /** + * Assert the pending scan was not consumed and no error was recorded. + */ + private function assertPendingScanUntouched(): void { + $pending = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, true ); + $this->assertArrayHasKey( self::SCAN_ID, $pending ); + $this->assertEmpty( get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::LAST_ERROR_META_KEY, true ) ); + } +} From daa81a4bc456e8989272ba06914502b372f42da8 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 11 Aug 2026 16:15:32 -0500 Subject: [PATCH 2/3] Plugin Directory: Require only acted-on finding fields in the callback contract. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings now require only risk_score — the one field the directory indexes unguarded — and the investigation sub-object is fully optional: descriptive fields are read defensively by every consumer, and requiring them contradicted the contract's leniency goal. Co-Authored-By: Claude Fable 5 --- .../api/routes/class-gandalf-scan.php | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php index 62d951bbc1..11058b6052 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php @@ -24,16 +24,11 @@ class Gandalf_Scan extends Base { * Registers the callback route. * * The args carry the callback body schema per the integration contract; - * cross-field invariants are validated in validate_callback_data(). - * - * The contract is strict only about what the directory acts on — the - * status branching, the plugin identity, and field types. It is - * deliberately lenient about the descriptive payload: no length caps, no - * unknown-field rejection, no enums on display-only fields, and no upper - * bound on scores. The REST server rejects arg violations before the - * callback runs, so anything stricter would void whole deliveries when - * the scanner evolves — an over-long model-generated string, a new - * severity, or an added field must not cost a verdict. + * cross-field invariants are validated in validate_callback_data(). The + * schema is strict only about what the directory acts on: the REST server + * rejects violations before the callback runs, so anything stricter — + * length caps, unknown-field rejection, display-only enums — would void + * whole deliveries when the scanner evolves. */ public function __construct() { register_rest_route( @@ -98,7 +93,8 @@ public function __construct() { 'type' => 'array', 'items' => [ 'type' => 'object', - 'required' => [ 'id', 'ref', 'title', 'severity', 'file_path', 'risk_score', 'investigation' ], + // Only the score is acted on; descriptive fields are read defensively. + 'required' => [ 'risk_score' ], 'properties' => [ 'id' => [ 'type' => 'string', @@ -134,7 +130,6 @@ public function __construct() { ], 'investigation' => [ 'type' => 'object', - 'required' => [ 'status', 'result', 'summary' ], 'properties' => [ 'status' => [ 'type' => 'string', From 73daf6c47f139631049765801ea391d21f054162 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 11 Aug 2026 16:49:18 -0500 Subject: [PATCH 3/3] Plugin Directory: Drop the per-status required-field check. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scanner builds callback bodies through strict schemas on its own side, so a completed verdict missing its envelope is not a constructible delivery — the same reasoning that keeps the contract free of unknown-field rejection. The args still validate types and identity, and the handlers read verdict fields defensively. Co-Authored-By: Claude Fable 5 --- .../api/routes/class-gandalf-scan.php | 49 +++---------------- 1 file changed, 6 insertions(+), 43 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php index 11058b6052..906b6071f7 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php @@ -23,12 +23,11 @@ class Gandalf_Scan extends Base { /** * Registers the callback route. * - * The args carry the callback body schema per the integration contract; - * cross-field invariants are validated in validate_callback_data(). The - * schema is strict only about what the directory acts on: the REST server - * rejects violations before the callback runs, so anything stricter — - * length caps, unknown-field rejection, display-only enums — would void - * whole deliveries when the scanner evolves. + * The args carry the callback body schema per the integration contract. + * The schema is strict only about what the directory acts on: the REST + * server rejects violations before the callback runs, so anything + * stricter — length caps, unknown-field rejection, display-only enums — + * would void whole deliveries when the scanner evolves. */ public function __construct() { register_rest_route( @@ -182,37 +181,6 @@ public function __construct() { ); } - /** - * Validate the cross-field invariants of a security scan callback. - * - * Field-level validation — types and the status enum — is handled by the - * route args; this checks only the per-status required fields, which a - * per-field schema cannot express. Unknown fields are deliberately not - * rejected, so the scanner can add fields without breaking deliveries. - * - * @param array $data The security scan callback data. - * @return WP_Error|null An error for invalid callbacks, null otherwise. - */ - public static function validate_callback_data( $data ) { - if ( 'completed' === ( $data['status'] ?? '' ) ) { - $required_fields = [ 'verdict_hash', 'findings_count', 'findings', 'max_risk_score', 'severity_counts', 'report_url' ]; - } else { - $required_fields = [ 'error' ]; - } - - foreach ( $required_fields as $field ) { - if ( ! isset( $data[ $field ] ) ) { - return new WP_Error( - 'invalid_gandalf_scan_callback', - sprintf( 'Invalid security scan callback: missing %s.', $field ), - [ 'status' => WP_Http::BAD_REQUEST ] - ); - } - } - - return null; - } - /** * Receive a security scan callback. * @@ -242,14 +210,9 @@ public function scan_callback( $request ) { return $error; } - $error = self::validate_callback_data( $data ); - // The payload must assert the same plugin the callback was routed to. - if ( ! $error && ( $data['slug'] ?? '' ) !== $plugin->post_name ) { + if ( ( $data['slug'] ?? '' ) !== $plugin->post_name ) { $error = new WP_Error( 'invalid_gandalf_scan', 'Security scan callback slug does not match the plugin.', [ 'status' => WP_Http::BAD_REQUEST ] ); - } - - if ( is_wp_error( $error ) ) { Plugin_Scan_Gandalf::record_invalid_callback( $plugin, $error, sanitize_text_field( (string) ( $data['scan_id'] ?? '' ) ) ); return $error; }