]+[/\\\\])*([^\s/\\\\:\'"()<>]+)#'; /** The `[08-Oct-2026 10:04:59 UTC] ` prefix WordPress's debug.log and PHP's own log write. */ private const LOG_TIMESTAMP = '/^\s*\[[^\]]{0,40}\]\s*/'; /** `PHP Warning: ` and friends: the level rides as a count, so the prefix is noise in a sample. */ private const LOG_LEVEL_PREFIX = '/^PHP (?:Fatal error|Parse error|Recoverable fatal error|Warning|Notice|Deprecated|Strict Standards|Core Warning|Compile Warning)\s*:\s*/i'; private const SENSITIVE_KEY = '/(pass|secret|token|cookie|authorization|card|cvv|cvc|ssn|email|phone|address|first_name|last_name|billing_|shipping_)/i'; /** * C1 controls, zero-width characters, line/paragraph separators and bidi overrides: invisible on a screen, so a * value that carries them can hide text from a reviewer or reverse what a dashboard shows. Stripped, not replaced. */ private const INVISIBLE_CHARS = '/[\x{0080}-\x{009F}\x{200B}-\x{200F}\x{2028}\x{2029}\x{202A}-\x{202E}\x{2066}-\x{2069}]/u'; /** * Query and assignment keys whose value is a credential. Longer names first, because there is no left boundary * (`DB_PASSWORD=` and `api_key=` both have to match) and the alternation must not stop at `auth` inside * `authorization` or `key` inside `access_key`. */ private const CREDENTIAL_ASSIGNMENT = '/(password|passwd|pwd|secret|token|api[_-]?key|authorization|client_secret|refresh_token|id_token|access_key|signature|sig|_wpnonce|nonce|auth|key)(["\']?\s*[:=]\s*["\']?)[^\s"\',;&]{4,}/i'; /** * A full, compressed or IPv4-mapped IPv6 address. Every form needs at least two colons, and the caller requires * a digit so a scope-resolution like `DB::add` (hex letters either side of `::`) is left alone. */ private const IPV6 = '/(? preg_match( '/\d/', $m[0] ) ? '[hex]' : $m[0], $s ); $s = (string) preg_replace( self::CREDENTIAL_ASSIGNMENT, '$1$2[redacted]', $s ); // Before the card and phone rules, which would otherwise take the digits out of the middle of an IBAN. $s = (string) preg_replace_callback( self::IBAN, static fn( array $m ): string => strlen( $m[0] ) >= 15 && strlen( $m[0] ) <= 34 ? '[iban]' : $m[0], $s ); $rules = array( // A PEM block is the one secret that spans many "words"; it goes first so nothing below sees its inside. '/-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----/' => '[private_key]', '/-----BEGIN [A-Z ]*PRIVATE KEY-----.*$/' => '[private_key]', '/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/' => '[email]', // Percent-encoded form (once or twice), as an address would appear inside a URL or query string. Such a // string should never reach telemetry at all (ADR-020 §1); the redactor recognises it as defence in depth. '/[A-Za-z0-9._+-]+(?:%2540|%40)[A-Za-z0-9.-]+(?:\.|%2e)[A-Za-z]{2,}/i' => '[email]', // Obfuscated form: `jane (at) example (dot) com`, `jane[at]example.com`. '/[A-Za-z0-9._%+-]+\s*(?:\(at\)|\[at\]|\{at\})\s*[A-Za-z0-9.-]+\s*(?:\(dot\)|\[dot\]|\{dot\}|\.)\s*[A-Za-z]{2,}/i' => '[email]', '/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/' => '[jwt]', '#https?://hooks\.slack\.com/services/[A-Za-z0-9/_-]+#i' => 'hooks.slack.com/[redacted]', '/\bxox[abprs]-[A-Za-z0-9-]{10,}/' => '[slack_token]', '/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/' => '[github_token]', '/\bgithub_pat_[A-Za-z0-9_]{20,}\b/' => '[github_token]', '/\bAIza[0-9A-Za-z_-]{35}\b/' => '[google_key]', '/\b(?:\d[ -]?){13,19}\b/' => '[card]', '/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{8,}\b/' => '[api_key]', '/\bwhsec_[A-Za-z0-9]{8,}\b/' => '[webhook_secret]', '/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/' => '[aws_key]', // An IPv4-mapped or -embedded IPv6 address (`::ffff:192.0.2.1`), whole, before the IPv4 rule takes its tail. '/(? '[ip]', '/\b(?:\d{1,3}\.){3}\d{1,3}\b/' => '[ip]', '/\+?\d[\d\s().-]{8,}\d/' => '[phone]', ); foreach ( $rules as $re => $rep ) { $s = (string) preg_replace( $re, $rep, $s ); } $s = (string) preg_replace_callback( self::IPV6, static fn( array $m ): string => preg_match( '/\d/', $m[0] ) ? '[ip]' : $m[0], $s ); // Character-based like redaction.ts (`s.length` / `slice`); a byte-based cut could split UTF-8. if ( mb_strlen( $s, 'UTF-8' ) > $max ) { $s = mb_substr( $s, 0, $max - 1, 'UTF-8' ) . '…'; } return $s; } /** * Reduces every absolute path to its basename. A path is a username, a hosting layout and sometimes a site name * (`/var/www/vhosts/shop.example/`), which invariant 6 excludes; the basename is what names the plugin and the * file. Mirrors `redactPaths()` in redaction.ts; both sides run `log-line-cases.json`. */ public static function paths( string $input ): string { return (string) preg_replace( self::ABS_PATH, '$1', $input ); } /** * One line of a PHP error log as it may leave the site (ADR-047 §3): timestamp and level prefix dropped, paths * reduced to basenames, then the ordinary redactor, then the sample cap. Mirrors `redactLogLine()`. */ public static function log_line( string $line, int $max = self::MAX_LOG_SAMPLE_LENGTH ): string { $s = (string) preg_replace( self::LOG_TIMESTAMP, '', $line ); $s = (string) preg_replace( self::LOG_LEVEL_PREFIX, '', $s ); return self::text( self::paths( $s ), $max ); } /** * An exception or fatal-error message as it may be stored or leave the site. PHP's uncaught-exception message * carries the whole stack trace after `Stack trace:` — every frame a path and often an argument value — so the * message is cut there first, then paths reduced to basenames, then the ordinary redactor. PHP-side only: the * cloud never sees a trace to cut. */ public static function error_message( string $message, int $max = self::MAX_LENGTH ): string { $cut = stripos( $message, 'Stack trace:' ); if ( $cut !== false ) { $message = substr( $message, 0, $cut ); } return self::text( self::paths( rtrim( $message ) ), $max ); } /** * @param array $attrs * @return array */ public static function attributes( array $attrs ): array { $out = array(); foreach ( $attrs as $k => $v ) { if ( preg_match( self::SENSITIVE_KEY, (string) $k ) ) { continue; } if ( is_string( $v ) ) { $out[ $k ] = self::text( $v ); } elseif ( is_array( $v ) ) { $out[ $k ] = array_values( array_map( static fn( mixed $x ): mixed => is_string( $x ) ? self::text( $x ) : ( is_scalar( $x ) || $x === null ? $x : self::text( $x ) ), array_slice( $v, 0, 50 ) ) ); } elseif ( is_scalar( $v ) || $v === null ) { $out[ $k ] = $v; } else { $out[ $k ] = self::text( $v ); } } return $out; } }