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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,20 @@
use WP_Http;

/**
* Callback endpoint for advisory Gandalf scans.
* Callback endpoint for security scan results.
*
* @package WordPressdotorg_Plugin_Directory
*/
class Gandalf_Scan extends Base {

/**
* Registers the callback route.
*
* 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(
Expand All @@ -30,26 +36,167 @@ 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',
// Only the score is acted on; descriptive fields are read defensively.
'required' => [ 'risk_score' ],
'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',
'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.
* 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 ) ) {
Expand All @@ -63,6 +210,13 @@ public function scan_callback( $request ) {
return $error;
}

// The payload must assert the same plugin the callback was routed to.
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 ] );
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;
Expand Down
Loading