hash_key = $hash_key; } /** * Source exists only for the duration of this call. The returned structure * contains no source text, literal value, variable name, URL or symbol name. * * @return array{signal_types:list,occurrences:list,symbols:list,truncated:bool,syntax_state:string} */ public function extract( string $source, string $extension ): array { $extension = strtolower( $extension ); if ( strlen( $source ) > self::MAX_SOURCE_BYTES || ! in_array( $extension, array( 'php', 'inc', 'phtml', 'js', 'mjs', 'cjs', 'css' ), true ) ) { throw new InvalidArgumentException( 'unsupported_code_source' ); } if ( in_array( $extension, array( 'php', 'inc', 'phtml' ), true ) ) { return $this->extract_php( $source ); } return $this->extract_frontend( $source, $extension ); } /** * @return array{signal_types:list,occurrences:list,symbols:list,truncated:bool,syntax_state:string} */ private function extract_php( string $source ): array { $syntax_state = 'parsed'; try { $tokens = token_get_all( $source, TOKEN_PARSE ); } catch ( ParseError $exception ) { $syntax_state = 'invalid'; $tokens = token_get_all( $source ); } $signals = array(); $occurrences = array(); $symbols = array(); $namespace = ''; $has_input = false; $has_output = false; $db_calls = 0; $db_prepares = 0; $truncated = false; if ( 'invalid' === $syntax_state ) { $this->record( $signals, $occurrences, 'syntax.parse_error', 1, 'parse-error', $truncated ); } $count = count( $tokens ); for ( $index = 0; $index < $count; ++$index ) { $token = $tokens[ $index ]; if ( ! is_array( $token ) ) { if ( '`' === $token ) { $this->record( $signals, $occurrences, 'dynamic.shell_backtick', $this->line_at( $tokens, $index ), 'backtick', $truncated ); } continue; } $id = $token[0]; $text = $token[1]; $line = (int) $token[2]; $name = token_name( $id ); if ( T_NAMESPACE === $id ) { $namespace = $this->namespace_after( $tokens, $index ); } if ( in_array( $name, array( 'T_ATTRIBUTE', 'T_MATCH', 'T_NULLSAFE_OBJECT_OPERATOR', 'T_ENUM', 'T_READONLY' ), true ) ) { $this->record( $signals, $occurrences, 'compat.' . strtolower( substr( $name, 2 ) ), $line, strtolower( substr( $name, 2 ) ), $truncated ); } if ( T_EVAL === $id ) { $this->record( $signals, $occurrences, 'dynamic.eval', $line, 'eval', $truncated ); } if ( T_VARIABLE === $id ) { $variable = strtolower( $text ); if ( in_array( $variable, array( '$_get', '$_post', '$_request', '$_cookie', '$_files', '$_server' ), true ) ) { $has_input = true; $this->record( $signals, $occurrences, 'io.input_source', $line, 'request-input', $truncated ); } if ( preg_match( '/(?:pass(?:word)?|secret|token|credential|api[_-]?key|private[_-]?key|access[_-]?key)/i', $text ) && $this->assigned_literal_after( $tokens, $index ) ) { $this->record( $signals, $occurrences, 'secret.credential_literal', $line, 'credential-assignment', $truncated ); } if ( '$wpdb' === $variable && $this->wpdb_method_after( $tokens, $index ) ) { ++$db_calls; } } if ( in_array( $id, array( T_ECHO, T_PRINT, T_EXIT ), true ) ) { $has_output = true; $this->record( $signals, $occurrences, 'io.output_sink', $line, 'output', $truncated ); } if ( T_STRING === $id && $this->is_call( $tokens, $index ) ) { $call = strtolower( $text ); if ( 'prepare' === $call ) { ++$db_prepares; } $this->record_call( $call, $line, $signals, $occurrences, $truncated ); } if ( in_array( $name, array( 'T_FUNCTION', 'T_CLASS', 'T_INTERFACE', 'T_TRAIT', 'T_ENUM' ), true ) ) { $symbol = $this->declaration_after( $tokens, $index ); if ( null !== $symbol ) { if ( count( $symbols ) < self::MAX_SYMBOLS ) { $symbols[] = array( 'symbol_hash' => hash_hmac( 'sha256', strtolower( $namespace . '\\' . $symbol ), $this->hash_key ), 'kind' => strtolower( substr( $name, 2 ) ), 'line' => $line, ); } else { $truncated = true; } } } } if ( $has_input && $has_output ) { $this->record( $signals, $occurrences, 'io.input_output_cooccurrence', 1, 'manual-flow-review', $truncated ); } if ( $db_calls > $db_prepares ) { $this->record( $signals, $occurrences, 'database.unprepared_call_signal', 1, 'prepare-review', $truncated ); } if ( preg_match( '/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/', $source ) ) { $this->record( $signals, $occurrences, 'secret.private_key_material', 1, 'private-key-marker', $truncated ); } sort( $signals, SORT_STRING ); return array( 'signal_types' => array_values( array_unique( $signals ) ), 'occurrences' => $occurrences, 'symbols' => $symbols, 'truncated' => $truncated, 'syntax_state' => $syntax_state, ); } /** * @param list $signals Signal accumulator. * @param list $occurrences Occurrence accumulator. */ private function record_call( string $call, int $line, array &$signals, array &$occurrences, bool &$truncated ): void { $mapping = array( 'assert' => 'dynamic.assert', 'create_function' => 'dynamic.create_function', 'exec' => 'dynamic.shell_call', 'shell_exec' => 'dynamic.shell_call', 'system' => 'dynamic.shell_call', 'passthru' => 'dynamic.shell_call', 'proc_open' => 'dynamic.shell_call', 'popen' => 'dynamic.shell_call', 'file_put_contents' => 'filesystem.write_call', 'fwrite' => 'filesystem.write_call', 'unlink' => 'filesystem.delete_call', 'rename' => 'filesystem.move_call', 'move_uploaded_file' => 'filesystem.upload_move', 'wp_handle_upload' => 'filesystem.upload_handler', 'media_handle_upload' => 'filesystem.upload_handler', 'wp_remote_get' => 'outbound.wp_http', 'wp_remote_post' => 'outbound.wp_http', 'wp_remote_request' => 'outbound.wp_http', 'curl_exec' => 'outbound.curl', 'curl_multi_exec' => 'outbound.curl', 'fsockopen' => 'outbound.socket', 'pfsockopen' => 'outbound.socket', ); if ( isset( $mapping[ $call ] ) ) { $this->record( $signals, $occurrences, $mapping[ $call ], $line, $call, $truncated ); } if ( 'add_action' === $call || 'add_filter' === $call ) { $this->record( $signals, $occurrences, 'hot.hook_registration', $line, 'hook-registration', $truncated ); } } /** * @return array{signal_types:list,occurrences:list,symbols:list,truncated:bool,syntax_state:string} */ private function extract_frontend( string $source, string $extension ): array { $signals = array(); $occurrences = array(); $truncated = false; $patterns = 'css' === $extension ? array( 'frontend.source_map' => '/sourceMappingURL\s*=/i', 'frontend.old_ie_hack' => '/(?:expression\s*\(|behavior\s*:)/i', ) : array( 'frontend.console' => '/\bconsole\s*\.\s*(?:log|debug|trace)\s*\(/i', 'frontend.debugger' => '/\bdebugger\s*;/i', 'frontend.eval' => '/\beval\s*\(/i', 'frontend.document_write' => '/\bdocument\s*\.\s*write\s*\(/i', 'frontend.source_map' => '/sourceMappingURL\s*=/i', 'outbound.fetch' => '/\bfetch\s*\(/i', 'outbound.xhr' => '/\bXMLHttpRequest\b/i', ); foreach ( $patterns as $type => $pattern ) { if ( preg_match_all( $pattern, $source, $matches, PREG_OFFSET_CAPTURE ) ) { foreach ( $matches[0] as $match ) { $offset = (int) $match[1]; $line = 1 + substr_count( substr( $source, 0, $offset ), "\n" ); $this->record( $signals, $occurrences, $type, $line, str_replace( '.', '-', $type ), $truncated ); } } } if ( 'css' !== $extension && preg_match_all( '/\b(?:pass(?:word)?|secret|token|credential|api[_-]?key|private[_-]?key|access[_-]?key)\b\s*[:=]\s*["\'][^"\'\r\n]{8,}["\']/i', $source, $matches, PREG_OFFSET_CAPTURE ) ) { foreach ( $matches[0] as $match ) { $line = 1 + substr_count( substr( $source, 0, (int) $match[1] ), "\n" ); $this->record( $signals, $occurrences, 'secret.credential_literal', $line, 'credential-assignment', $truncated ); } } if ( preg_match( '/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/', $source ) ) { $this->record( $signals, $occurrences, 'secret.private_key_material', 1, 'private-key-marker', $truncated ); } sort( $signals, SORT_STRING ); return array( 'signal_types' => array_values( array_unique( $signals ) ), 'occurrences' => $occurrences, 'symbols' => array(), 'truncated' => $truncated, 'syntax_state' => 'lexed', ); } /** * @param list $signals Signal accumulator. * @param list $occurrences Occurrence accumulator. */ private function record( array &$signals, array &$occurrences, string $type, int $line, string $marker, bool &$truncated ): void { $signals[] = $type; if ( count( $occurrences ) >= self::MAX_OCCURRENCES ) { $truncated = true; return; } $normalized = preg_replace( '/[^a-z0-9_-]/', '-', strtolower( $marker ) ); if ( ! is_string( $normalized ) || '' === $normalized ) { $normalized = 'signal'; } $bounded = substr( $normalized, 0, 48 ); if ( false === $bounded || '' === $bounded ) { $bounded = 'signal'; } $occurrences[] = array( 'type' => $type, 'line' => max( 1, $line ), 'marker' => $bounded, ); } /** * @param list $tokens PHP tokens. */ private function is_call( array $tokens, int $index ): bool { $next = $this->next_meaningful( $tokens, $index + 1 ); return null !== $next && '(' === $tokens[ $next ]; } /** * @param list $tokens PHP tokens. */ private function assigned_literal_after( array $tokens, int $index ): bool { $equals = $this->next_meaningful( $tokens, $index + 1 ); if ( null === $equals || '=' !== $tokens[ $equals ] ) { return false; } $value = $this->next_meaningful( $tokens, $equals + 1 ); return null !== $value && is_array( $tokens[ $value ] ) && T_CONSTANT_ENCAPSED_STRING === $tokens[ $value ][0] && strlen( trim( $tokens[ $value ][1], "'\"" ) ) >= 8; } /** * @param list $tokens PHP tokens. */ private function wpdb_method_after( array $tokens, int $index ): bool { $operator = $this->next_meaningful( $tokens, $index + 1 ); $method = null === $operator ? null : $this->next_meaningful( $tokens, $operator + 1 ); if ( null === $operator || null === $method || ! is_array( $tokens[ $operator ] ) || T_OBJECT_OPERATOR !== $tokens[ $operator ][0] || ! is_array( $tokens[ $method ] ) || T_STRING !== $tokens[ $method ][0] ) { return false; } return in_array( strtolower( $tokens[ $method ][1] ), array( 'query', 'get_var', 'get_row', 'get_col', 'get_results' ), true ); } /** * @param list $tokens PHP tokens. */ private function declaration_after( array $tokens, int $index ): ?string { $next = $this->next_meaningful( $tokens, $index + 1 ); if ( null === $next || ! is_array( $tokens[ $next ] ) || T_STRING !== $tokens[ $next ][0] ) { return null; } return $tokens[ $next ][1]; } /** * @param list $tokens PHP tokens. */ private function namespace_after( array $tokens, int $index ): string { $parts = array(); $count = count( $tokens ); for ( $cursor = $index + 1; $cursor < $count; ++$cursor ) { $token = $tokens[ $cursor ]; if ( ';' === $token || '{' === $token ) { break; } if ( is_array( $token ) && in_array( token_name( $token[0] ), array( 'T_STRING', 'T_NS_SEPARATOR', 'T_NAME_QUALIFIED', 'T_NAME_FULLY_QUALIFIED' ), true ) ) { $parts[] = $token[1]; } } return trim( implode( '', $parts ), '\\' ); } /** * @param list $tokens PHP tokens. */ private function next_meaningful( array $tokens, int $index ): ?int { $count = count( $tokens ); for ( ; $index < $count; ++$index ) { $token = $tokens[ $index ]; if ( ! is_array( $token ) || ! in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { return $index; } } return null; } /** * @param list $tokens PHP tokens. */ private function line_at( array $tokens, int $index ): int { for ( ; $index >= 0; --$index ) { if ( is_array( $tokens[ $index ] ) ) { return (int) $tokens[ $index ][2]; } } return 1; } }