commit c4166d1cd40880cd5ee5d1a308901b74395a2645 Author: Jacek Pyziak Date: Fri Apr 17 19:18:32 2026 +0200 initial: v0.1 MVP scaffold Phase A complete — CLI + 5 scanner modules + reporter: - ftp-walker: basic-ftp + ssh2-sftp-client adapters with upload/download/walk - core-diff: MD5 check vs api.wordpress.org checksums - dropper-hunter: extension-blind PHP detection (catches .css/.svg/.tmp droppers) - cloaker-test: dual-UA (Googlebot vs browser) with sitemap auto-discovery - db-scanner: options, users, sessions, action-scheduler hooks - remote-helper: server-side scan with base64-obfuscated patterns (WAF bypass) - reporter: JSON + HTML + CLI output with severity-based exit codes Inspired by sweetbabyroom.pl hack recovery — captures techniques that detected a dropper Wordfence/custom scanners missed. Co-Authored-By: Claude Opus 4.7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccf82f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +*.log +.env +.env.local +.DS_Store +Thumbs.db +coverage/ +.vscode/settings.json +.idea/ +reports/ +*.tgz +tmp/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..75f19e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jacek Pyziak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdfa7b3 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# sbr-malwscan + +Malware persistence scanner for WordPress — detects droppers, cloakers, core file tampering, and database persistence that standard tools (Wordfence, Sucuri, MalCare) miss. + +## Why? + +Built from lessons learned during a real WordPress hack recovery where: + +- Wordfence scan died mid-run on shared hosting (heartbeat timeout, process killer) +- Custom file scanner missed the dropper because it filtered by extension (`.php/.js/.html` only) — the attacker hid PHP code inside a `.css` file +- Payload cache used `.tmp` extension in `wp-includes/blocks/gallery/` with base64-obfuscated header + plaintext PHP +- Host WAF (ModSecurity) blocked uploading helper scripts containing literal malware signatures — workaround: base64-encoded patterns in external JSON + +This scanner captures every detection technique that actually worked, in a reusable tool. + +## Features + +- **Core integrity check** — MD5 diff vs api.wordpress.org checksums for every core file +- **Dropper hunter** — finds PHP code hidden in `.css/.svg/.woff/.tmp/.dat` files (extension-blind scan) +- **Cloaker detection** — dual-UA fetch (Googlebot vs normal browser) to find SEO-spam cloakers +- **DB persistence scan** — malicious hooks in `wp_options`/`action_scheduler`, suspicious users, session tokens +- **WAF-bypass helpers** — base64-obfuscated signature patterns to get through ModSecurity +- **Safe-mode default** — zero modifications unless `--fix` is explicitly passed +- **CI-friendly** — JSON output, exit codes 0/1/2 for GitHub Actions scheduled scans + +## Install + +```bash +npm install -g sbr-malwscan +# or +bun add -g sbr-malwscan +``` + +## Quickstart + +```bash +# Scan via FTP +sbr-malwscan scan --wp --target ftp://user:pass@host/public_html + +# Cloaker test +sbr-malwscan cloaker --url https://example.com + +# DB scan (requires SSH or wp-config) +sbr-malwscan db --wp-config /path/to/wp-config.php + +# CI mode +sbr-malwscan scan --wp --target ftp://... --quiet --json > report.json +``` + +## Project status + +Active development — v0.1 MVP in progress. + +See [ROADMAP.md](./docs/ROADMAP.md) for detailed phase plan. + +## License + +MIT © 2026 Jacek Pyziak diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..1353705 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,41 @@ +# sbr-malwscan roadmap + +## v0.1 MVP (current) + +**Phase A — Completed:** +- ✅ A1: Repo setup (Node.js + TypeScript, MIT) +- ✅ A2: FTP/SFTP walker + core-diff vs api.wordpress.org +- ✅ A3: Dropper hunter (extension-blind ``) +- Publish to npm + +## v0.2 Production-ready (planned) + +- B1: Optional WP plugin (admin panel trigger, notices) +- B2: Signatures DB (separate repo, community PRs, GitHub Actions release) +- B3: GitHub Actions template for scheduled scans +- B4: Multi-CMS (Magento, PrestaShop, Laravel) +- B5: Docs site + video tutorial + +## v0.3 Hardening (planned) + +- C1: Auto-remediation with confirmation (quarantine core restore, DB cleanup) +- C2: Threat intel feed (abuse.ch, VirusTotal, AlienVault OTX) +- C3: Incremental scans + parallel FTP connections + +## Origin story + +Built from lessons learned during sweetbabyroom.pl hack recovery (Apr 2026). The attacker's dropper (`wp-includes/blocks/gallery/editor-styles.css` containing PHP) evaded: + +- Wordfence free (scan died on shared hosting) +- Custom file scanner (extension-filtered to `.php/.js/.html` only) +- cyberFolks built-in AV (reactive, post-infection rename to `.VIRUS`) + +Each scanner module directly addresses a technique the attacker used or a blind spot of existing tools. diff --git a/helpers/remote-scan.php b/helpers/remote-scan.php new file mode 100644 index 0000000..74d6987 --- /dev/null +++ b/helpers/remote-scan.php @@ -0,0 +1,119 @@ + $p['id'], + 'severity' => $p['severity'], + 'needle' => base64_decode($p['b64']), + ]; + } + } + } + @unlink($patterns_file); +} + +$findings = []; +$files_scanned = 0; +$bytes_scanned = 0; + +$dir_iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::LEAVES_ONLY +); + +$exclude_dirs = ['/wp-content/cache/', '/wp-content/uploads/', '/wp-content/updraft/', '/node_modules/']; +$scan_extensions = ['php', 'phtml', 'phar', 'css', 'js', 'svg', 'woff', 'woff2', 'tmp', 'dat', 'html']; + +foreach ($dir_iter as $file) { + if (!$file->isFile()) continue; + $path = $file->getPathname(); + $rel = str_replace('\\', '/', substr($path, strlen($root))); + foreach ($exclude_dirs as $ex) { + if (strpos($rel, $ex) !== false) continue 2; + } + $ext = strtolower($file->getExtension()); + if (!in_array($ext, $scan_extensions, true)) continue; + $size = $file->getSize(); + if ($size > 5 * 1024 * 1024) continue; + if ($size === 0) continue; + + $files_scanned++; + $bytes_scanned += $size; + + $content = @file_get_contents($path); + if ($content === false) continue; + + $head = substr($content, 0, 256); + $php_extensions = ['php', 'phtml', 'phar']; + + // H1: non-PHP extension starting with $rel, + 'severity' => 'critical', + 'reason' => 'PHP shebang in .' . $ext . ' file', + 'size' => $size, + 'mtime' => $file->getMTime(), + ]; + continue; + } + } + + // H2: pattern match + foreach ($patterns as $pat) { + if ($pat['needle'] !== '' && stripos($content, $pat['needle']) !== false) { + $findings[] = [ + 'path' => $rel, + 'severity' => $pat['severity'], + 'reason' => 'Matched signature: ' . $pat['id'], + 'size' => $size, + 'mtime' => $file->getMTime(), + ]; + break; + } + } +} + +header('Content-Type: application/json'); +echo json_encode([ + 'files_scanned' => $files_scanned, + 'bytes_scanned' => $bytes_scanned, + 'findings' => $findings, + 'wp_version' => file_exists($root . '/wp-includes/version.php') ? (function () use ($root) { + $c = @file_get_contents($root . '/wp-includes/version.php'); + return preg_match('/\$wp_version\s*=\s*[\'"]([^\'"]+)/', $c, $m) ? $m[1] : 'unknown'; + })() : 'not-a-wp', +]); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..86afe07 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1246 @@ +{ + "name": "sbr-malwscan", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sbr-malwscan", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.5", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "mysql2": "^3.11.0", + "ora": "^8.1.0", + "ssh2-sftp-client": "^10.0.3", + "undici": "^6.19.8", + "zod": "^3.23.8" + }, + "bin": { + "sbr-malwscan": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "@types/ssh2-sftp-client": "^9.0.4", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-sftp-client": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz", + "integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^1.0.0" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.0.tgz", + "integrity": "sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mysql2": { + "version": "3.22.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.1.tgz", + "integrity": "sha512-48+9UXehKyxxiP2pqCxUq+MSFvX+v41jwsSpFDQO/jAoFuAELutBGJUhWJnDbe82/OBlIhSBMC82WeonmznT/Q==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-10.0.3.tgz", + "integrity": "sha512-Wlhasz/OCgrlqC8IlBZhF19Uw/X/dHI8ug4sFQybPE+0sDztvgvDf7Om6o7LbRLe68E7XkFZf3qMnqAvqn1vkQ==", + "license": "Apache-2.0", + "dependencies": { + "concat-stream": "^2.0.0", + "promise-retry": "^2.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">=16.20.2" + }, + "funding": { + "type": "individual", + "url": "https://square.link/u/4g7sPflL" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..048263a --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "sbr-malwscan", + "version": "0.1.0", + "description": "Malware persistence scanner for WordPress — detects droppers, cloakers, core file tampering, and DB persistence that standard tools miss", + "type": "module", + "bin": { + "sbr-malwscan": "./dist/cli.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsx src/cli.ts", + "start": "node dist/cli.js", + "test": "node --test tests/", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "keywords": ["wordpress", "malware", "scanner", "security", "cli", "audit", "dropper", "cloaker"], + "author": "Jacek Pyziak", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "dependencies": { + "basic-ftp": "^5.0.5", + "ssh2-sftp-client": "^10.0.3", + "commander": "^12.1.0", + "chalk": "^5.3.0", + "ora": "^8.1.0", + "mysql2": "^3.11.0", + "undici": "^6.19.8", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "@types/ssh2-sftp-client": "^9.0.4", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + }, + "files": [ + "dist/", + "helpers/", + "patterns/", + "README.md", + "LICENSE" + ] +} diff --git a/patterns/signatures.json b/patterns/signatures.json new file mode 100644 index 0000000..1dd8d96 --- /dev/null +++ b/patterns/signatures.json @@ -0,0 +1,22 @@ +{ + "version": "0.1.0", + "updated": "2026-04-17", + "note": "Patterns are base64-encoded to bypass host WAF (ModSecurity) blocking PHP uploads with literal malware signatures. Helper decodes at runtime.", + "patterns": [ + { "id": "eval-b64", "severity": "critical", "b64": "ZXZhbChiYXNlNjRfZGVjb2RlKA==" }, + { "id": "eval-gz", "severity": "critical", "b64": "ZXZhbChnemluZmxhdGUo" }, + { "id": "eval-rot13", "severity": "critical", "b64": "ZXZhbChzdHJfcm90MTMo" }, + { "id": "assert-var", "severity": "critical", "b64": "YXNzZXJ0KCRf" }, + { "id": "preg-replace-e", "severity": "critical", "b64": "cHJlZ19yZXBsYWNlKC8uKiovZSI=" }, + { "id": "create-fn", "severity": "high", "b64": "Y3JlYXRlX2Z1bmN0aW9uKA==" }, + { "id": "system-var", "severity": "high", "b64": "c3lzdGVtKCRf" }, + { "id": "exec-var", "severity": "high", "b64": "ZXhlYygkXw==" }, + { "id": "passthru-var", "severity": "high", "b64": "cGFzc3RocnUoJF8=" }, + { "id": "shell-exec-var", "severity": "high", "b64": "c2hlbGxfZXhlYygkXw==" }, + { "id": "proc-open-var", "severity": "high", "b64": "cHJvY19vcGVuKCRf" }, + { "id": "file-put-contents-req", "severity": "medium", "b64": "ZmlsZV9wdXRfY29udGVudHMoJF9SRVFVRVNU" }, + { "id": "dynamic-var-exec", "severity": "high", "b64": "JHskXw==" }, + { "id": "goto-obfuscation", "severity": "medium", "b64": "Z290byA=" }, + { "id": "unicode-escape", "severity": "medium", "b64": "XHgw" } + ] +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..51da2b4 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,178 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { readFile } from 'node:fs/promises'; +import { createAdapter } from './modules/ftp-walker.js'; +import { runCoreDiff } from './modules/core-diff.js'; +import { runDropperHunter } from './modules/dropper-hunter.js'; +import { testUrls, discoverSitemapUrls, resultsToFindings } from './modules/cloaker-test.js'; +import { runDbScan, parseWpConfig } from './modules/db-scanner.js'; +import { runRemoteScan } from './helpers/remote-helper.js'; +import { buildReport, printReport, writeJson, writeHtml, computeExitCode } from './modules/reporter.js'; +import type { Finding, FtpTarget } from './types/index.js'; + +const program = new Command(); + +program + .name('sbr-malwscan') + .description('Malware persistence scanner for WordPress — detects droppers, cloakers, and DB persistence that standard tools miss') + .version('0.1.0'); + +program.command('scan') + .description('Full WordPress scan: core-diff + dropper hunter (optional: --remote for server-side scan)') + .requiredOption('--target ', 'FTP/SFTP URI: ftp://user:pass@host:port/rootPath') + .option('--site-url ', 'Public site URL (required for --remote)') + .option('--remote', 'Also run server-side helper scan (WAF-bypass patterns)') + .option('--json ', 'Write JSON report to path') + .option('--html ', 'Write HTML report to path') + .option('--quiet', 'No stdout output (CI mode)') + .action(async (opts: ScanOpts) => { + const target = parseTargetUri(opts.target); + const startedAt = new Date(); + const allFindings: Finding[] = []; + const modules: string[] = []; + let filesScanned = 0; + + const adapter = createAdapter(target); + const spinner = opts.quiet ? null : ora('Connecting to target...').start(); + try { + await adapter.connect(); + spinner?.succeed('Connected'); + + // Core-diff + spinner?.start('Running core-diff vs api.wordpress.org...'); + modules.push('core-diff'); + const { result: coreResult, findings: coreFindings } = await runCoreDiff(adapter); + allFindings.push(...coreFindings); + filesScanned += coreResult.filesChecked; + spinner?.succeed(`Core-diff: WP ${coreResult.wpVersion}, ${coreResult.filesChecked} files, ${coreFindings.length} findings`); + + // Dropper hunter + spinner?.start('Hunting droppers (extension-blind scan)...'); + modules.push('dropper-hunter'); + const { findings: dropFindings } = await runDropperHunter(adapter, (_, n) => { + if (spinner && n % 100 === 0) spinner.text = `Hunting droppers... ${n} files checked`; + }); + allFindings.push(...dropFindings); + spinner?.succeed(`Dropper hunter: ${dropFindings.length} findings`); + + await adapter.disconnect(); + + // Optional remote helper scan + if (opts.remote) { + if (!opts.siteUrl) throw new Error('--remote requires --site-url'); + spinner?.start('Running server-side helper scan...'); + modules.push('remote-scan'); + const remote = await runRemoteScan({ target, siteUrl: opts.siteUrl }); + allFindings.push(...remote.findings); + filesScanned += remote.filesScanned; + spinner?.succeed(`Remote scan: ${remote.filesScanned} files, ${remote.findings.length} findings`); + } + + } catch (err) { + spinner?.fail((err as Error).message); + process.exit(3); + } + + const report = buildReport(opts.target, modules, allFindings, startedAt, filesScanned); + printReport(report, opts.quiet); + if (opts.json) await writeJson(report, opts.json); + if (opts.html) await writeHtml(report, opts.html); + process.exit(computeExitCode(allFindings)); + }); + +program.command('cloaker') + .description('Dual-UA cloaker test: fetch URLs as Googlebot + normal browser and diff responses') + .option('--url ', 'Single URL to test') + .option('--site ', 'Site URL — auto-discover sitemap and test pages') + .option('--limit ', 'Max URLs to test from sitemap', '20') + .option('--json ', 'Write JSON report to path') + .option('--html ', 'Write HTML report to path') + .option('--quiet', 'No stdout output') + .action(async (opts: CloakerOpts) => { + if (!opts.url && !opts.site) { + console.error(chalk.red('Must provide --url or --site')); + process.exit(3); + } + const startedAt = new Date(); + const spinner = opts.quiet ? null : ora('Gathering URLs...').start(); + + const urls = opts.url ? [opts.url] : await discoverSitemapUrls(opts.site!, parseInt(opts.limit || '20', 10)); + spinner?.succeed(`${urls.length} URL(s) to test`); + + spinner?.start('Running dual-UA fetches...'); + const results = await testUrls(urls, 3); + const findings = resultsToFindings(results); + spinner?.succeed(`${results.length} tests, ${findings.length} suspicious`); + + const target = opts.url || opts.site || 'unknown'; + const report = buildReport(target, ['cloaker-test'], findings, startedAt, results.length); + printReport(report, opts.quiet); + if (opts.json) await writeJson(report, opts.json); + if (opts.html) await writeHtml(report, opts.html); + process.exit(computeExitCode(findings)); + }); + +program.command('db') + .description('Database persistence scan: suspicious options, users, sessions, action-scheduler hooks') + .option('--wp-config ', 'Path to wp-config.php (extracts DB creds)') + .option('--host ', 'DB host') + .option('--port ', 'DB port', '3306') + .option('--user ', 'DB user') + .option('--password ', 'DB password') + .option('--database ', 'DB name') + .option('--prefix ', 'Table prefix', 'wp_') + .option('--json ', 'Write JSON report to path') + .option('--html ', 'Write HTML report to path') + .option('--quiet', 'No stdout output') + .action(async (opts: DbOpts) => { + const startedAt = new Date(); + const spinner = opts.quiet ? null : ora('Loading DB config...').start(); + + const cfg = opts.wpConfig + ? await parseWpConfig(await readFile(opts.wpConfig, 'utf8')) + : { + host: opts.host || 'localhost', + port: parseInt(opts.port || '3306', 10), + user: opts.user || '', + password: opts.password || '', + database: opts.database || '', + tablePrefix: opts.prefix || 'wp_', + }; + + spinner?.succeed(`DB: ${cfg.user}@${cfg.host}:${cfg.port}/${cfg.database} (prefix ${cfg.tablePrefix})`); + + spinner?.start('Querying DB tables...'); + const { findings } = await runDbScan(cfg); + spinner?.succeed(`DB scan: ${findings.length} findings`); + + const report = buildReport(`db://${cfg.user}@${cfg.host}/${cfg.database}`, ['db-scanner'], findings, startedAt); + printReport(report, opts.quiet); + if (opts.json) await writeJson(report, opts.json); + if (opts.html) await writeHtml(report, opts.html); + process.exit(computeExitCode(findings)); + }); + +function parseTargetUri(uri: string): FtpTarget { + const match = uri.match(/^(ftp|sftp):\/\/([^:]+):([^@]+)@([^:/]+)(?::(\d+))?(\/.*)?$/); + if (!match) throw new Error('Invalid target URI. Format: ftp://user:pass@host[:port][/path]'); + const [, protocol, user, password, host, portStr, path] = match; + return { + protocol: protocol as 'ftp' | 'sftp', + host: host!, + port: portStr ? parseInt(portStr, 10) : (protocol === 'sftp' ? 22 : 21), + user: decodeURIComponent(user!), + password: decodeURIComponent(password!), + rootPath: path || '/', + }; +} + +interface ScanOpts { target: string; siteUrl?: string; remote?: boolean; json?: string; html?: string; quiet?: boolean } +interface CloakerOpts { url?: string; site?: string; limit?: string; json?: string; html?: string; quiet?: boolean } +interface DbOpts { wpConfig?: string; host?: string; port?: string; user?: string; password?: string; database?: string; prefix?: string; json?: string; html?: string; quiet?: boolean } + +program.parseAsync(process.argv).catch(err => { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(3); +}); diff --git a/src/helpers/remote-helper.ts b/src/helpers/remote-helper.ts new file mode 100644 index 0000000..c7789fa --- /dev/null +++ b/src/helpers/remote-helper.ts @@ -0,0 +1,82 @@ +import { readFile } from 'node:fs/promises'; +import { Writable } from 'node:stream'; +import { request } from 'undici'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import type { Finding, FtpTarget } from '../types/index.js'; +import { createAdapter } from '../modules/ftp-walker.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface RemoteScanOptions { + target: FtpTarget; + siteUrl: string; + helperName?: string; + patternsName?: string; + timeoutMs?: number; +} + +export interface RemoteScanResult { + filesScanned: number; + bytesScanned: number; + wpVersion: string; + findings: Finding[]; +} + +export async function runRemoteScan(opts: RemoteScanOptions): Promise { + const helperName = opts.helperName ?? `sbr-scan-${randomBytes(4).toString('hex')}.php`; + const patternsName = opts.patternsName ?? 'sbr-patterns.json'; + const token = randomBytes(16).toString('hex'); + + const helperPath = join(__dirname, '..', '..', 'helpers', 'remote-scan.php'); + const patternsPath = join(__dirname, '..', '..', 'patterns', 'signatures.json'); + + const helperSource = await readFile(helperPath, 'utf8'); + const helperWithToken = helperSource.replace('__TOKEN_PLACEHOLDER__', token); + const patternsSource = await readFile(patternsPath, 'utf8'); + + const adapter = createAdapter(opts.target); + await adapter.connect(); + try { + await adapter.upload(helperName, Buffer.from(helperWithToken, 'utf8')); + await adapter.upload(patternsName, Buffer.from(patternsSource, 'utf8')); + + const url = `${opts.siteUrl.replace(/\/$/, '')}/${helperName}?t=${token}`; + const { statusCode, body } = await request(url, { + bodyTimeout: opts.timeoutMs ?? 300_000, + headersTimeout: 30_000, + }); + if (statusCode !== 200) { + throw new Error(`Remote helper returned HTTP ${statusCode} from ${url}`); + } + const json = await body.json() as { + files_scanned: number; + bytes_scanned: number; + wp_version: string; + findings: Array<{ path: string; severity: string; reason: string; size: number; mtime: number }>; + }; + + const findings: Finding[] = json.findings.map(f => ({ + id: `remote:${f.reason}:${f.path}`, + module: 'remote-scan', + severity: (f.severity as Finding['severity']) ?? 'medium', + category: 'signature-match', + path: f.path, + description: f.reason, + remediation: 'Download file, manually inspect; if malicious, quarantine (rename to .QUARANTINE).', + evidence: { size: f.size, mtime: new Date(f.mtime * 1000).toISOString() }, + })); + + return { + filesScanned: json.files_scanned, + bytesScanned: json.bytes_scanned, + wpVersion: json.wp_version, + findings, + }; + } finally { + await adapter.remove(helperName).catch(() => {}); + await adapter.remove(patternsName).catch(() => {}); + await adapter.disconnect(); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c1aa868 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +export * from './types/index.js'; +export { createAdapter, walk, md5RemoteFile } from './modules/ftp-walker.js'; +export { runCoreDiff, detectWpVersion, fetchCoreChecksums } from './modules/core-diff.js'; +export { runDropperHunter } from './modules/dropper-hunter.js'; +export { testUrl, testUrls, discoverSitemapUrls, resultsToFindings } from './modules/cloaker-test.js'; +export { runDbScan, parseWpConfig } from './modules/db-scanner.js'; +export { runRemoteScan } from './helpers/remote-helper.js'; +export { buildReport, printReport, writeJson, writeHtml, computeExitCode } from './modules/reporter.js'; diff --git a/src/modules/cloaker-test.ts b/src/modules/cloaker-test.ts new file mode 100644 index 0000000..facdcf9 --- /dev/null +++ b/src/modules/cloaker-test.ts @@ -0,0 +1,139 @@ +import { request } from 'undici'; +import type { CloakerResult, Finding } from '../types/index.js'; + +const UA_GOOGLEBOT = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'; +const UA_BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +const HAZARD_TERMS = [ + 'casino', 'bet365', 'porn', 'xxx', 'viagra', 'cialis', 'pharmacy', + 'loan', 'payday', 'replica watches', 'rolex replica', + 'crypto signals', 'binary options', + 'doge to the moon', 'meme coin', + 'escort', 'hookup', + 'essay writing', 'research paper cheap', +]; + +interface FetchResult { + status: number; + size: number; + title: string; + body: string; +} + +async function fetchAs(url: string, ua: string, timeoutMs = 30_000): Promise { + const { statusCode, body, headers } = await request(url, { + headers: { 'user-agent': ua, accept: 'text/html,application/xhtml+xml' }, + maxRedirections: 3, + bodyTimeout: timeoutMs, + headersTimeout: timeoutMs, + }); + const text = await body.text(); + const titleMatch = text.match(/]*>([\s\S]*?)<\/title>/i); + const title = titleMatch && titleMatch[1] ? titleMatch[1].replace(/\s+/g, ' ').trim() : ''; + const size = typeof headers['content-length'] === 'string' ? parseInt(headers['content-length'], 10) : text.length; + return { status: statusCode, size: Number.isFinite(size) ? size : text.length, title, body: text }; +} + +function detectHazards(body: string): string[] { + const lower = body.toLowerCase(); + return HAZARD_TERMS.filter(t => lower.includes(t)); +} + +export async function testUrl(url: string): Promise { + const [bot, user] = await Promise.all([ + fetchAs(url, UA_GOOGLEBOT).catch(e => ({ status: 0, size: 0, title: `ERROR: ${e.message}`, body: '' })), + fetchAs(url, UA_BROWSER).catch(e => ({ status: 0, size: 0, title: `ERROR: ${e.message}`, body: '' })), + ]); + + const sizeDiffPct = bot.size === 0 ? 0 : Math.abs(bot.size - user.size) / Math.max(bot.size, user.size) * 100; + const botHazards = detectHazards(bot.body); + const userHazards = detectHazards(user.body); + const onlyBotHazards = botHazards.filter(t => !userHazards.includes(t)); + + const suspicious = + sizeDiffPct > 30 || + onlyBotHazards.length > 0 || + (bot.title !== user.title && bot.status === 200 && user.status === 200) || + (bot.status !== user.status); + + return { + url, + botStatus: bot.status, + botSize: bot.size, + botTitle: bot.title, + userStatus: user.status, + userSize: user.size, + userTitle: user.title, + sizeDiffPct: Math.round(sizeDiffPct * 10) / 10, + hazardTerms: onlyBotHazards, + suspicious, + }; +} + +export async function testUrls(urls: string[], concurrency = 3): Promise { + const results: CloakerResult[] = []; + const queue = [...urls]; + const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { + while (queue.length) { + const url = queue.shift()!; + try { + results.push(await testUrl(url)); + } catch (e) { + results.push({ + url, + botStatus: 0, botSize: 0, botTitle: `ERROR: ${(e as Error).message}`, + userStatus: 0, userSize: 0, userTitle: '', + sizeDiffPct: 0, hazardTerms: [], suspicious: false, + }); + } + } + }); + await Promise.all(workers); + return results; +} + +export function resultsToFindings(results: CloakerResult[]): Finding[] { + return results.filter(r => r.suspicious).map(r => ({ + id: `cloaker:${r.url}`, + module: 'cloaker-test', + severity: r.hazardTerms.length > 0 ? 'critical' : 'high', + category: 'cloaker', + path: r.url, + description: r.hazardTerms.length > 0 + ? `Cloaker detected — Googlebot sees hazard terms (${r.hazardTerms.join(', ')}) absent for normal visitors` + : `Cloaker suspicion — size diff ${r.sizeDiffPct}%, bot title "${r.botTitle}" vs user "${r.userTitle}"`, + remediation: 'Search site for UA-dependent code. Check .htaccess mod_rewrite, mu-plugins, theme functions.php for $_SERVER["HTTP_USER_AGENT"] checks.', + evidence: { + botStatus: r.botStatus, + userStatus: r.userStatus, + botSize: r.botSize, + userSize: r.userSize, + sizeDiffPct: r.sizeDiffPct, + hazardTerms: r.hazardTerms, + }, + })); +} + +export async function discoverSitemapUrls(siteUrl: string, limit = 50): Promise { + const base = siteUrl.replace(/\/$/, ''); + const candidates = [`${base}/sitemap.xml`, `${base}/sitemap_index.xml`, `${base}/wp-sitemap.xml`]; + const urls = new Set(); + + for (const sm of candidates) { + try { + const { statusCode, body } = await request(sm, { maxRedirections: 2 }); + if (statusCode !== 200) continue; + const text = await body.text(); + const matches = text.matchAll(/([^<]+)<\/loc>/g); + for (const m of matches) { + if (m[1]) urls.add(m[1].trim()); + if (urls.size >= limit) break; + } + if (urls.size >= limit) break; + } catch { + continue; + } + } + + return [...urls].slice(0, limit); +} diff --git a/src/modules/core-diff.ts b/src/modules/core-diff.ts new file mode 100644 index 0000000..5293d7d --- /dev/null +++ b/src/modules/core-diff.ts @@ -0,0 +1,132 @@ +import { request } from 'undici'; +import { walk, md5RemoteFile, type WalkerAdapter } from './ftp-walker.js'; +import type { CoreDiffResult, Finding } from '../types/index.js'; + +const WP_CORE_PREFIXES = ['wp-admin/', 'wp-includes/']; +const WP_CORE_ROOT_FILES = [ + 'index.php', 'wp-activate.php', 'wp-blog-header.php', 'wp-comments-post.php', + 'wp-config-sample.php', 'wp-cron.php', 'wp-links-opml.php', 'wp-load.php', + 'wp-login.php', 'wp-mail.php', 'wp-settings.php', 'wp-signup.php', + 'wp-trackback.php', 'xmlrpc.php', +]; + +export async function detectWpVersion(adapter: WalkerAdapter): Promise { + try { + const buf = await adapter.downloadBuffer('wp-includes/version.php', 16 * 1024); + const match = buf.toString('utf8').match(/\$wp_version\s*=\s*['"]([^'"]+)['"]/); + return match ? match[1] ?? null : null; + } catch { + return null; + } +} + +export async function fetchCoreChecksums(version: string, locale = 'en_US'): Promise> { + const url = `https://api.wordpress.org/core/checksums/1.0/?version=${encodeURIComponent(version)}&locale=${encodeURIComponent(locale)}`; + const { statusCode, body } = await request(url); + if (statusCode !== 200) { + throw new Error(`WP.org checksums API returned ${statusCode} for version ${version}`); + } + const json = await body.json() as { checksums?: Record }; + if (!json.checksums) throw new Error(`No checksums in API response for version ${version}`); + return json.checksums; +} + +export async function runCoreDiff( + adapter: WalkerAdapter, + onProgress?: (path: string, i: number, total: number) => void, +): Promise<{ result: CoreDiffResult; findings: Finding[] }> { + const wpVersion = await detectWpVersion(adapter); + if (!wpVersion) { + throw new Error('Could not detect WordPress version from wp-includes/version.php'); + } + + const checksums = await fetchCoreChecksums(wpVersion); + const expectedPaths = new Set(Object.keys(checksums)); + + const missing: string[] = []; + const modified: string[] = []; + const extra: string[] = []; + let filesChecked = 0; + + const totalExpected = expectedPaths.size; + const checkedPaths = new Set(); + + // Walk only core dirs + root files to avoid scanning themes/plugins/uploads + const coreEntries: string[] = []; + for await (const file of walk(adapter, 'wp-admin')) coreEntries.push(file.path); + for await (const file of walk(adapter, 'wp-includes')) coreEntries.push(file.path); + const rootList = await adapter.list(''); + for (const f of rootList) { + if (!f.isDirectory && WP_CORE_ROOT_FILES.includes(f.path)) coreEntries.push(f.path); + } + + let i = 0; + for (const path of coreEntries) { + i++; + const normalized = path.replace(/\\/g, '/'); + onProgress?.(normalized, i, coreEntries.length); + + if (!expectedPaths.has(normalized)) { + const isCorePrefix = WP_CORE_PREFIXES.some(p => normalized.startsWith(p)); + if (isCorePrefix || WP_CORE_ROOT_FILES.includes(normalized)) { + extra.push(normalized); + } + continue; + } + + checkedPaths.add(normalized); + try { + const actualMd5 = await md5RemoteFile(adapter, normalized); + if (actualMd5 !== checksums[normalized]) modified.push(normalized); + filesChecked++; + } catch { + // Skip files we can't download + } + } + + for (const expected of expectedPaths) { + if (!checkedPaths.has(expected)) missing.push(expected); + } + + const findings: Finding[] = [ + ...modified.map(path => ({ + id: `core-diff:modified:${path}`, + module: 'core-diff', + severity: 'critical', + category: 'core-tamper', + path, + description: `Core file modified vs WP ${wpVersion} official checksums`, + remediation: `Restore ${path} from WordPress ${wpVersion} download`, + })), + ...extra.map(path => ({ + id: `core-diff:extra:${path}`, + module: 'core-diff', + severity: 'high', + category: 'core-extra', + path, + description: `File exists in core directory but not in WP ${wpVersion} manifest — possible dropper`, + remediation: `Verify legitimacy; if unknown, quarantine and inspect content`, + })), + ...missing.map(path => ({ + id: `core-diff:missing:${path}`, + module: 'core-diff', + severity: 'medium', + category: 'core-missing', + path, + description: `Expected core file is missing from WP ${wpVersion} install`, + remediation: `Restore ${path} from WordPress ${wpVersion} download`, + })), + ]; + + return { + result: { + wpVersion, + checksumsSource: `api.wordpress.org/core/checksums/1.0/?version=${wpVersion}`, + missing, + modified, + extra, + filesChecked, + }, + findings, + }; +} diff --git a/src/modules/db-scanner.ts b/src/modules/db-scanner.ts new file mode 100644 index 0000000..e2675e8 --- /dev/null +++ b/src/modules/db-scanner.ts @@ -0,0 +1,187 @@ +import mysql from 'mysql2/promise'; +import type { DbScanResult, Finding } from '../types/index.js'; + +export interface DbConfig { + host: string; + port?: number; + user: string; + password: string; + database: string; + tablePrefix: string; +} + +const SUSPICIOUS_USER_EMAIL_PATTERNS = [ + /@wordpress\.org$/i, + /@mail\.ru$/i, + /@yandex\.(ru|com)$/i, + /@tempmail/i, + /@mailinator/i, + /@guerrillamail/i, + /@10minutemail/i, +]; + +const SUSPICIOUS_IP_PATTERNS = [ + /^5\.188\./, // Yandex Cloud RU + /^194\.169\./, // Known C2 + /^45\.155\./, + /^91\.240\./, + /^141\.98\./, +]; + +const SUSPICIOUS_OPTION_NAMES = [ + /^_transient_[a-f0-9]{32}$/i, // hex-named transients (often malware cache) + /^wp_user_roles_backup/i, + /^theme_mods_backup/i, +]; + +export async function parseWpConfig(content: string): Promise { + const get = (name: string) => { + const re = new RegExp(`define\\s*\\(\\s*['"]${name}['"]\\s*,\\s*['"]([^'"]*)['"]\\s*\\)`, 'i'); + const m = content.match(re); + return m ? m[1] ?? '' : ''; + }; + const prefixMatch = content.match(/\$table_prefix\s*=\s*['"]([^'"]+)['"]/); + + const hostRaw = get('DB_HOST') || 'localhost'; + const [host, portStr] = hostRaw.split(':'); + const port = portStr ? parseInt(portStr, 10) : 3306; + + return { + host: host ?? 'localhost', + port, + user: get('DB_USER'), + password: get('DB_PASSWORD'), + database: get('DB_NAME'), + tablePrefix: prefixMatch && prefixMatch[1] ? prefixMatch[1] : 'wp_', + }; +} + +export async function runDbScan(cfg: DbConfig): Promise<{ result: DbScanResult; findings: Finding[] }> { + const conn = await mysql.createConnection({ + host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, database: cfg.database, + }); + + const p = cfg.tablePrefix; + const result: DbScanResult = { + suspiciousOptions: [], + suspiciousHooks: [], + suspiciousUsers: [], + suspiciousSessions: [], + }; + + try { + // 1. Suspicious options (PHP code in option_value, hex-named transients) + const [optRows] = await conn.query( + `SELECT option_name, LENGTH(option_value) AS len, option_value FROM ${p}options + WHERE (option_value LIKE '% r.test(row.option_name))) reason = 'Suspicious option_name pattern'; + result.suspiciousOptions.push({ name: row.option_name, reason, length: Number(row.len) }); + } + + // 2. Suspicious users + const [userRows] = await conn.query( + `SELECT ID, user_login, user_email, user_registered FROM ${p}users LIMIT 2000` + ); + for (const u of userRows) { + const email = String(u.user_email || ''); + const matched = SUSPICIOUS_USER_EMAIL_PATTERNS.find(r => r.test(email)); + if (matched) { + result.suspiciousUsers.push({ + id: Number(u.ID), + login: String(u.user_login), + email, + reason: `Email matches suspicious pattern: ${matched}`, + }); + } + } + + // 3. Suspicious session tokens (user_meta 'session_tokens') + const [sessRows] = await conn.query( + `SELECT user_id, meta_value FROM ${p}usermeta WHERE meta_key = 'session_tokens' LIMIT 500` + ); + for (const s of sessRows) { + const raw = String(s.meta_value); + const ipMatches = raw.match(/"ip";s:\d+:"([^"]+)"/g) || []; + for (const m of ipMatches) { + const ipMatch = m.match(/"ip";s:\d+:"([^"]+)"/); + if (!ipMatch) continue; + const ip = ipMatch[1] ?? ''; + const susPattern = SUSPICIOUS_IP_PATTERNS.find(r => r.test(ip)); + if (susPattern) { + result.suspiciousSessions.push({ + userId: Number(s.user_id), + ip, + reason: `Session from suspicious IP range (${susPattern})`, + }); + } + } + } + + // 4. Suspicious action_scheduler hooks (hex-named, or with PHP in args) + try { + const [hookRows] = await conn.query( + `SELECT hook, args FROM ${p}actionscheduler_actions + WHERE hook REGEXP '^[a-f0-9]{16,}$' OR args LIKE '%eval%' OR args LIKE '%base64_decode%' + LIMIT 100` + ); + for (const h of hookRows) { + result.suspiciousHooks.push({ + hook: String(h.hook), + callback: 'action_scheduler', + source: `args: ${String(h.args).slice(0, 200)}`, + }); + } + } catch { + // action_scheduler table may not exist + } + } finally { + await conn.end(); + } + + const findings: Finding[] = [ + ...result.suspiciousOptions.map(o => ({ + id: `db:option:${o.name}`, + module: 'db-scanner', + severity: o.reason.includes('(u => ({ + id: `db:user:${u.login}`, + module: 'db-scanner', + severity: 'high', + category: 'db-user', + description: `Suspicious user "${u.login}" (ID ${u.id}, ${u.email}): ${u.reason}`, + remediation: `Verify user is legitimate. If not, delete via WP admin or SQL.`, + })), + ...result.suspiciousSessions.map(s => ({ + id: `db:session:${s.userId}:${s.ip}`, + module: 'db-scanner', + severity: 'high', + category: 'db-session', + description: `User ${s.userId} session from ${s.ip} — ${s.reason}`, + remediation: `Reset password for user ${s.userId}, clear session_tokens meta.`, + })), + ...result.suspiciousHooks.map(h => ({ + id: `db:hook:${h.hook}`, + module: 'db-scanner', + severity: 'critical', + category: 'db-hook', + description: `Suspicious hook "${h.hook}" in action scheduler`, + remediation: `Inspect hook args, delete if malicious: DELETE FROM ${p}actionscheduler_actions WHERE hook = "${h.hook}"`, + evidence: { source: h.source }, + })), + ]; + + return { result, findings }; +} diff --git a/src/modules/dropper-hunter.ts b/src/modules/dropper-hunter.ts new file mode 100644 index 0000000..cd77bc7 --- /dev/null +++ b/src/modules/dropper-hunter.ts @@ -0,0 +1,151 @@ +import { walk, type WalkerAdapter } from './ftp-walker.js'; +import type { DropperMatch, Finding } from '../types/index.js'; + +const PHP_EXTENSIONS = new Set(['php', 'phtml', 'phar', 'php3', 'php4', 'php5', 'php7', 'php8']); +const SUSPICIOUS_NON_PHP_EXT = new Set([ + 'css', 'scss', 'less', + 'svg', 'woff', 'woff2', 'ttf', 'otf', 'eot', + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'ico', + 'mp3', 'mp4', 'wav', 'avi', + 'tmp', 'dat', 'bak', 'old', 'log', + 'txt', 'md', 'json', 'xml', 'yaml', 'yml', + 'js', 'mjs', + 'html', 'htm', + 'pdf', 'zip', +]); + +const HEAD_BYTES = 512; + +function extOf(path: string): string { + const idx = path.lastIndexOf('.'); + return idx === -1 ? '' : path.slice(idx + 1).toLowerCase(); +} + +function startsWithPhp(buf: Buffer): boolean { + const head = buf.toString('utf8', 0, Math.min(buf.length, 64)).trimStart(); + return head.startsWith(' void, +): Promise<{ matches: DropperMatch[]; findings: Finding[] }> { + const matches: DropperMatch[] = []; + let checked = 0; + + for await (const file of walk(adapter, '')) { + checked++; + onProgress?.(file.path, checked); + + const ext = extOf(file.path); + const name = file.path.split('/').pop() ?? file.path; + + const reasons: string[] = []; + + // Heuristic 1: non-PHP extension file starting with 0) { + try { + const head = await adapter.downloadBuffer(file.path, 64); + matches.push({ + path: file.path, + extension: ext, + size: file.size, + mtime: file.mtime.toISOString(), + mode: file.mode, + reason: reasons.join('; '), + firstBytes: head.toString('utf8').replace(/[^\x20-\x7E]/g, '.').slice(0, 64), + }); + } catch { + matches.push({ + path: file.path, + extension: ext, + size: file.size, + mtime: file.mtime.toISOString(), + mode: file.mode, + reason: reasons.join('; '), + firstBytes: '', + }); + } + } + } + + const findings: Finding[] = matches.map(m => { + const severity = m.reason.includes('PHP shebang in .') || m.reason.includes('Hidden PHP pattern') + ? 'critical' + : m.reason.includes('Read-only PHP') ? 'high' : 'medium'; + return { + id: `dropper:${m.path}`, + module: 'dropper-hunter', + severity, + category: 'dropper', + path: m.path, + description: m.reason, + remediation: 'Download file, manually inspect content. If malicious, quarantine (rename to .QUARANTINE) and scan related files.', + evidence: { + size: m.size, + mtime: m.mtime, + mode: m.mode, + firstBytes: m.firstBytes, + }, + }; + }); + + return { matches, findings }; +} diff --git a/src/modules/ftp-walker.ts b/src/modules/ftp-walker.ts new file mode 100644 index 0000000..d7bd180 --- /dev/null +++ b/src/modules/ftp-walker.ts @@ -0,0 +1,185 @@ +import { Client as FtpClient, FileInfo, FileType } from 'basic-ftp'; +import SftpClient from 'ssh2-sftp-client'; +import { createHash } from 'node:crypto'; +import { Writable } from 'node:stream'; +import type { FtpTarget } from '../types/index.js'; + +export interface RemoteFile { + path: string; // relative to target.rootPath + size: number; + mtime: Date; + mode?: number; + isDirectory: boolean; +} + +export interface WalkerAdapter { + connect(): Promise; + disconnect(): Promise; + list(dir: string): Promise; + download(remotePath: string, writable: Writable): Promise; + downloadBuffer(remotePath: string, maxBytes?: number): Promise; + upload(remotePath: string, content: Buffer): Promise; + remove(remotePath: string): Promise; +} + +export function createAdapter(target: FtpTarget): WalkerAdapter { + return target.protocol === 'sftp' ? new SftpAdapter(target) : new FtpAdapter(target); +} + +class FtpAdapter implements WalkerAdapter { + private client = new FtpClient(30_000); + constructor(private target: FtpTarget) {} + + async connect(): Promise { + await this.client.access({ + host: this.target.host, + port: this.target.port || 21, + user: this.target.user, + password: this.target.password, + secure: false, + }); + } + + async disconnect(): Promise { + this.client.close(); + } + + async list(dir: string): Promise { + const infos = await this.client.list(joinPath(this.target.rootPath, dir)); + return infos.map((info: FileInfo) => ({ + path: joinPath(dir, info.name).replace(/^\/+/, ''), + size: info.size, + mtime: info.modifiedAt ?? new Date(0), + mode: info.permissions ? (info.permissions.user << 6) | (info.permissions.group << 3) | info.permissions.world : undefined, + isDirectory: info.type === FileType.Directory, + })); + } + + async download(remotePath: string, writable: Writable): Promise { + await this.client.downloadTo(writable, joinPath(this.target.rootPath, remotePath)); + } + + async downloadBuffer(remotePath: string, maxBytes = 1024 * 1024): Promise { + const chunks: Buffer[] = []; + let total = 0; + const writable = new Writable({ + write(chunk, _enc, cb) { + if (total < maxBytes) { + const take = Math.min(chunk.length, maxBytes - total); + chunks.push(chunk.subarray(0, take)); + total += take; + } + cb(); + }, + }); + await this.download(remotePath, writable); + return Buffer.concat(chunks); + } + + async upload(remotePath: string, content: Buffer): Promise { + const { Readable } = await import('node:stream'); + await this.client.uploadFrom(Readable.from(content), joinPath(this.target.rootPath, remotePath)); + } + + async remove(remotePath: string): Promise { + try { + await this.client.remove(joinPath(this.target.rootPath, remotePath)); + } catch { + // Already deleted or not present + } + } +} + +class SftpAdapter implements WalkerAdapter { + private client = new SftpClient(); + constructor(private target: FtpTarget) {} + + async connect(): Promise { + await this.client.connect({ + host: this.target.host, + port: this.target.port || 22, + username: this.target.user, + password: this.target.password, + privateKey: this.target.privateKey, + }); + } + + async disconnect(): Promise { + await this.client.end(); + } + + async list(dir: string): Promise { + const entries = await this.client.list(joinPath(this.target.rootPath, dir)); + return entries.map(e => ({ + path: joinPath(dir, e.name).replace(/^\/+/, ''), + size: e.size, + mtime: new Date(e.modifyTime), + mode: typeof e.rights === 'object' ? parseRights(e.rights) : undefined, + isDirectory: e.type === 'd', + })); + } + + async download(remotePath: string, writable: Writable): Promise { + await this.client.get(joinPath(this.target.rootPath, remotePath), writable); + } + + async downloadBuffer(remotePath: string, maxBytes = 1024 * 1024): Promise { + const buf = await this.client.get(joinPath(this.target.rootPath, remotePath)) as Buffer; + return buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf; + } + + async upload(remotePath: string, content: Buffer): Promise { + await this.client.put(content, joinPath(this.target.rootPath, remotePath)); + } + + async remove(remotePath: string): Promise { + try { + await this.client.delete(joinPath(this.target.rootPath, remotePath)); + } catch { + // Already deleted + } + } +} + +function parseRights(r: { user: string; group: string; other: string }): number { + const t = (s: string) => (s.includes('r') ? 4 : 0) + (s.includes('w') ? 2 : 0) + (s.includes('x') ? 1 : 0); + return (t(r.user) << 6) | (t(r.group) << 3) | t(r.other); +} + +function joinPath(base: string, rel: string): string { + const b = base.replace(/\/+$/, ''); + const r = rel.replace(/^\/+/, ''); + return r ? `${b}/${r}` : b; +} + +export async function* walk(adapter: WalkerAdapter, startDir = ''): AsyncGenerator { + const stack: string[] = [startDir]; + while (stack.length) { + const dir = stack.pop()!; + let entries: RemoteFile[]; + try { + entries = await adapter.list(dir); + } catch { + continue; + } + for (const e of entries) { + if (e.isDirectory) { + stack.push(e.path); + } else { + yield e; + } + } + } +} + +export async function md5RemoteFile(adapter: WalkerAdapter, path: string): Promise { + const hash = createHash('md5'); + const writable = new Writable({ + write(chunk, _enc, cb) { + hash.update(chunk); + cb(); + }, + }); + await adapter.download(path, writable); + return hash.digest('hex'); +} diff --git a/src/modules/reporter.ts b/src/modules/reporter.ts new file mode 100644 index 0000000..76425ca --- /dev/null +++ b/src/modules/reporter.ts @@ -0,0 +1,138 @@ +import { writeFile } from 'node:fs/promises'; +import chalk from 'chalk'; +import type { Finding, ScanReport, Severity, ExitCode } from '../types/index.js'; + +const SEVERITY_RANK: Record = { info: 0, low: 1, medium: 2, high: 3, critical: 4 }; +const SEVERITY_COLOR: Record string> = { + info: chalk.gray, + low: chalk.blue, + medium: chalk.yellow, + high: chalk.magenta, + critical: chalk.red.bold, +}; + +export function computeExitCode(findings: Finding[]): ExitCode { + if (findings.some(f => f.severity === 'critical')) return 2; + if (findings.some(f => ['high', 'medium'].includes(f.severity))) return 1; + return 0; +} + +export function buildReport(target: string, modules: string[], findings: Finding[], startedAt: Date, filesScanned = 0): ScanReport { + const finishedAt = new Date(); + const bySeverity: Record = { info: 0, low: 0, medium: 0, high: 0, critical: 0 }; + for (const f of findings) bySeverity[f.severity]++; + + return { + tool: 'sbr-malwscan', + version: '0.1.0', + target, + modules, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs: finishedAt.getTime() - startedAt.getTime(), + findings: findings.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]), + stats: { filesScanned, bytesScanned: 0, findingsBySeverity: bySeverity }, + }; +} + +export function printReport(report: ScanReport, quiet = false): void { + if (quiet) return; + const lines: string[] = []; + lines.push(''); + lines.push(chalk.bold(` sbr-malwscan v${report.version}`)); + lines.push(chalk.gray(` Target: ${report.target}`)); + lines.push(chalk.gray(` Modules: ${report.modules.join(', ')}`)); + lines.push(chalk.gray(` Duration: ${Math.round(report.durationMs / 1000)}s Files scanned: ${report.stats.filesScanned}`)); + lines.push(''); + + const s = report.stats.findingsBySeverity; + lines.push( + ' ' + + SEVERITY_COLOR.critical(`CRITICAL:${s.critical}`) + ' ' + + SEVERITY_COLOR.high(`HIGH:${s.high}`) + ' ' + + SEVERITY_COLOR.medium(`MED:${s.medium}`) + ' ' + + SEVERITY_COLOR.low(`LOW:${s.low}`) + ' ' + + SEVERITY_COLOR.info(`INFO:${s.info}`) + ); + lines.push(''); + + for (const f of report.findings) { + const tag = SEVERITY_COLOR[f.severity](`[${f.severity.toUpperCase()}]`); + lines.push(` ${tag} ${chalk.bold(f.module)} · ${f.category}`); + if (f.path) lines.push(` ${chalk.cyan(f.path)}`); + lines.push(` ${f.description}`); + if (f.remediation) lines.push(chalk.gray(` ↪ ${f.remediation}`)); + lines.push(''); + } + + process.stdout.write(lines.join('\n') + '\n'); +} + +export async function writeJson(report: ScanReport, path: string): Promise { + await writeFile(path, JSON.stringify(report, null, 2), 'utf8'); +} + +export async function writeHtml(report: ScanReport, path: string): Promise { + const s = report.stats.findingsBySeverity; + const severityBadges = (sev: Severity) => ({ + critical: '#dc2626', high: '#ea580c', medium: '#ca8a04', low: '#2563eb', info: '#6b7280', + }[sev]); + + const findingsHtml = report.findings.map(f => ` +
+ ${f.severity.toUpperCase()} + ${escapeHtml(f.module)} + ${escapeHtml(f.category)} + ${f.path ? `
${escapeHtml(f.path)}
` : ''} +
${escapeHtml(f.description)}
+ ${f.remediation ? `
→ ${escapeHtml(f.remediation)}
` : ''} +
+ `).join('\n'); + + const html = ` + + + +sbr-malwscan report — ${escapeHtml(report.target)} + + + +

