diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php index 9e55118fd..ba6c716c8 100644 --- a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php @@ -17,6 +17,7 @@ use CleantalkSP\Common\Scanner\HeuristicAnalyser\Modules\Tokens; use CleantalkSP\Common\Scanner\HeuristicAnalyser\Modules\Transformations; use CleantalkSP\Common\Scanner\HeuristicAnalyser\Modules\Variables; +use CleantalkSP\Common\Scanner\HeuristicAnalyser\Modules\PHPCodeValidator; /** * Class Heuristic @@ -178,6 +179,11 @@ class HeuristicAnalyser */ private $mathematics; + /* + * @var PHPCodeValidator + */ + private $php_code_validator; + /** * Heuristic constructor. * Getting common info about file|text and it's content @@ -229,6 +235,8 @@ public function __construct($input, $self = null) $this->includes = new Includes($this->tokens, $this->variables, $this->curr_dir, $this->is_text); $this->evaluations = new Evaluations($this->tokens, $this->variables, $this->includes, $this->sqls); $this->code_style = new CodeStyle($this->tokens); + $this->php_code_validator = new PHPCodeValidator($this->tokens); + if ( isset($input['path']) && version_compare(PHP_VERSION, '8.1', '>=') && extension_loaded('mbstring') ) { // Do not run entropy analysis on included constructs @@ -276,12 +284,17 @@ private function checkFileSize($file_size) * * @return void * @psalm-suppress PossiblyUnusedMethod + * @throws HeuristicScannerException */ public function processContent() { // Skip files does not contain PHP code - if ( $this->extension !== 'php' && ! $this->code_style->hasPHPOpenTags() ) { - return; + if ( $this->extension !== 'php' && !$this->php_code_validator->hasCorrectPHPOpenTags() ) { + throw new HeuristicScannerException('NOT_PHP_CODE'); + } + + if (!$this->php_code_validator->isValidPHPCode()) { + throw new HeuristicScannerException('NOT_VALID_PHP_CODE'); } // Analysing code style diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php index c5688c4ba..b7daf7df2 100644 --- a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/CodeStyle.php @@ -224,32 +224,6 @@ public function detectBadLines() return $result; } - /** - * Check if file contains PHP open tags ("<\?php" or `<\?`). - * @return bool - */ - public function hasPHPOpenTags() - { - foreach ( $this->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')) - ) { - return true; - } - } - } - } - - return false; - } - /** * Count special service chars like <>!= etc. and return the proportion to the total chars count. * Uses $this->tokens as content source. diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/PHPCodeValidator.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/PHPCodeValidator.php new file mode 100644 index 000000000..0ff4241d9 --- /dev/null +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/PHPCodeValidator.php @@ -0,0 +1,243 @@ +tokens = $tokens; + } + + /** + * Checks if the PHP code is valid. + * + * @return bool Returns true if the PHP code is valid, false otherwise. + * @psalm-suppress PossiblyUnusedMethod + */ + public function isValidPHPCode() + { + $this->hasCorrectPHPOpenTags(); + $this->checkBraces(); + $this->checkBrackets(); + $this->checkParentheses(); + $this->checkSingleQuotes(); + $this->checkDoubleQuotes(); + $this->checkDigitsStartedVariables(); + return empty($this->check_list_result); + } + + /** + * Checks if the count of left and right braces and brackets are equal. + * + * @return bool Returns true if the count is equal, false otherwise. + */ + private function checkBraces() + { + $braces_l_count = 0; + $braces_r_count = 0; + + foreach ( $this->tokens as $token ) { + if ( $token[0] === '__SERV' ) { + if ( $token[1] === '(' ) { + $braces_l_count++; + } + if ( $token[1] === ')' ) { + $braces_r_count++; + } + } + } + + if ( $braces_l_count !== $braces_r_count ) { + $this->check_list_result[__FUNCTION__] = 'Braces () count is not equal ' . $braces_l_count . ' != ' . $braces_r_count; + return false; + } + + return true; + } + + /** + * Checks if the count of left and right brackets are equal. + * + * @return bool Returns true if the count is equal, false otherwise. + */ + private function checkBrackets() + { + $brackets_l_count = 0; + $brackets_r_count = 0; + + foreach ( $this->tokens as $token ) { + if ( $token[0] === '__SERV' ) { + if ( $token[1] === '[' ) { + $brackets_l_count++; + } + if ( $token[1] === ']' ) { + $brackets_r_count++; + } + } + } + + if ( $brackets_l_count !== $brackets_r_count ) { + $this->check_list_result[__FUNCTION__] = 'Brackets [] count is not equal'; + return false; + } + + return true; + } + + /** + * Checks if the count of left and right parentheses are equal. + * + * @return bool Returns true if the count is equal, false otherwise. + */ + private function checkParentheses() + { + $parentheses_l_count = 0; + $parentheses_r_count = 0; + + /** + * init opening and closing tokens, key is token type, value is string to search + */ + $opening_tokens = array( + '__SERV' => '{', + 'T_CURLY_OPEN' => '{', + 'T_DOLLAR_OPEN_CURLY_BRACES' => '${', + 'T_STRING_VARNAME' => '{' + ); + + $closing_tokens = array( + '__SERV' => '}', + 'T_STRING_VARNAME' => '}' + ); + + foreach ( $this->tokens as $token ) { + if ( isset($opening_tokens[$token[0]]) && + $token[1] === $opening_tokens[$token[0]] ) { + $parentheses_l_count++; + } + if ( isset($closing_tokens[$token[0]]) && + $token[1] === $closing_tokens[$token[0]] ) { + $parentheses_r_count++; + } + } + + if ( $parentheses_l_count !== $parentheses_r_count ) { + $this->check_list_result[__FUNCTION__] = 'Parentheses {} count is not equal'; + return false; + } + + return true; + } + + /** + * Checks if the count of single quotes are even. + * + * @return bool Returns true if the count is even, false otherwise. + */ + private function checkSingleQuotes() + { + $single_quotes_count = 0; + + foreach ( $this->tokens as $token ) { + if ( $token[0] === '__SERV' ) { + if ( $token[1] === "'" ) { + $single_quotes_count++; + } + } + } + + if ( $single_quotes_count % 2 !== 0 ) { + $this->check_list_result[__FUNCTION__] = 'Single quotes count is not even'; + return false; + } + return true; + } + + /** + * Checks if the count of double quotes are even. + * + * @return bool Returns true if the count is even, false otherwise. + */ + private function checkDoubleQuotes() + { + $double_quotes_count = 0; + + foreach ( $this->tokens as $token ) { + if ( $token[0] === '__SERV' ) { + if ( $token[1] === '"' ) { + $double_quotes_count++; + } + } + } + + if ( $double_quotes_count % 2 !== 0 ) { + $this->check_list_result[__FUNCTION__] = 'Double quotes count is not even'; + return false; + } + return true; + } + + /** + * Checks if variables contain digits. + * + * @return bool Returns true if no variables contain digits, false otherwise. + */ + private function checkDigitsStartedVariables() + { + foreach ( $this->tokens as $token ) { + if ( $token[0] === 'T_VARIABLE' ) { + if ( preg_match('/^\$\d.+/', $token[1]) ) { + $this->check_list_result[__FUNCTION__] = 'Variable starts with digits [' . $token[1] . ']'; + return false; + } + } + } + return true; + } + + /** + * Checks if the file does not contain PHP open tags ("<\?php" or `<\?`). + * + * @return bool Returns true if the file does not contain PHP open tags, false otherwise. + */ + public function hasCorrectPHPOpenTags() + { + foreach ( $this->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' ) { + $this->check_list_result[__FUNCTION__] = 'PHP open tags are not valid'; + return false; + } + } else { + return true; + } + } + } + } + + return false; + } +} diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Tokens.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Tokens.php index 166b9829f..21e798634 100644 --- a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Tokens.php +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Tokens.php @@ -66,6 +66,51 @@ class Tokens implements \Iterator, \ArrayAccess, \Countable */ private $groups; + /** + * @var Token|null $prev4 The fourth previous token in the iteration. + */ + public $prev4; + + /** + * @var Token|null $prev3 The third previous token in the iteration. + */ + public $prev3; + + /** + * @var Token|null $prev2 The second previous token in the iteration. + */ + public $prev2; + + /** + * @var Token|null $prev1 The first previous token in the iteration. + */ + public $prev1; + + /** + * @var Token|null $current The current token in the iteration. + */ + public $current; + + /** + * @var Token|null $next1 The first next token in the iteration. + */ + public $next1; + + /** + * @var Token|null $next2 The second next token in the iteration. + */ + public $next2; + + /** + * @var Token|null $next3 The third next token in the iteration. + */ + public $next3; + + /** + * @var Token|null $next4 The fourth next token in the iteration. + */ + public $next4; + /** * @param $content * @psalm-suppress PossiblyUnusedMethod @@ -646,6 +691,7 @@ public function count() * * @return Token|null * @psalm-suppress PossiblyUnusedReturnValue + * @psalm-suppress PossiblyUnusedMethod */ public function __get($name) { @@ -679,6 +725,7 @@ public function __get($name) /** * @param $name * @param $value + * @psalm-suppress PossiblyUnusedMethod */ public function __set($name, $value) { diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php index 604532b45..f7f15cdcd 100755 --- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php @@ -1369,6 +1369,12 @@ public function signature_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR $stage_data_obj->increase('scanned_count_files', $scanned); $stage_data_obj->merge('statuses', $statuses->getStatuses()); + //modify error status to skipped - this will be shown in the log only + foreach ( $processed_items as $fast_hash => $item ) { + if ( $item['status'] === 'ERROR' ) { + $processed_items[$fast_hash]['status'] = 'SKIPPED'; + } + } // Adding data for user log $out['processed_items'] = $processed_items; @@ -1527,26 +1533,37 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR $scanned = 5; } } - - $current_error_msg = self::handleErrorMsg($file['error_msg'], $result->error_msg, 'heuristic_analysis'); - - $error_msg = !empty($current_error_msg) - ? '\'' . $current_error_msg . '\'' - : 'NULL'; - - $result_db = $this->db->execute( - 'UPDATE ' . SPBC_TBL_SCAN_FILES - . ' SET ' - . ' checked_heuristic = 1,' - . ' status = \'' . ($file['status'] === 'MODIFIED' ? 'MODIFIED' : $result->status) . '\',' - . ' severity = ' . ($file['severity'] ? '\'' . $file['severity'] . '\'' : ($result->severity ? '\'' . $result->severity . '\'' : 'NULL')) . ',' - . ' weak_spots = ' . ($result->weak_spots - ? QueueHelper::prepareParamForSQLQuery(json_encode($result->weak_spots)) - : 'NULL') - . ',' - . ' error_msg = ' . $error_msg - . ' WHERE fast_hash = \'' . $file['fast_hash'] . '\';' - ); + /** + * If heuristic analysis failed, but file was successfully checked by signatures, just update as checked_heuristic + */ + if ($result->status === 'ERROR' && $file['status'] !== 'ERROR' && !empty($file['severity'])) { + $result_db = $this->db->execute( + 'UPDATE ' . SPBC_TBL_SCAN_FILES + . ' SET ' + . ' checked_heuristic = 1' + . ' WHERE fast_hash = \'' . $file['fast_hash'] . '\';' + ); + } else { + $current_error_msg = self::handleErrorMsg($file['error_msg'], $result->error_msg, 'heuristic_analysis'); + + $error_msg = !empty($current_error_msg) + ? '\'' . $current_error_msg . '\'' + : 'NULL'; + + $result_db = $this->db->execute( + 'UPDATE ' . SPBC_TBL_SCAN_FILES + . ' SET ' + . ' checked_heuristic = 1,' + . ' status = \'' . ($file['status'] === 'MODIFIED' ? 'MODIFIED' : $result->status) . '\',' + . ' severity = ' . ($file['severity'] ? '\'' . $file['severity'] . '\'' : ($result->severity ? '\'' . $result->severity . '\'' : 'NULL')) . ',' + . ' weak_spots = ' . ($result->weak_spots + ? QueueHelper::prepareParamForSQLQuery(json_encode($result->weak_spots)) + : 'NULL') + . ',' + . ' error_msg = ' . $error_msg + . ' WHERE fast_hash = \'' . $file['fast_hash'] . '\';' + ); + } $statuses->addStatus($file['status'] === 'MODIFIED' ? 'MODIFIED' : $result->status); @@ -1565,6 +1582,12 @@ public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR 'end' => (int)$scanned < $amount && !$size_breaking_flag, ); + //modify error status to skipped - this will be shown in the log only + foreach ( $processed_items as $fast_hash => $item ) { + if ( $item['status'] === 'ERROR' ) { + $processed_items[$fast_hash]['status'] = 'SKIPPED'; + } + } // Adding data for user log if ( $processed_items ) { $out['processed_items'] = $processed_items; @@ -1795,7 +1818,7 @@ public function auto_cure($offset = 0, $amount = 1) // phpcs:ignore PSR1.Methods $file_to_check = new FileInfoExtended($file); $result = $heuristic_scanner->scanFile($file_to_check, $root_path); - if ( is_object($result) ) { + if ( is_object($result) && $result->status !== 'ERROR') { $this->db->execute( 'UPDATE ' . SPBC_TBL_SCAN_FILES . ' SET' @@ -2407,7 +2430,7 @@ private static function handleErrorMsg($file_record_error, $new_error_msg, $type if (!in_array($type, ['signature_analysis', 'heuristic_analysis'])) { return ''; } - if (is_null($file_record_error)) { + if (is_null($new_error_msg)) { return ''; } $current_error_msg = json_decode($file_record_error, true); diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScanningLog/Repository.php b/lib/CleantalkSP/SpbctWP/Scanner/ScanningLog/Repository.php index 48108a1e4..8a7fca9e0 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/ScanningLog/Repository.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScanningLog/Repository.php @@ -8,6 +8,8 @@ class Repository { public static function write($content) { + //modify error status to skipped - this will be shown in the log only + $content = str_replace('ERROR', 'SKIPPED', $content); return DB::getInstance()->prepare( 'INSERT INTO ' . SPBC_TBL_SCAN_RESULTS_LOG