440 lines
18 KiB
JavaScript
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();
|
|
}
|