50, 'postmeta' => 100, 'comments' => 100, 'options' => 100, 'usermeta' => 100, 'users' => 100, ); /** * Hosts whose scripts and iframes are ordinary on a WordPress site, matched as the host * itself or any subdomain of it. Bare hostnames on purpose: nothing here is fetched, these * are only compared against what is found. Extend with the csdt_db_malware_allowed_hosts * filter rather than editing this list. */ const ALLOWED_HOSTS = array( // Video and audio embeds. 'youtube.com', 'youtube-nocookie.com', 'youtu.be', 'ytimg.com', 'vimeo.com', 'spotify.com', 'soundcloud.com', // Analytics, Tag Manager and AdSense (this site runs AdSense). 'googletagmanager.com', 'google-analytics.com', 'googlesyndication.com', 'googleadservices.com', 'doubleclick.net', 'adtrafficquality.google', 'google.com', 'recaptcha.net', 'cloudflareinsights.com', // Code and social embeds. 'github.com', 'codepen.io', 'jsfiddle.net', 'twitter.com', 'x.com', 'instagram.com', 'facebook.net', 'facebook.com', 'linkedin.com', 'tiktok.com', 'pinterest.com', 'reddit.com', 'disqus.com', // WordPress.com and Jetpack services. 'wp.com', 'wordpress.com', 'gravatar.com', ); /** Option name prefixes that are caches or this plugin's own reports, never scanned. */ const SKIP_OPTION_PREFIXES = array( '_transient_timeout_', '_site_transient_timeout_', '_transient_feed_', '_site_transient_feed_', '_transient_rss_', '_site_transient_update_', '_site_transient_browser_', '_site_transient_php_check_', '_site_transient_theme_roots', '_transient_dirsize_cache', '_transient_wp_remote_block_patterns_', '_site_transient_wp_remote_block_patterns_', // This plugin's own scan reports quote the payloads they found. 'csdt_', ); /** Options checked separately (cron) or derived data rebuilt on demand (rewrite rules). */ const SKIP_OPTION_NAMES = array( 'cron', 'rewrite_rules' ); /** Meta keys that are caches, bookkeeping, or this plugin's own records. Prefix match. */ const SKIP_META_PREFIXES = array( '_wp_attachment_metadata', '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date', '_thumbnail_id', '_oembed_', 'session_tokens', 'community-events-location', 'csdt_', '_csdt_', ); /** Per-request cache of the site's own registrable domains. */ private static $own_bases = null; /** Per-request cache of the allowed host list after the filter. */ private static $allowed = null; /** Regex evaluations that errored (backtrack limit, bad UTF-8) in the current step. */ private static $pcre_errors = 0; // ── Signatures ─────────────────────────────────────────────────────────── /** * Every signature this scan applies, in one place. * * kind regex: `pattern` is matched against `target`; special: a method decides. * target script: script bodies, on* handlers and javascript: URLs only. * markup: the field with quoted code removed. * sources where it applies: posts, comments, widgets, options, postmeta, usermeta, * or the special phases users, cron, siteurl. * * Severity: critical (no innocent reading), high (almost always injected), medium (worth * a look, has legitimate uses), info (shown, never counted as needing attention). * * @return array> */ public static function signatures(): array { $all_text = array( 'posts', 'comments', 'widgets', 'options', 'postmeta', 'usermeta' ); $stored = array( 'widgets', 'options', 'postmeta', 'usermeta' ); return array( // ── Script loading and obfuscation ───────────────────────────────── 'script-external-src' => array( 'kind' => 'special', 'sources' => $all_text, 'sev' => 'high', 'desc' => __( 'Script loaded from a host that is not this site or a common embed provider', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-79; MITRE ATT&CK T1059.007', ), 'script-data-uri' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => $all_text, 'sev' => 'high', 'pattern' => '#<\s*script\b[^>]{0,300}\bsrc\s*=\s*["\']?\s*data:#i', 'desc' => __( 'Script whose source is an inline data: URI, a way to hide the code', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-506', ), 'js-eval-decoder' => array( 'kind' => 'regex', 'target' => 'script', 'sources' => $all_text, 'sev' => 'critical', // eval wrapped round a decoder (atob, unescape, fromCharCode) or the p,a,c,k,e,d packer. 'pattern' => '#\beval\s*\(\s*(?:window\s*\.\s*)?(?:atob|unescape|decodeURIComponent|String\s*\.\s*fromCharCode|function\s*\(\s*p\s*,\s*a\s*,\s*c\s*,\s*k\s*,\s*e)\b#i', 'desc' => __( 'JavaScript that decodes a hidden string and runs it', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-506; MITRE ATT&CK T1027', ), 'js-fromcharcode-run' => array( 'kind' => 'regex', 'target' => 'script', 'sources' => $all_text, 'sev' => 'high', // Sixteen or more character codes in one call: text spelled out to dodge a search. 'pattern' => '#String\s*\.\s*fromCharCode\s*\(\s*\d{1,3}(?:\s*,\s*\d{1,3}){15,}#i', 'desc' => __( 'Long String.fromCharCode run, text spelled as numbers to avoid detection', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1027', ), 'js-unescape-blob' => array( 'kind' => 'regex', 'target' => 'script', 'sources' => $all_text, 'sev' => 'high', 'pattern' => '#\bunescape\s*\(\s*["\'](?:%[0-9a-f]{2}){20,}#i', 'desc' => __( 'unescape() of a long percent-encoded string', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1027', ), 'js-docwrite-script' => array( 'kind' => 'regex', 'target' => 'script', 'sources' => $all_text, 'sev' => 'high', // document.write of a decoded string, or of markup that opens a script or iframe. 'pattern' => '#document\s*\.\s*write(?:ln)?\s*\(\s*(?:(?:unescape|atob|decodeURIComponent|String\s*\.\s*fromCharCode)\s*\(|[^)]{0,300}<\s*[\'"+\s]*(?:scr|iframe))#i', 'desc' => __( 'document.write that injects a script or iframe', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-79', ), 'js-hex-run' => array( 'kind' => 'regex', 'target' => 'script', 'sources' => $all_text, 'sev' => 'medium', 'pattern' => '#(?:\\\\x[0-9a-f]{2}){24,}#i', 'desc' => __( 'Long run of \\x hex escapes inside JavaScript', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1027', ), 'js-obfuscator-io' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => $all_text, 'sev' => 'high', // The _0x1a2b variable naming of the javascript-obfuscator tool, as used by injection kits. 'pattern' => '#\bvar\s+_0x[0-9a-f]{4,}\s*=|\b_0x[0-9a-f]{4,}\s*\[\s*(?:0x[0-9a-f]+|\d+)\s*\]#i', 'desc' => __( 'Machine-obfuscated JavaScript (_0x variable naming)', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1027', ), 'marker-md5-comment' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => $all_text, 'sev' => 'critical', // A comment holding exactly 32 hex characters, the campaign marker injection kits // leave so they do not infect the same field twice. 'pattern' => '#/\*[0-9a-f]{32}\*/#i', 'desc' => __( 'Injection campaign marker: a comment holding a 32-character hash', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-506', ), 'marker-ndsw' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => $all_text, 'sev' => 'critical', 'pattern' => '#\bvar\s+nds[wx]\s*=|\bnds[wx]\s*===?\s*undefined#i', 'desc' => __( 'NDSW/NDSX injection loader', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-506', ), // ── Hidden content and redirects ──────────────────────────────────── 'iframe-hidden' => array( 'kind' => 'special', 'sources' => $all_text, 'sev' => 'high', 'desc' => __( 'Hidden or 1-pixel iframe from another host', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-1021', ), 'spam-hidden-links' => array( 'kind' => 'special', 'sources' => $all_text, 'sev' => 'high', 'desc' => __( 'Block hidden from visitors that holds several links to other sites (SEO spam)', 'cloudscale-cyber-devtools' ), 'ref' => 'Google Search spam policies: hidden text and links', ), 'spam-keywords' => array( 'kind' => 'special', 'sources' => $all_text, 'sev' => 'medium', 'desc' => __( 'Links naming three or more pharma, casino or similar spam terms', 'cloudscale-cyber-devtools' ), 'ref' => 'Google Search spam policies: link spam', ), 'redirect-js-external' => array( 'kind' => 'special', 'sources' => $all_text, 'sev' => 'high', 'desc' => __( 'JavaScript that sends the visitor to another site', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-601', ), 'redirect-meta-refresh' => array( 'kind' => 'special', 'sources' => $all_text, 'sev' => 'high', 'desc' => __( 'Meta refresh that sends the visitor to another site', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-601', ), // ── PHP stored in the database ───────────────────────────────────── 'php-open-tag' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => $stored, 'sev' => 'medium', 'pattern' => '#<\?php\b#i', 'desc' => __( 'PHP code stored in the database (normal only for code-snippet plugins)', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-94', ), 'php-eval-payload' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => $stored, 'sev' => 'critical', 'pattern' => '#(?:.])eval\s*\(\s*(?:base64_decode|gzinflate|gzuncompress|gzdecode|str_rot13|strrev|rawurldecode|\$_(?:POST|GET|REQUEST|COOKIE|SERVER))\b#i', 'desc' => __( 'PHP eval of decoded or request-supplied code', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-95', ), 'php-decode-chain' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => $stored, 'sev' => 'high', 'pattern' => '#\b(?:gzinflate|gzuncompress|gzdecode|str_rot13)\s*\(\s*(?:base64_decode|str_rot13|gzinflate)\s*\(#i', 'desc' => __( 'PHP decode chain (gzinflate of base64 and similar)', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1027', ), 'php-base64-blob' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => $stored, 'sev' => 'high', 'pattern' => '#\bbase64_decode\s*\(\s*["\'][A-Za-z0-9+/=]{60,}#i', 'desc' => __( 'PHP base64_decode of a long embedded string', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1027', ), 'php-eval-call' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => array( 'options' ), 'sev' => 'medium', 'pattern' => '#(?:.])(?:eval|assert|create_function)\s*\(#i', 'desc' => __( 'Code-execution call (eval, assert or create_function) stored in an option', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-95', ), 'php-base64-call' => array( 'kind' => 'regex', 'target' => 'markup', 'sources' => array( 'options' ), 'sev' => 'medium', 'pattern' => '#(?:.])(?:base64_decode|gzinflate)\s*\(#i', 'desc' => __( 'Decoder call (base64_decode or gzinflate) stored in an option', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1027', ), // ── Special phases ───────────────────────────────────────────────── 'cron-unknown-eval' => array( 'kind' => 'special', 'sources' => array( 'cron' ), 'sev' => 'high', 'desc' => __( 'Scheduled task with a code-execution name and no registered handler', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1053', ), 'cron-random-hook' => array( 'kind' => 'special', 'sources' => array( 'cron' ), 'sev' => 'medium', 'desc' => __( 'Scheduled task with a random-looking name and no registered handler', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1053', ), 'cron-payload-args' => array( 'kind' => 'special', 'sources' => array( 'cron' ), 'sev' => 'high', 'desc' => __( 'Scheduled task whose arguments carry encoded PHP', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1053', ), 'user-recent-admin' => array( 'kind' => 'special', 'sources' => array( 'users' ), 'sev' => 'medium', 'desc' => __( 'Administrator account created recently: confirm someone you trust made it', 'cloudscale-cyber-devtools' ), 'ref' => 'MITRE ATT&CK T1136', ), 'user-markup-name' => array( 'kind' => 'special', 'sources' => array( 'users' ), 'sev' => 'high', 'desc' => __( 'User name or website field holding markup or a javascript: URL', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-79', ), 'siteurl-domains-differ' => array( 'kind' => 'special', 'sources' => array( 'siteurl' ), 'sev' => 'critical', 'desc' => __( 'Site address and WordPress address point at different domains', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-601', ), 'siteurl-constant-mismatch' => array( 'kind' => 'special', 'sources' => array( 'siteurl' ), 'sev' => 'high', 'desc' => __( 'Stored address differs from the one set in wp-config.php', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-601', ), 'siteurl-request-host' => array( 'kind' => 'special', 'sources' => array( 'siteurl' ), 'sev' => 'medium', 'desc' => __( 'Stored address is on a different domain from the one this admin page was opened on', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-601', ), 'live-tag-in-code' => array( 'kind' => 'special', 'sources' => $all_text, 'sev' => 'info', 'desc' => __( 'Live tag inside a code region: shown as quoted text, but a browser still runs it', 'cloudscale-cyber-devtools' ), 'ref' => 'CWE-79', ), ); } /** Severity rank, highest first. */ const SEVERITIES = array( 'critical', 'high', 'medium', 'info' ); // ── Host helpers ───────────────────────────────────────────────────────── /** * The registrable part of a host: example.com from www.blog.example.com, and * example.co.uk from shop.example.co.uk. An approximation of the public suffix list, * which is all an own-site comparison needs. * * @param string $host Hostname. * @return string */ public static function base_domain( string $host ): string { $host = strtolower( trim( $host, ". \t" ) ); $labels = explode( '.', $host ); $n = count( $labels ); if ( $n <= 2 ) { return $host; } $keep = ( strlen( $labels[ $n - 1 ] ) === 2 && strlen( $labels[ $n - 2 ] ) <= 3 ) ? 3 : 2; return implode( '.', array_slice( $labels, -$keep ) ); } /** * Host of a URL found in content, lower case. '' for a relative URL, which is this site. * * @param string $url URL as written in the field. * @return string */ public static function host_of( string $url ): string { $url = trim( html_entity_decode( $url, ENT_QUOTES ), " \t\n\r\"'\\" ); if ( 0 === strpos( $url, '//' ) ) { $url = 'https:' . $url; } if ( ! preg_match( '#^[a-z][a-z0-9+.-]*://#i', $url ) ) { return ''; } $host = wp_parse_url( $url, PHP_URL_HOST ); return is_string( $host ) ? strtolower( $host ) : ''; } /** Clears the per-request host caches. For the tests, which change home_url between cases. */ public static function reset_caches(): void { self::$own_bases = null; self::$allowed = null; } /** * Whether a host is this site, or a subdomain of the same registrable domain. * * @param string $host Hostname, '' meaning relative. * @return bool */ public static function is_own_host( string $host ): bool { if ( '' === $host ) { return true; } if ( null === self::$own_bases ) { self::$own_bases = array(); foreach ( array( home_url( '/' ), site_url( '/' ) ) as $u ) { $h = wp_parse_url( (string) $u, PHP_URL_HOST ); if ( is_string( $h ) && '' !== $h ) { self::$own_bases[ self::base_domain( $h ) ] = true; } } } return isset( self::$own_bases[ self::base_domain( $host ) ] ); } /** * Whether a host is on the allow list, as itself or as a subdomain of an entry. * * @param string $host Hostname. * @return bool */ public static function is_allowed_host( string $host ): bool { if ( null === self::$allowed ) { $list = apply_filters( 'csdt_db_malware_allowed_hosts', self::ALLOWED_HOSTS ); self::$allowed = array_values( array_filter( array_map( 'strtolower', array_map( 'strval', (array) $list ) ) ) ); } $host = strtolower( $host ); foreach ( self::$allowed as $a ) { if ( $host === $a || str_ends_with( $host, '.' . $a ) ) { return true; } } return false; } /** * A host that is neither this site nor an allowed embed provider. * * @param string $host Hostname. * @return bool */ public static function is_foreign_host( string $host ): bool { return '' !== $host && ! self::is_own_host( $host ) && ! self::is_allowed_host( $host ); } // ── Text preparation ───────────────────────────────────────────────────── /** * Separates quoted code from the rest of a field. * * Quoted regions: code-type block comments (self-closing or paired), pre and code * elements, and the [code]/[sourcecode] shortcodes. A region is only treated as quoted * when it is CLOSED: an opener with no closer is left in the scanned text, so a stray * unclosed code tag cannot hide everything after it. * * @param string $text Field value. * @return array{0:string,1:array} [ text with quoted regions blanked, quoted regions ] */ public static function split_quoted( string $text ): array { $open = '#". Block // attributes cannot contain "-->": core escapes "--" inside them. $close = strpos( $text, '-->', $start ); if ( false !== $close ) { if ( '/' === substr( $text, $close - 1, 1 ) ) { $end = $close + 3; } elseif ( preg_match( '##i', $text, $c, PREG_OFFSET_CAPTURE, $close ) ) { $end = (int) $c[0][1] + strlen( $c[0][0] ); } } } elseif ( isset( $m[2] ) && '' !== $m[2][0] && $m[2][1] >= 0 ) { $closer = stripos( $text, '', $closer ); $end = false === $gt ? -1 : $gt + 1; } } elseif ( isset( $m[3] ) && '' !== $m[3][0] ) { $closer = stripos( $text, '[/' . $m[3][0] . ']', $start ); if ( false !== $closer ) { $end = $closer + strlen( $m[3][0] ) + 3; } } if ( $end <= $start ) { // Unclosed: not quoted. Keep the opener in the scanned text and move on. $step = strlen( $m[0][0] ); $out .= substr( $text, $pos, $start + $step - $pos ); $pos = $start + $step; continue; } $out .= substr( $text, $pos, $start - $pos ) . ' '; $quoted[] = substr( $text, $start, $end - $start ); $pos = $end; } $out .= (string) substr( $text, $pos ); return array( $out, $quoted ); } /** * Undoes the JSON escaping page builders and block attributes apply, so markup stored * as JSON (src=\"https:\/\/...\") reads the same as markup stored as HTML. * * Applied AFTER split_quoted(), so a code block's escaped attribute is already gone and * cannot be turned back into a live-looking tag here. * * @param string $text Text. * @return string */ public static function unescape_json( string $text ): string { if ( false === strpos( $text, '\\' ) ) { return $text; } return str_ireplace( array( '\\/', '\\"', '\\u003c', '\\u003e', '\\u0022', '\\u0026' ), array( '/', '"', '<', '>', '"', '&' ), $text ); } /** * The parts of markup a browser would run as JavaScript: script element bodies, on* * handler attributes, and javascript: URLs. Joined with newlines. * * @param string $markup Markup with quoted regions removed. * @return string */ public static function script_segments( string $markup ): string { $segs = array(); if ( preg_match_all( '#<\s*script\b[^>]{0,1000}>(.*?)(?:<\s*/\s*script\s*>|$)#is', $markup, $m ) ) { $segs = array_merge( $segs, $m[1] ); } if ( preg_match_all( '#\son[a-z]{3,30}\s*=\s*(?:"([^"]{0,8000})"|\'([^\']{0,8000})\')#i', $markup, $m ) ) { foreach ( $m[1] as $i => $dq ) { $segs[] = html_entity_decode( '' !== $dq ? $dq : $m[2][ $i ], ENT_QUOTES ); } } if ( preg_match_all( '#\b(?:href|src|action|formaction)\s*=\s*["\']?\s*javascript:([^"\'>]{0,8000})#i', $markup, $m ) ) { foreach ( $m[1] as $js ) { $segs[] = rawurldecode( html_entity_decode( $js, ENT_QUOTES ) ); } } return implode( "\n", $segs ); } /** * A short, single-line excerpt around a match. Stored raw, escaped on output. * * @param string $subject Text matched. * @param int $offset Byte offset of the match. * @return string */ public static function excerpt( string $subject, int $offset ): string { $start = max( 0, $offset - 40 ); $piece = function_exists( 'mb_strcut' ) ? mb_strcut( $subject, $start, self::EXCERPT_CHARS, 'UTF-8' ) : substr( $subject, $start, self::EXCERPT_CHARS ); if ( function_exists( 'mb_scrub' ) ) { $piece = mb_scrub( $piece, 'UTF-8' ); } $piece = (string) preg_replace( '/[\x00-\x1F\x7F\s]+/u', ' ', $piece ); return trim( ( $start > 0 ? '...' : '' ) . $piece . ( strlen( $subject ) > $start + self::EXCERPT_CHARS ? '...' : '' ) ); } /** * preg_match that counts an engine error instead of reading it as "no match". * * @param string $pattern Pattern. * @param string $subject Subject. * @param array $m Matches, with offsets. * @return bool */ private static function match( string $pattern, string $subject, &$m ): bool { $r = preg_match( $pattern, $subject, $m, PREG_OFFSET_CAPTURE ); if ( false === $r ) { ++self::$pcre_errors; return false; } return 1 === $r; } // ── Field scanning ─────────────────────────────────────────────────────── /** * Every signature hit in one field value. * * @param string $value Field value. * @param string $source One of posts, comments, widgets, options, postmeta, usermeta. * @return array */ public static function scan_text( string $value, string $source ): array { if ( '' === trim( $value ) ) { return array(); } $hits = array(); $sigs = self::signatures(); list( $visible, $quoted ) = self::split_quoted( $value ); $visible = self::unescape_json( $visible ); $script = self::script_segments( $visible ); foreach ( $sigs as $id => $sig ) { if ( 'regex' !== $sig['kind'] || ! in_array( $source, $sig['sources'], true ) ) { continue; } $subject = 'script' === $sig['target'] ? $script : $visible; if ( '' === $subject ) { continue; } if ( self::match( $sig['pattern'], $subject, $m ) ) { $hits[] = self::hit( $id, $sig['sev'], self::excerpt( $subject, (int) $m[0][1] ) ); } } $hits = array_merge( $hits, self::tag_hits( $visible, $source ), self::js_redirect_hits( $script ), self::spam_hits( $visible ) ); // Quoted code: no text signature applies, but a LIVE tag in a pre or code element // is still run by the browser, so it is shown, at info level only. foreach ( $quoted as $q ) { // A self-closing block comment is attributes only. A dynamic block renders those // through its own escaping, so nothing in them is live, and they are left // JSON-escaped here so a quoted tag cannot be turned back into one. if ( 0 === strpos( $q, '' === substr( rtrim( $q ), -4 ) ) { continue; } foreach ( self::tag_hits( $q, $source ) as $h ) { $hits[] = self::hit( 'live-tag-in-code', 'info', $h['excerpt'], $h['sig'] ); } if ( self::match( $sigs['script-data-uri']['pattern'], $q, $m ) ) { $hits[] = self::hit( 'live-tag-in-code', 'info', self::excerpt( $q, (int) $m[0][1] ), 'script-data-uri' ); } } // One hit per signature per field: the excerpt shows the first. $seen = array(); $out = array(); foreach ( $hits as $h ) { $k = $h['sig'] . '|' . $h['note']; if ( ! isset( $seen[ $k ] ) ) { $seen[ $k ] = true; $out[] = $h; } } return $out; } /** * Builds one hit. * * @param string $sig Signature id. * @param string $sev Severity. * @param string $excerpt Excerpt. * @param string $note Extra detail (a host, a signature a quoted hit stands for). * @return array{sig:string,sev:string,excerpt:string,note:string} */ private static function hit( string $sig, string $sev, string $excerpt, string $note = '' ): array { return array( 'sig' => $sig, 'sev' => $sev, 'excerpt' => $excerpt, 'note' => $note, ); } /** * Script tags from foreign hosts, hidden foreign iframes, and foreign meta refreshes. * * @param string $markup Markup. * @param string $source Source (unused today; kept so every hit function has one shape). * @return array */ public static function tag_hits( string $markup, string $source = '' ): array { $hits = array(); unset( $source ); if ( preg_match_all( '#<\s*script\b([^>]{0,1000})>#i', $markup, $m, PREG_OFFSET_CAPTURE ) ) { foreach ( $m[1] as $i => $attrs ) { if ( ! preg_match( '#\bsrc\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $attrs[0], $s ) ) { continue; } $url = (string) ( $s[1] ?? '' ) . (string) ( $s[2] ?? '' ) . (string) ( $s[3] ?? '' ); $host = self::host_of( $url ); if ( self::is_foreign_host( $host ) ) { $hits[] = self::hit( 'script-external-src', 'high', self::excerpt( $markup, (int) $m[0][ $i ][1] ), $host ); } } } if ( preg_match_all( '#<\s*iframe\b([^>]{0,2000})>#i', $markup, $m, PREG_OFFSET_CAPTURE ) ) { foreach ( $m[1] as $i => $attrs ) { $a = $attrs[0]; $hidden = preg_match( '#(?:^|\s)(?:width|height)\s*=\s*["\']?\s*[01](?:px)?\s*(?:["\'\s/]|$)#i', $a ) || preg_match( '#style\s*=\s*["\'][^"\']*(?:display\s*:\s*none|visibility\s*:\s*hidden|(?:width|height)\s*:\s*[01]px|opacity\s*:\s*0(?:\.0+)?\s*(?:;|$|["\']))#i', $a ); if ( ! $hidden ) { continue; } $host = preg_match( '#\bsrc\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $a, $s ) ? self::host_of( (string) ( $s[1] ?? '' ) . (string) ( $s[2] ?? '' ) . (string) ( $s[3] ?? '' ) ) : ''; if ( self::is_foreign_host( $host ) ) { $hits[] = self::hit( 'iframe-hidden', 'high', self::excerpt( $markup, (int) $m[0][ $i ][1] ), $host ); } } } if ( preg_match_all( '#<\s*meta\b[^>]{0,500}http-equiv\s*=\s*["\']?refresh[^>]{0,500}>#i', $markup, $m, PREG_OFFSET_CAPTURE ) ) { foreach ( $m[0] as $tag ) { if ( preg_match( '#url\s*=\s*([^"\'\s>;]+)#i', $tag[0], $u ) ) { $host = self::host_of( $u[1] ); if ( self::is_foreign_host( $host ) ) { $hits[] = self::hit( 'redirect-meta-refresh', 'high', self::excerpt( $markup, (int) $tag[1] ), $host ); } } } } return $hits; } /** * location = / location.replace() / location.assign() to a foreign host, inside script. * * @param string $script Script segments. * @return array */ public static function js_redirect_hits( string $script ): array { if ( '' === $script ) { return array(); } $hits = array(); $re = '#\blocation(?:\s*\.\s*href)?\s*=\s*["\']((?:https?:)?//[^"\'\s]{1,300})|\blocation\s*\.\s*(?:replace|assign)\s*\(\s*["\']((?:https?:)?//[^"\'\s]{1,300})#i'; if ( preg_match_all( $re, $script, $m, PREG_OFFSET_CAPTURE ) ) { foreach ( $m[0] as $i => $whole ) { $url = ( isset( $m[1][ $i ] ) && $m[1][ $i ][1] >= 0 ) ? $m[1][ $i ][0] : (string) ( $m[2][ $i ][0] ?? '' ); $host = self::host_of( $url ); if ( self::is_foreign_host( $host ) ) { $hits[] = self::hit( 'redirect-js-external', 'high', self::excerpt( $script, (int) $whole[1] ), $host ); } } } return $hits; } /** * SEO spam: a hidden block carrying three or more foreign links, and links whose text or * address names three or more distinct spam terms. * * @param string $markup Markup. * @return array */ public static function spam_hits( string $markup ): array { $hits = array(); $link = '#<\s*a\b[^>]{0,500}?href\s*=\s*["\']?((?:https?:)?//[^"\'\s>]+)#i'; $hidden = '#<\s*(div|span|p|section|ul|font|aside)\b[^>]{0,500}?style\s*=\s*["\'][^"\']{0,500}?(?:display\s*:\s*none|visibility\s*:\s*hidden|(?:left|top|text-indent)\s*:\s*-\d{3,}px|font-size\s*:\s*0(?:px)?\s*(?:;|["\']))#i'; if ( preg_match_all( $hidden, $markup, $m, PREG_OFFSET_CAPTURE ) ) { foreach ( $m[0] as $bi => $blk ) { // Only the hidden element itself: links after its close are visible ones. $window = self::element_extent( $markup, (int) $blk[1], strtolower( $m[1][ $bi ][0] ) ); $foreign = 0; if ( preg_match_all( $link, $window, $lm ) ) { foreach ( $lm[1] as $u ) { if ( self::is_foreign_host( self::host_of( $u ) ) ) { ++$foreign; } } } if ( $foreign >= 3 ) { $hits[] = self::hit( 'spam-hidden-links', 'high', self::excerpt( $markup, (int) $blk[1] ), (string) $foreign ); break; } } } $terms = '#\b(viagra|cialis|levitra|kamagra|tramadol|xanax|phentermine|oxycodone|payday\s+loans?|online\s+casino|casino\s+online|slot\s+gacor|judi\s+(?:online|slot|bola)|togel|situs\s+slot|replica\s+watch(?:es)?|essay\s+writing\s+service|buy\s+followers|porn|escort)\b#i'; if ( preg_match_all( '#<\s*a\b([^>]{0,500})>(.{0,500}?)<\s*/\s*a\s*>#is', $markup, $am, PREG_OFFSET_CAPTURE ) ) { $found = array(); $first = -1; foreach ( $am[0] as $i => $anchor ) { $text = $am[1][ $i ][0] . ' ' . wp_strip_all_tags( $am[2][ $i ][0] ); if ( preg_match_all( $terms, $text, $tm ) ) { foreach ( $tm[1] as $t ) { $found[ (string) preg_replace( '/\s+/', ' ', strtolower( $t ) ) ] = true; } if ( $first < 0 ) { $first = (int) $anchor[1]; } } } if ( count( $found ) >= 3 ) { $hits[] = self::hit( 'spam-keywords', 'medium', self::excerpt( $markup, max( 0, $first ) ), implode( ', ', array_slice( array_keys( $found ), 0, 6 ) ) ); } } return $hits; } /** * The markup of one element, from its opening tag to the matching close, counting nested * elements of the same name. An element that never closes yields its first 2000 bytes, so * an unclosed hidden div cannot claim every link on the page. * * @param string $markup Markup. * @param int $start Offset of the opening tag. * @param string $tag Element name. * @return string */ public static function element_extent( string $markup, int $start, string $tag ): string { $scan = substr( $markup, $start, 20000 ); if ( preg_match_all( '#<\s*(/?)\s*' . preg_quote( $tag, '#' ) . '\b#i', $scan, $tm, PREG_OFFSET_CAPTURE ) ) { $depth = 0; foreach ( $tm[1] as $i => $slash ) { $depth += '' === $slash[0] ? 1 : -1; if ( 0 === $depth ) { return substr( $scan, 0, (int) $tm[0][ $i ][1] ); } } } return substr( $scan, 0, 2000 ); } // ── Special phases ─────────────────────────────────────────────────────── /** * Cron entries that look planted: an eval-like or random hook name with nothing * registered to run it, or arguments carrying encoded PHP. * * @param mixed $cron The cron option. * @param callable $has_action Returns whether a hook has a handler (has_action in production). * @return array{count:int,hits:array} */ public static function scan_cron( $cron, callable $has_action ): array { $hits = array(); $count = 0; if ( ! is_array( $cron ) ) { return array( 'count' => 0, 'hits' => array(), ); } $seen = array(); $sigs = self::signatures(); $evals = '#(?:eval|base64|assert|gzinflate|gzuncompress|str_rot13|create_function|shell_exec|passthru)#i'; foreach ( $cron as $ts => $hooks ) { if ( ! is_array( $hooks ) ) { continue; } foreach ( $hooks as $hook => $events ) { ++$count; $hook = (string) $hook; if ( isset( $seen[ $hook ] ) ) { continue; } $known = (bool) call_user_func( $has_action, $hook ); if ( ! $known && preg_match( $evals, $hook ) ) { $hits[] = self::hit( 'cron-unknown-eval', 'high', $hook, (string) $ts ); $seen[ $hook ] = true; } elseif ( ! $known && preg_match( '#^[a-z0-9]{16,}$#i', $hook ) && preg_match( '#\d#', $hook ) && preg_match( '#[a-z]#i', $hook ) ) { $hits[] = self::hit( 'cron-random-hook', 'medium', $hook, (string) $ts ); $seen[ $hook ] = true; } $args = wp_json_encode( $events ); if ( is_string( $args ) && ! isset( $seen[ $hook . '#args' ] ) ) { $args = self::unescape_json( $args ); foreach ( array( 'php-eval-payload', 'php-decode-chain', 'php-base64-blob' ) as $sid ) { if ( self::match( $sigs[ $sid ]['pattern'], $args, $m ) ) { $hits[] = self::hit( 'cron-payload-args', 'high', $hook . ': ' . self::excerpt( $args, (int) $m[0][1] ), $sid ); $seen[ $hook . '#args' ] = true; break; } } } } } return array( 'count' => $count, 'hits' => $hits, ); } /** * Site address hijack checks. * * @param string $home Stored home option, read raw from the table. * @param string $siteurl Stored siteurl option, read raw from the table. * @param string $const_home WP_HOME if defined, else ''. * @param string $const_site WP_SITEURL if defined, else ''. * @param string $request_host Host this admin request arrived on, '' if unknown. * @return array */ public static function check_site_urls( string $home, string $siteurl, string $const_home, string $const_site, string $request_host ): array { $hits = array(); $hhost = self::host_of( $home ); $shost = self::host_of( $siteurl ); if ( '' !== $hhost && '' !== $shost && self::base_domain( $hhost ) !== self::base_domain( $shost ) ) { $hits[] = self::hit( 'siteurl-domains-differ', 'critical', 'home: ' . $home . ' | siteurl: ' . $siteurl ); } foreach ( array( array( 'home', $home, $const_home ), array( 'siteurl', $siteurl, $const_site ) ) as $pair ) { if ( '' !== $pair[2] && untrailingslashit( strtolower( $pair[1] ) ) !== untrailingslashit( strtolower( $pair[2] ) ) ) { $hits[] = self::hit( 'siteurl-constant-mismatch', 'high', $pair[0] . ': ' . $pair[1] . ' | wp-config.php: ' . $pair[2], $pair[0] ); } } $req = strtolower( (string) preg_replace( '/:\d+$/', '', $request_host ) ); $is_local = '' === $req || 'localhost' === $req || filter_var( $req, FILTER_VALIDATE_IP ); if ( ! $is_local ) { foreach ( array( 'home' => $hhost, 'siteurl' => $shost ) as $name => $h ) { if ( '' !== $h && self::base_domain( $h ) !== self::base_domain( $req ) ) { $hits[] = self::hit( 'siteurl-request-host', 'medium', $name . ': ' . ( 'home' === $name ? $home : $siteurl ) . ' | opened on: ' . $req, $name ); } } } return $hits; } /** * Checks one user row. * * @param object $row Row with user_login, user_nicename, display_name, user_url, user_registered, caps. * @param int $now Current Unix time. * @return array */ public static function scan_user( $row, int $now ): array { $hits = array(); foreach ( array( 'user_login', 'user_nicename', 'display_name', 'user_url' ) as $f ) { $v = (string) ( $row->$f ?? '' ); if ( '' !== $v && preg_match( '#[<>]|javascript\s*:|\bon[a-z]{3,30}\s*=#i', $v ) ) { $hits[] = self::hit( 'user-markup-name', 'high', $f . ': ' . self::excerpt( $v, 0 ), $f ); } } $caps = (string) ( $row->caps ?? '' ); $reg = strtotime( (string) ( $row->user_registered ?? '' ) . ' UTC' ); if ( false !== strpos( $caps, '"administrator"' ) && $reg && $reg > 0 && ( $now - $reg ) < self::RECENT_ADMIN_DAYS * 86400 ) { $days = (int) floor( ( $now - $reg ) / 86400 ); $hits[] = self::hit( 'user-recent-admin', 'medium', (string) ( $row->user_login ?? '' ) . ', registered ' . (string) $row->user_registered . ' UTC', (string) $days ); } return $hits; } // ── Skip rules ─────────────────────────────────────────────────────────── /** * Whether an option is skipped (a cache, or this plugin's own report). * * @param string $name Option name. * @return bool */ public static function skip_option( string $name ): bool { if ( in_array( $name, self::SKIP_OPTION_NAMES, true ) ) { return true; } foreach ( self::SKIP_OPTION_PREFIXES as $p ) { if ( 0 === strpos( $name, $p ) ) { return true; } } return false; } /** * Whether a post or user meta key is skipped. * * @param string $key Meta key. * @return bool */ public static function skip_meta( string $key ): bool { foreach ( self::SKIP_META_PREFIXES as $p ) { if ( 0 === strpos( $key, $p ) ) { return true; } } return false !== strpos( $key, 'user-settings' ); } // ── Batching ───────────────────────────────────────────────────────────── /** * One batch of rows for a table phase, primary key ascending above $after. * Every row carries its key as `id`. Returns null on a query error, which the caller * records against the table rather than reading as "no rows". * * @param string $phase Table phase. * @param int $after Last id already scanned. * @param int $limit Rows. * @return array|null */ public static function fetch_batch( string $phase, int $after, int $limit ): ?array { global $wpdb; $cap = self::MAX_VALUE_BYTES; $wpdb->last_error = ''; // One prepared query per table, each inline so the statement a reviewer reads is the // statement that runs. Read-only, and never cached: the point is the live rows. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching switch ( $phase ) { case 'posts': $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID AS id, post_type, post_status, post_title, LEFT(post_content, %d) AS post_content, LEFT(post_excerpt, %d) AS post_excerpt, LENGTH(post_content) AS len FROM {$wpdb->posts} WHERE ID > %d AND post_status IN ('publish','draft','future','pending','private') ORDER BY ID ASC LIMIT %d", $cap, $cap, $after, $limit ) ); break; case 'postmeta': $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_id AS id, post_id, meta_key, LEFT(meta_value, %d) AS meta_value, LENGTH(meta_value) AS len FROM {$wpdb->postmeta} WHERE meta_id > %d ORDER BY meta_id ASC LIMIT %d", $cap, $after, $limit ) ); break; case 'comments': $rows = $wpdb->get_results( $wpdb->prepare( "SELECT comment_ID AS id, comment_post_ID AS post_id, LEFT(comment_content, %d) AS comment_content, comment_author_url, LENGTH(comment_content) AS len FROM {$wpdb->comments} WHERE comment_ID > %d AND comment_approved = '1' ORDER BY comment_ID ASC LIMIT %d", $cap, $after, $limit ) ); break; case 'options': $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_id AS id, option_name, LEFT(option_value, %d) AS option_value, LENGTH(option_value) AS len FROM {$wpdb->options} WHERE option_id > %d ORDER BY option_id ASC LIMIT %d", $cap, $after, $limit ) ); break; case 'usermeta': $rows = $wpdb->get_results( $wpdb->prepare( "SELECT umeta_id AS id, user_id, meta_key, LEFT(meta_value, %d) AS meta_value, LENGTH(meta_value) AS len FROM {$wpdb->usermeta} WHERE umeta_id > %d ORDER BY umeta_id ASC LIMIT %d", $cap, $after, $limit ) ); break; case 'users': $rows = $wpdb->get_results( $wpdb->prepare( "SELECT u.ID AS id, u.user_login, u.user_nicename, u.display_name, u.user_url, u.user_registered, m.meta_value AS caps FROM {$wpdb->users} u LEFT JOIN {$wpdb->usermeta} m ON m.user_id = u.ID AND m.meta_key = %s WHERE u.ID > %d ORDER BY u.ID ASC LIMIT %d", $wpdb->get_blog_prefix() . 'capabilities', $after, $limit ) ); break; default: $rows = array(); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( '' !== (string) $wpdb->last_error || ! is_array( $rows ) ) { return null; } return $rows; } /** * Highest primary key per table at the start of a scan, for the progress figure. * * @return array */ private static function max_ids(): array { global $wpdb; // No input in any of these: core table names and an aggregate. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return array( 'posts' => (int) $wpdb->get_var( "SELECT MAX(ID) FROM {$wpdb->posts}" ), 'postmeta' => (int) $wpdb->get_var( "SELECT MAX(meta_id) FROM {$wpdb->postmeta}" ), 'comments' => (int) $wpdb->get_var( "SELECT MAX(comment_ID) FROM {$wpdb->comments}" ), 'options' => (int) $wpdb->get_var( "SELECT MAX(option_id) FROM {$wpdb->options}" ), 'usermeta' => (int) $wpdb->get_var( "SELECT MAX(umeta_id) FROM {$wpdb->usermeta}" ), 'users' => (int) $wpdb->get_var( "SELECT MAX(ID) FROM {$wpdb->users}" ), ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } /** * A fresh checkpoint. * * @return array */ public static function new_state(): array { $tables = array_fill_keys( array( 'posts', 'postmeta', 'comments', 'options', 'widgets', 'usermeta', 'users', 'cron', 'siteurl' ), 0 ); return array( 'started' => time(), 'phase' => 'posts', 'last_id' => 0, 'max_ids' => self::max_ids(), 'counts' => $tables, 'skipped' => $tables, 'truncated' => 0, 'complete' => array(), 'errors' => array(), 'pcre' => 0, 'findings' => array(), 'total' => 0, 'by_sev' => array_fill_keys( self::SEVERITIES, 0 ), 'batches' => 0, ); } /** * The stored checkpoint, or null when there is none or it is too old to trust. * * @return array|null */ public static function load_state(): ?array { $s = get_option( self::OPTION_STATE, array() ); if ( ! is_array( $s ) || empty( $s['phase'] ) || ! in_array( $s['phase'], self::PHASES, true ) ) { return null; } if ( time() - (int) ( $s['started'] ?? 0 ) > self::STATE_MAX_AGE ) { return null; } return $s; } /** * Runs the scan for up to $budget seconds and saves where it got to. * * At least one batch always runs, so every call makes progress. With $max_batches > 0 * the call also stops after that many batches (the tests use it to force a resume). * * @param int $budget Seconds. * @param bool $restart Discard any checkpoint and start again. * @param int $max_batches Batch cap, 0 for none. * @return array Progress, see progress(). */ public static function step( int $budget = self::DEFAULT_BUDGET, bool $restart = false, int $max_batches = 0 ): array { $state = $restart ? null : self::load_state(); if ( null === $state ) { $state = self::new_state(); } self::$pcre_errors = 0; $deadline = microtime( true ) + max( 1, $budget ); $ran = 0; while ( 'done' !== $state['phase'] ) { self::advance( $state ); ++$ran; if ( microtime( true ) >= $deadline || ( $max_batches > 0 && $ran >= $max_batches ) ) { break; } } $state['pcre'] += self::$pcre_errors; if ( 'done' === $state['phase'] ) { $result = self::finalise( $state ); update_option( self::OPTION_RESULT, $result, false ); delete_option( self::OPTION_STATE ); } else { update_option( self::OPTION_STATE, $state, false ); } return self::progress( $state ); } /** * Processes one batch, or moves to the next phase when a table is exhausted. * * @param array $state Checkpoint, updated in place. * @return void */ private static function advance( array &$state ): void { $phase = $state['phase']; ++$state['batches']; if ( 'checks' === $phase ) { self::run_checks( $state ); $state['complete']['cron'] = true; $state['complete']['siteurl'] = true; $state['phase'] = 'done'; return; } $rows = self::fetch_batch( $phase, (int) $state['last_id'], (int) ( self::BATCH[ $phase ] ?? 100 ) ); if ( null === $rows ) { $state['errors'][ $phase ] = 'query failed'; self::next_phase( $state ); return; } if ( empty( $rows ) ) { $state['complete'][ $phase ] = true; if ( 'options' === $phase ) { $state['complete']['widgets'] = true; } self::next_phase( $state ); return; } foreach ( $rows as $row ) { self::scan_row( $phase, $row, $state ); $state['last_id'] = max( (int) $state['last_id'], (int) $row->id ); } } /** * Moves the checkpoint to the next phase. * * @param array $state Checkpoint. * @return void */ private static function next_phase( array &$state ): void { $i = array_search( $state['phase'], self::PHASES, true ); $state['phase'] = ( false === $i || ! isset( self::PHASES[ $i + 1 ] ) ) ? 'done' : self::PHASES[ $i + 1 ]; $state['last_id'] = 0; } /** * Scans one row and records its findings. * * @param string $phase Table phase. * @param object $row Row. * @param array $state Checkpoint. * @return void */ private static function scan_row( string $phase, $row, array &$state ): void { $id = (int) $row->id; if ( isset( $row->len ) && (int) $row->len > self::MAX_VALUE_BYTES ) { ++$state['truncated']; } switch ( $phase ) { case 'posts': ++$state['counts']['posts']; $label = wp_html_excerpt( (string) $row->post_title, 60, '...' ) . ' (' . (string) $row->post_type . ', ' . (string) $row->post_status . ')'; foreach ( array( 'post_title', 'post_content', 'post_excerpt' ) as $f ) { foreach ( self::scan_text( (string) $row->$f, 'posts' ) as $h ) { self::record( $state, 'posts', $id, $f, $label, $h, array( 'post_id' => $id ) ); } } return; case 'postmeta': if ( self::skip_meta( (string) $row->meta_key ) ) { ++$state['skipped']['postmeta']; return; } ++$state['counts']['postmeta']; foreach ( self::scan_text( (string) $row->meta_value, 'postmeta' ) as $h ) { self::record( $state, 'postmeta', $id, (string) $row->meta_key, 'post #' . (int) $row->post_id, $h, array( 'post_id' => (int) $row->post_id ) ); } return; case 'comments': ++$state['counts']['comments']; $text = (string) $row->comment_content . "\n" . ( '' !== (string) $row->comment_author_url ? 'author' : '' ); foreach ( self::scan_text( $text, 'comments' ) as $h ) { self::record( $state, 'comments', $id, 'comment_content', 'comment on post #' . (int) $row->post_id, $h, array( 'post_id' => (int) $row->post_id ) ); } return; case 'options': $name = (string) $row->option_name; if ( self::skip_option( $name ) ) { ++$state['skipped']['options']; return; } $src = ( 0 === strpos( $name, 'widget_' ) || 'sidebars_widgets' === $name ) ? 'widgets' : 'options'; ++$state['counts'][ $src ]; foreach ( self::scan_text( (string) $row->option_value, $src ) as $h ) { self::record( $state, $src, $id, 'option_value', $name, $h, array( 'key' => $name ) ); } return; case 'usermeta': if ( self::skip_meta( (string) $row->meta_key ) ) { ++$state['skipped']['usermeta']; return; } ++$state['counts']['usermeta']; foreach ( self::scan_text( (string) $row->meta_value, 'usermeta' ) as $h ) { self::record( $state, 'usermeta', $id, (string) $row->meta_key, 'user #' . (int) $row->user_id, $h, array( 'user_id' => (int) $row->user_id ) ); } return; case 'users': ++$state['counts']['users']; foreach ( self::scan_user( $row, time() ) as $h ) { self::record( $state, 'users', $id, $h['note'], (string) $row->user_login, $h, array( 'user_id' => $id ) ); } return; } } /** * Cron and site address checks, run once at the end. * * @param array $state Checkpoint. * @return void */ private static function run_checks( array &$state ): void { $cron = self::scan_cron( get_option( 'cron', array() ), static function ( string $hook ): bool { return (bool) has_action( $hook ); } ); $state['counts']['cron'] = (int) $cron['count']; foreach ( $cron['hits'] as $h ) { self::record( $state, 'cron', 0, 'cron', (string) strtok( $h['excerpt'], ':' ), $h, array() ); } global $wpdb; $raw = array(); foreach ( array( 'home', 'siteurl' ) as $name ) { $raw[ $name ] = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the stored value, not the one wp-config.php constants filter in } $state['counts']['siteurl'] = 2; $request_host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; $hits = self::check_site_urls( $raw['home'], $raw['siteurl'], defined( 'WP_HOME' ) ? (string) constant( 'WP_HOME' ) : '', defined( 'WP_SITEURL' ) ? (string) constant( 'WP_SITEURL' ) : '', $request_host ); foreach ( $hits as $h ) { self::record( $state, 'siteurl', 0, '' !== $h['note'] ? $h['note'] : 'home/siteurl', 'site address', $h, array( 'key' => 'siteurl' ) ); } } /** * Records one finding, deduplicated, capped, always counted. * * @param array $state Checkpoint. * @param string $src Source table. * @param int $id Row id. * @param string $field Field or key. * @param string $label Human label for the row. * @param array $hit Hit. * @param array $extra post_id / user_id / key, for the edit link. * @return void */ private static function record( array &$state, string $src, int $id, string $field, string $label, array $hit, array $extra ): void { $key = $src . '|' . $id . '|' . $field . '|' . $hit['sig'] . '|' . $hit['note']; foreach ( $state['findings'] as $f ) { if ( ( $f['k'] ?? '' ) === $key ) { return; } } ++$state['total']; $sev = in_array( $hit['sev'], self::SEVERITIES, true ) ? $hit['sev'] : 'medium'; ++$state['by_sev'][ $sev ]; if ( count( $state['findings'] ) >= self::MAX_FINDINGS ) { return; } $state['findings'][] = array_merge( array( 'k' => $key, 'src' => $src, 'id' => $id, 'field' => $field, 'label' => $label, 'sig' => $hit['sig'], 'sev' => $sev, 'excerpt' => $hit['excerpt'], 'note' => $hit['note'], ), $extra ); } /** * The stored result for a finished checkpoint. * * @param array $state Checkpoint. * @return array */ private static function finalise( array $state ): array { $rank = array_flip( self::SEVERITIES ); $findings = $state['findings']; usort( $findings, static function ( $a, $b ) use ( $rank ) { return ( $rank[ $a['sev'] ] ?? 9 ) <=> ( $rank[ $b['sev'] ] ?? 9 ); } ); return array( 'ts' => time(), 'started' => (int) $state['started'], 'counts' => $state['counts'], 'skipped' => $state['skipped'], 'truncated' => (int) $state['truncated'], 'complete' => $state['complete'], 'errors' => $state['errors'], 'pcre' => (int) $state['pcre'], 'findings' => $findings, 'total' => (int) $state['total'], 'by_sev' => $state['by_sev'], ); } /** * Findings that need attention: everything except info. * * @param array $by_sev Counts by severity. * @return int */ public static function attention_count( array $by_sev ): int { return (int) ( $by_sev['critical'] ?? 0 ) + (int) ( $by_sev['high'] ?? 0 ) + (int) ( $by_sev['medium'] ?? 0 ); } /** * Progress for the browser. * * @param array $state Checkpoint (or the finished one). * @return array */ public static function progress( array $state ): array { $done = 'done' === $state['phase']; $phases = self::PHASES; $i = $done ? count( $phases ) : (int) array_search( $state['phase'], $phases, true ); $frac = 0.0; if ( ! $done ) { $max = (int) ( $state['max_ids'][ $state['phase'] ] ?? 0 ); $frac = $max > 0 ? min( 1.0, (int) $state['last_id'] / $max ) : 0.0; } return array( 'done' => $done, 'phase' => $done ? 'done' : (string) $state['phase'], 'rows' => (int) array_sum( array_map( 'intval', (array) $state['counts'] ) ), 'pct' => $done ? 100 : (int) floor( ( $i + $frac ) / count( $phases ) * 100 ), 'findings' => self::attention_count( (array) $state['by_sev'] ), 'info' => (int) ( $state['by_sev']['info'] ?? 0 ), ); } /** Last stored result. */ public static function last_result(): array { $r = get_option( self::OPTION_RESULT, array() ); return is_array( $r ) ? $r : array(); } // ── AJAX ───────────────────────────────────────────────────────────────── /** * One budgeted step of the scan. The browser calls this until done is true. * Read-only: nothing in the scanned tables is changed. * * @return void */ public static function ajax_scan(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $restart = '1' === sanitize_text_field( wp_unslash( $_POST['restart'] ?? '0' ) ); if ( ! $restart && get_transient( self::LOCK_TRANSIENT ) ) { // Another tab is mid-batch. Say so; the caller waits and asks again. wp_send_json_success( array( 'busy' => true ) ); return; } set_transient( self::LOCK_TRANSIENT, 1, self::DEFAULT_BUDGET + 30 ); try { $progress = self::step( self::DEFAULT_BUDGET, $restart ); } catch ( \Throwable $e ) { delete_transient( self::LOCK_TRANSIENT ); CSDT_Ajax::fail( $e, __METHOD__ ); return; } delete_transient( self::LOCK_TRANSIENT ); wp_send_json_success( $progress ); } // ── Admin UI ───────────────────────────────────────────────────────────── /** * Where to go to look at, or clean, a finding. * * @param array $f Finding. * @return array{url:string,label:string} */ public static function action_for( array $f ): array { $src = (string) ( $f['src'] ?? '' ); if ( in_array( $src, array( 'posts', 'postmeta' ), true ) && ! empty( $f['post_id'] ) ) { return array( 'url' => admin_url( 'post.php?post=' . (int) $f['post_id'] . '&action=edit' ), 'label' => 'posts' === $src ? __( 'Clean in editor', 'cloudscale-cyber-devtools' ) : __( 'Open post', 'cloudscale-cyber-devtools' ), ); } if ( 'comments' === $src ) { return array( 'url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $f['id'] ), 'label' => __( 'Edit comment', 'cloudscale-cyber-devtools' ), ); } if ( 'widgets' === $src ) { return array( 'url' => admin_url( 'widgets.php' ), 'label' => __( 'Open widgets', 'cloudscale-cyber-devtools' ), ); } if ( 'options' === $src ) { return array( 'url' => admin_url( 'options.php' ) . '#' . rawurlencode( (string) ( $f['key'] ?? '' ) ), 'label' => __( 'View option', 'cloudscale-cyber-devtools' ), ); } if ( in_array( $src, array( 'users', 'usermeta' ), true ) && ! empty( $f['user_id'] ) ) { return array( 'url' => admin_url( 'user-edit.php?user_id=' . (int) $f['user_id'] ), 'label' => __( 'Open user', 'cloudscale-cyber-devtools' ), ); } if ( 'siteurl' === $src ) { return array( 'url' => admin_url( 'options-general.php' ), 'label' => __( 'General settings', 'cloudscale-cyber-devtools' ), ); } return array( 'url' => '', 'label' => '', ); } /** Badge colour per severity. Dark solid backgrounds with white text. */ const SEV_COLOURS = array( 'critical' => '#b91c1c', 'high' => '#c2410c', 'medium' => '#a16207', 'info' => '#475569', ); /** * Renders the Database Malware Scan panel, beside File Integrity on the scan tab. * * @return void */ public static function render_panel(): void { $r = self::last_result(); $state = self::load_state(); $sigs = self::signatures(); $ts = (int) ( $r['ts'] ?? 0 ); $by_sev = (array) ( $r['by_sev'] ?? array() ); $attn = self::attention_count( $by_sev ); $counts = (array) ( $r['counts'] ?? array() ); $tables = array( 'posts' => __( 'Posts and pages', 'cloudscale-cyber-devtools' ), 'postmeta' => __( 'Post meta', 'cloudscale-cyber-devtools' ), 'comments' => __( 'Approved comments', 'cloudscale-cyber-devtools' ), 'options' => __( 'Options', 'cloudscale-cyber-devtools' ), 'widgets' => __( 'Widgets', 'cloudscale-cyber-devtools' ), 'usermeta' => __( 'User meta', 'cloudscale-cyber-devtools' ), 'users' => __( 'Users', 'cloudscale-cyber-devtools' ), 'cron' => __( 'Scheduled tasks', 'cloudscale-cyber-devtools' ), 'siteurl' => __( 'Site addresses', 'cloudscale-cyber-devtools' ), ); ?>
🧪 'What it reads', 'rec' => 'How it works', 'html' => 'Many compromises never change a file. The payload is written into the database instead: a script appended to every post, a block of hidden spam links in a widget, an encoded PHP loader parked in an option, or the site address pointed at another domain.

This scan reads, in batches: post titles, content and excerpts (published, draft, scheduled, pending and private, every post type); post meta; approved comments; every option including widgets; user meta; user accounts; scheduled tasks; and the stored site and WordPress addresses.', ), array( 'name' => 'Rows scanned', 'rec' => 'Check this first', 'html' => 'The table under the result says how many rows of each table were read and how many were skipped. A table showing 0 rows was not covered, so a clean result says nothing about it. A table marked incomplete stopped on a database error and was not fully read.', ), array( 'name' => 'What it looks for', 'rec' => 'Signatures', 'html' => 'Scripts loaded from hosts that are neither this site nor a common embed provider; JavaScript that decodes and runs hidden text (eval of atob, unescape or fromCharCode, the _0x obfuscator naming, long hex escape runs, document.write of a script); hidden or one-pixel iframes; known campaign markers; hidden blocks full of outbound links and pharma or casino link clusters; JavaScript and meta-refresh redirects to other sites; PHP stored in options and meta (eval of decoded input, gzinflate and base64 chains); scheduled tasks with eval-like names and no handler; recently created administrators; markup in user names; and site addresses that disagree with each other or with wp-config.php.

Each signature has an id, a severity and a reference (a CWE or MITRE ATT&CK id) shown beside every finding.', ), array( 'name' => 'Code examples are not flagged', 'rec' => 'By design', 'html' => 'Text inside a code block, a <pre> or a <code> element is treated as quoted, so a post that explains a payload in a code example is not reported. JavaScript signatures are matched only against code a browser would run: script bodies, on-event attributes and javascript: links. A sentence describing an attack is prose.

One exception is shown at Info level: a live, unescaped <script> or hidden iframe inside a <pre> or <code>. A browser still runs it, so it is listed, but it is not counted as needing attention.', ), array( 'name' => 'Hosts that are allowed', 'rec' => 'Info', 'html' => 'This site and its subdomains, and common embed and measurement providers: YouTube, Vimeo, Google Tag Manager, Google Analytics, AdSense, reCAPTCHA, GitHub gists, CodePen, X, Instagram, Facebook, LinkedIn, TikTok, Pinterest, Disqus, Spotify, SoundCloud, Cloudflare Web Analytics, WordPress.com and Gravatar. A developer can extend the list with the csdt_db_malware_allowed_hosts filter.', ), array( 'name' => 'What it skips', 'rec' => 'Info', 'html' => 'Caches that WordPress rebuilds (feed, update and pattern transients, rewrite rules, oEmbed caches), attachment metadata, edit locks, login sessions, and this plugin\'s own records, which quote the payloads they found. Values longer than 256 KB are read up to that size and counted as truncated. On multisite only this site\'s tables are read.', ), array( 'name' => 'Cleaning', 'rec' => 'Read-only', 'html' => 'The scan never changes the database. For a post, Clean in editor opens it so you can remove the injected code yourself; switch the block to Edit as HTML, or use the Code editor, to see it. For other rows the link opens the screen where that data is managed.

Before cleaning, find out how it got there: an out-of-date plugin, a stolen administrator password, or a recently created administrator account. Injected content returns if the way in is left open.', ), array( 'name' => 'Running it', 'rec' => 'Safe', 'html' => 'The scan runs in short steps from this page and shows progress. If you leave the page, the next run continues where it stopped (for up to a day). Start over discards the partial scan.', ), ), 'Reads your database for code an attacker injected, without changing anything. Detection is signature based: a clean result means nothing known was found, not that nothing is there.' ); ?>
0 && 0 === $attn ) : ?>
✓
0 ) : ?>
⏳
0 ) : ?>
open>
$t_label ) : ?>


()
min( 100, count( $findings ) ) ) : ?>