From 2d58194621a23d91bb012d33320df82b0c0bec7d Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Wed, 23 Sep 2026 14:45:46 +0700 Subject: [PATCH 1/7] Fix. Code. Optimizing the parsing of approved hashes https://app.doboard.com/1/task/57727 --- .../BinaryCheckModule/BinaryCheckModule.php | 126 +++++------ lib/CleantalkSP/SpbctWP/Scanner/Helper.php | 39 ---- .../SpbctWP/Scanner/RemoteHashesReader.php | 201 ++++++++++++++++++ .../SpbctWP/Scanner/ScannerQueue.php | 87 ++++---- lib/CleantalkSP/SpbctWP/Scanner/Surface.php | 85 ++++++-- 5 files changed, 361 insertions(+), 177 deletions(-) create mode 100644 lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php diff --git a/lib/CleantalkSP/SpbctWP/Scanner/BinaryCheckModule/BinaryCheckModule.php b/lib/CleantalkSP/SpbctWP/Scanner/BinaryCheckModule/BinaryCheckModule.php index fc0d6228f..8286e3183 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/BinaryCheckModule/BinaryCheckModule.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/BinaryCheckModule/BinaryCheckModule.php @@ -19,13 +19,6 @@ class BinaryCheckModule */ private $statuses = array(); - /** - * @var string[] - */ - private static $executable_mime_types = array( - 'application/x-executable' - ); - /** * Runs the BinaryCheckModule to find malicious binary files in a single batch. * @@ -186,47 +179,7 @@ private function incrementStatus($status) private function analyzeBinaryFile($binary_file_path) { - $first_bytes = $this->getBinaryBytes($binary_file_path); - if (false === $first_bytes) { - return false; - } - - // Method 1: Check using ord() to get byte values - $byte1 = ord($first_bytes[0]); - $byte2 = ord($first_bytes[1]); - $byte3 = ord($first_bytes[2]); - $byte4 = ord($first_bytes[3]); - - // Check for ELF (0x7F followed by "ELF" = 0x45 0x4C 0x46) - if ($byte1 === 0x7F && $byte2 === 0x45 && $byte3 === 0x4C && $byte4 === 0x46) { - return true; // ELF file detected - } - - // Check for PE/MZ (Windows executable: 0x4D 0x5A = "MZ") - if ($byte1 === 0x4D && $byte2 === 0x5A) { - return true; // PE file detected - } - - // Check for MACHO (macOS/iOS executables) - // 32-bit big-endian: 0xFE 0xED 0xFA 0xCE - // 32-bit little-endian: 0xCE 0xFA 0xED 0xFE - // 64-bit big-endian: 0xFE 0xED 0xFA 0xCF - // 64-bit little-endian: 0xCF 0xFA 0xED 0xFE - $macho_signatures = array( - array(0xFE, 0xED, 0xFA, 0xCE), // 32-bit big-endian - array(0xCE, 0xFA, 0xED, 0xFE), // 32-bit little-endian - array(0xFE, 0xED, 0xFA, 0xCF), // 64-bit big-endian - array(0xCF, 0xFA, 0xED, 0xFE), // 64-bit little-endian - ); - - foreach ($macho_signatures as $signature) { - if ($byte1 === $signature[0] && $byte2 === $signature[1] && - $byte3 === $signature[2] && $byte4 === $signature[3]) { - return true; // MACHO file detected - } - } - - return false; + return self::hasExecutableSignature($binary_file_path); } private function markAsCritical($binary_file_path) @@ -246,13 +199,9 @@ private function markAsCritical($binary_file_path) * @param $filename * @return false|string String of bytes on success, false on failure. */ - private function getBinaryBytes($filename) + private static function getBinaryBytes($filename) { - if (!file_exists($filename)) { - return false; - } - - $handle = fopen($filename, 'rb'); + $handle = @fopen($filename, 'rb'); if (!$handle) { return false; } @@ -268,37 +217,74 @@ private function getBinaryBytes($filename) } /** - * Read MIME type and make decision if MIME is in expected static array. + * Check the file signature for the known executable formats. + * + * Reading 4 bytes is used instead of finfo_file() intentionally: finfo reads a large part of + * every file and makes the file system passes of the scanner dozens of times slower. + * * @param string $filename - * @param string $extension + * * @return bool */ - public static function probablyBinaryExecutableFile($filename, $extension) + public static function hasExecutableSignature($filename) { - if (!is_string($filename) || !file_exists($filename)) { + $first_bytes = self::getBinaryBytes($filename); + if (false === $first_bytes) { return false; } - $extension = strtolower((string) $extension); + $byte1 = ord($first_bytes[0]); + $byte2 = ord($first_bytes[1]); + $byte3 = ord($first_bytes[2]); + $byte4 = ord($first_bytes[3]); - // only empty or elf extension - if (!in_array($extension, ['', 'elf'], true)) { - return false; + // ELF (0x7F followed by "ELF" = 0x45 0x4C 0x46) + if ($byte1 === 0x7F && $byte2 === 0x45 && $byte3 === 0x4C && $byte4 === 0x46) { + return true; } - if (function_exists('finfo_open') && function_exists('finfo_file')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - if (!$finfo) { - return false; - } - $mime = finfo_file($finfo, $filename); - finfo_close($finfo); + // PE/MZ (Windows executable: 0x4D 0x5A = "MZ") + if ($byte1 === 0x4D && $byte2 === 0x5A) { + return true; + } + + // MACHO (macOS/iOS executables) + $macho_signatures = array( + array(0xFE, 0xED, 0xFA, 0xCE), // 32-bit big-endian + array(0xCE, 0xFA, 0xED, 0xFE), // 32-bit little-endian + array(0xFE, 0xED, 0xFA, 0xCF), // 64-bit big-endian + array(0xCF, 0xFA, 0xED, 0xFE), // 64-bit little-endian + ); - if (in_array($mime, self::$executable_mime_types, true)) { + foreach ($macho_signatures as $signature) { + if ($byte1 === $signature[0] && $byte2 === $signature[1] && + $byte3 === $signature[2] && $byte4 === $signature[3]) { return true; } } return false; } + + /** + * Make decision if the file is an executable binary one. + * @param string $filename + * @param string $extension + * @return bool + */ + public static function probablyBinaryExecutableFile($filename, $extension) + { + $extension = strtolower((string) $extension); + + // only empty or elf extension, checked before any filesystem call + if (!in_array($extension, ['', 'elf'], true)) { + return false; + } + + if (!is_string($filename) || $filename === '') { + return false; + } + + return self::hasExecutableSignature($filename); + } } diff --git a/lib/CleantalkSP/SpbctWP/Scanner/Helper.php b/lib/CleantalkSP/SpbctWP/Scanner/Helper.php index b45b8cbcb..a936d6e40 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/Helper.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/Helper.php @@ -121,45 +121,6 @@ public static function getHashesForModules($cms, $type, $plugin, $version) return array('error' => 'REMOTE_FILE_NOT_FOUND_OR_VERSION_IS_NOT_SUPPORTED__PLUG'); } - /** - * Getting real hashs of approved files - * - * @param string $cms CMS name - * @param string $type Type - approved/rejected - * @param string $version - * - * @return false|string[]|non-empty-array Array with all files hashes or Error Array - */ - public static function getHashesForFiles($cms, $type, $version) - { - $file_path = 'https://cleantalk-security.s3-us-west-2.amazonaws.com/extensions_checksums/' . $cms . '/' . $type . '/' . $version . '.csv.gz'; - if ( HTTP::getResponseCode($file_path) !== 200 ) { - return array('error' => 'REMOTE_FILE_NOT_FOUND'); - } - - $gz_data = HTTP::getContentFromURL($file_path, false); - if ( !empty($gz_data['error']) ) { - return (array) $gz_data; - } - - if ( !function_exists('gzdecode') ) { - return array('error' => 'Function gzdecode not exists. Please update your PHP to version 5.4'); - } - - $data = gzdecode($gz_data); - if ( $data === false ) { - return array('error' => 'COULDNT_UNPACK'); - } - - /** @psalm-suppress UndefinedMethod */ - $lines = CSV::parseCSVLite($data); - if ( count($lines) < 1 ) { - return array('error' => 'Empty hashes file'); - } - - return $lines; - } - /** * Getting real hashes of CMS language (translation) files for all available translations * diff --git a/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php b/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php new file mode 100644 index 000000000..62c580644 --- /dev/null +++ b/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php @@ -0,0 +1,201 @@ +url = sprintf(self::URL_TEMPLATE, $cms, $type, $version); + } + + /** + * Downloads the compressed list. Has to be called before the chunks() iteration. + * + * @return bool False on any error, see getError() + */ + public function fetch() + { + if ( ! function_exists('inflate_init') ) { + $this->error = 'Required zlib functions are not available. Please enable the zlib PHP extension.'; + return false; + } + + if ( HTTP::getResponseCode($this->url) !== 200 ) { + $this->error = 'REMOTE_FILE_NOT_FOUND'; + return false; + } + + $gz_data = HTTP::getContentFromURL($this->url, false); + + if ( is_array($gz_data) ) { + $this->error = ! empty($gz_data['error']) ? (string)$gz_data['error'] : 'REMOTE_FILE_BAD_RESPONSE'; + return false; + } + + if ( ! is_string($gz_data) || $gz_data === '' ) { + $this->error = 'Empty hashes file'; + return false; + } + + $this->gz_data = $gz_data; + + return true; + } + + /** + * Inflates the downloaded list and yields validated hashes by chunks. + * + * @param int $chunk_size + * + * @return \Generator + * @psalm-return \Generator, mixed, void> + */ + public function chunks($chunk_size = 5000) + { + $this->processed_count = 0; + + $inflate_context = inflate_init(ZLIB_ENCODING_GZIP); + if ( $inflate_context === false ) { + $this->error = 'COULDNT_UNPACK'; + return; + } + + $gz_length = strlen($this->gz_data); + $chunk = array(); + $tail = ''; + + for ( $offset = 0; $offset < $gz_length; $offset += self::READ_BLOCK_SIZE ) { + $is_last_block = $offset + self::READ_BLOCK_SIZE >= $gz_length; + + $inflated = @inflate_add( + $inflate_context, + substr($this->gz_data, $offset, self::READ_BLOCK_SIZE), + $is_last_block ? ZLIB_FINISH : ZLIB_NO_FLUSH + ); + + if ( $inflated === false ) { + $this->gz_data = ''; + $this->error = 'COULDNT_UNPACK'; + return; + } + + $tail .= $inflated; + $inflated = null; + + $lines = explode("\n", $tail); + // the last element is an incomplete line, it will be completed by the next block + $tail = (string)array_pop($lines); + + foreach ( $lines as $line ) { + $hash = self::extractHash($line); + if ( $hash === null ) { + continue; + } + + $chunk[] = $hash; + + if ( count($chunk) >= $chunk_size ) { + $this->processed_count += count($chunk); + yield $chunk; + $chunk = array(); + } + } + } + + $this->gz_data = ''; + + // the very last line may come without the trailing newline + $hash = self::extractHash($tail); + if ( $hash !== null ) { + $chunk[] = $hash; + } + + if ( count($chunk) ) { + $this->processed_count += count($chunk); + yield $chunk; + } + } + + /** + * Amount of the hashes passed to the caller during the last chunks() iteration + * + * @return int + */ + public function getProcessedCount() + { + return $this->processed_count; + } + + /** + * @return string|null + */ + public function getError() + { + return $this->error; + } + + /** + * Gets the hash from the "path,hash" CSV line + * + * @param string $line + * + * @return string|null Null if the line is empty or malformed + */ + private static function extractHash($line) + { + $line = trim($line); + if ( $line === '' ) { + return null; + } + + $delimiter_position = strrpos($line, ','); + $hash = $delimiter_position === false ? $line : substr($line, $delimiter_position + 1); + + // the hashes are used in the raw SQL below, so anything but an alphanumeric string is skipped + return preg_match('#^[a-zA-Z0-9]+$#', $hash) ? $hash : null; + } +} diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php index 2c0957dff..b646d4cce 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php @@ -1023,16 +1023,18 @@ public function file_system_analysis($offset = null, $amount = null, $path_to_sc $sql_hat = 'INSERT INTO ' . SPBC_TBL_SCAN_FILES . ' (`path`, `size`, `perms`, `mtime`, `fast_hash`, `full_hash`, `detected_at`, `checked_heuristic`, `source`) VALUES '; + $cure_log = new CureLog(); + $restored_files = $cure_log->getRestoredFiles(); + $restored_paths = array(); + foreach ( $restored_files as $restored_file ) { + $restored_paths[$restored_file['real_path']] = true; + } + unset($restored_files); + foreach ( $scanner->output_files as $_key => $file ) { // skip restored files as is - $cure_log = new CureLog(); - $restored = $cure_log->getRestoredFiles(); - if (!empty($restored)) { - foreach ($restored as $restored_file) { - if ($restored_file['real_path'] === $file['path']) { - continue 2; - } - } + if ( isset($restored_paths[$file['path']]) ) { + continue; } $raw_path = $file['path']; $file['path'] = trim($this->db->prepare('%s', $file['path'])->getQuery(), '\''); @@ -1319,29 +1321,16 @@ public function get_denied_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMethod $warnings_before_iteration = ScanResultsTableActions::getFilesByCategory('critical'); - $result = \CleantalkSP\SpbctWP\Scanner\Helper::getHashesForFiles('wordpress', 'denied', '1.0.0'); + $hashes_reader = new RemoteHashesReader('wordpress', 'denied', '1.0.0'); + $hashes_fetched = $hashes_reader->fetch(); $scanning_stages_storage = new ScanningStagesStorage(); $scanning_stages_storage->converter->loadCollection(); $stage_data_obj = $scanning_stages_storage->getStage(GetDeniedHashes::class); - if ( empty($result['error']) ) { - $spbc->data['scanner']['checksums_count_ct'] = count($result); - $spbc->save('data'); - - /** @var list $result_escaped */ - $result_escaped = array_map(function ($v): string { - $value = is_scalar($v) ? (string) $v : ''; - $escaped = esc_sql($value); - return is_string($escaped) ? $escaped : ''; - }, $result); - - foreach ( $result_escaped as $hash ) { - if ( ! preg_match('#^[a-zA-Z0-9]+$#', $hash) ) { - return array('error' => 'BAD_PARAMS'); - } - } + $denied_hashes_count = 0; + if ( $hashes_fetched ) { /** * Reset previously denied files; hashes still in the list will be re-marked below. * Avoids a single huge NOT IN (...) that can exceed max_allowed_packet. @@ -1358,10 +1347,11 @@ public function get_denied_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMethod ); /** - * Update the db depend on current hashes list (chunked to avoid max_allowed_packet). + * Update the db depend on current hashes list (chunked to avoid max_allowed_packet + * and to keep the whole list out of the memory). */ $updated_rows = 0; - foreach ( array_chunk($result_escaped, 5000) as $chunk ) { + foreach ( $hashes_reader->chunks(5000) as $chunk ) { $where = implode('\',\'', $chunk); $updated_rows += (int) $this->db->execute( 'UPDATE ' . SPBC_TBL_SCAN_FILES @@ -1376,14 +1366,19 @@ public function get_denied_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMethod ); } - $stage_data_obj->set('count_denied_hashes', count($result)); + $denied_hashes_count = $hashes_reader->getProcessedCount(); + + $spbc->data['scanner']['checksums_count_ct'] = $denied_hashes_count; + $spbc->save('data'); + + $stage_data_obj->set('count_denied_hashes', $denied_hashes_count); $stage_data_obj->set('count_denied_hashes_in_db', $updated_rows); $scanning_stages_storage->saveToDb(); } $out = array( 'end' => 1, - 'processed' => empty($result['error']) ? count($result) : 0, + 'processed' => $denied_hashes_count, ); // Adding to log @@ -1424,32 +1419,16 @@ public function get_approved_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMeth $warnings_before_iteration = ScanResultsTableActions::getFilesByCategory('critical'); - $result = \CleantalkSP\SpbctWP\Scanner\Helper::getHashesForFiles('wordpress', 'approved', '1.0.0'); + $hashes_reader = new RemoteHashesReader('wordpress', 'approved', '1.0.0'); + $hashes_fetched = $hashes_reader->fetch(); $scanning_stages_storage = new ScanningStagesStorage(); $scanning_stages_storage->converter->loadCollection(); $stage_data_obj = $scanning_stages_storage->getStage(GetApprovedHashes::class); - $approved_hashes_count = empty($result['error']) ? count($result) : 0; - - if ( empty($result['error']) ) { - /** @var list $result_escaped */ - $result_escaped = array_map(function ($v): string { - $value = is_scalar($v) ? (string) $v : ''; - $escaped = esc_sql($value); - return is_string($escaped) ? $escaped : ''; - }, $result); - unset($result); - - $spbc->data['scanner']['checksums_count_ct'] = $approved_hashes_count; - $spbc->save('data'); - - foreach ( $result_escaped as $hash ) { - if ( ! preg_match('#^[a-zA-Z0-9]+$#', $hash) ) { - return array('error' => 'BAD_PARAMS'); - } - } + $approved_hashes_count = 0; + if ( $hashes_fetched ) { /** * Reset previously approved files; hashes still in the list will be re-marked below. * Avoids a single huge NOT IN (...) that can exceed max_allowed_packet. @@ -1465,10 +1444,11 @@ public function get_approved_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMeth ); /** - * Update the db depend on current hashes list (chunked to avoid max_allowed_packet). + * Update the db depend on current hashes list (chunked to avoid max_allowed_packet + * and to keep the whole list out of the memory). */ $updated_rows = 0; - foreach ( array_chunk($result_escaped, 5000) as $chunk ) { + foreach ( $hashes_reader->chunks(5000) as $chunk ) { $where = implode('\',\'', $chunk); $updated_rows += (int) $this->db->execute( 'UPDATE ' . SPBC_TBL_SCAN_FILES @@ -1481,6 +1461,11 @@ public function get_approved_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMeth ); } + $approved_hashes_count = $hashes_reader->getProcessedCount(); + + $spbc->data['scanner']['checksums_count_ct'] = $approved_hashes_count; + $spbc->save('data'); + $stage_data_obj->set('count_approved_hashes', $approved_hashes_count); $stage_data_obj->set('count_approved_hashes_in_db', $updated_rows); $scanning_stages_storage->saveToDb(); diff --git a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php index 4f18b6072..fe9fa08bb 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php @@ -542,13 +542,17 @@ public function countFilesInDir($main_path) if ($fileInfo->isFile()) { $filename = $fileInfo->getFileName(); $current_file_extension = pathinfo($filename, PATHINFO_EXTENSION); - $real_path = $fileInfo->getRealPath(); + // getRealPath() resolves symlinks and is expensive, links are skipped above anyway + $real_path = $fileInfo->getPathname(); - $probably_binary = BinaryCheckModule::probablyBinaryExecutableFile($real_path, $current_file_extension); $skip_by_restrictions = $this->skipFileByRestrictions($current_file_extension, $real_path); $skip_by_filename = ! empty($this->files_except) && in_array($fileInfo->getPathname(), $this->files_except, true); - if (!$probably_binary && ($skip_by_restrictions || $skip_by_filename)) { + // binary executables are counted even when the common filters reject them + if ( + ($skip_by_restrictions || $skip_by_filename) && + ! BinaryCheckModule::probablyBinaryExecutableFile($real_path, $current_file_extension) + ) { continue; } @@ -627,7 +631,6 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) // $current_file_extension = pathinfo($path, PATHINFO_EXTENSION); - $probably_binary = BinaryCheckModule::probablyBinaryExecutableFile($path, $current_file_extension); $skip_by_restrictions = $this->skipFileByRestrictions($current_file_extension, $path); $skip_by_filename = false; @@ -643,8 +646,11 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) $skip_by_filename = in_array(ltrim($relative_path, '/\\'), $files_except_array, true); } - //main skip logic check - if (!$probably_binary && ($skip_by_restrictions || $skip_by_filename)) { + // main skip logic check, binary executables are collected even when the common filters reject them + if ( + ($skip_by_restrictions || $skip_by_filename) && + ! BinaryCheckModule::probablyBinaryExecutableFile($path, $current_file_extension) + ) { continue; } @@ -716,15 +722,12 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) public function fileDetails($file_list, $path_offset) { global $wpdb, $spbc; - + $stored_full_hashes = $this->getStoredFullHashes($file_list, $path_offset); foreach ( $file_list as $key => $val ) { // Cutting file's path, leave path from CMS ROOT to file // This order is important!!! - $this->output_files[$key]['path'] = substr( - $spbc->is_windows ? str_replace('/', '\\', $val['path']) : $val['path'], - $path_offset - ); + $this->output_files[$key]['path'] = $this->getRelativePath($val['path'], $path_offset); $this->output_files[$key]['size'] = @filesize($val['path']); $this->output_files[$key]['perms'] = substr(decoct(@fileperms($val['path'])), 3); $mtime = @filemtime($val['path']); @@ -757,12 +760,7 @@ public function fileDetails($file_list, $path_offset) $current_file_full_hash = md5_file($val['path']); - $sql = $wpdb->prepare( - 'SELECT `full_hash` FROM ' . SPBC_TBL_SCAN_FILES . ' WHERE `fast_hash` = %s', - $fast_hash - ); - - $db_full_hash = $wpdb->get_var($sql); + $db_full_hash = isset($stored_full_hashes[$fast_hash]) ? $stored_full_hashes[$fast_hash] : null; if ( $db_full_hash @@ -798,6 +796,59 @@ public function fileDetails($file_list, $path_offset) } } + /** + * Path from the CMS root to the file + * + * @param string $path + * @param int $path_offset + * + * @return string + */ + private function getRelativePath($path, $path_offset) + { + global $spbc; + + return substr($spbc->is_windows ? str_replace('/', '\\', $path) : $path, $path_offset); + } + + /** + * Gets the stored full hashes for the whole files batch at once + * + * @param array $file_list + * @param int $path_offset + * + * @return array fast_hash => full_hash + */ + private function getStoredFullHashes($file_list, $path_offset) + { + global $wpdb; + + $fast_hashes = array(); + foreach ( $file_list as $val ) { + $fast_hashes[] = md5($this->getRelativePath($val['path'], $path_offset)); + } + + $stored_full_hashes = array(); + + foreach ( array_chunk($fast_hashes, 1000) as $chunk ) { + $placeholders = implode(',', array_fill(0, count($chunk), '%s')); + $rows = $wpdb->get_results( + $wpdb->prepare( + 'SELECT `fast_hash`, `full_hash` FROM ' . SPBC_TBL_SCAN_FILES + . ' WHERE `fast_hash` IN (' . $placeholders . ')', + $chunk + ), + ARRAY_A + ); + + foreach ( (array)$rows as $row ) { + $stored_full_hashes[$row['fast_hash']] = $row['full_hash']; + } + } + + return $stored_full_hashes; + } + /** * Getting dir details * From 99e828d0123fd50e9f0e06fee2f647b958fd856f Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Wed, 23 Sep 2026 15:01:56 +0700 Subject: [PATCH 2/7] Fix psalm errors --- lib/CleantalkSP/Common/Helpers/CSV.php | 1 + lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/CleantalkSP/Common/Helpers/CSV.php b/lib/CleantalkSP/Common/Helpers/CSV.php index 72b3279c6..5d91b9635 100644 --- a/lib/CleantalkSP/Common/Helpers/CSV.php +++ b/lib/CleantalkSP/Common/Helpers/CSV.php @@ -41,6 +41,7 @@ public static function sanitizeFromEmptyLines(&$buffer) * @param string $buffer * * @return string[] + * @psalm-suppress PossiblyUnusedMethod */ public static function parseCSVLite($buffer) { diff --git a/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php b/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php index 62c580644..e521c3538 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php @@ -123,12 +123,11 @@ public function chunks($chunk_size = 5000) return; } - $tail .= $inflated; - $inflated = null; + $tail .= $inflated; $lines = explode("\n", $tail); // the last element is an incomplete line, it will be completed by the next block - $tail = (string)array_pop($lines); + $tail = array_pop($lines); foreach ( $lines as $line ) { $hash = self::extractHash($line); @@ -172,6 +171,7 @@ public function getProcessedCount() /** * @return string|null + * @psalm-suppress PossiblyUnusedMethod */ public function getError() { From 4938760f5be3109f0028ecc6760075e5910df348 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Thu, 24 Sep 2026 17:23:55 +0700 Subject: [PATCH 3/7] Fix. HeuristicAnalyser. Remove is_callable check --- .../Scanner/HeuristicAnalyser/HeuristicAnalyser.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php index 84e2ad581..d67c6e70e 100644 --- a/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php +++ b/lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/HeuristicAnalyser.php @@ -639,9 +639,13 @@ private function checkingSpecialDecryptedToken(DataStructures\Token $token) return false; } - return $token->type === 'T_CONSTANT_ENCAPSED_STRING' && - is_callable(trim((string)$token->value, '\'')) && - in_array(trim((string)$token->value, '\''), $this->dangerous_decoded_values, true); + if ( $token->type !== 'T_CONSTANT_ENCAPSED_STRING' ) { + return false; + } + + $value = trim((string)$token->value, '\''); + + return in_array($value, $this->dangerous_decoded_values, true); } private function checkingDecryptedToken(DataStructures\Token $token) From 6f71bc04d67e3b1ffa68e8ce0626f6b7e44cdd62 Mon Sep 17 00:00:00 2001 From: Glomberg Date: Mon, 28 Sep 2026 14:55:08 +0300 Subject: [PATCH 4/7] Version: 2.189.99-dev. --- security-malware-firewall.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/security-malware-firewall.php b/security-malware-firewall.php index 0f6225608..1813c541d 100644 --- a/security-malware-firewall.php +++ b/security-malware-firewall.php @@ -5,7 +5,7 @@ Plugin URI: https://wordpress.org/plugins/security-malware-firewall/ Description: Security & Malware scan by CleanTalk to protect your website from online threats and viruses. IP/Country FireWall, Web application FireWall. Detailed stats and logs to have full control. Author: CleanTalk Security -Version: 2.189 +Version: 2.189.99-fix Author URI: https://cleantalk.org Text Domain: security-malware-firewall Domain Path: /i18n From 62c6eb42169cf3ed1752b5ea41752f4b9ed23f72 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Wed, 7 Oct 2026 16:01:14 +0700 Subject: [PATCH 5/7] Dev. Code. Copilot reviewe --- .../SpbctWP/Scanner/RemoteHashesReader.php | 107 ++++++++++++++---- .../SpbctWP/Scanner/ScannerQueue.php | 8 +- 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php b/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php index e521c3538..24f8447d1 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/RemoteHashesReader.php @@ -86,6 +86,51 @@ public function fetch() return true; } + /** + * Inflates the whole stream once without exposing the data, so the caller can start writing + * to the database only when the list is known to be complete. + * + * @return bool False on any error, see getError() + */ + public function validate() + { + $expected_length = $this->getExpectedRawLength(); + $inflated_length = 0; + + foreach ( $this->inflateStream() as $block ) { + $inflated_length += strlen($block); + } + + if ( $this->error !== null ) { + return false; + } + + // A truncated body inflates without an error, only the gzip trailer exposes it + if ( $expected_length > 0 && $inflated_length !== $expected_length ) { + $this->error = 'TRUNCATED_HASHES_FILE'; + return false; + } + + return true; + } + + /** + * Uncompressed size stored in the gzip trailer. + * + * @return int 0 when it cannot be read or does not fit into an integer + */ + private function getExpectedRawLength() + { + if ( strlen($this->gz_data) < 18 ) { + return 0; + } + + $trailer = unpack('V', substr($this->gz_data, -4)); + $length = isset($trailer[1]) ? (int)$trailer[1] : 0; + + return $length > 0 ? $length : 0; + } + /** * Inflates the downloaded list and yields validated hashes by chunks. * @@ -98,32 +143,11 @@ public function chunks($chunk_size = 5000) { $this->processed_count = 0; - $inflate_context = inflate_init(ZLIB_ENCODING_GZIP); - if ( $inflate_context === false ) { - $this->error = 'COULDNT_UNPACK'; - return; - } - - $gz_length = strlen($this->gz_data); - $chunk = array(); - $tail = ''; - - for ( $offset = 0; $offset < $gz_length; $offset += self::READ_BLOCK_SIZE ) { - $is_last_block = $offset + self::READ_BLOCK_SIZE >= $gz_length; - - $inflated = @inflate_add( - $inflate_context, - substr($this->gz_data, $offset, self::READ_BLOCK_SIZE), - $is_last_block ? ZLIB_FINISH : ZLIB_NO_FLUSH - ); - - if ( $inflated === false ) { - $this->gz_data = ''; - $this->error = 'COULDNT_UNPACK'; - return; - } + $chunk = array(); + $tail = ''; - $tail .= $inflated; + foreach ( $this->inflateStream() as $block ) { + $tail .= $block; $lines = explode("\n", $tail); // the last element is an incomplete line, it will be completed by the next block @@ -159,6 +183,39 @@ public function chunks($chunk_size = 5000) } } + /** + * Inflates the gzip stream block by block, the decompressed list never exists as a whole. + * + * @return \Generator + * @psalm-return \Generator + */ + private function inflateStream() + { + $inflate_context = inflate_init(ZLIB_ENCODING_GZIP); + $gz_length = strlen($this->gz_data); + $failed = $inflate_context === false; + + for ( $offset = 0; ! $failed && $offset < $gz_length; $offset += self::READ_BLOCK_SIZE ) { + $is_last_block = $offset + self::READ_BLOCK_SIZE >= $gz_length; + + $inflated = @inflate_add( + $inflate_context, + substr($this->gz_data, $offset, self::READ_BLOCK_SIZE), + $is_last_block ? ZLIB_FINISH : ZLIB_NO_FLUSH + ); + + if ( $inflated === false ) { + $failed = true; + } else { + yield $inflated; + } + } + + if ( $failed ) { + $this->error = 'COULDNT_UNPACK'; + } + } + /** * Amount of the hashes passed to the caller during the last chunks() iteration * diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php index dda36a911..5a22c5ba5 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php @@ -2318,7 +2318,9 @@ public function get_denied_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMethod $warnings_before_iteration = ScanResultsTableActions::getFilesByCategory('critical'); $hashes_reader = new RemoteHashesReader('wordpress', 'denied', '1.0.0'); - $hashes_fetched = $hashes_reader->fetch(); + // the list is validated before the reset below: a truncated one would leave the already + // classified files unmarked + $hashes_fetched = $hashes_reader->fetch() && $hashes_reader->validate(); $scanning_stages_storage = new ScanningStagesStorage(); $scanning_stages_storage->converter->loadCollection(); @@ -2416,7 +2418,9 @@ public function get_approved_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMeth $warnings_before_iteration = ScanResultsTableActions::getFilesByCategory('critical'); $hashes_reader = new RemoteHashesReader('wordpress', 'approved', '1.0.0'); - $hashes_fetched = $hashes_reader->fetch(); + // the list is validated before the reset below: a truncated one would leave the already + // classified files unmarked + $hashes_fetched = $hashes_reader->fetch() && $hashes_reader->validate(); $scanning_stages_storage = new ScanningStagesStorage(); $scanning_stages_storage->converter->loadCollection(); From 35c99819b9a21b4cf41fe6d7b9934f7e8c2e72d5 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Wed, 7 Oct 2026 16:05:47 +0700 Subject: [PATCH 6/7] Dev. Code. Sync common lib --- lib/CleantalkSP/Common/Helpers/CSV.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/CleantalkSP/Common/Helpers/CSV.php b/lib/CleantalkSP/Common/Helpers/CSV.php index 5d91b9635..72b3279c6 100644 --- a/lib/CleantalkSP/Common/Helpers/CSV.php +++ b/lib/CleantalkSP/Common/Helpers/CSV.php @@ -41,7 +41,6 @@ public static function sanitizeFromEmptyLines(&$buffer) * @param string $buffer * * @return string[] - * @psalm-suppress PossiblyUnusedMethod */ public static function parseCSVLite($buffer) { From c80d383b99ccfaf2dbd27cfc04cf57c178baa161 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Wed, 7 Oct 2026 16:38:39 +0700 Subject: [PATCH 7/7] Dev. Code. Sync common lib --- lib/CleantalkSP/Common/Helpers/CSV.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/CleantalkSP/Common/Helpers/CSV.php b/lib/CleantalkSP/Common/Helpers/CSV.php index 72b3279c6..5d91b9635 100644 --- a/lib/CleantalkSP/Common/Helpers/CSV.php +++ b/lib/CleantalkSP/Common/Helpers/CSV.php @@ -41,6 +41,7 @@ public static function sanitizeFromEmptyLines(&$buffer) * @param string $buffer * * @return string[] + * @psalm-suppress PossiblyUnusedMethod */ public static function parseCSVLite($buffer) {