array( 'open', 'acknowledged', 'excepted', 'resolved' ), 'severity' => array( 'critical', 'high', 'medium', 'low', 'info' ), 'confidence' => array( 'confirmed', 'strong', 'suspicious', 'informational' ), 'category' => array( 'known_malware', 'suspicious_behavior', 'integrity_change', 'vulnerable_software', 'configuration' ), ); $out = array(); foreach ( $input as $key => $value ) { if ( ! is_string( $key ) || ! in_array( $key, array_merge( array_keys( $choices ), array( 'scan_id', 'search' ) ), true ) || ! is_string( $value ) ) { throw new \InvalidArgumentException( 'Invalid finding filter. Reload the findings view.' ); } if ( '' === $value ) { continue; } if ( 'search' === $key ) { if ( strlen( $value ) > 200 || sanitize_text_field( $value ) !== $value ) { throw new \InvalidArgumentException( 'Invalid evidence search.' ); } } elseif ( 'scan_id' === $key ) { if ( ! preg_match( '/^s_[a-f0-9]{32}$/D', $value ) ) { throw new \InvalidArgumentException( 'Invalid scan filter.' ); } } elseif ( ! in_array( $value, $choices[ $key ], true ) ) { throw new \InvalidArgumentException( 'Invalid finding filter value.' ); } $out[ $key ] = $value; } ksort( $out ); return $out; } private static function findingId( string $id ): string { if ( '' !== $id && ! preg_match( '/^f_[a-f0-9]{60}$/D', $id ) ) { throw new \InvalidArgumentException( 'Invalid selected finding.' ); } return $id; } /** The signed form captures selection criteria, not an assertion that old rows still exist. */ public static function fields( array $filters, string $finding = '' ): array { if ( ! WorkflowAuthority::administrator() || get_current_user_id() < 1 ) { throw new \RuntimeException( 'Administrator access is required.' ); } $value = array( 'v' => 1, 'actor' => get_current_user_id(), 'site' => get_current_blog_id(), 'expires' => time() + 1800, 'filters' => self::filters( $filters ), 'finding' => self::findingId( $finding ) ); $json = wp_json_encode( $value, JSON_UNESCAPED_SLASHES ); if ( ! is_string( $json ) ) { throw new \RuntimeException( 'The export selection could not be encoded.' ); } $encoded = base64_encode( $json ); return array( 'export_selection' => $encoded, 'export_signature' => wp_hash( 'finding-export|' . $encoded, 'nonce' ) ); } public static function selection( string $encoded, string $signature ): array { if ( strlen( $encoded ) > 2048 || ! preg_match( '/^[A-Za-z0-9+\/=]+$/D', $encoded ) || ! preg_match( '/^[a-f0-9]{32}$/D', $signature ) || ! hash_equals( wp_hash( 'finding-export|' . $encoded, 'nonce' ), $signature ) ) { throw new \RuntimeException( 'The export selection changed. Reload the findings view.' ); } $json = base64_decode( $encoded, true ); $value = is_string( $json ) ? json_decode( $json, true ) : null; if ( ! is_array( $value ) || array_keys( $value ) !== array( 'v', 'actor', 'site', 'expires', 'filters', 'finding' ) || 1 !== $value['v'] || $value['actor'] !== get_current_user_id() || $value['site'] !== get_current_blog_id() || ! is_int( $value['expires'] ) || $value['expires'] < time() || $value['expires'] > time() + 1800 || ! is_array( $value['filters'] ) || ! is_string( $value['finding'] ) || ! WorkflowAuthority::administrator() ) { throw new \RuntimeException( 'This export selection expired, access was revoked, or the administrator or site changed.' ); } $value['filters'] = self::filters( $value['filters'] ); $value['finding'] = self::findingId( $value['finding'] ); return $value; } private static function snapshot( Store $store, array $selection ): array { if ( '' !== $selection['finding'] ) { $row = $store->get( $selection['finding'] ); if ( ! $row || 'finding' !== ( $row['_kind'] ?? '' ) || FindingGuide::id( $row ) !== $selection['finding'] ) { throw new \RuntimeException( 'The selected finding is no longer available. No export was created.' ); } return array( 'items' => array( $row ), 'total' => 1, 'bytes_limited' => false ); } $items = array(); $total = null; $bytes = 0; $limited = false; for ( $offset = 0; $offset < self::ROW_LIMIT; $offset += 200 ) { $page = $store->searchFindings( $selection['filters'], 200, $offset ); if ( null !== $total && $total !== $page['total'] ) { throw new \RuntimeException( 'Findings changed while preparing the export. Reload and try again.' ); } $total = (int) $page['total']; foreach ( $page['items'] as $row ) { $json = wp_json_encode( $row, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES ); if ( ! is_string( $json ) ) { throw new \RuntimeException( 'Evidence could not be encoded.' ); } if ( $bytes + strlen( $json ) + 1 > self::BYTE_LIMIT - 8192 ) { $limited = true; break 2; } $items[] = $row; $bytes += strlen( $json ) + 1; } if ( count( $page['items'] ) < 200 ) { break; } } return array( 'items' => $items, 'total' => (int) $total, 'bytes_limited' => $limited ); } /** Prepare the complete response before headers; a second read refuses concurrent changes. */ public static function prepare( Store $store, string $encoded, string $signature ): string { $actor = get_current_user_id(); $site = get_current_blog_id(); $writeObserved = false; // Observe, never intercept, native writes during the read-only disclosure phase. // This catches permission callbacks that alter evidence or a stored role mid-check. $observer = static function ( string $sql ) use ( &$writeObserved ): string { if ( ! preg_match( '/^\s*(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\b/i', $sql ) ) { $writeObserved = true; } return $sql; }; add_filter( 'query', $observer, PHP_INT_MAX ); try { $selection = self::selection( $encoded, $signature ); $before = self::snapshot( $store, $selection ); $after = self::snapshot( $store, $selection ); if ( $before !== $after ) { throw new \RuntimeException( 'Findings changed while preparing the export. Reload and try again.' ); } $count = count( $after['items'] ); $data = array( 'schema_version' => 1, 'export_type' => 'current_filtered_findings', 'exported_at' => gmdate( 'c' ), 'sensitive_metadata' => true, 'filters' => $selection['filters'], 'selected_finding' => $selection['finding'], 'count' => $count, 'matching_count' => $after['total'], 'row_limit' => self::ROW_LIMIT, 'byte_limit' => self::BYTE_LIMIT, 'truncated' => $count < $after['total'], 'truncation_reason' => $after['bytes_limited'] ? 'byte_limit' : ( $count < $after['total'] ? 'row_limit' : '' ), 'limits' => array( 'Current retained records matching the selected view; this is not a historical scan snapshot.', 'The selected finding takes precedence over list filters when viewing one finding.', 'No finding status, exception or protection setting was changed.' ), 'findings' => $after['items'] ); $json = wp_json_encode( $data, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES ); if ( ! is_string( $json ) || strlen( $json ) > self::BYTE_LIMIT ) { throw new \RuntimeException( 'The filtered export exceeded its byte budget.' ); } self::selection( $encoded, $signature ); if ( $writeObserved || $actor !== get_current_user_id() || $site !== get_current_blog_id() ) { throw new \RuntimeException( 'Export authority or local data changed during preparation. Reload and retry.' ); } return $json; } finally { remove_filter( 'query', $observer, PHP_INT_MAX ); } } }