#!/usr/bin/env node
/**
 * Malicious Code Scanner
 * Detects obfuscated droppers, eval-based loaders, suspicious global assignments,
 * blockchain C2 patterns, high-entropy payload strings, and stealthy child processes.
 *
 * Exit codes: 0 = clean, 1 = threats found, 2 = scanner error
 */

"use strict";

const fs = require("fs");
const path = require("path");

// ─── Configuration ────────────────────────────────────────────────────────────

const EXTENSIONS_TO_SCAN = new Set([
	".js",
	".cjs",
	".mjs",
	".ts",
	".tsx",
	".jsx",
	".json",
	".html",
	".htm",
	".vue",
	".svelte",
]);

const ALWAYS_SKIP = new Set([
	"node_modules",
	".git",
	"dist",
	"build",
	".next",
	".nuxt",
	"coverage",
	".nyc_output",
	"__pycache__",
	"scan-malware.js",
	".turbo",
]);

const ENTROPY_THRESHOLD = 5.2; // Shannon bits/char – high = likely encoded payload
const ENTROPY_MIN_STRING_LEN = 64; // only test strings at least this long
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // skip files > 2 MB

// ─── Detection Rules ──────────────────────────────────────────────────────────

/**
 * Each rule: { id, description, severity, test(content, filePath) }
 * test() returns null | { line, snippet }[]
 */
