#!/usr/bin/env node /** * scripts/defender-scan.mjs -- Windows Defender static scan smoke for APS build artifacts. * * Scans release artifacts (PyInstaller sidecar exe / frozen desktop package / NSIS * installer) with Windows Defender MpCmdRun.exe: * * MpCmdRun.exe -Scan -ScanType 3 -File -DisableRemediation * * Usage: * node scripts/defender-scan.mjs auto-discover release artifacts and scan * node scripts/defender-scan.mjs ... scan explicit targets (dir scans are recursive) * node scripts/defender-scan.mjs --timeout 900000 per-file timeout in ms (default 600000) * node scripts/defender-scan.mjs --mpcmdrun strict MpCmdRun override (env MPCMDRUN otherwise honored) * node scripts/defender-scan.mjs --json emit a machine-readable JSON summary * node scripts/defender-scan.mjs --no-copy-fallback scan in place even for non-ASCII paths (likely fails) * node scripts/defender-scan.mjs --self-test run internal parser/discovery smoke (no scanning) * * Exit codes: * 0 PASS -- every scanned artifact is clean (skipped artifacts are warnings, see notes) * 1 FAIL -- a threat was detected, a scan timed out, or a scan failed * 2 SKIP -- MpCmdRun.exe is unavailable (or not Windows) * 3 NONE -- no artifacts found to scan * * Notes: * - -DisableRemediation keeps build artifacts untouched: detection is reported only. * - MpCmdRun cannot resolve non-ASCII paths: scanning in place fails with * "CmdTool: Failed with hr = 0x80508023" (exit 2) or reports "was skipped" * (exit 0) depending on the invocation, e.g. for a non-ASCII workspace path. * When a target path contains non-ASCII characters the file is copied to an * ASCII temp path and the copy is scanned (same bytes -> same verdict). * Directories are always scanned in place (recursive) and are subject to the * same MpCmdRun limitation on non-ASCII roots. * - "skipped" means Defender did not scan the file (path/policy). It is reported * as a warning, does not fail the run, and should be reviewed (MpPreference * exclusions) before treating the run as a release gate. */ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DEFAULT_MPCMDRUN = 'C:\\Program Files\\Windows Defender\\MpCmdRun.exe'; const DEFAULT_TIMEOUT_MS = 600000; const MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const EXIT_PASS = 0; const EXIT_FAIL = 1; const EXIT_UNAVAILABLE = 2; const EXIT_NOTHING_TO_SCAN = 3; export function hasNonAscii(value) { return /[^\x00-\x7F]/.test(String(value)); } export function findMpCmdRun(override) { if (process.platform !== 'win32') return null; // An explicit --mpcmdrun is authoritative: if it does not exist the scan is skipped // (exit 2) instead of silently falling back to a different engine binary. const candidates = []; if (override) { candidates.push(override); } else { if (process.env.MPCMDRUN) candidates.push(process.env.MPCMDRUN); candidates.push(DEFAULT_MPCMDRUN); if (process.env.ProgramW6432) { candidates.push(path.join(process.env.ProgramW6432, 'Windows Defender', 'MpCmdRun.exe')); } candidates.push('C:\\Program Files (x86)\\Windows Defender\\MpCmdRun.exe'); } for (const candidate of candidates) { if (candidate && fs.existsSync(candidate)) return candidate; } return null; } export function readDesktopPackage(root) { const file = path.join(root, 'apps', 'desktop', 'package.json'); const raw = JSON.parse(fs.readFileSync(file, 'utf8')); const build = raw.build || {}; return { file, version: raw.version || '0.0.0', productName: build.productName || raw.name || 'aps-agent', artifactName: (build.win && build.win.artifactName) || 'aps-agent-desktop-' + '$' + '{version}' + '.' + '$' + '{ext}', outputDir: (build.directories && build.directories.output) || 'release', }; } export function resolveInstallerName(config) { const versionToken = '$' + '{version}'; const extToken = '$' + '{ext}'; return String(config.artifactName || '') .replace(versionToken, config.version) .replace(extToken, 'exe'); } export function discoverArtifacts(root) { const config = readDesktopPackage(root); const desktopDir = path.join(root, 'apps', 'desktop', config.outputDir); const entries = [ { id: 'sidecar-exe', label: 'Sidecar exe (PyInstaller one-dir)', file: path.join(root, 'dist', 'sidecar', 'aps-sidecar', 'aps-sidecar.exe') }, { id: 'desktop-frozen-exe', label: 'Desktop frozen exe (win-unpacked)', file: path.join(desktopDir, 'win-unpacked', config.productName + '.exe') }, { id: 'bundled-sidecar-exe', label: 'Bundled sidecar exe (win-unpacked)', file: path.join(desktopDir, 'win-unpacked', 'resources', 'sidecar', 'aps-sidecar.exe') }, { id: 'nsis-installer', label: 'NSIS installer', file: path.join(desktopDir, resolveInstallerName(config)) }, ]; return entries.map((entry) => { const stat = fs.existsSync(entry.file) ? fs.statSync(entry.file) : null; return Object.assign({}, entry, { exists: Boolean(stat), bytes: stat ? stat.size : 0 }); }); } export function parseScanOutput(text) { const clean = String(text || ''); const noThreats = /\bfound\s+no\s+threats?\b/i.test(clean); const countMatch = /found\s+(\d+)\s+threats?/i.exec(clean); const threatCount = noThreats ? 0 : countMatch ? Number(countMatch[1]) : null; const threatNames = []; const nameRe = /^Threat\s*:\s*(.+)$/gim; let match; while ((match = nameRe.exec(clean)) !== null) { const name = String(match[1]).trim(); if (name && threatNames.indexOf(name) === -1) threatNames.push(name); } const skipped = /was\s+skipped/i.test(clean); const listed = /LIST\s+OF\s+DETECTED\s+THREATS/i.test(clean); const hasThreats = listed || threatNames.length > 0 || (threatCount !== null && threatCount > 0); let status; if (skipped) status = 'skipped'; else if (hasThreats) status = 'threat'; else if (threatCount === 0) status = 'clean'; else status = 'unknown'; return { status, threatCount, threatNames, skipped, hasThreats }; } export function formatBytes(bytes) { if (!Number.isFinite(bytes)) return '?'; const units = ['B', 'KB', 'MB', 'GB']; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return (unit === 0 ? String(value) : value.toFixed(1)) + ' ' + units[unit]; } export function formatDuration(ms) { const totalSeconds = ms / 1000; if (totalSeconds < 60) { const rounded = Math.round(totalSeconds); return (rounded < 10 ? totalSeconds.toFixed(1) : String(rounded)) + 's'; } const minutes = Math.floor(totalSeconds / 60); return minutes + 'm ' + Math.round(totalSeconds % 60) + 's'; } export function scanTarget(mpCmdRun, targetFile, options) { const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS; const copyFallback = options.copyFallback !== false; const stat = fs.existsSync(targetFile) ? fs.statSync(targetFile) : null; const isDir = Boolean(stat && stat.isDirectory()); const useCopy = copyFallback && !isDir && hasNonAscii(targetFile); let scanPath = targetFile; let tempDir = null; let copyError = null; if (useCopy) { try { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-defender-')); scanPath = path.join(tempDir, 'scan-target' + path.extname(targetFile)); fs.copyFileSync(targetFile, scanPath); } catch (error) { copyError = error; scanPath = targetFile; } } const startedAt = Date.now(); const spawned = spawnSync(mpCmdRun, ['-Scan', '-ScanType', '3', '-File', scanPath, '-DisableRemediation'], { encoding: 'utf8', windowsHide: true, timeout: timeoutMs, maxBuffer: MAX_OUTPUT_BYTES, }); const durationMs = Date.now() - startedAt; const output = String(spawned.stdout || '') + String(spawned.stderr || ''); const parsed = parseScanOutput(output); let status = parsed.status; let error = null; if (spawned.error) { error = String(spawned.error.message || spawned.error); status = (spawned.error.code === 'ETIMEDOUT' || spawned.signal) ? 'timeout' : 'error'; } else if (status === 'unknown') { status = spawned.status === 0 ? 'clean' : 'error'; } if (status === 'error' && !error) { const failMatch = /CmdTool:\s+Failed[^\r\n]*/i.exec(output); error = failMatch ? failMatch[0] : 'MpCmdRun exited with code ' + spawned.status; } const outcome = { file: targetFile, strategy: useCopy ? 'temp-copy' : isDir ? 'in-place-dir' : 'in-place', exitCode: spawned.status, error, copyError: copyError ? String(copyError.message || copyError) : null, durationMs, status, threatCount: parsed.threatCount, threatNames: parsed.threatNames, outputTail: output.slice(-800), }; if (tempDir) { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // best-effort cleanup } } return outcome; } export function classifyVerdict(outcomes) { if (outcomes.some((outcome) => outcome.status === 'threat')) return 'THREAT'; if (outcomes.some((outcome) => outcome.status === 'error' || outcome.status === 'timeout')) return 'FAIL'; return 'PASS'; } export function printSummary(outcomes, mpCmdRun, skippedCount) { const lines = []; lines.push('Defender scan summary'); lines.push(' MpCmdRun: ' + mpCmdRun); for (const outcome of outcomes) { const tag = outcome.status === 'clean' ? 'PASS' : outcome.status === 'threat' ? 'THREAT' : outcome.status === 'skipped' ? 'SKIP' : 'FAIL'; let detail; if (outcome.status === 'clean') detail = 'clean'; else if (outcome.status === 'threat') { detail = outcome.threatNames.length > 0 ? outcome.threatNames.join(', ') : String(outcome.threatCount) + ' threat(s)'; } else if (outcome.status === 'skipped') detail = 'not scanned by MpCmdRun (path/policy)'; else if (outcome.status === 'timeout') detail = 'timed out'; else detail = outcome.error || outcome.copyError || 'scan failed'; lines.push(' [' + tag + '] ' + outcome.label + ' -- ' + outcome.file + ' (' + formatBytes(outcome.bytes) + ') ' + detail + ' in ' + formatDuration(outcome.durationMs) + ' [' + outcome.strategy + ']'); } if (skippedCount > 0) { lines.push(' WARNING: ' + skippedCount + ' artifact(s) were skipped and NOT scanned; review MpPreference exclusions before gating.'); } console.log(lines.join('\n')); } export function parseArgs(argv) { const args = { positional: [], mpCmdRun: null, timeoutMs: DEFAULT_TIMEOUT_MS, json: false, copyFallback: true, selfTest: false, help: false, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--json') args.json = true; else if (arg === '--self-test') args.selfTest = true; else if (arg === '--no-copy-fallback') args.copyFallback = false; else if (arg === '--help' || arg === '-h') args.help = true; else if (arg === '--mpcmdrun') { i += 1; args.mpCmdRun = argv[i]; } else if (arg === '--timeout') { i += 1; args.timeoutMs = Number(argv[i]) || DEFAULT_TIMEOUT_MS; } else if (arg.startsWith('--')) { throw new Error('Unknown option: ' + arg); } else { args.positional.push(arg); } } return args; } export function printHelp() { console.log([ 'Usage: node scripts/defender-scan.mjs [options] [target ...]', '', 'Scan APS build artifacts with Windows Defender MpCmdRun.exe (static, no remediation).', '', 'Options:', ' --timeout per-file timeout (default 600000)', ' --mpcmdrun MpCmdRun.exe path override (env MPCMDRUN also honored)', ' --json machine-readable JSON summary', ' --no-copy-fallback scan non-ASCII paths in place (MpCmdRun will likely skip them)', ' --self-test internal parser/discovery smoke, no scanning', ' -h, --help this help', '', 'Exit codes: 0 PASS / 1 FAIL / 2 MpCmdRun unavailable / 3 nothing to scan', ].join('\n')); } export function runSelfTest() { const checks = []; const check = (name, fn) => { try { fn(); checks.push({ name, pass: true }); } catch (error) { checks.push({ name, pass: false, error: String((error && error.message) || error) }); } }; check('parse clean output', () => { const parsed = parseScanOutput('Scan starting...\nScan finished.\nScanning X found no threats.\n'); if (parsed.status !== 'clean' || parsed.threatCount !== 0) throw new Error('expected clean'); }); check('parse threat output (DisableRemediation block)', () => { const text = 'Scanning X found 1 threats.\n\n<===========================LIST OF DETECTED THREATS==========================>\nThreat : Virus:DOS/EICAR_Test_File\nResources : 1 total\n file : X\n'; const parsed = parseScanOutput(text); if (parsed.status !== 'threat') throw new Error('expected threat, got ' + parsed.status); if (parsed.threatCount !== 1) throw new Error('expected threatCount 1'); if (parsed.threatNames.indexOf('Virus:DOS/EICAR_Test_File') === -1) throw new Error('missing threat name'); }); check('parse remediating-mode threat output', () => { const parsed = parseScanOutput('Scanning X found 2 threats.\nCleaning started...\nCleaning finished.\n'); if (parsed.status !== 'threat' || parsed.threatCount !== 2) throw new Error('bad remediating parse'); }); check('parse skipped output', () => { const parsed = parseScanOutput('Scan starting...\nScan finished.\nScanning D:\\abc was skipped.\n'); if (parsed.status !== 'skipped') throw new Error('expected skipped, got ' + parsed.status); }); check('findMpCmdRun override', () => { if (findMpCmdRun(DEFAULT_MPCMDRUN) !== DEFAULT_MPCMDRUN) throw new Error('expected DEFAULT_MPCMDRUN match'); if (findMpCmdRun('C:\\definitely-missing\\MpCmdRun.exe') !== null) throw new Error('expected null for missing override'); }); check('installer name resolution', () => { const config = { artifactName: 'aps-agent-desktop-' + '$' + '{version}' + '.' + '$' + '{ext}', version: '0.1.0' }; if (resolveInstallerName(config) !== 'aps-agent-desktop-0.1.0.exe') throw new Error('got ' + resolveInstallerName(config)); }); check('discover artifacts from desktop config', () => { const artifacts = discoverArtifacts(repoRoot); const ids = artifacts.map((a) => a.id); const expectedIds = 'sidecar-exe,desktop-frozen-exe,bundled-sidecar-exe,nsis-installer'; if (ids.join(',') !== expectedIds) throw new Error('unexpected ids: ' + ids.join(',')); const installer = artifacts.find((a) => a.id === 'nsis-installer'); if (!installer.file.endsWith('aps-agent-desktop-0.1.0.exe')) throw new Error('unexpected installer path: ' + installer.file); if (!installer.exists) throw new Error('expected a built NSIS installer on this machine'); }); check('verdict classification', () => { if (classifyVerdict([{ status: 'clean' }]) !== 'PASS') throw new Error('clean should be PASS'); if (classifyVerdict([{ status: 'clean' }, { status: 'threat' }]) !== 'THREAT') throw new Error('threat should be THREAT'); if (classifyVerdict([{ status: 'error' }]) !== 'FAIL') throw new Error('error should be FAIL'); if (classifyVerdict([{ status: 'timeout' }]) !== 'FAIL') throw new Error('timeout should be FAIL'); if (classifyVerdict([{ status: 'skipped' }]) !== 'PASS') throw new Error('skipped-only should be PASS with warning'); }); let failed = 0; for (const entry of checks) { if (entry.pass) { console.log(' [PASS] ' + entry.name); } else { failed += 1; console.log(' [FAIL] ' + entry.name + ': ' + entry.error); } } console.log('defender-scan self-test: ' + (failed === 0 ? 'PASS (' + checks.length + '/' + checks.length + ')' : 'FAIL (' + (checks.length - failed) + '/' + checks.length + ')')); process.exitCode = failed === 0 ? EXIT_PASS : EXIT_FAIL; } async function main() { let args; try { args = parseArgs(process.argv.slice(2)); } catch (error) { console.error('defender-scan: ' + String((error && error.message) || error)); process.exitCode = EXIT_FAIL; return; } if (args.help) { printHelp(); return; } if (args.selfTest) { runSelfTest(); return; } const mpCmdRun = findMpCmdRun(args.mpCmdRun); if (!mpCmdRun) { console.log(JSON.stringify({ verdict: 'SKIPPED', reason: 'MpCmdRun.exe unavailable (not Windows or not installed)', mpCmdRun: null })); process.exitCode = EXIT_UNAVAILABLE; return; } let targets; if (args.positional.length > 0) { targets = args.positional.map((file) => { const stat = fs.existsSync(file) ? fs.statSync(file) : null; return { id: path.basename(file), label: file, file, exists: Boolean(stat), bytes: stat ? stat.size : 0 }; }); } else { targets = discoverArtifacts(repoRoot); } const present = targets.filter((target) => target.exists); const missing = targets.filter((target) => !target.exists); if (!args.json) { for (const target of missing) { console.log(' [MISSING] ' + target.label + ' -- ' + target.file + ' (not found; skipped)'); } } if (present.length === 0) { console.log('No build artifacts found to scan. Build them first (npm run build:desktop) or pass explicit paths.'); process.exitCode = EXIT_NOTHING_TO_SCAN; return; } const outcomes = []; for (const target of present) { const outcome = scanTarget(mpCmdRun, target.file, { timeoutMs: args.timeoutMs, copyFallback: args.copyFallback }); outcome.id = target.id; outcome.label = target.label; outcome.bytes = target.bytes; outcomes.push(outcome); } const skippedCount = outcomes.filter((outcome) => outcome.status === 'skipped').length; const verdict = classifyVerdict(outcomes); if (args.json) { console.log(JSON.stringify({ verdict, mpCmdRun, timeoutMs: args.timeoutMs, skippedCount, missing: missing.map((target) => ({ id: target.id, file: target.file })), artifacts: outcomes, }, null, 2)); } else { printSummary(outcomes, mpCmdRun, skippedCount); console.log('Verdict: ' + verdict + (verdict === 'PASS' ? ' -- no threats found in scanned artifacts' : '')); } process.exitCode = verdict === 'PASS' ? EXIT_PASS : EXIT_FAIL; } const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; if (isMain) { await main(); }