$m[1], $body); } return preg_replace_callback('~\\\\(x[0-9a-fA-F]{1,2}|[0-7]{1,3}|u\{[0-9a-fA-F]+\}|.)~s', static function ($m): string { $value = $m[1]; $basic = ['n' => "\n", 'r' => "\r", 't' => "\t", 'v' => "\v", 'e' => "\x1b", 'f' => "\f", '\\' => '\\', '$' => '$', '"' => '"']; if (array_key_exists($value, $basic)) { return $basic[$value]; } if (preg_match('/^x([0-9a-fA-F]{1,2})$/', $value, $match)) { return chr(hexdec($match[1])); } if (preg_match('/^[0-7]{1,3}$/', $value)) { return chr(octdec($value) & 255); } if (preg_match('/^u\{([0-9a-fA-F]+)\}$/', $value, $match)) { $codepoint = hexdec($match[1]); if ($codepoint > 0x10FFFF || ($codepoint >= 0xD800 && $codepoint <= 0xDFFF)) { throw new RuntimeException('Invalid Unicode escape in configuration.php'); } return html_entity_decode('&#' . $codepoint . ';', ENT_QUOTES | ENT_HTML5, 'UTF-8'); } return '\\' . $value; // PHP preserves unknown escapes in double-quoted literals. }, $body); } function readJoomlaConfiguration(string $path): array { $source = @file_get_contents($path); if ($source === false) { throw new RuntimeException('Cannot read Joomla configuration.php'); } $tokens = token_get_all($source); // Lex only: never include/eval compromised PHP. $properties = []; $wanted = ['host', 'user', 'password', 'db', 'dbprefix', 'dbtype']; $depth = 0; $classDepth = null; $pendingClass = false; foreach ($tokens as $i => $token) { if (is_array($token) && $token[0] === T_CLASS && $classDepth === null) { $nameAt = nextToken($tokens, $i + 1); $name = $tokens[$nameAt] ?? null; if (is_array($name) && $name[0] === T_STRING && strcasecmp($name[1], 'JConfig') === 0) { $pendingClass = true; } } if ($token === '{') { $depth++; if ($pendingClass) { $classDepth = $depth; $pendingClass = false; } continue; } if ($token === '}') { if ($classDepth === $depth) { $classDepth = null; } $depth--; continue; } if ($classDepth === null || $depth !== $classDepth || !is_array($token) || $token[0] !== T_VARIABLE) { continue; } $key = substr($token[1], 1); if (!in_array($key, $wanted, true)) { continue; } $equalsAt = nextToken($tokens, $i + 1); if (($tokens[$equalsAt] ?? null) !== '=') { continue; } $valueAt = nextToken($tokens, $equalsAt + 1); $value = $tokens[$valueAt] ?? null; if (!is_array($value) || $value[0] !== T_CONSTANT_ENCAPSED_STRING || ($tokens[nextToken($tokens, $valueAt + 1)] ?? null) !== ';') { throw new RuntimeException('Database setting $' . $key . ' is not a simple quoted string'); } if (array_key_exists($key, $properties)) { throw new RuntimeException('Duplicate database setting $' . $key); } $properties[$key] = literalString($value[1]); } foreach (['host', 'user', 'password', 'db', 'dbprefix'] as $key) { if (!array_key_exists($key, $properties)) { throw new RuntimeException('Missing database setting $' . $key . ' in JConfig'); } } if (isset($properties['dbtype']) && !in_array(strtolower($properties['dbtype']), ['mysqli', 'pdomysql', 'mysql'], true)) { throw new RuntimeException('Only MySQL/MariaDB Joomla databases are supported'); } return $properties; } function databaseEndpoint(string $value): array { $host = $value; $port = 3306; $socket = null; if (preg_match('/^([^:]+):(\/.*)$/', $value, $match)) { $host = $match[1]; $socket = $match[2]; } elseif (preg_match('/^\[([^]]+)\]:(\d+)$/', $value, $match)) { $host = $match[1]; $port = (int) $match[2]; } elseif (preg_match('/^([^:]+):(\d+)$/', $value, $match)) { $host = $match[1]; $port = (int) $match[2]; } return [$host, $port, $socket]; } function quotedIdentifier(string $value): string { return '`' . str_replace('`', '``', $value) . '`'; } function safeLabel(string $value): string { // Identifiers only; never place database cell contents or commands in the report. $value = preg_replace('/[^a-zA-Z0-9_.-]/', '?', $value); return substr($value, 0, 120); } /** Returns the labels for one high-confidence cluster, or null. */ function classifyWindow(string $text): ?array { $social = preg_match('~\b(?:verify (?:that )?you(?:\x27|’)?re human|verify you are human|human verification|complete (?:these|the) verification steps|i(?:\x27|’)?m not a robot|captcha|cloudflare verification)\b~i', $text); $action = preg_match('~\b(?:win(?:dows)?\s*\+\s*r|ctrl\s*\+\s*v|paste (?:the )?(?:copied )?command|paste (?:it )?into (?:the )?(?:run|powershell|terminal)|open (?:the )?(?:windows )?run (?:dialog|box))\b~i', $text); $clipboard = preg_match('~(?:navigator\s*\.\s*clipboard\s*\.\s*writeText|clipboardData\s*\.\s*setData|execCommand\s*\(\s*[\x27\x22]copy[\x27\x22])~i', $text); $shell = preg_match('~\b(?:powershell(?:\.exe)?|pwsh(?:\.exe)?|mshta(?:\.exe)?|cmd\.exe)\b~i', $text); $strongCommand = preg_match('~(?:\b(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\b.{0,160}(?:-enc(?:odedcommand)?\b|\b(?:iex|irm|iwr)\b|invoke-(?:expression|restmethod|webrequest)|frombase64string|\-w(?:indowstyle)?\s+hidden)|\b(?:iex|invoke-expression)\s*\(?\s*(?:irm|iwr|invoke-(?:restmethod|webrequest))\b|\bmshta(?:\.exe)?\s+(?:[\x27\x22])?https?://|\b(?:curl|wget)\b.{0,160}\|\s*(?:sh|bash|zsh)\b)~is', $text); if (!(($social && $action && ($clipboard || $strongCommand)) || ($clipboard && $strongCommand))) { return null; } $labels = []; if ($social) $labels[] = 'fake-verification wording'; if ($action) $labels[] = 'run/paste instructions'; if ($clipboard) $labels[] = 'clipboard copying'; if ($shell || $strongCommand) $labels[] = 'shell command'; if ($strongCommand) $labels[] = 'download/execute pattern'; return $labels; } /** Only examine nearby indicators; unrelated features in a long file must not combine. */ function findClickfix(string $source): ?array { $source = html_entity_decode($source, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $anchor = '~(?:clipboard|execCommand|verification|captcha|verify.{0,35}human|win(?:dows)?\s*\+\s*r|ctrl\s*\+\s*v|paste (?:the )?command)~i'; $offset = 0; while (preg_match($anchor, $source, $matches, PREG_OFFSET_CAPTURE, $offset) === 1) { $at = $matches[0][1]; $start = max(0, $at - 1000); $window = substr($source, $start, 2000); $labels = classifyWindow($window); if ($labels !== null) { $line = substr_count($source, "\n", 0, $at) + 1; return ['line' => $line, 'labels' => $labels]; } $offset = $at + strlen($matches[0][0]); } return null; } /** Keep byte offsets intact while hiding comments, strings and HTML from PHP rules. */ function executablePhp(string $source): string { $output = ''; foreach (token_get_all($source) as $token) { $part = is_array($token) ? $token[1] : $token; if (is_array($token) && in_array($token[0], [T_COMMENT, T_DOC_COMMENT, T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE, T_INLINE_HTML], true) && !($token[0] === T_CONSTANT_ENCAPSED_STRING && stripos($part, 'php://input') !== false)) { $output .= preg_replace('/[^\n]/', ' ', $part); } else { $output .= $part; } } return $output; } function lineNumber(string $source, int $offset): int { return substr_count($source, "\n", 0, $offset) + 1; } /** These patterns require an executable action, not a standalone suspicious word. */ function findOtherIndicators(string $source, bool $checkPhp, bool $checkWeb, bool $checkHtaccess = false): array { $hits = []; if ($checkPhp && str_contains($source, ' '~\b(?:eval|system|exec|shell_exec|passthru|popen|proc_open)\s*\(\s*@?\s*(?:\$_(?:GET|POST|REQUEST|COOKIE)\s*\[|file_get_contents\s*\(\s*[\x27\x22]php://input)~i', 'PHP decoder passed to eval' => '~\beval\s*\(\s*@?\s*(?:base64_decode|gzinflate|gzuncompress|str_rot13|hex2bin)\s*\(~i', 'PHP fetched content passed to eval' => '~\beval\s*\(\s*@?\s*(?:file_get_contents|curl_exec)\s*\(~i', ]; foreach ($phpRules as $reason => $pattern) { if (preg_match($pattern, $code, $match, PREG_OFFSET_CAPTURE) === 1) { $hits[] = ['kind' => $reason, 'line' => lineNumber($source, $match[0][1])]; } } } if ($checkWeb) { $web = html_entity_decode($source, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $webRules = [ 'JavaScript eval of decoded payload' => '~\b(?:eval|Function)\s*\(\s*(?:(?:window|self)\s*\.\s*)?atob\s*\(~i', 'JavaScript redirect to decoded destination' => '~\b(?:window\s*\.\s*)?location(?:\s*\.\s*(?:href|assign|replace))?\s*(?:=|\()\s*atob\s*\(~i', 'Base64 JavaScript in script source' => '~]{0,600}\bsrc\s*=\s*[\x27\x22]data:(?:text|application)/javascript;base64,~i', ]; foreach ($webRules as $reason => $pattern) { if (preg_match($pattern, $web, $match, PREG_OFFSET_CAPTURE) === 1) { $hits[] = ['kind' => $reason, 'line' => lineNumber($web, $match[0][1])]; } } } if ($checkHtaccess && preg_match('~]{0,250}\b(?:jpe?g|png|gif|webp)\b[^>]*>.{0,600}\b(?:SetHandler|AddType)\s+application/x-httpd-php~is', $source, $match, PREG_OFFSET_CAPTURE) === 1) { $hits[] = ['kind' => 'PHP execution enabled for image extensions', 'line' => lineNumber($source, $match[0][1])]; } return $hits; } function put($handle, string $message): void { if (fwrite($handle, $message . "\n") === false) { throw new RuntimeException('Could not write results file'); } } function scanDatabase(array $config, $report, array &$stats): void { if (!extension_loaded('mysqli')) { throw new RuntimeException('CLI PHP needs the mysqli extension'); } mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); [$host, $port, $socket] = databaseEndpoint($config['host']); $db = mysqli_init(); $db->options(MYSQLI_OPT_CONNECT_TIMEOUT, 8); $db->real_connect($host, $config['user'], $config['password'], $config['db'], $port, $socket); try { $db->set_charset('utf8mb4'); $db->query('START TRANSACTION READ ONLY'); try { $tables = []; $res = $db->query("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'"); while ($row = $res->fetch_assoc()) { $name = $row['TABLE_NAME']; if (str_starts_with($name, $config['dbprefix'])) { $tables[$name] = ['pk' => [], 'text' => []]; } } $res->free(); $res = $db->query('SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_KEY FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME, ORDINAL_POSITION'); while ($row = $res->fetch_assoc()) { $table = $row['TABLE_NAME']; if (!isset($tables[$table])) continue; if ($row['COLUMN_KEY'] === 'PRI') $tables[$table]['pk'][] = $row['COLUMN_NAME']; if (in_array(strtolower($row['DATA_TYPE']), ['char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'json', 'enum', 'set'], true)) { $tables[$table]['text'][] = $row['COLUMN_NAME']; } } $res->free(); foreach ($tables as $table => $columns) { $stats['tables']++; foreach ($columns['text'] as $column) { $stats['columns']++; $idColumns = array_values(array_filter($columns['pk'], static fn($pk) => $pk !== $column)); $selected = array_merge($idColumns, [$column]); $sql = 'SELECT ' . implode(', ', array_map('quotedIdentifier', $selected)) . ' FROM ' . quotedIdentifier($table) . ' WHERE ' . quotedIdentifier($column) . ' IS NOT NULL'; try { $result = $db->query($sql, MYSQLI_USE_RESULT); try { $ordinal = 0; while ($row = $result->fetch_assoc()) { $ordinal++; $stats['cells']++; $value = (string) $row[$column]; if (strlen($value) > MAX_TEXT_BYTES) { $stats['largeCells']++; continue; } $hits = findOtherIndicators($value, str_contains($value, ' 'ClickFix: ' . implode(', ', $clickfix['labels']), 'line' => $clickfix['line']]; } if (!$hits) continue; $identity = []; foreach ($idColumns as $pk) { $identity[] = safeLabel($pk) . '=' . safeLabel((string) $row[$pk]); } $where = $identity ? implode(', ', $identity) : 'row #' . $ordinal . ' in column scan (no usable primary key)'; foreach ($hits as $hit) { put($report, 'DB ' . safeLabel($table) . ' [' . $where . '] ' . safeLabel($column) . ' (about line ' . $hit['line'] . '): ' . $hit['kind']); $stats['dbHits']++; } } } finally { $result->free(); } } catch (Throwable $e) { $stats['errors']++; put($report, 'DB scan error at ' . safeLabel($table) . '.' . safeLabel($column) . ' (query failed; no data changed)'); } } } put($report, ''); put($report, 'DATABASE PERSISTENCE INVENTORY (review only; presence does not prove compromise)'); $visibleTriggers = 0; try { $result = $db->query('SELECT TRIGGER_NAME, EVENT_OBJECT_TABLE, EVENT_MANIPULATION FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE()'); while ($row = $result->fetch_assoc()) { if (!str_starts_with($row['EVENT_OBJECT_TABLE'], $config['dbprefix'])) continue; $visibleTriggers++; put($report, 'TRIGGER ' . safeLabel($row['TRIGGER_NAME']) . ' on ' . safeLabel($row['EVENT_OBJECT_TABLE']) . ' (' . safeLabel($row['EVENT_MANIPULATION']) . ')'); } $result->free(); } catch (Throwable $e) { $stats['errors']++; put($report, 'Trigger inventory unavailable to this DB user'); } $visibleEvents = 0; try { $result = $db->query('SELECT EVENT_NAME, STATUS FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE()'); while ($row = $result->fetch_assoc()) { $visibleEvents++; put($report, 'EVENT ' . safeLabel($row['EVENT_NAME']) . ' (' . safeLabel($row['STATUS']) . ')'); } $result->free(); } catch (Throwable $e) { $stats['errors']++; put($report, 'Event inventory unavailable to this DB user'); } $stats['triggers'] = $visibleTriggers; $stats['events'] = $visibleEvents; put($report, 'Visible items: ' . $visibleTriggers . ' triggers, ' . $visibleEvents . ' events. Visibility depends on database permissions.'); put($report, ''); put($report, 'ADMIN GROUP INVENTORY (review only; group 8 is normally Super Users, verify site ACL)'); $usersTable = $config['dbprefix'] . 'users'; $mapTable = $config['dbprefix'] . 'user_usergroup_map'; if (isset($tables[$usersTable], $tables[$mapTable])) { try { $sql = 'SELECT u.id, u.registerDate, u.block FROM ' . quotedIdentifier($usersTable) . ' u JOIN ' . quotedIdentifier($mapTable) . ' m ON m.user_id = u.id WHERE m.group_id = 8'; $result = $db->query($sql, MYSQLI_USE_RESULT); $visibleAdmins = 0; while ($row = $result->fetch_assoc()) { $visibleAdmins++; put($report, 'GROUP-8 USER id=' . safeLabel((string) $row['id']) . ' registered=' . safeLabel((string) $row['registerDate']) . ' blocked=' . safeLabel((string) $row['block'])); } $result->free(); $stats['group8'] = $visibleAdmins; put($report, 'Visible group-8 accounts: ' . $visibleAdmins . '. Legitimate accounts are expected.'); } catch (Throwable $e) { $stats['errors']++; put($report, 'Group-8 account inventory unavailable'); } } else { put($report, 'Joomla users or group mapping tables not visible'); } } finally { $db->query('ROLLBACK'); } } finally { $db->close(); } } function scanFiles(string $root, $report, array &$stats): void { $directory = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS); $filtered = new RecursiveCallbackFilterIterator($directory, static function (SplFileInfo $file) use (&$stats): bool { if ($file->isLink()) { $stats['symlinks']++; return false; } if ($file->isDir() && in_array($file->getFilename(), ['.git', '.svn'], true)) return false; return true; }); $files = new RecursiveIteratorIterator($filtered, RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD); foreach ($files as $file) { if (!$file->isFile()) continue; if ($file->getPathname() === __FILE__ || realpath($file->getPathname()) === __FILE__) continue; $name = $file->getFilename(); $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $settings = in_array($name, ['.htaccess', '.user.ini'], true); $disguisedPhp = false; if (!in_array($extension, SOURCE_EXTENSIONS, true) && !$settings) { if (!in_array($extension, DISGUISED_PHP_EXTENSIONS, true) || $file->getSize() > MAX_TEXT_BYTES) continue; $handle = @fopen($file->getPathname(), 'rb'); if ($handle === false) continue; $beginning = fread($handle, 512); fclose($handle); if ($beginning === false || !preg_match('/^\s*<\?(?:php|=)/i', $beginning)) continue; $disguisedPhp = true; } if ($extension === 'txt' && preg_match('/^(?:readme|license|licence|changelog|changes|credits)/i', $name)) continue; $stats['files']++; if ($file->getSize() > MAX_TEXT_BYTES) { $stats['largeFiles']++; continue; } $content = @file_get_contents($file->getPathname()); if ($content === false) { $stats['errors']++; put($report, 'File read error: ' . safeLabel(substr($file->getPathname(), strlen($root) + 1))); continue; } if (str_contains(substr($content, 0, 4096), "\0")) { $stats['binaryFiles']++; continue; } $checkPhp = in_array($extension, PHP_EXTENSIONS, true) || $disguisedPhp; $checkWeb = !$settings && !$disguisedPhp; $hits = findOtherIndicators($content, $checkPhp, $checkWeb, $name === '.htaccess'); if ($checkWeb) { $clickfix = findClickfix($content); if ($clickfix !== null) { $hits[] = ['kind' => 'ClickFix: ' . implode(', ', $clickfix['labels']), 'line' => $clickfix['line']]; } } $relative = substr($file->getPathname(), strlen($root) + 1); $relative = str_replace(["\r", "\n"], '?', $relative); foreach ($hits as $hit) { put($report, 'FILE ' . $relative . ':' . $hit['line'] . ': ' . $hit['kind']); $stats['fileHits']++; } if ($disguisedPhp) { put($report, 'REVIEW FILE ' . $relative . ': image-named file begins with PHP (verify server handler and file origin)'); $stats['reviewItems']++; } if ($settings && preg_match('/^\s*(?:php_value\s+auto_prepend_file|auto_prepend_file\s*=)\s*(?!none\b|off\b|0\b)\S+/im', $content, $match, PREG_OFFSET_CAPTURE) === 1) { put($report, 'REVIEW FILE ' . $relative . ':' . lineNumber($content, $match[0][1]) . ': auto_prepend_file directive (can be a legitimate WAF)'); $stats['reviewItems']++; } } } function selfTest(): void { $clickfixCases = [ [true, 'Verify you are human. Press Win+R, then Ctrl+V. '], [true, 'Cloudflare verification: press Win+R and paste the command: powershell -EncodedCommand AAAA'], [true, ''], [false, 'Cloudflare CAPTCHA: verify you are human to continue.'], [false, 'navigator.clipboard.writeText("Copy the article URL");'], [false, 'The admin manual explains how to run a PowerShell script.'], [false, 'Security article: a fake verify you are human prompt asks visitors to press Win+R and open PowerShell. Do not do this.'], [false, 'navigator.clipboard.writeText("copy article URL") ' . str_repeat('ordinary site content ', 200) . 'Admin note: powershell -EncodedCommand AAAA'], ]; foreach ($clickfixCases as $index => [$expected, $text]) { if ((findClickfix($text) !== null) !== $expected) { throw new RuntimeException('ClickFix self-test failed at case ' . ($index + 1)); } } $otherCases = [ [true, 'eval(atob("YWJj"))', false, true, false], [true, '' . "\n" . 'SetHandler application/x-httpd-php' . "\n" . '', false, false, true], [false, 'const text = atob("YWJj");', false, true, false], [false, '
', false, true, false], ]; foreach ($otherCases as $index => [$expected, $text, $php, $web, $htaccess]) { if ((count(findOtherIndicators($text, $php, $web, $htaccess)) > 0) !== $expected) { throw new RuntimeException('Compromise indicator self-test failed at case ' . ($index + 1)); } } echo "Indicator self-tests passed\n"; } try { $args = getopt('', ['site:', 'output:', 'help', 'self-test']); if (isset($args['help'])) { echo "Usage: php scan-joomla-compromise.php --site=/path/to/joomla --output=/private/results.txt\n"; echo " php scan-joomla-compromise.php --self-test\n"; exit(0); } if (isset($args['self-test'])) { selfTest(); exit(0); } $root = realpath($args['site'] ?? getcwd()); if ($root === false || !is_dir($root) || !is_file($root . '/configuration.php')) { throw new RuntimeException('Provide a Joomla root with --site=/path/to/joomla'); } $config = readJoomlaConfiguration($root . '/configuration.php'); $requested = $args['output'] ?? 'results.txt'; $parent = realpath(dirname($requested)); if ($parent === false || !is_dir($parent)) { throw new RuntimeException('The output directory must exist'); } $out = $parent . DIRECTORY_SEPARATOR . basename($requested); if ($out === $root || str_starts_with($out, $root . DIRECTORY_SEPARATOR)) { throw new RuntimeException('Write results.txt outside the Joomla directory'); } if (is_link($out) || file_exists($out)) { throw new RuntimeException('Results file already exists; choose a new --output path'); } $oldUmask = umask(0077); $report = @fopen($out, 'x'); umask($oldUmask); if ($report === false) { throw new RuntimeException('Cannot create results file'); } $stats = ['tables' => 0, 'columns' => 0, 'cells' => 0, 'dbHits' => 0, 'files' => 0, 'fileHits' => 0, 'triggers' => 0, 'events' => 0, 'group8' => 0, 'reviewItems' => 0, 'largeCells' => 0, 'largeFiles' => 0, 'binaryFiles' => 0, 'symlinks' => 0, 'errors' => 0]; try { put($report, 'Joomla compromise indicator scan - ' . date('c')); put($report, 'Site: ' . $root); put($report, 'Findings are indicators for manual review, not proof of compromise. No payloads or cell contents are printed.'); put($report, 'Isolated CAPTCHA, clipboard, PowerShell, base64_decode and atob references are ignored.'); put($report, ''); put($report, 'DATABASE'); try { scanDatabase($config, $report, $stats); } catch (Throwable $e) { $stats['errors']++; put($report, 'Database scan incomplete: ' . ($e instanceof mysqli_sql_exception ? 'connection or database query failed (code ' . $e->getCode() . ')' : $e->getMessage())); } put($report, ''); put($report, 'FILES'); try { scanFiles($root, $report, $stats); } catch (Throwable $e) { $stats['errors']++; put($report, 'File scan incomplete: ' . get_class($e)); } put($report, ''); put($report, 'SUMMARY'); put($report, 'Indicator hits: database ' . $stats['dbHits'] . '; files ' . $stats['fileHits'] . '; review-only file items ' . $stats['reviewItems']); put($report, 'Database persistence inventory: ' . $stats['triggers'] . ' visible triggers, ' . $stats['events'] . ' visible events (not counted as indicator hits)'); put($report, 'Group-8 accounts listed for review: ' . $stats['group8'] . ' (not counted as indicator hits)'); put($report, 'Scanned: ' . $stats['tables'] . ' prefixed tables, ' . $stats['columns'] . ' text columns, ' . $stats['cells'] . ' non-NULL text cells, ' . $stats['files'] . ' source files'); put($report, 'Coverage limits: ' . $stats['largeCells'] . ' oversized cells, ' . $stats['largeFiles'] . ' oversized files, ' . $stats['binaryFiles'] . ' binary files, ' . $stats['symlinks'] . ' symlinks skipped; ' . $stats['errors'] . ' errors'); put($report, 'A clean result does not rule out changed core files, hidden accounts, split/encoded payloads,'); put($report, 'external scripts, malicious cron jobs or compromised hosts outside the site directory.'); } finally { fclose($report); } echo 'Wrote ' . $out . "\n"; exit($stats['errors'] ? 2 : 0); } catch (Throwable $e) { fwrite(STDERR, 'Scan failed: ' . $e->getMessage() . "\n"); exit(1); }