-backup"; requiring the separator is // what closes that whole family, not just the one spelling of it. if ( $candidate === $root ) { return $candidate; } if ( 0 === strpos( $candidate, $root . DIRECTORY_SEPARATOR ) ) { return $candidate; } return false; } /** * The scan-surface extension for a path, normalising the dotfiles whose * extension PHP reports as empty. */ public static function extension_of( string $path ): string { $base = strtolower( basename( $path ) ); if ( '.htaccess' === $base ) { return 'htaccess'; } $dot = strrpos( $base, '.' ); if ( false === $dot || 0 === $dot ) { return ''; } return substr( $base, $dot + 1 ); } /** Whether this path belongs to the scan surface at all (enumeration). */ public static function is_scannable( string $path ): bool { return in_array( self::extension_of( $path ), self::SCANNABLE_EXTENSIONS, true ); } /** * Whether this file's content may leave the site. * * Answered by asking what the file IS, not by trying to guess what might be * inside it. An earlier version transmitted every scannable file and relied * on recognising credential-shaped assignments to strip secrets out — which * cannot be proved, because a secret may be called `OPENAI_KEY` or `JWT` or * sit in a nested field of a format we do not parse. A boundary that depends * on anticipating every name an unknown developer might choose is not a * boundary. * * So content travels only from the directories that hold distributed code, * and only for extensions that carry code. Everything else — the site root * including wp-config.php, uploads, .htaccess, and every data file — is * inspected locally instead (§ observe()). * * Withheld files remain enumerated and fingerprinted: their presence and * every change to them is still reported. Only the bytes stay local. * * @param string $path Absolute path, or a site-relative one beginning "/". */ public static function transmits_content( string $path ): bool { if ( ! self::is_scannable( $path ) ) { return false; } $base = strtolower( basename( $path ) ); if ( in_array( $base, self::CONTENT_WITHHELD_BASENAMES, true ) ) { return false; } // A compound suffix such as ".sql.gz" or ".php.bak" is withheld on any // of its parts, so renaming a dump does not smuggle it out. foreach ( self::CONTENT_WITHHELD_EXTENSIONS as $withheld ) { if ( substr( $base, -( strlen( $withheld ) + 1 ) ) === '.' . $withheld ) { return false; } if ( false !== strpos( $base, '.' . $withheld . '.' ) ) { return false; } } // Only code, and only from where code is distributed. if ( ! in_array( self::extension_of( $path ), self::CODE_EXTENSIONS, true ) ) { return false; } return self::in_code_directory( $path ); } /** * Whether a path sits under one of the distributed-code directories. * * Compared on the site-relative path so a site whose wp-content lives * elsewhere is judged on where the file actually is, and matched on a * directory boundary so `wp-content/plugins-backup/` is not mistaken for * `wp-content/plugins/`. */ private static function in_code_directory( string $path ): bool { $relative = self::relative_path( $path ); foreach ( self::CODE_DIRECTORIES as $dir ) { if ( 0 === strpos( $relative, $dir ) ) { return true; } } return false; } /** A path relative to the site root, without a leading slash. */ private static function relative_path( string $path ): string { $normalised = str_replace( '\\', '/', $path ); $root = realpath( ABSPATH ); if ( false !== $root ) { $root = str_replace( '\\', '/', rtrim( $root, '/\\' ) ) . '/'; if ( 0 === strpos( $normalised, $root ) ) { $normalised = substr( $normalised, strlen( $root ) ); } } return ltrim( $normalised, '/' ); } /** * Constructs that are worth reporting when they appear in a file whose * content stays on the site. * * This is what preserves security monitoring across the boundary. A file we * will not transmit is still read here, and if it contains something a * WordPress configuration file has no business containing, we say so — by * name, count and line number, never by quoting the file. * * Deliberately small and high-signal. It is not a reimplementation of the * service's 274 compound definitions and does not pretend to be; see the * tradeoff recorded in observe(). * * @var array label => regex */ private const SUSPICIOUS_CONSTRUCTS = array( 'eval' => '/\beval\s*\(/i', 'assert' => '/\bassert\s*\(/i', 'create_function' => '/\bcreate_function\s*\(/i', 'base64_decode' => '/\bbase64_decode\s*\(/i', 'gzinflate' => '/\b(?:gzinflate|gzuncompress|str_rot13)\s*\(/i', 'shell_execution' => '/\b(?:shell_exec|passthru|proc_open|popen|pcntl_exec)\s*\(/i', 'system_execution' => '/\b(?:system|exec)\s*\(/i', 'preg_replace_eval' => '/preg_replace\s*\(\s*([\'"]).*?\1[eimsuxADSUXJ]*e[eimsuxADSUXJ]*\1/i', 'variable_function' => '/\$[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\$/', 'request_execution' => '/\b(?:eval|assert|system|exec|shell_exec|passthru)\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)/i', 'remote_include' => '/\b(?:include|require)(?:_once)?\s*[\(\s][\'"]https?:/i', 'php_in_data_file' => '/<\?php/i', 'inline_script' => '/]/i', // .htaccess directives that turn a data directory into an executable one // or hand requests to somebody else's server. 'htaccess_php_handler' => '/^\s*(?:AddType|AddHandler|SetHandler)\s+[^\n]*php/im', 'htaccess_auto_prepend' => '/^\s*php_value\s+auto_(?:pre|ap)pend_file/im', 'htaccess_remote_rewrite' => '/^\s*RewriteRule\s+[^\n]*\s+https?:\/\/(?!\s)/im', ); /** * Inspect a file locally and return factual observations about it. * * This is the other half of "inspection is not transmission". A file whose * bytes stay on the site is still examined here, and what travels is a * statement — "this file contains two eval() calls, the first on line 14" — * which carries no secret because it carries no content. * * THE TRADEOFF, STATED. Files inspected this way no longer receive the * service's 274 compound malware definitions, which need the bytes. They * receive this smaller, high-signal set instead. That is a real reduction in * pattern coverage, accepted deliberately for configuration, uploads and * data files, because the alternative — shipping their contents and hoping a * list of credential names is complete — is a privacy claim we cannot * honour. Distributed code, which is where backdoors overwhelmingly land and * which holds no site-specific secrets, still gets the full remote analysis. * * @param string $content Raw file bytes. * @param string $path Path, used to pick format-appropriate checks. * @return array */ public static function observe( string $content, string $path ): array { $ext = self::extension_of( $path ); $is_php_like = in_array( $ext, array( 'php', 'phtml', 'inc' ), true ); $is_htaccess = 'htaccess' === $ext; $observations = array(); foreach ( self::SUSPICIOUS_CONSTRUCTS as $label => $regex ) { // PHP opening tags are only notable in a file that should not // contain them; in a .php file they are the point. if ( 'php_in_data_file' === $label && ( $is_php_like || $is_htaccess ) ) { continue; } // The htaccess directives are meaningless anywhere else, and the // PHP-function checks are meaningless inside htaccess. if ( 0 === strpos( $label, 'htaccess_' ) && ! $is_htaccess ) { continue; } if ( $is_htaccess && 0 !== strpos( $label, 'htaccess_' ) && 'inline_script' !== $label ) { continue; } $matches = array(); $count = preg_match_all( $regex, $content, $matches, PREG_OFFSET_CAPTURE ); if ( ! $count ) { continue; } $offset = isset( $matches[0][0][1] ) ? (int) $matches[0][0][1] : 0; $observations[] = array( 'construct' => $label, 'count' => (int) $count, 'line' => substr_count( substr( $content, 0, $offset ), "\n" ) + 1, ); } return $observations; } /** * Remove credential material from file content. * * Defence in depth, NOT the privacy boundary. The boundary is * transmits_content(): content only travels from distributed-code * directories, which is a property of the file rather than a guess about * its contents. This runs on top of that, to catch the credential a plugin * author left in their own source — and it will miss names nobody * anticipated, which is exactly why nothing is claimed on its behalf. * * Only VALUES in assignment position are touched, and only where the name * beside them denotes a credential or the block is unmistakably a private * key. Code structure — function names, control flow, includes, calls to * eval/base64_decode and the literal filenames malware patterns match on — * is left byte-for-byte intact, which is why redaction costs no detection. * * @param string $content Raw file bytes. * @param string $path Path, used to skip formats with no assignment syntax. * @return array{content: string, redactions: int} */ public static function sanitize( string $content, string $path ): array { $redactions = 0; $marker = self::REDACTED; // Private-key blocks are unmistakable and never legitimate scan input. // Handled for every format, including .htaccess and plain text. $content = preg_replace_callback( '/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----/s', static function () use ( $marker, &$redactions ) { ++$redactions; return '-----BEGIN PRIVATE KEY-----' . $marker . '-----END PRIVATE KEY-----'; }, $content ) ?? $content; $ext = self::extension_of( $path ); // .htaccess has no assignment syntax and its directives are exactly // what the htaccess definitions match on. Nothing further to do. if ( 'htaccess' === $ext ) { return array( 'content' => $content, 'redactions' => $redactions, ); } $credential_name = '(?:' . implode( '|', array_map( 'preg_quote', self::CREDENTIAL_KEY_MARKERS ) ) . ')'; $wp_constants = '(?:' . implode( '|', self::WP_CREDENTIAL_CONSTANTS ) . ')'; // define( 'DB_PASSWORD', '…' ) — WordPress's own documented surface. // Matched by exact constant name, so a short or weak password is // removed just as reliably as a 64-character salt. $content = preg_replace_callback( '/(define\s*\(\s*([\'"])' . $wp_constants . '\2\s*,\s*)([\'"])(?:\\\\.|(?!\3).)*\3/i', static function ( $m ) use ( $marker, &$redactions ) { ++$redactions; return $m[1] . $m[3] . $marker . $m[3]; }, $content ) ?? $content; // define( 'ANYTHING_SECRET', '…' ) — a constant whose own name says it // holds a credential, for the plugins that define their own. $content = preg_replace_callback( '/(define\s*\(\s*([\'"])[A-Za-z0-9_]*' . $credential_name . '[A-Za-z0-9_]*\2\s*,\s*)([\'"])(?:\\\\.|(?!\3).)*\3/i', static function ( $m ) use ( $marker, &$redactions ) { ++$redactions; return $m[1] . $m[3] . $marker . $m[3]; }, $content ) ?? $content; // $password = '…' / 'api_key' => '…' / "client_secret": "…" // One expression covers the PHP variable, the PHP array key and the // JSON property, because all three are "credential name, separator, // quoted value" and differ only in the separator. $content = preg_replace_callback( '/([\'"$]?[A-Za-z0-9_\-]*' . $credential_name . '[A-Za-z0-9_\-]*[\'"]?\s*(?:=>|=|:)\s*)([\'"])(?:\\\\.|(?!\2).)*\2/i', static function ( $m ) use ( $marker, &$redactions ) { ++$redactions; return $m[1] . $m[2] . $marker . $m[2]; }, $content ) ?? $content; return array( 'content' => $content, 'redactions' => $redactions, ); } /* ------------------------------------------------------------------ * * Named values — the same credential markers, applied to data. * * * * `sanitize()` above redacts assignment values inside CODE. The reads * * that return a post's own stored meta need the same judgement applied * * to DATA: a key whose name says "credential", a nested field with such * * a name, or a value whose shape is unmistakably a secret. The markers * * are shared so the two cannot disagree about what a credential is * * called. * * ------------------------------------------------------------------ */ /** Deepest nesting a value is walked; anything beneath is replaced whole. */ private const REDACT_MAX_DEPTH = 8; /** * Value shapes that are secrets whatever they are called: vendor-prefixed * API keys, and PEM blocks. Matched on the value alone. */ private const SECRET_VALUE_PATTERNS = array( '/^(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}$/', // Stripe '/^AKIA[0-9A-Z]{16}$/', // AWS access key id '/^gh[pousr]_[A-Za-z0-9]{30,}$/', // GitHub tokens '/^xox[abprs]-[A-Za-z0-9-]{20,}$/', // Slack '/^AIza[0-9A-Za-z_\-]{30,}$/', // Google API key '/^SG\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}$/', // SendGrid '/^eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\./', // a JWT '/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/', // PEM ); /** * Whether a key or field NAME denotes a credential. * * The same markers `sanitize()` uses for code, matched as a substring, * case-insensitively — `stripe_secret_key` and `smtp_password` both match * without either being enumerated. */ public static function is_credential_name( string $name ): bool { $lower = strtolower( $name ); foreach ( self::CREDENTIAL_KEY_MARKERS as $marker ) { if ( str_contains( $lower, $marker ) ) { return true; } } return false; } /** * Whether a VALUE, by its shape alone, is a secret. * * Deliberately narrow: vendor-prefixed keys and PEM blocks, which cannot * be anything else. A long random-looking string is NOT redacted on shape * alone — builder data is full of hashes and ids that would match, and * redacting a page's layout ids to protect a hypothetical token would * blind the reader to the structure it exists to read. Name-based * redaction is the primary net; this catches the credentials that were * stored under an innocent name. */ public static function looks_like_secret( string $value ): bool { if ( strlen( $value ) < 16 ) { return false; } foreach ( self::SECRET_VALUE_PATTERNS as $pattern ) { if ( preg_match( $pattern, $value ) ) { return true; } } return false; } /** * Redact a named value before it leaves the site. * * Walks arrays and objects: a nested field with a credential name is * replaced, a leaf whose shape is a secret is replaced, and the count of * replacements is reported so the reader is told a secret WAS there rather * than left to read the marker as a literal. A credential-named top-level * key is replaced whole, however it is shaped. Depth is bounded; beyond it * the remainder is replaced whole rather than walked. * * @param string $name The key or field name the value is stored under. * @param mixed $value The stored value, already unserialized. * @return array{value: mixed, redactions: int} */ public static function redact_value( string $name, mixed $value ): array { if ( self::is_credential_name( $name ) ) { return array( 'value' => self::REDACTED, 'redactions' => 1, ); } $redactions = 0; $out = self::redact_walk( $value, 0, $redactions ); return array( 'value' => $out, 'redactions' => $redactions, ); } /** * @param mixed $value The node. * @param int $depth Current depth. * @param int $redactions Running count, by reference. * @return mixed */ private static function redact_walk( mixed $value, int $depth, int &$redactions ): mixed { if ( is_string( $value ) ) { if ( self::looks_like_secret( $value ) ) { ++$redactions; return self::REDACTED; } return $value; } if ( is_object( $value ) ) { $value = get_object_vars( $value ); } if ( ! is_array( $value ) ) { return $value; } if ( $depth >= self::REDACT_MAX_DEPTH ) { ++$redactions; return self::REDACTED; } $out = array(); foreach ( $value as $key => $child ) { if ( is_string( $key ) && self::is_credential_name( $key ) ) { ++$redactions; $out[ $key ] = self::REDACTED; continue; } $out[ $key ] = self::redact_walk( $child, $depth + 1, $redactions ); } return $out; } }