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 <noreply@anthropic.com>
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
coverage/
|
||||
.vscode/settings.json
|
||||
.idea/
|
||||
reports/
|
||||
*.tgz
|
||||
tmp/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
58
README.md
Normal file
58
README.md
Normal file
@@ -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
|
||||
41
docs/ROADMAP.md
Normal file
41
docs/ROADMAP.md
Normal file
@@ -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 `<?php` scan, suspicious names/locations, anti-DELE perms)
|
||||
- ✅ A4: Remote helper (base64-obfuscated patterns for WAF bypass, self-delete)
|
||||
- ✅ A5: Cloaker tester (dual-UA Googlebot vs browser, hazard-term detection, sitemap discovery)
|
||||
- ✅ A6: DB scanner (options, users, sessions, action-scheduler hooks)
|
||||
- ✅ A7: Reporter (JSON + HTML + CLI TUI, exit codes 0/1/2)
|
||||
|
||||
**Still pending for v0.1 release:**
|
||||
- Integration tests (fixture: mock WP install + known malware samples)
|
||||
- `remediation` command (quarantine mode, safe rename to `.QUARANTINE-<ts>`)
|
||||
- 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.
|
||||
119
helpers/remote-scan.php
Normal file
119
helpers/remote-scan.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
/**
|
||||
* sbr-malwscan remote helper.
|
||||
* Uploaded to WP root, executes server-side scan, returns JSON, self-deletes.
|
||||
*
|
||||
* Protection:
|
||||
* - Token-based auth via GET param
|
||||
* - Signatures loaded from separate base64-encoded JSON (WAF bypass)
|
||||
* - Self-deletes on shutdown
|
||||
* - Only scans if token valid
|
||||
*/
|
||||
register_shutdown_function(function () {
|
||||
@unlink(__FILE__);
|
||||
});
|
||||
|
||||
$token = $_GET['t'] ?? '';
|
||||
$expected_token = '__TOKEN_PLACEHOLDER__';
|
||||
if ($token === '' || $token !== $expected_token) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
@set_time_limit(300);
|
||||
@ini_set('memory_limit', '256M');
|
||||
|
||||
$root = dirname(__FILE__);
|
||||
$patterns_file = $root . '/sbr-patterns.json';
|
||||
$patterns = [];
|
||||
if (file_exists($patterns_file)) {
|
||||
$raw = @file_get_contents($patterns_file);
|
||||
$data = @json_decode($raw, true);
|
||||
if (is_array($data) && isset($data['patterns'])) {
|
||||
foreach ($data['patterns'] as $p) {
|
||||
if (isset($p['b64'], $p['id'], $p['severity'])) {
|
||||
$patterns[] = [
|
||||
'id' => $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 <?php
|
||||
if (!in_array($ext, $php_extensions, true)) {
|
||||
$trimmed = ltrim($head);
|
||||
if (strpos($trimmed, '<?php') === 0 || strpos($trimmed, '<?=') === 0) {
|
||||
$findings[] = [
|
||||
'path' => $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',
|
||||
]);
|
||||
1246
package-lock.json
generated
Normal file
1246
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
package.json
Normal file
48
package.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
22
patterns/signatures.json
Normal file
22
patterns/signatures.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
178
src/cli.ts
Normal file
178
src/cli.ts
Normal file
@@ -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 <uri>', 'FTP/SFTP URI: ftp://user:pass@host:port/rootPath')
|
||||
.option('--site-url <url>', 'Public site URL (required for --remote)')
|
||||
.option('--remote', 'Also run server-side helper scan (WAF-bypass patterns)')
|
||||
.option('--json <path>', 'Write JSON report to path')
|
||||
.option('--html <path>', '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 <url>', 'Single URL to test')
|
||||
.option('--site <url>', 'Site URL — auto-discover sitemap and test pages')
|
||||
.option('--limit <n>', 'Max URLs to test from sitemap', '20')
|
||||
.option('--json <path>', 'Write JSON report to path')
|
||||
.option('--html <path>', '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>', 'Path to wp-config.php (extracts DB creds)')
|
||||
.option('--host <host>', 'DB host')
|
||||
.option('--port <port>', 'DB port', '3306')
|
||||
.option('--user <user>', 'DB user')
|
||||
.option('--password <password>', 'DB password')
|
||||
.option('--database <name>', 'DB name')
|
||||
.option('--prefix <prefix>', 'Table prefix', 'wp_')
|
||||
.option('--json <path>', 'Write JSON report to path')
|
||||
.option('--html <path>', '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);
|
||||
});
|
||||
82
src/helpers/remote-helper.ts
Normal file
82
src/helpers/remote-helper.ts
Normal file
@@ -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<RemoteScanResult> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
8
src/index.ts
Normal file
8
src/index.ts
Normal file
@@ -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';
|
||||
139
src/modules/cloaker-test.ts
Normal file
139
src/modules/cloaker-test.ts
Normal file
@@ -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<FetchResult> {
|
||||
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(/<title[^>]*>([\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<CloakerResult> {
|
||||
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<CloakerResult[]> {
|
||||
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<string[]> {
|
||||
const base = siteUrl.replace(/\/$/, '');
|
||||
const candidates = [`${base}/sitemap.xml`, `${base}/sitemap_index.xml`, `${base}/wp-sitemap.xml`];
|
||||
const urls = new Set<string>();
|
||||
|
||||
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>([^<]+)<\/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);
|
||||
}
|
||||
132
src/modules/core-diff.ts
Normal file
132
src/modules/core-diff.ts
Normal file
@@ -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<string | null> {
|
||||
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<Record<string, string>> {
|
||||
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<string, string> };
|
||||
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<string>();
|
||||
|
||||
// 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<Finding>(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<Finding>(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<Finding>(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,
|
||||
};
|
||||
}
|
||||
187
src/modules/db-scanner.ts
Normal file
187
src/modules/db-scanner.ts
Normal file
@@ -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<DbConfig> {
|
||||
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<mysql.RowDataPacket[]>(
|
||||
`SELECT option_name, LENGTH(option_value) AS len, option_value FROM ${p}options
|
||||
WHERE (option_value LIKE '%<?php%' OR option_value LIKE '%eval(base64_decode%' OR option_value LIKE '%assert($_%')
|
||||
OR option_name REGEXP '^_transient_[a-f0-9]{32}$'
|
||||
LIMIT 200`
|
||||
);
|
||||
for (const row of optRows) {
|
||||
const val = String(row.option_value);
|
||||
let reason = 'PHP/eval content in option_value';
|
||||
if (val.includes('<?php')) reason = 'Contains <?php literal';
|
||||
else if (val.includes('eval(base64_decode')) reason = 'Contains eval(base64_decode(...))';
|
||||
else if (val.includes('assert($_')) reason = 'Contains assert($_VAR)';
|
||||
else if (SUSPICIOUS_OPTION_NAMES.some(r => 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<mysql.RowDataPacket[]>(
|
||||
`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<mysql.RowDataPacket[]>(
|
||||
`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<mysql.RowDataPacket[]>(
|
||||
`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<Finding>(o => ({
|
||||
id: `db:option:${o.name}`,
|
||||
module: 'db-scanner',
|
||||
severity: o.reason.includes('<?php') || o.reason.includes('eval') ? 'critical' : 'high',
|
||||
category: 'db-persistence',
|
||||
description: `Suspicious option "${o.name}" (${o.length} bytes): ${o.reason}`,
|
||||
remediation: `Inspect ${p}options row for "${o.name}". If malicious, DELETE and check related hooks.`,
|
||||
})),
|
||||
...result.suspiciousUsers.map<Finding>(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<Finding>(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<Finding>(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 };
|
||||
}
|
||||
151
src/modules/dropper-hunter.ts
Normal file
151
src/modules/dropper-hunter.ts
Normal file
@@ -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('<?php') || head.startsWith('<?=') || head.startsWith('<?');
|
||||
}
|
||||
|
||||
function looksLikeHiddenPhp(buf: Buffer): { hit: boolean; marker?: string } {
|
||||
const s = buf.toString('utf8', 0, Math.min(buf.length, HEAD_BYTES));
|
||||
if (/<\?php[\s\r\n]/i.test(s)) return { hit: true, marker: '<?php found in body' };
|
||||
// Common obfuscation markers
|
||||
if (/eval\s*\(\s*(base64_decode|gzinflate|str_rot13)/i.test(s)) return { hit: true, marker: 'eval(base64/gz/rot13)' };
|
||||
if (/assert\s*\(\s*\$_/i.test(s)) return { hit: true, marker: 'assert($_var)' };
|
||||
if (/\$\{[^}]+\}\(\$_/i.test(s)) return { hit: true, marker: '${var}($_...) dynamic exec' };
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
const SUSPICIOUS_NAMES = [
|
||||
/^\./, // hidden files
|
||||
/^[a-f0-9]{8,}\.(php|css|js)$/i, // hex-named files
|
||||
/^wp-[a-z]+\.php$/i, // wp-fake.php in wrong location
|
||||
];
|
||||
|
||||
const SUSPICIOUS_LOCATIONS = [
|
||||
/^wp-content\/uploads\/.*\.php$/i,
|
||||
/^wp-content\/plugins\/[^/]+\/[^/]+\.php\.suspected$/i,
|
||||
/^wp-includes\/blocks\/.*\.(tmp|dat|log)$/i,
|
||||
];
|
||||
|
||||
export async function runDropperHunter(
|
||||
adapter: WalkerAdapter,
|
||||
onProgress?: (path: string, checked: number) => 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 <?php
|
||||
if (!PHP_EXTENSIONS.has(ext) && SUSPICIOUS_NON_PHP_EXT.has(ext) && file.size < 2 * 1024 * 1024) {
|
||||
try {
|
||||
const head = await adapter.downloadBuffer(file.path, HEAD_BYTES);
|
||||
if (startsWithPhp(head)) {
|
||||
reasons.push(`PHP shebang in .${ext} file`);
|
||||
} else {
|
||||
const deep = looksLikeHiddenPhp(head);
|
||||
if (deep.hit) reasons.push(`Hidden PHP pattern: ${deep.marker}`);
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic 2: suspicious filenames
|
||||
for (const pattern of SUSPICIOUS_NAMES) {
|
||||
if (pattern.test(name) && !name.startsWith('.htaccess') && !name.startsWith('.user.ini')) {
|
||||
reasons.push(`Suspicious filename: matches ${pattern}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic 3: suspicious locations
|
||||
for (const pattern of SUSPICIOUS_LOCATIONS) {
|
||||
if (pattern.test(file.path)) {
|
||||
reasons.push(`PHP/suspect file in unexpected location`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic 4: read-only perms (anti-DELE trick)
|
||||
if (file.mode !== undefined && (file.mode & 0o222) === 0 && ext === 'php') {
|
||||
reasons.push(`Read-only PHP file (anti-deletion lock)`);
|
||||
}
|
||||
|
||||
if (reasons.length > 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 };
|
||||
}
|
||||
185
src/modules/ftp-walker.ts
Normal file
185
src/modules/ftp-walker.ts
Normal file
@@ -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<void>;
|
||||
disconnect(): Promise<void>;
|
||||
list(dir: string): Promise<RemoteFile[]>;
|
||||
download(remotePath: string, writable: Writable): Promise<void>;
|
||||
downloadBuffer(remotePath: string, maxBytes?: number): Promise<Buffer>;
|
||||
upload(remotePath: string, content: Buffer): Promise<void>;
|
||||
remove(remotePath: string): Promise<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.client.close();
|
||||
}
|
||||
|
||||
async list(dir: string): Promise<RemoteFile[]> {
|
||||
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<void> {
|
||||
await this.client.downloadTo(writable, joinPath(this.target.rootPath, remotePath));
|
||||
}
|
||||
|
||||
async downloadBuffer(remotePath: string, maxBytes = 1024 * 1024): Promise<Buffer> {
|
||||
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<void> {
|
||||
const { Readable } = await import('node:stream');
|
||||
await this.client.uploadFrom(Readable.from(content), joinPath(this.target.rootPath, remotePath));
|
||||
}
|
||||
|
||||
async remove(remotePath: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.client.end();
|
||||
}
|
||||
|
||||
async list(dir: string): Promise<RemoteFile[]> {
|
||||
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<void> {
|
||||
await this.client.get(joinPath(this.target.rootPath, remotePath), writable);
|
||||
}
|
||||
|
||||
async downloadBuffer(remotePath: string, maxBytes = 1024 * 1024): Promise<Buffer> {
|
||||
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<void> {
|
||||
await this.client.put(content, joinPath(this.target.rootPath, remotePath));
|
||||
}
|
||||
|
||||
async remove(remotePath: string): Promise<void> {
|
||||
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<RemoteFile> {
|
||||
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<string> {
|
||||
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');
|
||||
}
|
||||
138
src/modules/reporter.ts
Normal file
138
src/modules/reporter.ts
Normal file
@@ -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<Severity, number> = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
||||
const SEVERITY_COLOR: Record<Severity, (s: string) => 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<Severity, number> = { 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<void> {
|
||||
await writeFile(path, JSON.stringify(report, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export async function writeHtml(report: ScanReport, path: string): Promise<void> {
|
||||
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 => `
|
||||
<div class="finding ${f.severity}">
|
||||
<span class="sev" style="background:${severityBadges(f.severity)}">${f.severity.toUpperCase()}</span>
|
||||
<span class="mod">${escapeHtml(f.module)}</span>
|
||||
<span class="cat">${escapeHtml(f.category)}</span>
|
||||
${f.path ? `<div class="path">${escapeHtml(f.path)}</div>` : ''}
|
||||
<div class="desc">${escapeHtml(f.description)}</div>
|
||||
${f.remediation ? `<div class="rem">→ ${escapeHtml(f.remediation)}</div>` : ''}
|
||||
</div>
|
||||
`).join('\n');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>sbr-malwscan report — ${escapeHtml(report.target)}</title>
|
||||
<style>
|
||||
body{font:14px/1.5 -apple-system,system-ui,Segoe UI,Arial,sans-serif;max-width:1000px;margin:2em auto;padding:0 1em;color:#111}
|
||||
h1{margin:0 0 .25em;font-size:1.5em}
|
||||
.meta{color:#666;font-size:.9em;margin-bottom:1em}
|
||||
.stats{display:flex;gap:.5em;margin-bottom:1.5em;flex-wrap:wrap}
|
||||
.stat{padding:.3em .6em;border-radius:4px;color:#fff;font-weight:600;font-size:.85em}
|
||||
.finding{border-left:4px solid #ccc;padding:.5em .8em;margin-bottom:.5em;background:#fafafa}
|
||||
.finding.critical{border-color:#dc2626}
|
||||
.finding.high{border-color:#ea580c}
|
||||
.finding.medium{border-color:#ca8a04}
|
||||
.sev{display:inline-block;padding:.1em .5em;border-radius:3px;color:#fff;font-size:.75em;font-weight:700;margin-right:.3em}
|
||||
.mod{font-weight:600}
|
||||
.cat{color:#666;font-size:.9em;margin-left:.3em}
|
||||
.path{font-family:Menlo,Consolas,monospace;font-size:.9em;color:#0369a1;margin-top:.3em;word-break:break-all}
|
||||
.desc{margin-top:.2em}
|
||||
.rem{margin-top:.3em;color:#555;font-size:.9em}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>sbr-malwscan v${report.version}</h1>
|
||||
<div class="meta">
|
||||
Target: <strong>${escapeHtml(report.target)}</strong><br>
|
||||
Modules: ${escapeHtml(report.modules.join(', '))}<br>
|
||||
Scanned at: ${report.startedAt} (${Math.round(report.durationMs / 1000)}s, ${report.stats.filesScanned} files)
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat" style="background:#dc2626">CRITICAL: ${s.critical}</span>
|
||||
<span class="stat" style="background:#ea580c">HIGH: ${s.high}</span>
|
||||
<span class="stat" style="background:#ca8a04">MEDIUM: ${s.medium}</span>
|
||||
<span class="stat" style="background:#2563eb">LOW: ${s.low}</span>
|
||||
<span class="stat" style="background:#6b7280">INFO: ${s.info}</span>
|
||||
</div>
|
||||
${findingsHtml}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
await writeFile(path, html, 'utf8');
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
||||
}
|
||||
81
src/types/index.ts
Normal file
81
src/types/index.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ScanStats {
|
||||
filesScanned: number;
|
||||
bytesScanned: number;
|
||||
findingsBySeverity: Record<Severity, number>;
|
||||
}
|
||||
|
||||
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;
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user