array( 'id' => 'manual-condition', 'revision' => 1, 'label' => 'Manual policy condition', 'reason' => 'A deliberately configured manual condition matched; this is not an attack finding. Check the recorded action to see whether a response was issued.', 'confidence' => 'not_applicable', 'priority' => 'informational', 'overlap' => 'Ordinary benign requests can match a manual condition. A match alone does not prove a denial or an attack.', ), 'reserved_configuration' => array( 'id' => 'core.reserved-configuration', 'revision' => 2, 'label' => 'Configuration-resource probe pattern', 'reason' => 'Exact reserved configuration filename or recognized backup variant.', 'confidence' => 'high_pattern_only', 'priority' => 'review', 'overlap' => 'An authorized scanner or broken integration can request these resources.', ), 'source_diagnostic' => array( 'id' => 'core.source-diagnostic', 'revision' => 1, 'label' => 'Source-control or diagnostic discovery', 'reason' => 'Exact source-control metadata or diagnostic resource template.', 'confidence' => 'high_pattern_only', 'priority' => 'review', 'overlap' => 'Authorized diagnostics can deliberately request these resources.', ), 'traversal_shape' => array( 'id' => 'core.traversal-shape', 'revision' => 1, 'label' => 'Traversal-shaped resource request', 'reason' => 'Two parent-directory segments precede a recognized configuration/system-resource suffix.', 'confidence' => 'high_pattern_only', 'priority' => 'review', 'overlap' => 'Malformed links and authorized scanners are possible; target application normalization is unknown.', ), 'sql_query_shape' => array( 'id' => 'core.sql-query-shape', 'revision' => 1, 'label' => 'SQL-shaped numeric parameter', 'reason' => 'A numeric-identifier parameter contains a compound boolean or UNION projection with a comment delimiter.', 'confidence' => 'structural_only', 'priority' => 'review', 'overlap' => 'A test or code example can contain the same structure; no execution or intent is established.', ), 'script_query_shape' => array( 'id' => 'core.script-query-shape', 'revision' => 1, 'label' => 'Executable-markup-shaped parameter', 'reason' => 'A numeric/navigation parameter contains paired script markup, an event-handler tag, or an executable URL scheme.', 'confidence' => 'structural_only', 'priority' => 'review', 'overlap' => 'Authorized testing can contain the same syntax. Editing/search content parameters are not classified.', ), 'component_recon' => array( 'id' => 'core.component-readme', 'revision' => 1, 'label' => 'Component metadata enumeration', 'reason' => 'A plugin readme or theme metadata path was requested; enumeration is not exploitation.', 'confidence' => 'structural_only', 'priority' => 'informational', 'overlap' => 'Documentation tools and legitimate theme stylesheets can use related routes; theme style.css is excluded.', ), 'unusual_method' => array( 'id' => 'core.unusual-method', 'revision' => 1, 'label' => 'Unusual method at a non-API resource', 'reason' => 'TRACE/TRACK/CONNECT or a write method was used on a reserved metadata/configuration resource.', 'confidence' => 'context_only', 'priority' => 'informational', 'overlap' => 'Protocol/security tests may intentionally exercise methods; ordinary API writes and OPTIONS are excluded.', ), ); } /** * Classify bounded transient path and query views without retaining raw values. * * @param string $target Untrusted request target; truncated before parsing. * @param string $method Request method supplied by the caller. * * @return array Finite matches, quality flags and observation limits. */ public static function analyze( string $target, string $method ): array { $flags = array(); if ( strlen( $target ) > 16384 ) { $flags['target_truncated'] = true; } $target = substr( $target, 0, 16384 ); $q = strpos( $target, '?' ); $path = false === $q ? $target : substr( $target, 0, $q ); $query = false === $q ? '' : substr( $target, $q + 1 ); if ( strlen( $path ) > 8192 ) { $flags['path_truncated'] = true; } if ( strlen( $query ) > 8192 ) { $flags['query_truncated'] = true; } $path = substr( $path, 0, 8192 ); $query = substr( $query, 0, 8192 ); if ( '' === $path || '/' !== $path[0] || str_starts_with( $path, '//' ) ) { $flags['non_origin_target'] = true; } if ( preg_match( '/[\x00-\x1f\x7f]/', $target ) ) { $flags['control_bytes'] = true; } if ( str_contains( $target, '#' ) ) { $flags['fragment_in_target'] = true; } if ( 1 !== preg_match( '//u', $target ) ) { $flags['invalid_utf8'] = true; } if ( preg_match( '/%(?![0-9A-Fa-f]{2})/', $target ) ) { $flags['malformed_percent'] = true; } if ( str_contains( $path, '\\' ) || str_contains( $path, '//' ) || preg_match( '~(?:^|/)\.{1,2}(?:/|$)~', $path ) ) { $flags['ambiguous_path'] = true; } if ( str_contains( $path, '%' ) ) { $flags['encoded_path'] = true; } $matches = array(); $path_views = array( 'literal' => $path, 'percent_decode_1' => rawurldecode( $path ), ); $path_views['percent_decode_2'] = rawurldecode( $path_views['percent_decode_1'] ); foreach ( $path_views as $view => $value ) { if ( ( 'percent_decode_1' === $view && $value === $path ) || ( 'percent_decode_2' === $view && $value === $path_views['percent_decode_1'] ) ) { continue; } if ( preg_match( '/[\x00-\x1f\x7f]/', $value ) ) { $flags['decoded_control_bytes'] = true; } if ( 1 !== preg_match( '//u', $value ) ) { $flags['decoded_invalid_utf8'] = true; } if ( in_array( $value, self::RESERVED, true ) ) { self::add( $matches, 'reserved_configuration', 'path', $value, $view ); } if ( in_array( $value, self::DISCOVERY, true ) ) { self::add( $matches, 'source_diagnostic', 'path', $value, $view ); } // Parent segments alone are common malformed URLs, not a sufficient rule. $slash = str_replace( '\\', '/', $value ); if ( substr_count( $slash, '../' ) >= 2 && preg_match( '~(?:^|/)(?:etc/(?:passwd|shadow)|wp-config\.php|\.env)$~D', $slash ) ) { self::add( $matches, 'traversal_shape', 'path', 'parent_segments_to_reserved_resource', $view ); $flags['ambiguous_path'] = true; } if ( preg_match( '~^/wp-content/plugins/[a-zA-Z0-9_-]{1,80}/(?:readme\.(?:txt|html)|changelog\.txt)$~D', $value ) ) { self::add( $matches, 'component_recon', 'path', 'plugin_metadata_resource', $view ); } if ( preg_match( '~^/wp-content/themes/[a-zA-Z0-9_-]{1,80}/(?:readme\.txt|theme\.json)$~D', $value ) ) { self::add( $matches, 'component_recon', 'path', 'theme_metadata_resource', $view ); } } $pairs = '' === $query ? array() : explode( '&', $query, 129 ); if ( count( $pairs ) > 128 ) { $flags['query_pairs_truncated'] = true; $pairs = array_slice( $pairs, 0, 128 ); } $seen = array(); foreach ( $pairs as $pair ) { $parts = explode( '=', $pair, 2 ); $key = urldecode( $parts[0] ); // Never persist arbitrary keys; even a key itself can be a token. $key_hash = hash( 'sha256', $key ); if ( isset( $seen[ $key_hash ] ) ) { $flags['duplicate_query_key'] = true; } $seen[ $key_hash ] = true; if ( strlen( $key ) > 64 ) { $flags['unclassified_query_key'] = true; continue; } $numeric = in_array( $key, self::NUMERIC_KEYS, true ); $navigation = in_array( $key, self::URL_KEYS, true ); $file = in_array( $key, self::FILE_KEYS, true ); if ( ! $numeric && ! $navigation && ! $file ) { continue; } $once = urldecode( $parts[1] ?? '' ); foreach ( array( 'form_decode_1' => $once, 'percent_decode_2' => rawurldecode( $once ), ) as $view => $value ) { if ( 'percent_decode_2' === $view && $value === $once ) { continue; } if ( preg_match( '/[\x00-\x1f\x7f]/', $value ) ) { $flags['decoded_control_bytes'] = true; } if ( 1 !== preg_match( '//u', $value ) ) { $flags['decoded_invalid_utf8'] = true; } $slash = str_replace( '\\', '/', $value ); if ( $file && substr_count( $slash, '../' ) >= 2 && preg_match( '~(?:^|/)(?:etc/(?:passwd|shadow)|wp-config\.php|\.env)$~D', $slash ) ) { self::add( $matches, 'traversal_shape', 'query_value', 'file_parameter_to_reserved_resource', $view ); } if ( $numeric && ( preg_match( '~^\s*\d{1,12}[\x27"]?\s+(?:OR|AND)\s+\d{1,12}\s*=\s*\d{1,12}\s*(?:--|\#|/\*)~i', $value ) || preg_match( '~^\s*\d{1,12}[\x27"]?\s+UNION(?:\s+ALL)?\s+SELECT\s+(?:NULL|\d{1,12})(?:\s*,\s*(?:NULL|\d{1,12})){1,7}\s*(?:--|\#|/\*)~i', $value ) ) ) { self::add( $matches, 'sql_query_shape', 'query_value', 'numeric_identifier_parameter', $view ); } if ( ( $numeric || $navigation ) && ( ( false !== stripos( $value, ']{0,512})?>[\s\S]{0,4096}~i', $value ) ) || preg_match( '~<(?:img|svg|iframe)\b[^>]{0,512}\bon(?:error|load)\s*=\s*[\x27"]?[^>]{1,512}>~i', $value ) || ( $navigation && preg_match( '~^\s*(?:javascript|vbscript)\s*:\s*[^\s].{0,2048}$~iD', $value ) ) ) ) { self::add( $matches, 'script_query_shape', 'query_value', $numeric ? 'numeric_identifier_parameter' : 'navigation_parameter', $view ); } } } $reserved = isset( $matches['reserved_configuration'] ) || isset( $matches['source_diagnostic'] ); if ( ( in_array( $method, array( 'TRACE', 'TRACK', 'CONNECT' ), true ) && ! str_starts_with( $path, '/wp-json/' ) ) || ( $reserved && in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) ) { self::add( $matches, 'unusual_method', 'method', $reserved ? 'reserved_resource' : 'non_api_route', 'literal' ); } $flags = array_keys( $flags ); sort( $flags, SORT_STRING ); return array( 'version' => self::VERSION, 'matches' => array_values( $matches ), 'quality_flags' => $flags, 'partial' => (bool) array_intersect( $flags, array( 'target_truncated', 'path_truncated', 'query_truncated', 'query_pairs_truncated', 'non_origin_target', 'control_bytes', 'fragment_in_target', 'invalid_utf8', 'malformed_percent', 'decoded_control_bytes', 'decoded_invalid_utf8', 'ambiguous_path', 'encoded_path' ) ), 'query_pairs_examined' => count( $pairs ), 'retained_input' => 'safe_templates_only', 'automatic_response_eligible' => false, ); } /** * Retain at most one fixed-template observation per pattern family. * * @param array $matches Accumulated observations, updated by reference. * @param string $family Known key in the fixed rule catalog. * @param string $component Observed path, query-value or method component. * @param string $template Fixed template or exact allowlisted resource path. * @param string $view Literal or bounded decoding view that matched. * * @return void No return value. */ private static function add( array &$matches, string $family, string $component, string $template, string $view ): void { if ( isset( $matches[ $family ] ) || count( $matches ) >= 8 ) { return; } $rule = self::rules()[ $family ]; $matches[ $family ] = array( 'family' => $family, 'rule_id' => $rule['id'], 'revision' => $rule['revision'], 'component' => $component, 'safe_template' => $template, 'view' => $view, ); } }