aps-agent/scripts/defender-scan.mjs

440 lines
18 KiB
JavaScript

#!/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 <path> -DisableRemediation
*
* Usage:
* node scripts/defender-scan.mjs auto-discover release artifacts and scan
* node scripts/defender-scan.mjs <file-or-dir> ... 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 <path> 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 <ms> per-file timeout (default 600000)',
' --mpcmdrun <path> 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();
}