self::POLICY_VERSION, 'signed_bundle' => RuleBundle::summary() ); } public static function ids(): array { return array( 'php.request-execution', 'js.browser-input-execution', 'php.decoded-execution', 'php.assigned-request-execution', 'php.request-include', 'php.request-callback', 'php.executable-write', 'js.browser-input-dom', 'file.executable-upload', 'php.literal-execution-alias', 'php.request-selected-function', 'js.browser-input-timer', 'signature.exact-sha256' ); } /** Strict legacy API: callers without coverage support must never accept partial work as clean. */ public static function inspect( string $content, array $context ): array { $result = self::assess( $content, $context ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. if ( $result['limitations'] ) { throw new \RuntimeException( $result['limitations'][0]['reason'] ); } return $result['findings']; } /** Independent bounded passes retain observations even when another pass cannot finish. */ public static function assess( string $content, array $context ): array { if ( strlen( $content ) > self::MAX_BYTES ) { return array( 'findings' => array(), 'completed' => array(), 'limitations' => array( array( 'detectors' => self::ids(), 'reason' => 'Structural analysis byte budget exceeded.' ) ) ); } $base = $context + array( 'content_hash' => hash( 'sha256', $content ), 'location' => 'unknown', 'component_version' => 'unknown', 'policy_version' => self::POLICY_VERSION ); $php = str_contains( $content, ' array(), 'completed' => array(), 'limitations' => array() ); $passes = array( array( array( 'php.request-execution' ), static function ( array &$out ) use ( $php, $base ): void { self::directPhp( $php, $base, $out ); } ), array( array( 'js.browser-input-execution' ), static function ( array &$out ) use ( $js, $base ): void { self::directJs( $js, $base, $out ); } ), array( array( 'php.decoded-execution', 'php.assigned-request-execution', 'php.request-include', 'php.request-callback', 'php.executable-write', 'js.browser-input-dom', 'file.executable-upload' ), static function ( array &$out ) use ( $content, $php, $js, $base ): void { self::additional( $content, $php, $js, $base, $out ); } ), array( array( 'php.literal-execution-alias', 'php.request-selected-function', 'js.browser-input-timer' ), static function ( array &$out ) use ( $content, $php, $js, $base ): void { self::indirectCalls( $content, $php, $js, $base, $out ); } ), array( array( 'signature.exact-sha256' ), static function ( array &$out ) use ( $content, $base ): void { self::loadBundle(); $out = array_merge( $out, RuleBundle::inspect( $content, $base ) ); } ), ); foreach ( $passes as list( $ids, $pass ) ) { try { $pass( $result['findings'] ); $result['completed'] = array_merge( $result['completed'], $ids ); } catch ( \Throwable $error ) { $result['limitations'][] = array( 'detectors' => $ids, 'reason' => $error->getMessage() ); } } return $result; } private static function directPhp( string $php, array $base, array &$out ): void { if ( '' !== $php ) { $offset = 0; $candidates = 0; while ( preg_match( '/\b(eval|system|exec|shell_exec|passthru|popen|proc_open)\s*\(/i', $php, $match, PREG_OFFSET_CAPTURE, $offset ) ) { if ( $candidates++ >= 512 ) { throw new \RuntimeException( 'PHP structural candidate budget reached; remaining calls were not assessed.' ); } $start = $match[0][1]; $offset = $start + strlen( $match[0][0] ); if ( $start > 0 && ( ctype_alnum( $php[ $start - 1 ] ) || str_contains( '_$>:', $php[ $start - 1 ] ) ) ) { continue; } $expression = self::arguments( self::expression( $php, $offset ) )[0]; if ( preg_match( '/\$_(?:GET|POST|REQUEST|COOKIE|FILES)\b/', $expression, $input ) ) { $out[] = Finding::make( $base + array( 'category' => 'suspicious_behavior', 'detector_id' => 'php.request-execution', 'severity' => 'high', 'confidence' => 'suspicious', 'reason' => 'A PHP code or command execution call directly contains request-controlled data.', 'limitations' => self::rule( 'php.request-execution' )['limitations'], 'recommended_action' => 'Review this call and its input validation with a developer. Do not remove custom code without a verified replacement.', 'evidence' => array( array( 'type' => 'execution_call', 'value' => strtolower( $match[1][0] ), 'byte_offset' => $start, ), array( 'type' => 'untrusted_input', 'value' => $input[0], ), ), ) ); break; } } } } private static function directJs( string $js, array $base, array &$out ): void { $offset = 0; $candidates = 0; while ( preg_match( '/\b(eval|Function)\s*\(/', $js, $match, PREG_OFFSET_CAPTURE, $offset ) ) { if ( $candidates++ >= 512 ) { throw new \RuntimeException( 'JavaScript structural candidate budget reached; remaining calls were not assessed.' ); } $start = $match[0][1]; $offset = $start + strlen( $match[0][0] ); $expression = self::expression( $js, $offset ); if ( preg_match( '/\b(atob|decodeURIComponent|unescape)\s*\(/', $expression, $decoder ) && preg_match( '/\b(?:location\s*\.\s*(?:hash|search)|document\s*\.\s*cookie|localStorage\s*\.\s*getItem)\b/', $expression, $input ) ) { $out[] = Finding::make( $base + array( 'category' => 'suspicious_behavior', 'detector_id' => 'js.browser-input-execution', 'severity' => 'high', 'confidence' => 'suspicious', 'reason' => 'Dynamic JavaScript execution directly combines decoding with browser-controlled input.', 'limitations' => self::rule( 'js.browser-input-execution' )['limitations'], 'recommended_action' => 'Review the execution path and the origin of its input. Confirm intent with the component maintainer.', 'evidence' => array( array( 'type' => 'execution_call', 'value' => $match[1][0], 'byte_offset' => $start, ), array( 'type' => 'decoder', 'value' => $decoder[1], ), array( 'type' => 'browser_input', 'value' => $input[0], ), ), ) ); break; } } } private static function loadBundle(): void { // Standalone parser tests and CLI consumers need no WordPress bootstrap. if ( ! class_exists( RuleBundle::class, false ) ) { require_once __DIR__ . '/RuleBundle.php'; } } /** Additional original heuristics: no samples, expressions or remote code are executed. */ private static function additional( string $original, string $php, string $js, array $base, array &$out ): void { $seen = array(); if ( true === ( $base['upload_executable'] ?? false ) && '' !== trim( $php ) && ! preg_match( '/^\s*(?:(?:exit|die)\s*(?:\(\s*\))?\s*;?\s*)?$/iD', $php ) ) { $out[] = self::heuristic( $base, 'file.executable-upload', 'A file beneath the verified uploads directory has an executable PHP extension and contains PHP code beyond an empty access guard.', array( array( 'type' => 'upload_php_surface', 'value' => 'Verified uploads path and PHP code' ) ), 'medium' ); } $offset = 0; $candidates = 0; while ( '' !== $php && preg_match( '/\b(eval|system|exec|shell_exec|passthru|popen|proc_open|call_user_func|call_user_func_array|file_put_contents)\s*\(/i', $php, $match, PREG_OFFSET_CAPTURE, $offset ) ) { if ( ++$candidates > 512 ) { throw new \RuntimeException( 'Additional PHP structural candidate budget reached; remaining calls were not assessed.' ); } $start = $match[0][1]; $offset = $start + strlen( $match[0][0] ); if ( $start > 0 && ( ctype_alnum( $php[ $start - 1 ] ) || str_contains( '_$>:', $php[ $start - 1 ] ) ) ) { continue; } $expression = self::expression( $php, $offset ); $arguments = self::arguments( $expression ); $call = strtolower( $match[1][0] ); $id = null; $reason = ''; $severity = 'high'; $evidence = array( array( 'type' => 'execution_call', 'value' => $call, 'byte_offset' => $start ) ); if ( 'eval' === $call && preg_match( '/\b(base64_decode|gzinflate|gzuncompress|gzdecode|str_rot13|hex2bin)\s*\(/i', $arguments[0], $decoder ) ) { $id = 'php.decoded-execution'; $reason = 'A PHP dynamic execution expression directly contains a payload decoder.'; $evidence[] = array( 'type' => 'decoder', 'value' => strtolower( $decoder[1] ) ); } elseif ( in_array( $call, array( 'call_user_func', 'call_user_func_array' ), true ) && preg_match( '/^\s*\$_(?:GET|POST|REQUEST|COOKIE)\b/', $expression ) ) { $id = 'php.request-callback'; $reason = 'A PHP callback function is selected directly from request-controlled data.'; } elseif ( 'file_put_contents' === $call && preg_match( '/\$_(?:GET|POST|REQUEST|COOKIE)\b|\b(?:base64_decode|gzinflate|gzuncompress|gzdecode)\s*\(/i', $arguments[1] ?? '' ) ) { // Only literal executable-extension targets; arbitrary upload/storage code is not enough. $rawExpression = substr( $original, $offset, strlen( $expression ) ); if ( count( $arguments ) > 1 && preg_match( '/[\x27\x22][^\x27\x22\r\n]{0,240}\.php(?:[0-9])?[\x27\x22]/i', substr( $rawExpression, 0, strlen( $arguments[0] ) ) ) ) { $id = 'php.executable-write'; $reason = 'A PHP write targets an executable PHP filename and directly includes request input or decoded data.'; } } elseif ( in_array( $call, array( 'eval', 'system', 'exec', 'shell_exec', 'passthru', 'popen', 'proc_open' ), true ) ) { // Deliberately narrow one-statement propagation; never claim complete data-flow analysis. $prefix = substr( $php, max( 0, $start - 1100 ), min( $start, 1100 ) ); if ( preg_match( '/(?:^|[;{}])\s*(\$[A-Za-z_][A-Za-z0-9_]{0,63})\s*=\s*([^;{}]{1,1024});\s*$/D', $prefix, $assignment ) && preg_match( '/\$_(?:GET|POST|REQUEST|COOKIE)\b/', $assignment[2] ) && preg_match( '/' . preg_quote( $assignment[1], '/' ) . '(?![A-Za-z0-9_])/', $arguments[0] ) ) { $id = 'php.assigned-request-execution'; $reason = 'A variable assigned request-controlled data in the preceding statement is passed to PHP code or command execution.'; $evidence[] = array( 'type' => 'adjacent_assignment', 'value' => $assignment[1] ); } } if ( null !== $id && ! isset( $seen[ $id ] ) ) { $out[] = self::heuristic( $base, $id, $reason, $evidence, $severity ); $seen[ $id ] = true; } } if ( '' !== $php && preg_match( '/\b(?:include|require)(?:_once)?\s*(?:\(\s*)?\$_(?:GET|POST|REQUEST|COOKIE)\b/i', $php, $include, PREG_OFFSET_CAPTURE ) ) { $out[] = self::heuristic( $base, 'php.request-include', 'A PHP include or require expression directly selects its source from request-controlled data.', array( array( 'type' => 'request_include', 'byte_offset' => $include[0][1] ) ), 'high' ); } $offset = 0; $candidates = 0; while ( preg_match( '/\bdocument\s*\.\s*(?:write|writeln)\s*\(/', $js, $match, PREG_OFFSET_CAPTURE, $offset ) ) { if ( ++$candidates > 512 ) { throw new \RuntimeException( 'JavaScript DOM candidate budget reached; remaining calls were not assessed.' ); } $offset = $match[0][1] + strlen( $match[0][0] ); $expression = self::expression( $js, $offset ); if ( preg_match( '/\b(?:location\s*\.\s*(?:hash|search)|document\s*\.\s*cookie)\b/', $expression, $input ) && preg_match( '/\b(?:atob|decodeURIComponent|unescape)\s*\(/', $expression ) ) { $out[] = self::heuristic( $base, 'js.browser-input-dom', 'Decoded browser-controlled data is passed directly to a document HTML-writing function.', array( array( 'type' => 'dom_write', 'byte_offset' => $match[0][1] ), array( 'type' => 'browser_input', 'value' => $input[0] ) ), 'high' ); break; } } } /** Narrow adjacent-statement aliases; this is not whole-program taint analysis. */ private static function indirectCalls( string $original, string $php, string $js, array $base, array &$out ): void { $seen = array(); $offset = 0; $candidates = 0; while ( '' !== $php && preg_match( '/\$[A-Za-z_][A-Za-z0-9_]{0,63}\s*\(/', $php, $call, PREG_OFFSET_CAPTURE, $offset ) ) { if ( ++$candidates > 512 ) { throw new \RuntimeException( 'Indirect PHP call budget reached; remaining input was not assessed.' ); } $start = $call[0][1]; $offset = $start + strlen( $call[0][0] ); if ( $start > 0 && str_contains( '$>:', $php[ $start - 1 ] ) ) { continue; } $prefixStart = max( 0, $start - 1100 ); $prefix = substr( $php, $prefixStart, $start - $prefixStart ); if ( ! preg_match( '/(?:^|[;{}])\s*(\$[A-Za-z_][A-Za-z0-9_]{0,63})\s*=([^;{}]{1,1024});\s*$/D', $prefix, $assignment, PREG_OFFSET_CAPTURE ) ) { continue; } $variable = $assignment[1][0]; if ( trim( substr( $call[0][0], 0, -1 ) ) !== $variable ) { continue; } $id = null; $reason = ''; $evidence = array( array( 'type' => 'adjacent_variable_call', 'variable' => $variable, 'byte_offset' => $start ) ); if ( preg_match( '/^\s*\$_(?:GET|POST|REQUEST|COOKIE)\b/', $assignment[2][0] ) ) { $id = 'php.request-selected-function'; $reason = 'A function variable is selected from request-controlled data in the immediately preceding statement and then called.'; } else { $raw = substr( $original, $prefixStart + $assignment[2][1], strlen( $assignment[2][0] ) ); $name = self::literalFunctionName( $raw ); if ( in_array( $name, array( 'system', 'exec', 'shell_exec', 'passthru', 'popen', 'proc_open' ), true ) ) { $expression = self::arguments( self::expression( $php, $offset ) )[0]; if ( preg_match( '/\$_(?:GET|POST|REQUEST|COOKIE)\b/', $expression ) ) { $id = 'php.literal-execution-alias'; $reason = 'A literal or safely decoded function alias resolves to command execution, then directly receives request-controlled data.'; $evidence[] = array( 'type' => 'resolved_function_name', 'value' => $name ); } } } if ( null !== $id && ! isset( $seen[ $id ] ) ) { $out[] = self::heuristic( $base, $id, $reason, $evidence, 'high' ); $seen[ $id ] = true; } } $offset = 0; $candidates = 0; while ( preg_match( '/\b(setTimeout|setInterval)\s*\(\s*(?:atob|decodeURIComponent|unescape)\s*\(/', $js, $call, PREG_OFFSET_CAPTURE, $offset ) ) { if ( ++$candidates > 512 ) { throw new \RuntimeException( 'Decoded JavaScript timer budget reached; remaining input was not assessed.' ); } $start = $call[0][1]; $open = strpos( $js, '(', $start ); $offset = $start + strlen( $call[0][0] ); $expression = self::arguments( self::expression( $js, $open + 1 ) )[0]; if ( preg_match( '/\b(?:location\s*\.\s*(?:hash|search)|document\s*\.\s*cookie|localStorage\s*\.\s*getItem)\b/', $expression, $input ) ) { $out[] = self::heuristic( $base, 'js.browser-input-timer', 'A JavaScript timer receives decoded browser-controlled text as its code argument.', array( array( 'type' => 'string_timer_call', 'value' => $call[1][0], 'byte_offset' => $start ), array( 'type' => 'browser_input', 'value' => $input[0] ) ), 'high' ); break; } } } /** Decode only small literal identifier strings; never a payload, expression, callable or file. */ private static function literalFunctionName( string $raw ): string { $raw = trim( $raw ); if ( strlen( $raw ) > 512 ) { return ''; } $decoder = ''; if ( preg_match( '/^(base64_decode|hex2bin|str_rot13)\s*\(\s*(.{1,256})\s*\)$/iD', $raw, $match ) ) { $decoder = strtolower( $match[1] ); $raw = trim( $match[2] ); } $parts = explode( '.', $raw ); if ( count( $parts ) > 8 ) { return ''; } $value = ''; foreach ( $parts as $part ) { if ( ! preg_match( '/^([\x27\x22])([A-Za-z0-9_+\/=\-]{1,128})\1$/D', trim( $part ), $literal ) ) { return ''; } $value .= $literal[2]; if ( strlen( $value ) > 128 ) { return ''; } } if ( 'base64_decode' === $decoder ) { $value = base64_decode( $value, true ); } elseif ( 'hex2bin' === $decoder ) { $value = 0 === strlen( $value ) % 2 && ctype_xdigit( $value ) ? hex2bin( $value ) : false; } // phpcs:ignore Generic.PHP.ForbiddenFunctions.Found -- Decode bounded suspicious ROT13 literals for non-executing malware inspection; decoded data is never evaluated. elseif ( 'str_rot13' === $decoder ) { $value = str_rot13( $value ); } return is_string( $value ) && preg_match( '/^[A-Za-z_]{1,32}$/D', $value ) ? strtolower( $value ) : ''; } private static function heuristic( array $base, string $id, string $reason, array $evidence, string $severity ): array { $rule = self::rule( $id ); return Finding::make( $base + array( 'category' => 'suspicious_behavior', 'detector_id' => $id, 'detector_version' => $rule['version'], 'severity' => $severity, 'confidence' => 'suspicious', 'reason' => $reason, 'evidence' => $evidence, 'limitations' => $rule['limitations'], 'rule_freshness' => array( 'version' => self::registry()['version'], 'published_at' => self::registry()['published_at'] ), 'recommended_action' => 'Review the evidence and intended behavior with the component maintainer. Preserve a protected copy and use an authentic replacement where available; this heuristic does not authorize automatic removal.', ) ); } /** Split already-masked bounded arguments, retaining offsets and excluding output/flag parameters. */ private static function arguments( string $expression ): array { $depth = 0; $start = 0; $parts = array(); for ( $i = 0, $length = strlen( $expression ); $i < $length; ++$i ) { $ch = $expression[ $i ]; if ( str_contains( '([{', $ch ) ) { ++$depth; } elseif ( str_contains( ')]}', $ch ) ) { --$depth; } elseif ( ',' === $ch && 0 === $depth ) { $parts[] = substr( $expression, $start, $i - $start ); $start = $i + 1; if ( count( $parts ) >= 32 ) { throw new \RuntimeException( 'Structural argument budget exceeded.' ); } } } $parts[] = substr( $expression, $start ); return $parts; } private static function rule( string $id ): array { foreach ( self::registry()['rules'] as $rule ) { if ( $rule['id'] === $id ) { return $rule; } } throw new \RuntimeException( 'Detector registry mismatch.' ); } private static function expression( string $code, int $offset ): string { $stack = array( '(' ); $end = min( strlen( $code ), $offset + 1024 ); for ( $i = $offset;$i < $end;$i++ ) { $ch = $code[ $i ]; if ( str_contains( '([{', $ch ) ) { $stack[] = $ch; if ( count( $stack ) > 16 ) { break; } } elseif ( str_contains( ')]}', $ch ) ) { $expected = array( ')' => '(', ']' => '[', '}' => '{' )[ $ch ]; if ( array_pop( $stack ) !== $expected ) { break; } if ( ! $stack ) { return substr( $code, $offset, $i - $offset ); } } elseif ( ';' === $ch && 1 === count( $stack ) ) { // A statement cannot end an argument at call depth. Semicolons inside // a nested closure or for-loop are valid and retain their actual code. break; } } // An unassessed execution call must not become a cached clean file or // resolve older evidence merely because its expression exceeded policy. throw new \RuntimeException( 'Structural expression budget reached or expression incomplete; remaining input was not assessed.' ); } private static function mask( string $input, bool $php ): string { $length = strlen( $input ); $out = $input; $i = 0; $inPhp = ! $php; while ( $i < $length ) { if ( $php && ! $inPhp ) { $open = strpos( $input, '= $length || ctype_space( $input[ $open + 5 ] ) ) ? 5 : ( '' === substr( $input, $i, 2 ) ) { $inPhp = false; $out[ $i ] = ' '; $out[ $i + 1 ] = ' '; $i += 2; continue; } $ch = $input[ $i ]; $next = $i + 1 < $length ? $input[ $i + 1 ] : ''; if ( ( '/' === $ch && '/' === $next ) || ( $php && '#' === $ch ) ) { $end = strpos( $input, "\n", $i ); $end = false === $end ? $length : $end; self::blank( $out, $i, $end ); $i = $end; continue; } if ( '/' === $ch && '*' === $next ) { $end = strpos( $input, '*/', $i + 2 ); $end = false === $end ? $length : $end + 2; self::blank( $out, $i, $end ); $i = $end; continue; } if ( ! $php && '/' === $ch ) { $previous = $i - 1; while ( $previous >= 0 && ctype_space( $input[ $previous ] ) ) { --$previous; } if ( $previous < 0 || str_contains( '=([{,:;!?&|', $input[ $previous ] ) ) { $start = $i++; $inClass = false; while ( $i < $length ) { if ( '\\' === $input[ $i ] ) { $i += 2; continue; } if ( '[' === $input[ $i ] ) { $inClass = true; } elseif ( ']' === $input[ $i ] ) { $inClass = false; } elseif ( '/' === $input[ $i ] && ! $inClass ) { ++$i; break; } elseif ( "\n" === $input[ $i ] || "\r" === $input[ $i ] ) { break; } ++$i; } $i = min( $i, $length ); self::blank( $out, $start, $i ); continue; } } if ( '\'' === $ch || '"' === $ch || '`' === $ch ) { $start = $i++; while ( $i < $length ) { if ( '\\' === $input[ $i ] ) { $i += 2; continue; } if ( $input[ $i++ ] === $ch ) { break; } } $i = min( $i, $length ); self::blank( $out, $start, $i ); continue; } if ( $php && '<<<' === substr( $input, $i, 3 ) && preg_match( '/\G<<<[ \t]*[\'"]?([A-Za-z_][A-Za-z0-9_]{0,63})[\'"]?\r?\n/A', $input, $m, 0, $i ) ) { $start = $i; $i += strlen( $m[0] ); if ( preg_match( '/^[ \t]*' . preg_quote( $m[1], '/' ) . '(?:;|,|\)|\r?$)/m', $input, $end, PREG_OFFSET_CAPTURE, $i ) ) { $i = $end[0][1] + strlen( $end[0][0] ); } else { $i = $length; } self::blank( $out, $start, $i ); continue; } ++$i; } return $out; } private static function blank( string &$value, int $start, int $end ): void { for ( $i = $start;$i < $end;$i++ ) { $value[ $i ] = ' '; } } }