]*?[\\\\/]wp-content(?=[\\\\/])~'; /** * Denylist for file paths whose contents must NEVER be read or exported. */ public static function is_denied_file(string $path): bool { $basename = strtolower(basename($path)); if ($basename === 'wp-config.php') { return true; } if ($basename === '.env' || substr($basename, -4) === '.env') { return true; } if (preg_match('/\.env\./i', $basename)) { return true; } // SSH and private key filenames. if (in_array($basename, ['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519'], true)) { return true; } // Certificate/key stores and private key-like extensions. if (preg_match('/\.(key|pem|p12|pfx|jks|keystore)$/i', $basename)) { return true; } // Backups / dumps. if (preg_match('/\.(zip|tar|gz|sql)$/i', $basename)) { return true; } return false; } /** * Best-effort detection of secret-like patterns in a string. */ public static function redact(string $text, int $max_chars = self::MAX_SNIPPET_CHARS): string { $t = (string) $text; // Hard block for private keys. if (preg_match('/BEGIN(?:[A-Z ]+)?PRIVATE KEY/i', $t) || preg_match('/PRIVATE KEY/i', $t)) { return '[REDACTED:PRIVATE_KEY]'; } // Common key/value secrets. $patterns = [ '/(DB_PASSWORD\s*[=:]\s*)([^\s\"\']+)/i', '/(AUTH_KEY\s*[=:]\s*)([^\s\"\']+)/i', '/(SECURE_AUTH_KEY\s*[=:]\s*)([^\s\"\']+)/i', '/(LOGGED_IN_KEY\s*[=:]\s*)([^\s\"\']+)/i', '/(NONCE_KEY\s*[=:]\s*)([^\s\"\']+)/i', '/(AUTH_SALT\s*[=:]\s*)([^\s\"\']+)/i', '/(SECURE_AUTH_SALT\s*[=:]\s*)([^\s\"\']+)/i', '/(LOGGED_IN_SALT\s*[=:]\s*)([^\s\"\']+)/i', '/(NONCE_SALT\s*[=:]\s*)([^\s\"\']+)/i', '/((?:SECRET|TOKEN|API_KEY|ACCESS_KEY|PRIVATE|PASSWORD)\s*[=:]\s*)([^\s\"\']+)/i', ]; foreach ($patterns as $p) { $t = preg_replace($p, '$1[REDACTED]', $t); } // Bearer tokens. $t = preg_replace('/(Authorization:\s*Bearer\s+)([A-Za-z0-9\-\._~\+\/]+=*)/i', '$1[REDACTED]', $t); $t = preg_replace('/(Bearer\s+)([A-Za-z0-9\-\._~\+\/]+=*)/i', '$1[REDACTED]', $t); // Query string sensitive params. $t = preg_replace_callback('/([\?&])([^=\s]+)=([^&\s]+)/', static function ($m) { $k = (string) $m[2]; if (preg_match('/(token|key|secret|pass|password|auth|signature|sig|nonce)/i', $k)) { return $m[1] . $k . '=[REDACTED]'; } // Keep as-is. return $m[0]; }, $t); // Long random strings (very rough) — mask sequences that look like keys/tokens. // Skip readable snake_case/kebab-case identifiers (3+ word segments, each ≤ 20 chars). $t = preg_replace_callback('/\b([A-Za-z0-9_\-]{32,})\b/', static function ($m) { $v = (string) $m[1]; // Skip if it looks like a readable compound identifier (e.g. perf_multiple_page_cache_plugins). $segments = preg_split('/[_\-]/', $v); if (is_array($segments) && count($segments) >= 3) { $max_seg = max(array_map('strlen', $segments)); if ($max_seg <= 20) { return $v; } } return substr($v, 0, 4) . '…' . substr($v, -4) . ' [MASKED]'; }, $t); // Redact server paths (best effort) — keep UI/log previews from exposing host paths. foreach (self::known_path_roots() as $marker => $path) { $real = realpath($path); if (!is_string($real) || $real === '') { continue; } $native = rtrim($real, "\\/"); $forward = rtrim(str_replace('\\', '/', $real), '/'); $backward = rtrim(str_replace('/', '\\', $real), '\\'); $variants = array_values(array_unique(array_filter([$native, $forward, $backward]))); if (DIRECTORY_SEPARATOR === '\\') { $t = str_ireplace($variants, '<' . $marker . '>', $t); } else { $t = str_replace($variants, '<' . $marker . '>', $t); } } // 4.0.17: catch host paths that the exact-root pass misses (mixed // separators, symlinked docroots): keep only the part from wp-content. // 4.0.18: never start inside a URL (after "scheme:" or "//"); the old // pattern turned http://host/wp-content/... into htt/... $generic = preg_replace(self::GENERIC_WP_CONTENT_PATH, '', $t); if (is_string($generic)) { $t = $generic; } // Truncate. if (strlen($t) > $max_chars) { $t = substr($t, 0, $max_chars) . '…'; } return $t; } /** * @param mixed $value * @return mixed */ public static function redact_mixed($value) { if (is_string($value)) { return self::redact($value); } if (is_array($value)) { return self::redact_array($value); } return $value; } /** * Structural keys that must never be redacted — they are machine-readable IDs, * not secrets. Adding a key here means its value is passed through as-is. */ private const STRUCTURAL_SAFE_KEYS = [ 'id', 'run_id', 'finding_id', 'check_id', 'status', 'severity', 'category', 'score_impact', 'plugin_file', 'type', 'run_type', // v1.0.58 additions — content/SEO data fields. The previous broad regex // matched substrings like "auth" which masked author_name and // has_author_link as `[REDACTED]`, which broke EeatCheck/AiFriendlyCheck. 'author_name', 'has_author_link', 'author_url', 'author', 'name', 'title', 'meta_desc', 'description', 'headline', 'caption', 'og_title', 'og_description', 'og_image', 'og_type', 'twitter_title', 'twitter_card', 'twitter_description', 'h1_count', 'h2_count', 'h3_count', 'word_count', 'images_total', 'images_missing_alt', 'list_count', 'table_count', 'dl_count', 'outbound_links_count', 'lang', 'hreflang_count', 'jsonld_types', 'schema_types_detailed', 'has_breadcrumb_nav', 'has_breadcrumb_schema', 'has_publisher_schema', 'has_favicon', 'lazyload_detected', 'webp_images', 'avif_images', 'srcset_count', 'has_srcset', 'font_display_swap_detected', 'heading_hierarchy_valid', 'heading_sequence', 'text_to_html_ratio', 'nofollow_internal_count', 'last_modified', 'article_modified_time', 'article_published_time', 'normalized_url', 'canonical', 'redirect_chain', 'robots_noindex', 'x_robots_tag', 'discovered_from', 'content_type', 'html_analyzed', 'css_count', 'js_count', 'js_blocking_head', 'images_without_dimensions', // Finding/log structure fields 'occurrences', 'first_seen', 'last_seen', 'related_plugin', 'related_theme', 'function', 'line', 'file', 'message', 'seo_impact', 'rank', 'why', 'phase', 'progress_percent', 'updated_at', // Environment / infrastructure 'wp_version', 'php_version', 'memory_limit', 'server_software', 'cdn_detected', 'edge_cdn_detected', 'origin_cache_or_server_detected', 'http_protocol', 'powered_by', 'cdn_signals', 'is_eol', 'eol_date', 'years_since_eol', 'major_minor', 'version', ]; /** * Match keys that *actually* look like secret containers — not just * substrings. The previous regex matched "auth" anywhere and broke * legitimate fields like `author_name` and `has_author_link`. */ private static function key_looks_like_secret(string $key): bool { $k = strtolower($key); // Anchored matches for whole-word secret signals. $patterns = [ // Exact tokens '/^(?:.*_)?(?:password|passwd|pwd|secret|salt|nonce_secret|csrf_token|xsrf_token|jwt_token|access_token|refresh_token|client_secret|private_key|public_key|api_key|api_token|app_key|app_secret|webhook_secret|bearer_token|session_id|session_token|cookie_secret|encryption_key|hmac_key|signing_key|license_key|product_key|activation_key|recovery_key|master_key|root_password)(?:_.*)?$/', // Authorization header field '/^authorization$/', // Anything ending in _password / _secret / _token / _key (but not _key_count, etc.) '/_(password|secret|token|key)$/', ]; foreach ($patterns as $p) { if (preg_match($p, $k)) { return true; } } return false; } /** * @param array $arr * @return array */ public static function redact_array(array $arr): array { $out = []; foreach ($arr as $k => $v) { $key = is_string($k) ? $k : (string) $k; // 4.0.17: `file` stays readable but never carries a host path. // It was structural-safe, so an absolute upload path reached the // Pro JSON export untouched. if ($key === 'file' && is_string($v) && (strpos($v, '/') !== false || strpos($v, '\\') !== false)) { $out[$k] = self::redact($v); continue; } // Never redact structural machine-readable fields. if (in_array($key, self::STRUCTURAL_SAFE_KEYS, true)) { $out[$k] = $v; continue; } if (self::key_looks_like_secret($key)) { // For string values, replace with marker. For non-strings, // preserve type so booleans don't become the string '[REDACTED]' // (which is truthy and breaks downstream `if ($var)` checks). if (is_string($v)) { $out[$k] = '[REDACTED]'; } elseif (is_bool($v)) { $out[$k] = false; } elseif (is_int($v) || is_float($v)) { $out[$k] = 0; } else { $out[$k] = null; } continue; } if (is_array($v)) { $out[$k] = self::redact_array($v); } elseif (is_string($v)) { $out[$k] = self::redact($v); } else { // Booleans, ints, floats, null pass through unchanged. $out[$k] = $v; } } return $out; } /** * Backward-compatible alias used by some components. */ public static function redact_text(string $text, int $max_chars = self::MAX_SNIPPET_CHARS): string { return self::redact($text, $max_chars); } /** * Redact URLs for sharing externally: removes query strings and masks the host. */ public static function redact_url(string $url, int $max_chars = 300): string { $u = trim($url); if ($u === '') { return ''; } $parts = @wp_parse_url($u); if (!is_array($parts)) { return self::redact($u, $max_chars); } $scheme = isset($parts['scheme']) ? (string) $parts['scheme'] : ''; $host = isset($parts['host']) ? (string) $parts['host'] : ''; $path = isset($parts['path']) ? (string) $parts['path'] : ''; // Keep scheme for readability but mask host. $out = ''; if ($scheme !== '') { $out .= $scheme . '://'; } if ($host !== '') { $out .= ''; } $out .= $path !== '' ? $path : '/'; return self::redact($out, $max_chars); } /** * Redact filesystem paths for display. */ public static function redact_path(string $path): string { $p = trim($path); if ($p === '') { return ''; } $real = @realpath($p); if (is_string($real) && $real !== '') { $p = $real; } $p = str_replace('\\', '/', $p); foreach (self::known_path_roots() as $marker => $path_root) { $real_root = realpath($path_root); if (!is_string($real_root) || $real_root === '') { continue; } $real_root = rtrim(str_replace('\\', '/', $real_root), '/'); if (strpos($p, $real_root) === 0) { $rel = ltrim(substr($p, strlen($real_root)), '/'); return '<' . $marker . '>/' . $rel; } } return basename($p); } /** * @return array */ private static function known_path_roots(): array { $roots = []; if (function_exists('aetos_seo_site_root_path')) { $site_root = aetos_seo_site_root_path(); if ($site_root !== '') { $roots['SITE_ROOT'] = $site_root; } } if (function_exists('aetos_seo_plugins_root')) { $plugins_root = aetos_seo_plugins_root(); if ($plugins_root !== '') { $roots['WP_PLUGINS'] = $plugins_root; $content_root = dirname($plugins_root); if (basename($plugins_root) === 'plugins' && is_dir($content_root)) { $roots['WP_CONTENT'] = $content_root; } } } if (!isset($roots['WP_CONTENT']) && function_exists('wp_upload_dir')) { // 4.0.17: derive the content root from the uploads directory that // WordPress reports, never from a composed constant path. $uploads = wp_upload_dir(null, false); $basedir = is_array($uploads) ? (string) ($uploads['basedir'] ?? '') : ''; if ($basedir !== '' && is_dir(dirname($basedir))) { $roots['WP_CONTENT'] = dirname($basedir); } } if (!isset($roots['SITE_ROOT']) && defined('ABSPATH') && is_string(ABSPATH) && ABSPATH !== '' && ABSPATH !== '/') { $roots['SITE_ROOT'] = rtrim(ABSPATH, '/\\'); } return $roots; } }