const RULES = [
	// ── 1. Global require/module hijacking ──────────────────────────────────────
	{
		id: "GLOBAL_REQUIRE_ASSIGN",
		severity: "CRITICAL",
		description:
			"Assigns require/module/process to a global slot to survive closure boundaries",
		test(src) {
			return matchAll(src, [
				// Quoted-key form:  global['literal'] = require
				/global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*require\b/g,
				/global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*module\b/g,
				/global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*process\b/g,
				// Computed-key form: global[_$_1e42[0]] = require  (real sample pattern)
				/global\s*\[\s*[^\]]{1,60}\]\s*=\s*require\b/g,
				/global\s*\[\s*[^\]]{1,60}\]\s*=\s*module\b/g,
				/global\s*\[\s*[^\]]{1,60}\]\s*=\s*process\b/g,
				// Dot form: global.x = require
				/global\s*\.\s*\w+\s*=\s*require\b/g,
			]);
		},
	},

	// ── 2. Obfuscator fingerprints ───────────────────────────────────────────────
	{
		id: "OBFUSCATOR_VAR_NAMES",
		severity: "HIGH",
		description:
			"Variable names matching known obfuscator output patterns (_$_, _$af…, sfL…)",
		test(src) {
			return matchAll(
				src,
				[
					// _$_1e42 family — the exact dropper signature (minHits=1, one hit is definitive)
					/\b_\$_[0-9a-zA-Z]{4,}\b/g,
					// _$af163278 style
					/\b_\$[a-f0-9]{6,}\b/g,
					// sfL as a standalone identifier (the shuffler function name in this dropper family)
					/\bsfL\b/g,
					// sfLxxx variants
					/\bsfL[A-Za-z0-9]{2,}\b/g,
					// generic hex-suffix identifiers (broader catch for other obfuscators)
					/\b[a-zA-Z]{1,3}[0-9a-f]{8,}\b/g,
				],
				1,
			); // one strong match is enough — these patterns don't appear in legitimate code
		},
	},

	// ── 3. Function-constructor eval-by-constructor ──────────────────────────────
	{
		id: "FUNCTION_CONSTRUCTOR_EVAL",
		severity: "CRITICAL",
		description:
			"Dynamically constructs and executes code via the Function constructor",
		test(src) {
			return matchAll(src, [
				// Explicit new Function(...)
				/new\s+Function\s*\(\s*[^)]{40,}\)/g,
				/Function\s*\(\s*['"`][^'"`]{40,}['"`]\s*\)\s*\(\)/g,
				/\(\s*new\s+Function\s*\(/g,
				/\bFunction\b[^(]*\([^)]*\)\s*\(\s*\)/g,
				// ── Stolen-property pattern (the real sample's technique) ──
				// var x = fn[computed]  then  x('', decode(bigString))
				// Step 1: extract constructor via computed property on a function
				/var\s+\w+\s*=\s*\w+\s*\[\s*\w+\s*\]\s*;[\s\S]{0,120}var\s+\w+\s*=\s*\w+\s*\(\s*(?:''|""|``|\w+)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g,
				// Step 2: direct two-arg call with empty first arg (how Function ctor is invoked)
				/\w+\s*\(\s*(?:''|""|``)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g,
				// Step 3: fn[computed](empty, decoder(str)) in one expression
				/\w+\s*\[\s*\w+\s*\]\s*\(\s*(?:''|""|``|\w*)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g,
			]);
		},
	},

	// ── 4. Encoded-string eval calls ────────────────────────────────────────────
	{
		id: "EVAL_ENCODED_STRING",
		severity: "CRITICAL",
		description: "Passes a large encoded/obfuscated literal directly to eval()",
		test(src) {
			return matchAll(src, [
				/\beval\s*\(\s*['"`][A-Za-z0-9+/=%\\]{80,}['"`]\s*\)/g,
				/\beval\s*\(\s*[A-Za-z_$][A-Za-z0-9_$]*\s*\(\s*[^)]{0,60}\)\s*\)/g, // eval(decode(...))
				/\beval\s*\(\s*atob\s*\(/g,
				/\beval\s*\(\s*Buffer\s*\.from\s*\(/g,
			]);
		},
	},

	// ── 5. Plain suspicious eval ────────────────────────────────────────────────
	{
		id: "SUSPICIOUS_EVAL",
		severity: "MEDIUM",
		description: "eval() used in a context that suggests dynamic code loading",
		test(src) {
			return matchAll(src, [
				/\beval\s*\(\s*(?!\/[\/*])(?!\s*['"`]\s*['"`])[^;)]{20,}\)/g,
			]);
		},
	},

	// ── 6. Stealthy child_process spawn ─────────────────────────────────────────
	{
		id: "DETACHED_CHILD_PROCESS",
		severity: "CRITICAL",
		description:
			"Spawns a detached, stdio-less child process — classic dropper persistence",
		test(src) {
			return matchAll(src, [
				/detached\s*:\s*true/g,
				/stdio\s*:\s*['"`]ignore['"`]/g,
				/windowsHide\s*:\s*true/g,
				/spawn\s*\(\s*['"`]node['"`]\s*,\s*\[\s*['"`]-e['"`]/g,
				/execFile\s*\([^)]+detached/g,
			]);
		},
	},

	// ── 7. Blockchain C2 endpoints ──────────────────────────────────────────────
	{
		id: "BLOCKCHAIN_C2",
		severity: "CRITICAL",
		description:
			"Contacts blockchain APIs (TronGrid, Aptos, etc.) to retrieve a payload",
		test(src) {
			return matchAll(src, [
				/trongrid\.io/gi,
				/tronscan\.org/gi,
				/fullnode\.mainnet\.aptoslabs\.com/gi,
				/aptos\.dev\/v1\/accounts/gi,
				/getTransactionInfo|getTransactionById/g,
				/\.resource\.data\.value\b/g, // Aptos on-chain data access pattern
				/wallet_address.*blockchain|blockchain.*wallet_address/gi,
			]);
		},
	},

	// ── 8. XOR-based decryption of a payload ────────────────────────────────────
	{
		id: "XOR_PAYLOAD_DECRYPT",
		severity: "HIGH",
		description:
			"XOR-decryption loop over a fetched or hardcoded payload buffer",
		test(src) {
			return matchAll(src, [
				/charCodeAt\s*\([^)]*\)\s*\^\s*\w+\.charCodeAt\s*\(/g,
				/\^\s*key\.charCodeAt\s*\(/g,
				/\.map\s*\(\s*\([^)]*\)\s*=>\s*[^.]+\.\s*charCodeAt[^)]*\s*\^/g,
				/fromCharCode\s*\([^)]*\^[^)]*\)/g,
			]);
		},
	},

	// ── 9. String.fromCharCode(127) split (known dropper separator) ───────────────
	{
		id: "FROMCHARCODE_SEPARATOR",
		severity: "HIGH",
		description:
			"Uses String.fromCharCode() as a string delimiter/split marker",
		test(src) {
			return matchAll(src, [
				/String\.fromCharCode\s*\(\s*1[0-2][0-9]\s*\)/g, // DEL, extended ctrl chars
				/\.split\s*\(\s*String\.fromCharCode\s*\(/g,
				/String\.fromCharCode\s*\(\s*0\s*\)/g, // null byte as delimiter
			]);
		},
	},

	// ── 10. Rate-limiting / re-execution guard (anti-detection) ──────────────────
	{
		id: "EXECUTION_RATE_LIMIT",
		severity: "MEDIUM",
		description:
			"Hardcoded timing gate (30 s window) used to avoid repeated execution",
		test(src) {
			return matchAll(src, [
				/Date\.now\s*\(\)\s*-\s*\w+\s*[<>]=?\s*3000[0-9]/g, // 30 000 ms
				/setTimeout[^)]+3[0-9]{4}/g,
				/lastRun|_lastExec|_rateLimit|__ts/g,
			]);
		},
	},

	// ── 11. High-entropy string literals ─────────────────────────────────────────
	{
		id: "HIGH_ENTROPY_STRING",
		severity: "HIGH",
		description:
			"Long string literal with entropy above threshold — likely encoded payload",
		test(src, filePath) {
			// Skip minified bundles and lockfiles
			if (/\.(min\.js|lock|map)$/.test(filePath)) return null;
			const hits = [];
			// Broader charset: the real dropper's joW/pYd blobs contain spaces, brackets,
			// semicolons, etc. — any non-newline, non-quote content of 100+ chars qualifies.
			// We use two regexes: one for tightly-packed base64-like strings, one for the
			// looser mixed-content payload strings this dropper family actually uses.
			const patterns = [
				// Tight: no spaces (base64, hex, classic obfuscation)
				/(['"`])([A-Za-z0-9+/=\\%^&*!@#$\-_.~]{64,})\1/g,
				// Loose: mixed printable chars including spaces — catches joW / pYd style blobs
				/(['"`])([^'"` \t\r\n]{50,}[^'"` \t\r\n])\1/g,
				// Single-quoted with internal spaces — the exact form used in this dropper
				/'([^'\r\n]{100,})'/g,
			];
			const seen = new Set();
			for (const strRe of patterns) {
				strRe.lastIndex = 0;
				let m;
				while ((m = strRe.exec(src)) !== null) {
					const s =
						m[1] !== undefined && m[1].length === 1 ? (m[2] ?? m[1]) : m[1];
					const payload = typeof s === "string" ? s : m[0].slice(1, -1);
					if (payload.length < 50) continue;
					const key = payload.slice(0, 32); // dedup by prefix
					if (seen.has(key)) continue;
					seen.add(key);
					if (shannonEntropy(payload) >= ENTROPY_THRESHOLD) {
						hits.push({
							line: lineOf(src, m.index),
							snippet: payload.slice(0, 80) + "…",
						});
					}
				}
			}
			return hits.length ? hits : null;
		},
	},

	// ── 15. Global nonce / infection marker ─────────────────────────────────────
	{
		id: "GLOBAL_NONCE_MARKER",
		severity: "CRITICAL",
		description:
			"Sets a short global marker string (e.g.
		test(src) {
			return matchAll(src, [
				// global['!'] = '8-3946'  or  global["x"] = "abc-123"
				/global\s*\[\s*['"][^'"]{0,5}['"]\s*\]\s*=\s*['"][0-9!@#$%^&*\-]{3,20}['"]/g,
				//
				/global\['[^']{0,5}'\]='[^']{2,20}'/g,
			]);
		},
	},

	// ── 16. String-shuffler IIFE ──────────────────────────────────────────────────
	{
		id: "STRING_SHUFFLER_IIFE",
		severity: "CRITICAL",
		description:
			"Self-invoking string-shuffler function (seeded character-swap loop) used to decode obfuscated identifiers and payloads",
		test(src) {
			return matchAll(src, [
				// The core pattern: (function(x,y){ ... charAt ... % bignum ... })(str, bigint)
				/\(function\s*\(\s*\w\s*,\s*\w\s*\)\s*\{[^}]{30,}charAt[^}]{10,}%\s*[0-9]{5,}/g,
				// Seeded arithmetic inside a loop: e = (s + w) % bignum
				/[a-z]\s*=\s*\(\s*[a-z]\s*\+\s*[a-z]\s*\)\s*%\s*[0-9]{6,}/g,
				// The characteristic swap: var y=g[t]; g[t]=g[p]; g[p]=y
				/var\s+\w\s*=\s*\w\s*\[\s*\w\s*\]\s*;\s*\w\s*\[\s*\w\s*\]\s*=\s*\w\s*\[\s*\w\s*\]\s*;\s*\w\s*\[\s*\w\s*\]\s*=\s*\w/g,
			]);
		},
	},

	// ── 17. Method-extraction constructor theft ───────────────────────────────────
	{
		id: "CONSTRUCTOR_THEFT",
		severity: "CRITICAL",
		description:
			"Extracts the Function constructor via a computed property on a function object (e.g. sfL['constructor']), bypassing direct 'Function' keyword detection",
		test(src) {
			return matchAll(
				src,
				[
					// var EKc = sfL('...').substr(0, N)  — computing the property name 'constructor'
					/\w+\s*\([^)]{5,50}\)\.substr\s*\(\s*0\s*,\s*\w+\s*\)/g,
					// var dgC = sfL[EKc]  — stealing the constructor via computed key
					/var\s+\w+\s*=\s*\w+\s*\[\s*\w+\s*\]/g,
					// multi-step: var x=fn[computed]; var y=x; var z=x(empty, decode(blob))
					/var\s+\w+\s*=\s*\w+;\s*var\s+\w+\s*=\s*\w+\s*\(\s*(?:\w+|''|"")\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g,
				],
				2,
			); // need ≥2: the substr alone can appear legitimately, but substr + bracket-access together is the tell
		},
	},

	// ── 18. Multi-step join/split decode chain ────────────────────────────────────
	{
		id: "JOIN_SPLIT_DECODE_CHAIN",
		severity: "HIGH",
		description:
			"Repeated join/split/join sequence used to reassemble an obfuscated string — characteristic of this dropper family",
		test(src) {
			return matchAll(src, [
				// .join(x).split(y).join(z)  — at least two chained steps
				/\.join\s*\([^)]{0,20}\)\s*\.split\s*\([^)]{0,20}\)\s*\.join\s*\([^)]{0,20}\)/g,
				// Three+ steps (the real sample has 4):  .join.split.join.split.join
				/(?:\.join\s*\([^)]{0,20}\)\s*\.split\s*\([^)]{0,20}\)\s*){2,}/g,
			]);
		},
	},

	// ── 19. typeof-against-dynamic-string ─────────────────────────────────────────
	{
		id: "TYPEOF_DYNAMIC_CHECK",
		severity: "HIGH",
		description:
			"Uses typeof x === decoded_var[n] instead of typeof x === 'object' to hide the string 'object' from static analysis",
		test(src) {
			return matchAll(src, [
				// typeof module === _$_1e42[1]
				/typeof\s+\w+\s*===\s*_\$_/g,
				// typeof x === anyVar[digit]
				/typeof\s+\w+\s*===\s*\w+\s*\[\s*\d+\s*\]/g,
				// typeof x === dynamicVar  (no bracket, just a variable holding the type string)
				/typeof\s+(?:module|require|process|exports)\s*===\s*[A-Za-z_$][A-Za-z0-9_$]*(?!\s*[[(])/g,
			]);
		},
	},

	// ── 20. IIFE with numeric seed + numeric return (dropper wrapper) ──────────────
	{
		id: "DROPPER_IIFE_WRAPPER",
		severity: "CRITICAL",
		description:
			"Top-level IIFE that calls the final compiled payload with a numeric seed and returns a fake numeric value — canonical dropper wrapper structure",
		test(src) {
			return matchAll(src, [
				// Tgw(2509); return 1358})()
				/\w+\s*\(\s*\d{4,5}\s*\)\s*;\s*return\s+\d{3,6}\s*\}\s*\)\s*\(\s*\)/g,
				// return NNNN})()  — the fake return at the end of the outer IIFE
				/return\s+\d{3,6}\s*\}\s*\)\s*\(\s*\)/g,
				// (function(){...})()  containing a numeric final call + numeric return
				/\w+\s*\(\s*[0-9]{4}\s*\)[^)]*return\s+[0-9]{4}/g,
			]);
		},
	},

	// ── 12. Dynamic property access on require/module ────────────────────────────
	{
		id: "DYNAMIC_REQUIRE",
		severity: "HIGH",
		description: "require() called with a computed or obfuscated argument",
		test(src) {
			return matchAll(src, [
				/require\s*\(\s*\w+\s*\[\s*\d+\s*\]\s*\)/g, // require(arr[0])
				/require\s*\(\s*[A-Za-z_$]+\s*\(\s*[^)]{20,}\)\s*\)/g, // require(decode(...))
				/\[['"`]require['"`]\]\s*\(/g, // ['require'](...)
			]);
		},
	},

	// ── 13. Self-deletion / evidence wiping ──────────────────────────────────────
	{
		id: "SELF_DELETE",
		severity: "CRITICAL",
		description: "File deletes itself or wipes evidence after running",
		test(src) {
			return matchAll(src, [
				/fs\.unlink.*__filename/g,
				/fs\.unlinkSync.*__filename/g,
				/rimraf.*__dirname/g,
				/process\.argv\[1\].*unlink/g,
			]);
		},
	},

	// ── 14. Exfiltration patterns ────────────────────────────────────────────────
	{
		id: "EXFILTRATION_PATTERN",
		severity: "HIGH",
		description:
			"Reads sensitive files or env vars and sends them over the network",
		test(src) {
			return matchAll(src, [
				/readFileSync.*\.ssh/g,
				/readFileSync.*\.aws/g,
				/readFileSync.*\.env/g,
				/process\.env\.[A-Z_]{4,}.*fetch|fetch.*process\.env\.[A-Z_]{4,}/g,
				/HOME.*\.npmrc.*post|post.*HOME.*\.npmrc/g,
			]);
		},
	},
];

// ─── Helpers ──────────────────────────────────────────────────────────────────

function shannonEntropy(str) {
	const freq = {};
	for (const c of str) freq[c] = (freq[c] || 0) + 1;
	const len = str.length;
	return -Object.values(freq).reduce((acc, f) => {
		const p = f / len;
		return acc + p * Math.log2(p);
	}, 0);
}

function lineOf(src, idx) {
	return src.slice(0, idx).split("\n").length;
}

/** Run one or more regexes; return hits if total unique-line matches >= minHits */
function matchAll(src, patterns, minHits = 1) {
	const hits = [];
	for (const re of patterns) {
		re.lastIndex = 0;
		let m;
		while ((m = re.exec(src)) !== null) {
			hits.push({ line: lineOf(src, m.index), snippet: m[0].slice(0, 120) });
			if (re.lastIndex === m.index) re.lastIndex++; // guard zero-width
		}
	}
	if (hits.length < minHits) return null;
	return hits;
}

// ─── File Walking ──────────────────────────────────────────────────────────────

function* walk(dir) {
	for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
		if (ALWAYS_SKIP.has(entry.name)) continue;
		const full = path.join(dir, entry.name);
		if (entry.isDirectory()) {
			yield* walk(full);
		} else if (
			entry.isFile() &&
			EXTENSIONS_TO_SCAN.has(path.extname(entry.name).toLowerCase())
		) {
			yield full;
		}
	}
}

// ─── Main Scanner ─────────────────────────────────────────────────────────────

function scan(rootDir) {
	const findings = []; // { file, rule, hits }
	let scanned = 0;
	let skipped = 0;

	for (const filePath of walk(rootDir)) {
		const stat = fs.statSync(filePath);
		if (stat.size > MAX_FILE_SIZE_BYTES) {
			skipped++;
			continue;
		}
		if (stat.size === 0) {
			continue;
		}

		let src;
		try {
			src = fs.readFileSync(filePath, "utf8");
		} catch {
			skipped++;
			continue;
		}

		scanned++;
		const relPath = path.relative(rootDir, filePath);

		for (const rule of RULES) {
			try {
				const hits = rule.test(src, filePath);
				if (hits && hits.length > 0) {
					findings.push({ file: relPath, rule, hits });
				}
			} catch (err) {
				// Never let a broken rule crash the whole scan
				process.stderr.write(
					`[WARN] Rule ${rule.id} threw on ${relPath}: ${err.message}\n`,
				);
			}
		}
	}

	return { findings, scanned, skipped };
}

// ─── Reporting ────────────────────────────────────────────────────────────────

const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
const SEVERITY_COLOR = {
	CRITICAL: "\x1b[31;1m", // bold red
	HIGH: "\x1b[33;1m", // bold yellow
	MEDIUM: "\x1b[36m", // cyan
	LOW: "\x1b[37m", // white
};
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";

function color(sev, text) {
	if (!process.stdout.isTTY) return text;
	return `${SEVERITY_COLOR[sev] || ""}${text}${RESET}`;
}

function report({ findings, scanned, skipped }) {
	const sorted = [...findings].sort(
		(a, b) =>
			(SEVERITY_ORDER[a.rule.severity] ?? 9) -
			(SEVERITY_ORDER[b.rule.severity] ?? 9),
	);

	console.log("\n" + "═".repeat(72));
	console.log(`${BOLD}  Malicious Code Scanner — Results${RESET}`);
	console.log("═".repeat(72));
	console.log(`  Files scanned : ${scanned}`);
	console.log(`  Files skipped : ${skipped}`);
	console.log(`  Findings      : ${sorted.length}`);
	console.log("─".repeat(72));

	if (sorted.length === 0) {
		console.log("\n  ✅  No suspicious patterns detected.\n");
		return 0;
	}

	// Group by severity
	const bySeverity = {};
	for (const f of sorted) {
		(bySeverity[f.rule.severity] ??= []).push(f);
	}

	for (const sev of ["CRITICAL", "HIGH", "MEDIUM", "LOW"]) {
		const group = bySeverity[sev];
		if (!group) continue;
		console.log(`\n  ${color(sev, `── ${sev} (${group.length})`)}`);
		for (const { file, rule, hits } of group) {
			console.log(`\n    ${BOLD}${file}${RESET}`);
			console.log(`    Rule    : ${rule.id}`);
			console.log(`    Detail  : ${rule.description}`);
			const shown = hits.slice(0, 3);
			for (const h of shown) {
				console.log(`    Line ~${h.line}: ${color(sev, h.snippet)}`);
			}
			if (hits.length > 3)
				console.log(`    … and ${hits.length - 3} more occurrences`);
		}
	}

	// Summary counts
	const counts = Object.fromEntries(
		["CRITICAL", "HIGH", "MEDIUM", "LOW"].map((s) => [
			s,
			(bySeverity[s] || []).length,
		]),
	);

	console.log("\n" + "─".repeat(72));
	console.log(
		`  Summary: ` +
			color("CRITICAL", `${counts.CRITICAL} CRITICAL`) +
			"  " +
			color("HIGH", `${counts.HIGH} HIGH`) +
			"  " +
			color("MEDIUM", `${counts.MEDIUM} MEDIUM`) +
			"  " +
			`${counts.LOW} LOW`,
	);
	console.log("═".repeat(72) + "\n");

	// Fail CI on CRITICAL or HIGH
	return counts.CRITICAL + counts.HIGH > 0 ? 1 : 0;
}

// ─── JSON output for upload-artifact / SARIF consumers ───────────────────────

function writeJsonReport(findings, outPath) {
	const out = findings.map(({ file, rule, hits }) => ({
		file,
		rule_id: rule.id,
		severity: rule.severity,
		description: rule.description,
		occurrences: hits,
	}));
	fs.writeFileSync(outPath, JSON.stringify(out, null, 2));
}

// ─── Entry point ─────────────────────────────────────────────────────────────

const targetArg = process.argv[2] || process.cwd();
const jsonOut = process.env.SCAN_JSON_OUT || "";

// Accept either a directory OR a single file as the scan target
let result;
try {
	const targetStat = fs.statSync(targetArg);
	if (targetStat.isFile()) {
		// Single-file mode: scan just that file regardless of extension
		const src = fs.readFileSync(targetArg, "utf8");
		const findings = [];
		for (const rule of RULES) {
			try {
				const hits = rule.test(src, targetArg);
				if (hits && hits.length > 0) {
					findings.push({ file: path.basename(targetArg), rule, hits });
				}
			} catch (err) {
				process.stderr.write(`[WARN] Rule ${rule.id} threw: ${err.message}\n`);
			}
		}
		result = { findings, scanned: 1, skipped: 0 };
	} else {
		result = scan(targetArg);
	}
} catch (err) {
	console.error(`Scanner internal error: ${err.message}`);
	process.exit(2);
}

const exitCode = report(result);

if (jsonOut) {
	try {
		writeJsonReport(result.findings, jsonOut);
		console.log(`JSON report written to: ${jsonOut}`);
	} catch (err) {
		console.error(`Failed to write JSON report: ${err.message}`);
	}
}

process.exit(exitCode);