sbr-malwscan v${report.version}

+
+ Target: ${escapeHtml(report.target)}
+ Modules: ${escapeHtml(report.modules.join(', '))}
+ Scanned at: ${report.startedAt} (${Math.round(report.durationMs / 1000)}s, ${report.stats.filesScanned} files) +
+
+ CRITICAL: ${s.critical} + HIGH: ${s.high} + MEDIUM: ${s.medium} + LOW: ${s.low} + INFO: ${s.info} +
+${findingsHtml} + +`; + + await writeFile(path, html, 'utf8'); +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)); +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..fb1a706 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,81 @@ +export type Severity = 'info' | 'low' | 'medium' | 'high' | 'critical'; + +export interface Finding { + id: string; + module: string; + severity: Severity; + category: string; + path?: string; + description: string; + remediation?: string; + evidence?: Record; +} + +export interface ScanStats { + filesScanned: number; + bytesScanned: number; + findingsBySeverity: Record; +} + +export interface ScanReport { + tool: string; + version: string; + target: string; + modules: string[]; + startedAt: string; + finishedAt: string; + durationMs: number; + findings: Finding[]; + stats: ScanStats; +} + +export interface FtpTarget { + protocol: 'ftp' | 'sftp'; + host: string; + port: number; + user: string; + password?: string; + privateKey?: string; + rootPath: string; +} + +export interface CoreDiffResult { + wpVersion: string; + checksumsSource: string; + missing: string[]; + modified: string[]; + extra: string[]; + filesChecked: number; +} + +export interface DropperMatch { + path: string; + extension: string; + size: number; + mtime: string; + mode?: number; + reason: string; + firstBytes: string; +} + +export interface CloakerResult { + url: string; + botStatus: number; + botSize: number; + botTitle: string; + userStatus: number; + userSize: number; + userTitle: string; + sizeDiffPct: number; + hazardTerms: string[]; + suspicious: boolean; +} + +export interface DbScanResult { + suspiciousOptions: Array<{ name: string; reason: string; length: number }>; + suspiciousHooks: Array<{ hook: string; callback: string; source: string }>; + suspiciousUsers: Array<{ id: number; login: string; email: string; reason: string }>; + suspiciousSessions: Array<{ userId: number; ip: string; reason: string }>; +} + +export type ExitCode = 0 | 1 | 2; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6df358e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +}