From 5118495af06c10a75aa60b0bad40c7edcd28b8dd Mon Sep 17 00:00:00 2001 From: svfcode Date: Sun, 19 Jul 2026 12:28:57 +0300 Subject: [PATCH 1/6] Upd. Scan. Improve check included files. --- .../HeuristicAnalyser/HeuristicAnalyser.php | 2 + .../HeuristicAnalyser/Modules/CodeStyle.php | 32 ++- .../HeuristicAnalyser/Modules/Includes.php | 224 ++++++++++++++---- .../HeuristicAnalyser/Modules/Variables.php | 40 ++++ .../SpbctWP/Scanner/ScannerQueue.php | 98 +++++++- 5 files changed, 326 insertions(+), 70 deletions(-) diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php index 48e4198f4..441eaa114 100644 --- a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php @@ -228,6 +228,8 @@ public function __construct($input, $self = null) $this->mathematics = new Mathematics($this->tokens); $this->strings = new Strings($this->tokens); $this->variables = new Variables($this->tokens); + // Allow resolving ABSPATH . 'path' / __DIR__ . 'path' during include analysis (#36876) + $this->variables->seedKnownCmsConstants($this->is_text ? null : $this->curr_dir); $this->sqls = new SQLs($this->tokens, $this->variables); $this->transformations = new Transformations($this->tokens); $this->includes = new Includes($this->tokens, $this->variables, $this->curr_dir, $this->is_text); diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php index 3ab72f46b..bf71f7657 100644 --- a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php @@ -231,24 +231,30 @@ public function detectBadLines() /** * Check if file contains PHP open tags ("<\?php" or `<\?`). + * Used to decide whether a non-.php file still needs heuristic analysis + * (e.g. .js include with PHP payload). + * + * Note: PHP tokenizer often keeps the newline inside T_OPEN_TAG ("tokens as $_token => $content ) { - if ( isset($content[0]) && isset($this->tokens->next1[0]) ) { - if ( $content[0] === 'T_OPEN_TAG' ) { - //check if open tag is short - $is_short = isset($content[1]) && $content[1] === 'tokens->next1[0] === 'T_WHITESPACE') || - // should be whitespaces or variable after tag - (!$is_short && in_array($this->tokens->next1[0], array('T_WHITESPACE', 'T_VARIABLE', 'T_FUNCTION'))) - ) { - return true; - } - } + if ( ! isset($content[0]) || $content[0] !== 'T_OPEN_TAG' ) { + continue; + } + + // Full tokens->next1[0]) && $this->tokens->next1[0] === 'T_WHITESPACE' ) { + return true; } } diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Includes.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Includes.php index 42ee2eb65..dff867fc5 100644 --- a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Includes.php +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Includes.php @@ -154,57 +154,10 @@ public function process($include, $file_exists, $_key) $properties['error_free'] = $this->tokens->prev1->value !== '@'; $properties['good'] = ! $this->variables_handler->isSetOfTokensHasBadVariables($include); - $include_value = ''; + $path = $this->extractPathFromIncludeTokens($include); - if ( count($include) === 1 && $include[0]->type === 'T_CONSTANT_ENCAPSED_STRING' ) { - // Include is a single string like `include 'file.php';` - $include_value = $include[0]->value; - } elseif ( - // Include is a single string within bracers like `include('file.php');` - count($include) === 3 && - $include[0]->value === '(' && - $include[1]->type === 'T_CONSTANT_ENCAPSED_STRING' && - $include[2]->value === ')' - ) { - $include_value = $include[1]->value; - } - - if ( $include_value ) { - // Extracting path from the string token. Cutting quotes. - $properties['path'] = substr($include_value, 1, -1); - $properties['not_url'] = ! filter_var($properties['path'], FILTER_VALIDATE_URL); - - // If the filepath is absolute. - $properties['is_absolute'] = preg_match('@^([A-Z]:[\\\\]|[\/])@', $properties['path']); - - // Make path absolute - $properties['path'] = ! $properties['is_absolute'] && $properties['not_url'] - ? $this->current_directory . DIRECTORY_SEPARATOR . preg_replace( - '@^([\\\\]|[\/])@', - '', - $properties['path'] - ) - : $properties['path']; - - // Extract filename from the path - $properties['name'] = basename($properties['path']); - - // Checks file for existence. null if checking text (not file). - $properties['exists'] = - $this->is_text && - ! ( - $file_exists && - $file_exists[2]->type === 'T_CONSTANT_ENCAPSED_STRING' && - $file_exists[2]->value === $properties['path'] - ) - ? null - : (bool)realpath($properties['path']); - - // Getting extension. - $properties['ext'] = preg_match('/.*\.(\S{1,10})$/', $properties['path'], $matches) ? $matches[1] : ''; - - // Is extension appropriate? - $properties['ext_good'] = in_array($properties['ext'], array('php', 'inc')) || is_dir($properties['path']); + if ( $path !== null && $path !== '' ) { + $this->fillPathProperties($properties, $path, $file_exists); } // Gather result in one flag @@ -215,4 +168,175 @@ public function process($include, $file_exists, $_key) $this->includes[] = $properties; } + + /** + * Extract filesystem path from include/require argument tokens. + * Supports string literals and safe concatenations: ABSPATH . 'file', __DIR__ . '/file'. + * + * @param Token[] $include + * + * @return string|null Path without surrounding quotes, or null if unresolved + */ + private function extractPathFromIncludeTokens(array $include) + { + // Strip wrapping parentheses: ( expr ) + if ( + count($include) >= 3 && + $include[0]->value === '(' && + $include[count($include) - 1]->value === ')' + ) { + $include = array_slice($include, 1, -1); + $include = array_values($include); + } + + // Drop whitespace tokens + $include = array_values(array_filter($include, static function ($token) { + return $token->type !== 'T_WHITESPACE'; + })); + + if ( ! count($include) ) { + return null; + } + + // Single string: include 'file.php'; + if ( count($include) === 1 && $include[0]->type === 'T_CONSTANT_ENCAPSED_STRING' ) { + return $this->unquoteStringToken((string) $include[0]->value); + } + + // Concatenation of safe parts: CONST/__DIR__/string . string . ... + $parts = array(); + $expect_operand = true; + foreach ( $include as $token ) { + if ( $expect_operand ) { + $part = $this->resolveConcatOperand($token); + if ( $part === null ) { + return null; + } + $parts[] = $part; + $expect_operand = false; + continue; + } + + if ( $token->value === '.' ) { + $expect_operand = true; + continue; + } + + return null; + } + + if ( $expect_operand || ! count($parts) ) { + return null; + } + + return implode('', $parts); + } + + /** + * @param Token $token + * + * @return string|null + */ + private function resolveConcatOperand(Token $token) + { + if ( $token->type === 'T_CONSTANT_ENCAPSED_STRING' ) { + return $this->unquoteStringToken((string) $token->value); + } + + if ( $token->type === 'T_LNUMBER' || $token->type === 'T_DNUMBER' ) { + return (string) $token->value; + } + + // __DIR__ + if ( + $token->type === 'T_DIR' || + ($token->type === 'T_STRING' && $token->value === '__DIR__') + ) { + if ( $this->current_directory ) { + return rtrim($this->current_directory, '/\\') . DIRECTORY_SEPARATOR; + } + + return null; + } + + // Known CMS constants (ABSPATH, WP_CONTENT_DIR, ...) + if ( + $token->type === 'T_STRING' && + isset($this->variables_handler->constants[$token->value]) + ) { + return (string) $this->variables_handler->constants[$token->value]; + } + + return null; + } + + /** + * @param string $token_value Quoted string token value + * + * @return string + */ + private function unquoteStringToken($token_value) + { + $path = substr((string) $token_value, 1, -1); + + // Tokenized PHP source keeps escapes: 'C:\\dir\\file' → C:\\dir\\file (doubled). + if ( preg_match('@^[A-Za-z]:\\\\@', $path) ) { + $path = str_replace('\\\\', '\\', $path); + } + + return $path; + } + + /** + * @param array $properties + * @param string $path + * @param mixed $file_exists + * + * @return void + */ + private function fillPathProperties(array &$properties, $path, $file_exists) + { + $properties['path'] = $path; + $properties['not_url'] = ! filter_var($properties['path'], FILTER_VALIDATE_URL); + + // Absolute: Unix /path, Windows C:\path or C:/path + $properties['is_absolute'] = (bool) preg_match('@^([A-Za-z]:[\\\\/]|[\/])@', $properties['path']); + + // Make path absolute + $properties['path'] = ! $properties['is_absolute'] && $properties['not_url'] + ? $this->current_directory . DIRECTORY_SEPARATOR . preg_replace( + '@^([\\\\]|[\/])@', + '', + $properties['path'] + ) + : $properties['path']; + + // Prefer canonical filesystem path when the file exists + $resolved_path = $properties['not_url'] ? realpath($properties['path']) : false; + if ( $resolved_path ) { + $properties['path'] = $resolved_path; + } + + // Extract filename from the path + $properties['name'] = basename($properties['path']); + + // Checks file for existence. null if checking text (not file). + $properties['exists'] = + $this->is_text && + ! ( + $file_exists && + $file_exists[2]->type === 'T_CONSTANT_ENCAPSED_STRING' && + $file_exists[2]->value === $properties['path'] + ) + ? null + : (bool) $resolved_path; + + // Getting extension. + $properties['ext'] = preg_match('/.*\.(\S{1,10})$/', $properties['path'], $matches) ? $matches[1] : ''; + + // PHP executes included content regardless of extension (e.g. .js with PHP). + $properties['ext_good'] = $properties['exists'] + || is_dir($properties['path']) + || in_array($properties['ext'], array('php', 'inc'), true); + } } diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Variables.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Variables.php index b66459c06..7a0518e4f 100644 --- a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Variables.php +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Variables.php @@ -12,6 +12,13 @@ class Variables public $arrays = array(); public $constants = array(); + /** + * Directory of the file being analysed (for __DIR__ replacement). + * + * @var string|null + */ + private $analysis_directory; + /** * @var Tokens */ @@ -58,6 +65,25 @@ public function __construct(Tokens $tokens) $this->tokens = $tokens; } + /** + * Seed CMS / runtime constants so constructs like ABSPATH . 'file.php' can be resolved. + * + * @param string|null $analysis_directory Directory of the scanned file (for __DIR__) + * + * @return void + */ + public function seedKnownCmsConstants($analysis_directory = null) + { + $this->analysis_directory = $analysis_directory ? (string) $analysis_directory : null; + + $known = array('ABSPATH', 'WP_CONTENT_DIR', 'WP_PLUGIN_DIR', 'WPINC', 'DIRECTORY_SEPARATOR'); + foreach ( $known as $name ) { + if ( defined($name) ) { + $this->constants[$name] = (string) constant($name); + } + } + } + /** * Replaces ${'string'} to $variable * @@ -717,6 +743,20 @@ public function replace($_key) $this->tokens->current->line, $this->tokens->current->key ); + } elseif ( + // __DIR__ → directory of the analysed file + $this->analysis_directory !== null && + ( + $this->tokens->current->type === 'T_DIR' || + ($this->tokens->current->type === 'T_STRING' && $this->tokens->current->value === '__DIR__') + ) + ) { + $this->tokens['current'] = new Token( + 'T_CONSTANT_ENCAPSED_STRING', + '\'' . rtrim($this->analysis_directory, '/\\') . DIRECTORY_SEPARATOR . '\'', + $this->tokens->current->line, + $this->tokens->current->key + ); } } diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php index f9d08f27a..17f264230 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php @@ -1138,6 +1138,28 @@ public function file_system_analysis($offset = null, $amount = null, $path_to_sc $unsafe_permissions_handler->handle(); } } + } elseif ( $scanner->stage_end ) { + // Last chunk can be empty (all remaining paths excluded / already counted). + // Without end/processed the frontend gets NaN% and freezes the scan loop. + $output = array( + 'processed' => 0, + 'files_count' => 0, + 'dirs_count' => $scanner->dirs_count, + 'offset' => $offset, + 'amount' => $amount, + 'end' => true, + ); + $unsafe_permissions_handler = new UnsafePermissionsHandler(); + $unsafe_permissions_handler->handle(); + } else { + $output = array( + 'processed' => 0, + 'files_count' => 0, + 'dirs_count' => $scanner->dirs_count, + 'offset' => $offset, + 'amount' => $amount, + 'end' => false, + ); } // Count files if it's first iteration @@ -1713,6 +1735,8 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR $scanned = 0; $statuses = new ScannerFileStatuses(); + $signatures_for_includes = null; + $signatures_scanner_for_includes = null; if ( count($files_to_check) ) { $root_path = spbc_get_root_path(); @@ -1742,15 +1766,19 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR unset($include['include']); // Cutting file's path, leave path from CMS ROOT to file - $real_path = $include['path']; + $real_path = realpath($include['path']) ?: $include['path']; $path = str_replace($this->root, '', $real_path); + // realpath() may differ in drive letter case vs $this->root on Windows + if ( $spbc->is_windows && $path === $real_path ) { + $path = str_ireplace($this->root, '', $real_path); + } $path = $spbc->is_windows ? str_replace('/', '\\', $path) : $path; $mtime = @filemtime($real_path); if ( empty($mtime) ) { - clearstatcache($real_path); + clearstatcache(true, (string) $real_path); $mtime = @filemtime($real_path); if ( empty($mtime) ) { - clearstatcache($real_path); + clearstatcache(true, (string) $real_path); $mtime = @filemtime($path); if ( empty($mtime) ) { $mtime = @filectime($real_path); @@ -1767,17 +1795,73 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR ? md5_file($real_path) : 'unknown'; + // Non-PHP includes (e.g. .js) are skipped for heuristic on Surface. + // Queue them for heuristic and run signatures here (signature stage already finished). + // Pure JS without db->prepare( 'INSERT INTO ' . SPBC_TBL_SCAN_FILES - . ' (`path`, `size`, `perms`, `mtime`,`status`,`fast_hash`, `full_hash`, `detected_at`) VALUES' - . "(%s, %d, %d, %d, 'UNKNOWN', %s, %s, %d)" + . ' (`path`, `size`, `perms`, `mtime`,`status`,`fast_hash`, `full_hash`, `detected_at`, `checked_heuristic`, `checked_signatures`) VALUES' + . "(%s, %d, %d, %d, 'UNKNOWN', %s, %s, %d, 0, 0)" . 'ON DUPLICATE KEY UPDATE - size = VALUES(`size`)', + size = VALUES(`size`), + checked_heuristic = IF(%d, 0, checked_heuristic), + checked_signatures = IF(%d, 0, checked_signatures)', //should be offset to use in date() - array($path, $size, $perms, $mtime, $fast_hash, $full_hash, current_time('timestamp')) + array( + $path, + $size, + $perms, + $mtime, + $fast_hash, + $full_hash, + current_time('timestamp'), + $force_recheck, + $force_recheck, + ) ) ->execute(); + // Signatures stage already passed — scan non-PHP include targets inline. + if ( $force_recheck ) { + if ( $signatures_for_includes === null ) { + $signatures_for_includes = $this->db->fetchAll('SELECT * FROM ' . SPBC_TBL_SCAN_SIGNATURES); + $signatures_scanner_for_includes = new \CleantalkSP\Common\Scanner\SignaturesAnalyser\Controller(); + } + + $include_file_info = new \CleantalkSP\Common\Scanner\SignaturesAnalyser\Structures\FileInfo( + $path, + $full_hash + ); + $sig_result = $signatures_scanner_for_includes->scanFile( + $include_file_info, + $root_path, + $signatures_for_includes + ); + + if ( empty($sig_result->error_msg) ) { + $sig_status = $sig_result->status; + $sig_weak_spots = ! empty($sig_result->weak_spots) ? json_encode($sig_result->weak_spots) : 'NULL'; + $sig_severity = $sig_result->severity; + + $this->db->execute( + 'UPDATE ' . SPBC_TBL_SCAN_FILES + . ' SET' + . ' checked_signatures = 1,' + . ' status = \'' . $sig_status . '\',' + . ' severity = ' . QueueHelper::prepareParamForSQLQuery($sig_severity) . ',' + . ' weak_spots = ' . QueueHelper::prepareParamForSQLQuery($sig_weak_spots) + . ' WHERE fast_hash = \'' . $fast_hash . '\';' + ); + + if ( $sig_result->status === 'INFECTED' ) { + $suspicious_files_found++; + } + } + } + // Make 'processed' counter big enough to make an another iteration with new files $scanned = 5; } From 280f5c822bff92b7bac28ddd0de36dce1ca657e0 Mon Sep 17 00:00:00 2001 From: svfcode Date: Sun, 19 Jul 2026 14:33:42 +0300 Subject: [PATCH 2/6] fix cp --- .../SpbctWP/Scanner/ScannerQueue.php | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php index 17f264230..67aba52d2 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php @@ -1842,19 +1842,27 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR ); if ( empty($sig_result->error_msg) ) { - $sig_status = $sig_result->status; - $sig_weak_spots = ! empty($sig_result->weak_spots) ? json_encode($sig_result->weak_spots) : 'NULL'; - $sig_severity = $sig_result->severity; + $has_severity = ! empty($sig_result->severity); + $has_weak_spots = ! empty($sig_result->weak_spots); + $sql_vars = array($sig_result->status); - $this->db->execute( - 'UPDATE ' . SPBC_TBL_SCAN_FILES + $sql = 'UPDATE ' . SPBC_TBL_SCAN_FILES . ' SET' . ' checked_signatures = 1,' - . ' status = \'' . $sig_status . '\',' - . ' severity = ' . QueueHelper::prepareParamForSQLQuery($sig_severity) . ',' - . ' weak_spots = ' . QueueHelper::prepareParamForSQLQuery($sig_weak_spots) - . ' WHERE fast_hash = \'' . $fast_hash . '\';' - ); + . ' status = %s,' + . ' severity = ' . ($has_severity ? '%s' : 'NULL') . ',' + . ' weak_spots = ' . ($has_weak_spots ? '%s' : 'NULL') + . ' WHERE fast_hash = %s'; + + if ( $has_severity ) { + $sql_vars[] = $sig_result->severity; + } + if ( $has_weak_spots ) { + $sql_vars[] = json_encode($sig_result->weak_spots); + } + $sql_vars[] = $fast_hash; + + $this->db->prepare($sql, $sql_vars)->execute(); if ( $sig_result->status === 'INFECTED' ) { $suspicious_files_found++; From 8d9159b6123cd3644fac97fcba5c649b40e2a367 Mon Sep 17 00:00:00 2001 From: svfcode Date: Sun, 19 Jul 2026 14:54:26 +0300 Subject: [PATCH 3/6] fix cp --- .../Scanner/HeuristicAnalyser/Modules/CodeStyle.php | 13 +++++++++++-- lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php | 9 +++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php index bf71f7657..d8522cba4 100644 --- a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php @@ -230,7 +230,7 @@ public function detectBadLines() } /** - * Check if file contains PHP open tags ("<\?php" or `<\?`). + * Check if file contains PHP open tags ("<\?php", `<\?`, or `tokens as $_token => $content ) { - if ( ! isset($content[0]) || $content[0] !== 'T_OPEN_TAG' ) { + if ( ! isset($content[0]) ) { + continue; + } + + // db->prepare( 'INSERT INTO ' . SPBC_TBL_SCAN_FILES From 768f2042a17308473223d318814f02647c115572 Mon Sep 17 00:00:00 2001 From: svfcode Date: Sun, 19 Jul 2026 15:34:06 +0300 Subject: [PATCH 4/6] fix cp --- .../SpbctWP/Scanner/ScannerQueue.php | 115 +++++++++----- .../IncludesResolutionTest.php | 149 ++++++++++++++++++ 2 files changed, 221 insertions(+), 43 deletions(-) create mode 100644 tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php index 3e6029e35..cf78ec49a 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php @@ -1020,12 +1020,13 @@ public function file_system_analysis($offset = null, $amount = null, $path_to_sc $file['path'] = trim($this->db->prepare('%s', $file['path'])->getQuery(), '\''); $file['detected_at'] = $detected_at; - //JS files exclusion - $ext = @pathinfo($file['path'], PATHINFO_EXTENSION); - $file['checked_heuristic'] = $ext === 'js' ? 1 : 0; - //JS files end + // Heuristic skip for pure JS is handled cheaply in + // HeuristicAnalyser::skipAsFileWithNoPHPCode(). Do not pre-mark .js as + // checked — PHP-in-JS (e.g. via include) must still be analysed. + $file['checked_heuristic'] = 0; // if extension is not set, set 'source' to 'binary' + $ext = @pathinfo($file['path'], PATHINFO_EXTENSION); $file['source'] = ! $ext ? 'BINARY' : ''; if ( ! spbc_check_ascii($file['path']) ) { @@ -1767,7 +1768,18 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR // Cutting file's path, leave path from CMS ROOT to file $real_path = realpath($include['path']) ?: $include['path']; - $path = str_replace($this->root, '', $real_path); + $root_norm = rtrim(str_replace('\\', '/', (string) $this->root), '/'); + $real_norm = str_replace('\\', '/', (string) $real_path); + $under_root = $spbc->is_windows + ? (stripos($real_norm, $root_norm) === 0) + : (strpos($real_norm, $root_norm) === 0); + + // Outside CMS root → unscannable after $root_path . $path; skip + if ( ! $under_root ) { + continue; + } + + $path = str_replace($this->root, '', $real_path); // realpath() may differ in drive letter case vs $this->root on Windows if ( $spbc->is_windows && $path === $real_path ) { $path = str_ireplace($this->root, '', $real_path); @@ -1796,25 +1808,35 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR ? md5_file($real_path) : 'unknown'; - // Non-PHP includes (e.g. .js) are skipped for heuristic on Surface. - // Queue them for heuristic and run signatures here (signature stage already finished). - // Pure JS without db->fetch( + 'SELECT checked_heuristic, checked_signatures FROM ' . SPBC_TBL_SCAN_FILES + . ' WHERE fast_hash = ' . QueueHelper::prepareParamForSQLQuery($fast_hash), + ARRAY_A + ); + $is_new = empty($existing); + $needs_signatures = $force_recheck + && ( + $is_new + || (int) $existing['checked_signatures'] === 0 + ); + $this->db->prepare( 'INSERT INTO ' . SPBC_TBL_SCAN_FILES . ' (`path`, `size`, `perms`, `mtime`,`status`,`fast_hash`, `full_hash`, `detected_at`, `checked_heuristic`, `checked_signatures`) VALUES' . "(%s, %d, %d, %d, 'UNKNOWN', %s, %s, %d, 0, 0)" . 'ON DUPLICATE KEY UPDATE - size = VALUES(`size`), - checked_heuristic = IF(%d, 0, checked_heuristic), - checked_signatures = IF(%d, 0, checked_signatures)', - //should be offset to use in date() + size = VALUES(`size`)', array( $path, $size, @@ -1823,14 +1845,13 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR $fast_hash, $full_hash, current_time('timestamp'), - $force_recheck, - $force_recheck, ) ) ->execute(); - // Signatures stage already passed — scan non-PHP include targets inline. - if ( $force_recheck ) { + // Signatures stage already passed — scan non-PHP include targets inline once. + // Always mark checked_signatures=1 (including errors), same as signature_analysis(). + if ( $needs_signatures ) { if ( $signatures_for_includes === null ) { $signatures_for_includes = $this->db->fetchAll('SELECT * FROM ' . SPBC_TBL_SCAN_SIGNATURES); $signatures_scanner_for_includes = new \CleantalkSP\Common\Scanner\SignaturesAnalyser\Controller(); @@ -1846,37 +1867,45 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR $signatures_for_includes ); - if ( empty($sig_result->error_msg) ) { - $has_severity = ! empty($sig_result->severity); - $has_weak_spots = ! empty($sig_result->weak_spots); - $sql_vars = array($sig_result->status); - - $sql = 'UPDATE ' . SPBC_TBL_SCAN_FILES - . ' SET' - . ' checked_signatures = 1,' - . ' status = %s,' - . ' severity = ' . ($has_severity ? '%s' : 'NULL') . ',' - . ' weak_spots = ' . ($has_weak_spots ? '%s' : 'NULL') - . ' WHERE fast_hash = %s'; - - if ( $has_severity ) { - $sql_vars[] = $sig_result->severity; - } - if ( $has_weak_spots ) { - $sql_vars[] = json_encode($sig_result->weak_spots); - } - $sql_vars[] = $fast_hash; + $sig_status = ! empty($sig_result->status) ? $sig_result->status : 'ERROR'; + $has_severity = empty($sig_result->error_msg) && ! empty($sig_result->severity); + $has_weak_spots = empty($sig_result->error_msg) && ! empty($sig_result->weak_spots); + $sig_error_json = ! empty($sig_result->error_msg) + ? json_encode(array('signature_analysis' => $sig_result->error_msg)) + : ''; + + $sql_vars = array($sig_status); + $sql = 'UPDATE ' . SPBC_TBL_SCAN_FILES + . ' SET' + . ' checked_signatures = 1,' + . ' status = %s,' + . ' severity = ' . ($has_severity ? '%s' : 'NULL') . ',' + . ' weak_spots = ' . ($has_weak_spots ? '%s' : 'NULL') . ',' + . ' error_msg = ' . ($sig_error_json !== '' ? '%s' : 'NULL') + . ' WHERE fast_hash = %s'; + + if ( $has_severity ) { + $sql_vars[] = $sig_result->severity; + } + if ( $has_weak_spots ) { + $sql_vars[] = json_encode($sig_result->weak_spots); + } + if ( $sig_error_json !== '' ) { + $sql_vars[] = $sig_error_json; + } + $sql_vars[] = $fast_hash; - $this->db->prepare($sql, $sql_vars)->execute(); + $this->db->prepare($sql, $sql_vars)->execute(); - if ( $sig_result->status === 'INFECTED' ) { - $suspicious_files_found++; - } + if ( empty($sig_result->error_msg) && $sig_result->status === 'INFECTED' ) { + $suspicious_files_found++; } } - // Make 'processed' counter big enough to make an another iteration with new files - $scanned = 5; + // Prolong stage only when a new pending include was added (breaks A↔B loops). + if ( $is_new ) { + $scanned = 5; + } } } diff --git a/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php b/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php new file mode 100644 index 000000000..65dae3653 --- /dev/null +++ b/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php @@ -0,0 +1,149 @@ +tmp_dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'spbc_inc_' . uniqid('', true); + mkdir($this->tmp_dir); + } + + protected function tearDown(): void + { + if ( $this->abspath_fixture && is_file($this->abspath_fixture) ) { + @unlink($this->abspath_fixture); + } + if ( is_dir($this->tmp_dir) ) { + foreach ( glob($this->tmp_dir . DIRECTORY_SEPARATOR . '*') ?: array() as $file ) { + @unlink($file); + } + @rmdir($this->tmp_dir); + } + parent::tearDown(); + } + + public function testRelativeStringIncludeResolved() + { + $this->writeFixture('target.php', "writeFixture( + 'scanner.php', + "collectIncludes($scanner); + $this->assertCount(1, $includes); + $this->assertTrue($includes[0]['exists']); + $this->assertSame('target.php', $includes[0]['name']); + } + + public function testParenthesesAndWhitespaceResolved() + { + $this->writeFixture('target.php', "writeFixture( + 'scanner.php', + "collectIncludes($scanner); + $this->assertCount(1, $includes); + $this->assertTrue($includes[0]['exists']); + } + + public function testDirConcatResolved() + { + $this->writeFixture('target.php', "writeFixture( + 'scanner.php', + "collectIncludes($scanner); + $this->assertCount(1, $includes); + $this->assertTrue($includes[0]['exists'], 'Expected __DIR__ . \'/target.php\' to resolve'); + $this->assertSame('target.php', $includes[0]['name']); + } + + public function testAbspathConcatResolved() + { + $this->assertTrue(defined('ABSPATH'), 'ABSPATH must be defined in WP test bootstrap'); + + $rel_name = '_spbc_inc_abspath_fixture_' . uniqid('', true) . '.php'; + $this->abspath_fixture = rtrim(ABSPATH, '/\\') . DIRECTORY_SEPARATOR . $rel_name; + file_put_contents($this->abspath_fixture, "writeFixture( + 'scanner.php', + "collectIncludes($scanner); + $this->assertCount(1, $includes); + $this->assertTrue($includes[0]['exists'], 'Expected ABSPATH . \'' . $rel_name . '\' to resolve'); + $this->assertSame($rel_name, $includes[0]['name']); + } + + public function testVariableConcatStaysUnresolved() + { + $scanner = $this->writeFixture( + 'scanner.php', + "collectIncludes($scanner); + $this->assertCount(1, $includes); + $this->assertSame('', $includes[0]['path']); + } + + public function testUnknownConstantConcatStaysUnresolved() + { + $scanner = $this->writeFixture( + 'scanner.php', + "collectIncludes($scanner); + $this->assertCount(1, $includes); + $this->assertSame('', $includes[0]['path']); + } + + /** + * @param string $path + * + * @return array + */ + private function collectIncludes($path) + { + $analyser = new HeuristicAnalyser(array('path' => $path)); + $analyser->processContent(); + + return $analyser->getIncludes(); + } + + /** + * @param string $name + * @param string $content + * + * @return string Absolute path + */ + private function writeFixture($name, $content) + { + $path = $this->tmp_dir . DIRECTORY_SEPARATOR . $name; + file_put_contents($path, $content); + + return $path; + } +} From ebbd0528f3abdc27475a48f0b97208f148fc9f48 Mon Sep 17 00:00:00 2001 From: svfcode Date: Sun, 19 Jul 2026 15:48:42 +0300 Subject: [PATCH 5/6] fix phpcs --- .../SpbctWP/Scanner/ScannerQueue.php | 31 ++++++++++++++----- .../IncludesResolutionTest.php | 4 ++- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php index cf78ec49a..aa0914017 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php @@ -1017,16 +1017,24 @@ public function file_system_analysis($offset = null, $amount = null, $path_to_sc } } } + $raw_path = $file['path']; $file['path'] = trim($this->db->prepare('%s', $file['path'])->getQuery(), '\''); $file['detected_at'] = $detected_at; - // Heuristic skip for pure JS is handled cheaply in - // HeuristicAnalyser::skipAsFileWithNoPHPCode(). Do not pre-mark .js as - // checked — PHP-in-JS (e.g. via include) must still be analysed. - $file['checked_heuristic'] = 0; + // Skip pure .js on Surface (perf). PHP-in-JS still queued: cheap head check for root . $raw_path; + $head = @file_get_contents($abs_js, false, null, 0, 8192); + $file['checked_heuristic'] = ( + $head !== false && ! preg_match('/<\?(?:php|=)/i', $head) + ) ? 1 : 0; + } else { + $file['checked_heuristic'] = 0; + } // if extension is not set, set 'source' to 'binary' - $ext = @pathinfo($file['path'], PATHINFO_EXTENSION); $file['source'] = ! $ext ? 'BINARY' : ''; if ( ! spbc_check_ascii($file['path']) ) { @@ -1770,9 +1778,16 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR $real_path = realpath($include['path']) ?: $include['path']; $root_norm = rtrim(str_replace('\\', '/', (string) $this->root), '/'); $real_norm = str_replace('\\', '/', (string) $real_path); - $under_root = $spbc->is_windows - ? (stripos($real_norm, $root_norm) === 0) - : (strpos($real_norm, $root_norm) === 0); + $under_root = false; + if ( $spbc->is_windows ) { + if ( stripos($real_norm, $root_norm) === 0 ) { + $rest = substr($real_norm, strlen($root_norm)); + $under_root = ($rest === '' || isset($rest[0]) && $rest[0] === '/'); + } + } elseif ( strpos($real_norm, $root_norm) === 0 ) { + $rest = substr($real_norm, strlen($root_norm)); + $under_root = ($rest === '' || isset($rest[0]) && $rest[0] === '/'); + } // Outside CMS root → unscannable after $root_path . $path; skip if ( ! $under_root ) { diff --git a/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php b/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php index 65dae3653..9916ddda6 100644 --- a/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php +++ b/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php @@ -19,7 +19,9 @@ protected function setUp(): void { parent::setUp(); $this->tmp_dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'spbc_inc_' . uniqid('', true); - mkdir($this->tmp_dir); + if ( ! is_dir($this->tmp_dir) && ! mkdir($this->tmp_dir, 0755, true) && ! is_dir($this->tmp_dir) ) { + $this->fail('Failed to create temp dir: ' . $this->tmp_dir); + } } protected function tearDown(): void From 63e19d3064f6b7b184ba257c467307d315a174b2 Mon Sep 17 00:00:00 2001 From: svfcode Date: Sun, 19 Jul 2026 16:02:54 +0300 Subject: [PATCH 6/6] fix phpcs --- .../IncludesResolutionTest.php | 151 ------------------ 1 file changed, 151 deletions(-) delete mode 100644 tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php diff --git a/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php b/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php deleted file mode 100644 index 9916ddda6..000000000 --- a/tests/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/IncludesResolutionTest.php +++ /dev/null @@ -1,151 +0,0 @@ -tmp_dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'spbc_inc_' . uniqid('', true); - if ( ! is_dir($this->tmp_dir) && ! mkdir($this->tmp_dir, 0755, true) && ! is_dir($this->tmp_dir) ) { - $this->fail('Failed to create temp dir: ' . $this->tmp_dir); - } - } - - protected function tearDown(): void - { - if ( $this->abspath_fixture && is_file($this->abspath_fixture) ) { - @unlink($this->abspath_fixture); - } - if ( is_dir($this->tmp_dir) ) { - foreach ( glob($this->tmp_dir . DIRECTORY_SEPARATOR . '*') ?: array() as $file ) { - @unlink($file); - } - @rmdir($this->tmp_dir); - } - parent::tearDown(); - } - - public function testRelativeStringIncludeResolved() - { - $this->writeFixture('target.php', "writeFixture( - 'scanner.php', - "collectIncludes($scanner); - $this->assertCount(1, $includes); - $this->assertTrue($includes[0]['exists']); - $this->assertSame('target.php', $includes[0]['name']); - } - - public function testParenthesesAndWhitespaceResolved() - { - $this->writeFixture('target.php', "writeFixture( - 'scanner.php', - "collectIncludes($scanner); - $this->assertCount(1, $includes); - $this->assertTrue($includes[0]['exists']); - } - - public function testDirConcatResolved() - { - $this->writeFixture('target.php', "writeFixture( - 'scanner.php', - "collectIncludes($scanner); - $this->assertCount(1, $includes); - $this->assertTrue($includes[0]['exists'], 'Expected __DIR__ . \'/target.php\' to resolve'); - $this->assertSame('target.php', $includes[0]['name']); - } - - public function testAbspathConcatResolved() - { - $this->assertTrue(defined('ABSPATH'), 'ABSPATH must be defined in WP test bootstrap'); - - $rel_name = '_spbc_inc_abspath_fixture_' . uniqid('', true) . '.php'; - $this->abspath_fixture = rtrim(ABSPATH, '/\\') . DIRECTORY_SEPARATOR . $rel_name; - file_put_contents($this->abspath_fixture, "writeFixture( - 'scanner.php', - "collectIncludes($scanner); - $this->assertCount(1, $includes); - $this->assertTrue($includes[0]['exists'], 'Expected ABSPATH . \'' . $rel_name . '\' to resolve'); - $this->assertSame($rel_name, $includes[0]['name']); - } - - public function testVariableConcatStaysUnresolved() - { - $scanner = $this->writeFixture( - 'scanner.php', - "collectIncludes($scanner); - $this->assertCount(1, $includes); - $this->assertSame('', $includes[0]['path']); - } - - public function testUnknownConstantConcatStaysUnresolved() - { - $scanner = $this->writeFixture( - 'scanner.php', - "collectIncludes($scanner); - $this->assertCount(1, $includes); - $this->assertSame('', $includes[0]['path']); - } - - /** - * @param string $path - * - * @return array - */ - private function collectIncludes($path) - { - $analyser = new HeuristicAnalyser(array('path' => $path)); - $analyser->processContent(); - - return $analyser->getIncludes(); - } - - /** - * @param string $name - * @param string $content - * - * @return string Absolute path - */ - private function writeFixture($name, $content) - { - $path = $this->tmp_dir . DIRECTORY_SEPARATOR . $name; - file_put_contents($path, $content); - - return $path; - } -}