diff --git a/.github/scripts/scan-malware.js b/.github/scripts/scan-malware.js new file mode 100644 index 000000000..784f9f2ed --- /dev/null +++ b/.github/scripts/scan-malware.js @@ -0,0 +1,674 @@ +#!/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. global['!']='8-3946') as an infection flag / re-execution guard", + 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['!']='...' with no spaces (minified form) + /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);