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:
2026-04-17 19:18:32 +02:00
commit c4166d1cd4
19 changed files with 2872 additions and 0 deletions

13
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View 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
View 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
View 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);
});

View 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
View 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
View 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
View 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
View 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 };
}

View 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
View 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
View 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
}

81
src/types/index.ts Normal file
View 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
View 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"]
}