@blamejs/exceptd-skills 0.12.13 → 0.12.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +150 -0
- package/bin/exceptd.js +147 -9
- package/data/_indexes/_meta.json +45 -45
- package/data/_indexes/activity-feed.json +4 -4
- package/data/_indexes/catalog-summaries.json +29 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +319 -76
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +18 -5
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +742 -78
- package/lib/refresh-external.js +40 -22
- package/lib/refresh-network.js +193 -17
- package/lib/scoring.js +20 -7
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +56 -18
- package/lib/validate-indexes.js +88 -37
- package/lib/verify.js +72 -0
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +150 -17
- package/package.json +3 -1
- package/sbom.cdx.json +7 -7
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +83 -39
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
package/orchestrator/index.js
CHANGED
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* help Show this help
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
const fsMod = require('fs');
|
|
22
|
+
const osMod = require('os');
|
|
23
|
+
const pathMod = require('path');
|
|
24
|
+
|
|
21
25
|
const { scan } = require('./scanner');
|
|
22
26
|
const { dispatch, routeQuery, getSkillContext } = require('./dispatcher');
|
|
23
27
|
const { currencyCheck, initPipeline } = require('./pipeline');
|
|
@@ -27,6 +31,51 @@ const { start: startScheduler, stop: stopScheduler, runCurrencyNow } = require('
|
|
|
27
31
|
const cmd = process.argv[2];
|
|
28
32
|
const args = process.argv.slice(3);
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Minimal argv parser shared by verbs that want a structured view of
|
|
36
|
+
* `process.argv.slice(2)` instead of repeated `.includes()` calls. Returns
|
|
37
|
+
* `{ flags: Set<string>, options: Map<string,string>, positionals: string[] }`
|
|
38
|
+
* where boolean flags (e.g. `--json`) land in `flags`, option flags with a
|
|
39
|
+
* value (e.g. `--log-file path.log` or `--log-file=path.log`) land in
|
|
40
|
+
* `options`, and the rest are positionals.
|
|
41
|
+
*
|
|
42
|
+
* Kept inline rather than reaching into `lib/refresh-external.js#parseArgs`
|
|
43
|
+
* because that helper is hard-coded to the `refresh` flag set; this one
|
|
44
|
+
* stays generic. Both follow the same convention so verbs stay consistent.
|
|
45
|
+
*/
|
|
46
|
+
function parseFlags(argv, optionFlags) {
|
|
47
|
+
const optSet = new Set(optionFlags || []);
|
|
48
|
+
const flags = new Set();
|
|
49
|
+
const options = new Map();
|
|
50
|
+
const positionals = [];
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const tok = argv[i];
|
|
53
|
+
if (typeof tok !== 'string') continue;
|
|
54
|
+
if (tok.startsWith('--')) {
|
|
55
|
+
const eq = tok.indexOf('=');
|
|
56
|
+
if (eq !== -1) {
|
|
57
|
+
const key = tok.slice(0, eq);
|
|
58
|
+
const val = tok.slice(eq + 1);
|
|
59
|
+
if (optSet.has(key)) options.set(key, val);
|
|
60
|
+
else flags.add(tok);
|
|
61
|
+
} else if (optSet.has(tok)) {
|
|
62
|
+
const next = argv[i + 1];
|
|
63
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
64
|
+
options.set(tok, next);
|
|
65
|
+
i++;
|
|
66
|
+
} else {
|
|
67
|
+
options.set(tok, '');
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
flags.add(tok);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
positionals.push(tok);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { flags, options, positionals };
|
|
77
|
+
}
|
|
78
|
+
|
|
30
79
|
async function main() {
|
|
31
80
|
switch (cmd) {
|
|
32
81
|
case 'scan':
|
|
@@ -48,7 +97,7 @@ async function main() {
|
|
|
48
97
|
await runReport(args[0] || 'technical');
|
|
49
98
|
break;
|
|
50
99
|
case 'watch':
|
|
51
|
-
runWatch();
|
|
100
|
+
await runWatch();
|
|
52
101
|
break;
|
|
53
102
|
case 'validate-cves':
|
|
54
103
|
await runValidateCves(args);
|
|
@@ -56,7 +105,6 @@ async function main() {
|
|
|
56
105
|
case 'validate-rfcs':
|
|
57
106
|
await runValidateRfcs(args);
|
|
58
107
|
break;
|
|
59
|
-
case 'watch':
|
|
60
108
|
case 'watchlist':
|
|
61
109
|
runWatchlist(args);
|
|
62
110
|
break;
|
|
@@ -162,7 +210,12 @@ Examples:
|
|
|
162
210
|
// --- command implementations ---
|
|
163
211
|
|
|
164
212
|
async function runScan() {
|
|
165
|
-
|
|
213
|
+
// Use the shared parseFlags helper so flag handling is consistent with
|
|
214
|
+
// other verbs (validate-cves, watchlist, etc.). Previously this was a
|
|
215
|
+
// bare `process.argv.includes('--json')`, which differed in style from
|
|
216
|
+
// the verbs below and could miss `--json=true` or similar future forms.
|
|
217
|
+
const { flags } = parseFlags(process.argv.slice(2), []);
|
|
218
|
+
const jsonOut = flags.has('--json');
|
|
166
219
|
if (!jsonOut) console.log('[orchestrator] Scanning environment...\n');
|
|
167
220
|
const result = await scan();
|
|
168
221
|
if (jsonOut) {
|
|
@@ -407,29 +460,191 @@ async function runReport(format) {
|
|
|
407
460
|
}
|
|
408
461
|
}
|
|
409
462
|
|
|
410
|
-
|
|
463
|
+
/**
|
|
464
|
+
* Resolve a writable directory for the watch lockfile. Prefer the operator's
|
|
465
|
+
* home directory; fall back to the OS tempdir when home is non-writable or
|
|
466
|
+
* non-existent (CI runners, restricted shells, etc.).
|
|
467
|
+
*/
|
|
468
|
+
function _resolveWatchLockDir() {
|
|
469
|
+
const home = process.env.EXCEPTD_HOME || pathMod.join(osMod.homedir(), '.exceptd');
|
|
470
|
+
try {
|
|
471
|
+
fsMod.mkdirSync(home, { recursive: true });
|
|
472
|
+
// Probe writability with a small marker; remove on success.
|
|
473
|
+
const probe = pathMod.join(home, `.write-probe-${process.pid}`);
|
|
474
|
+
fsMod.writeFileSync(probe, '');
|
|
475
|
+
fsMod.unlinkSync(probe);
|
|
476
|
+
return home;
|
|
477
|
+
} catch {
|
|
478
|
+
const fallback = pathMod.join(osMod.tmpdir(), 'exceptd');
|
|
479
|
+
try { fsMod.mkdirSync(fallback, { recursive: true }); } catch { /* tmp always exists */ }
|
|
480
|
+
return fallback;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Acquire an exclusive watch lockfile. Returns `{ path, release }` on
|
|
486
|
+
* success. Throws with code 'EWATCHLOCKED' when another watcher holds the
|
|
487
|
+
* lock and the lock looks fresh. Staleness is determined by two checks
|
|
488
|
+
* combined: (a) the recorded PID is no longer alive, or (b) the file
|
|
489
|
+
* mtime is older than 60s. Either path reclaims the lock atomically.
|
|
490
|
+
*
|
|
491
|
+
* The PID-alive check covers Windows, where SIGTERM cannot be delivered
|
|
492
|
+
* to the prior watcher and our graceful release handler never ran (e.g.
|
|
493
|
+
* the test harness kills with taskkill /F). Without it, every second
|
|
494
|
+
* watch invocation would inherit a stale lock until the 60s mtime ages out.
|
|
495
|
+
*/
|
|
496
|
+
function _acquireWatchLock() {
|
|
497
|
+
const dir = _resolveWatchLockDir();
|
|
498
|
+
const lockPath = pathMod.join(dir, 'watch.lock');
|
|
499
|
+
const STALE_MS = 60_000;
|
|
500
|
+
|
|
501
|
+
function tryCreate() {
|
|
502
|
+
const fd = fsMod.openSync(lockPath, 'wx');
|
|
503
|
+
fsMod.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
|
|
504
|
+
fsMod.closeSync(fd);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function _pidAlive(pid) {
|
|
508
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
509
|
+
try {
|
|
510
|
+
// signal 0 is the POSIX "is the process alive" probe; Node implements
|
|
511
|
+
// it on Windows too via OpenProcess. Throws ESRCH (or EPERM, which
|
|
512
|
+
// also implies alive) when the PID is dead.
|
|
513
|
+
process.kill(pid, 0);
|
|
514
|
+
return true;
|
|
515
|
+
} catch (e) {
|
|
516
|
+
return e.code === 'EPERM';
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
tryCreate();
|
|
522
|
+
} catch (err) {
|
|
523
|
+
if (err.code !== 'EEXIST') throw err;
|
|
524
|
+
let stale = false;
|
|
525
|
+
let recordedPid = null;
|
|
526
|
+
try {
|
|
527
|
+
const raw = fsMod.readFileSync(lockPath, 'utf8');
|
|
528
|
+
try { recordedPid = JSON.parse(raw).pid; } catch { /* malformed file = stale */ stale = true; }
|
|
529
|
+
const st = fsMod.statSync(lockPath);
|
|
530
|
+
const ageStale = (Date.now() - st.mtimeMs) > STALE_MS;
|
|
531
|
+
const pidStale = recordedPid != null && !_pidAlive(recordedPid);
|
|
532
|
+
stale = stale || ageStale || pidStale;
|
|
533
|
+
} catch {
|
|
534
|
+
stale = true;
|
|
535
|
+
}
|
|
536
|
+
if (!stale) {
|
|
537
|
+
const e = new Error(
|
|
538
|
+
`another exceptd watch process (pid ${recordedPid}) appears to hold ${lockPath}; remove the file if you're sure no watcher is running`
|
|
539
|
+
);
|
|
540
|
+
e.code = 'EWATCHLOCKED';
|
|
541
|
+
throw e;
|
|
542
|
+
}
|
|
543
|
+
// Stale — reclaim atomically. unlink + re-create with O_EXCL so a race
|
|
544
|
+
// with another reclaimer surfaces as EEXIST cleanly.
|
|
545
|
+
try { fsMod.unlinkSync(lockPath); } catch { /* concurrent reclaim, fine */ }
|
|
546
|
+
tryCreate();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
path: lockPath,
|
|
551
|
+
release() {
|
|
552
|
+
try { fsMod.unlinkSync(lockPath); } catch { /* idempotent */ }
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function runWatch() {
|
|
558
|
+
const { flags, options } = parseFlags(args, ['--log-file']);
|
|
559
|
+
const logFilePath = options.get('--log-file');
|
|
560
|
+
let logStream = null;
|
|
561
|
+
// Tee stdout when --log-file is set. We intercept process.stdout.write so
|
|
562
|
+
// every console.log + raw write also lands in the log file; this is the
|
|
563
|
+
// simplest pattern that captures scheduler + event-bus + this verb's
|
|
564
|
+
// output uniformly without rewriting every console.log call site.
|
|
565
|
+
if (logFilePath) {
|
|
566
|
+
try {
|
|
567
|
+
fsMod.mkdirSync(pathMod.dirname(pathMod.resolve(logFilePath)), { recursive: true });
|
|
568
|
+
logStream = fsMod.createWriteStream(pathMod.resolve(logFilePath), { flags: 'a' });
|
|
569
|
+
} catch (err) {
|
|
570
|
+
console.error(`[orchestrator] --log-file ${logFilePath}: ${err.message}`);
|
|
571
|
+
process.exitCode = 2;
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
575
|
+
process.stdout.write = function teeWrite(chunk, enc, cb) {
|
|
576
|
+
try { logStream.write(chunk, enc); } catch { /* best-effort */ }
|
|
577
|
+
return origWrite(chunk, enc, cb);
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
// Touch flags to keep eslint-style linters quiet about unused locals.
|
|
581
|
+
void flags;
|
|
582
|
+
|
|
583
|
+
// Acquire the cross-process watch lock before any heavy work. The lock
|
|
584
|
+
// prevents two concurrent watchers from emitting double events or
|
|
585
|
+
// double-firing scheduler bootstraps. Release happens in every shutdown
|
|
586
|
+
// path (SIGINT/SIGTERM/SIGHUP/SIGBREAK).
|
|
587
|
+
let lock;
|
|
588
|
+
try {
|
|
589
|
+
lock = _acquireWatchLock();
|
|
590
|
+
} catch (err) {
|
|
591
|
+
console.error(`[orchestrator] cannot start watch: ${err.message}`);
|
|
592
|
+
process.exitCode = err.code === 'EWATCHLOCKED' ? 75 : 1;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
411
596
|
console.log('[orchestrator] Starting event watcher...');
|
|
597
|
+
console.log(`[orchestrator] Lockfile: ${lock.path}`);
|
|
412
598
|
console.log('Listening for: CISA KEV additions, ATLAS updates, CVE drops, framework amendments.\n');
|
|
413
599
|
|
|
414
|
-
|
|
600
|
+
// Save the listener reference so the shutdown path can detach it instead
|
|
601
|
+
// of leaving it bound (a leaked '*' listener accumulates across
|
|
602
|
+
// start/stop cycles in long-running embedders).
|
|
603
|
+
const anyListener = (event) => {
|
|
415
604
|
console.log(`[event] ${event.type} — ${event.timestamp}`);
|
|
416
605
|
if (event.affected_skills.length > 0) {
|
|
417
606
|
console.log(` Affected skills: ${event.affected_skills.join(', ')}`);
|
|
418
607
|
}
|
|
419
|
-
if (event.payload.cve_id) {
|
|
608
|
+
if (event.payload && event.payload.cve_id) {
|
|
420
609
|
console.log(` CVE: ${event.payload.cve_id}`);
|
|
421
610
|
}
|
|
422
|
-
}
|
|
611
|
+
};
|
|
612
|
+
bus.onAny(anyListener);
|
|
423
613
|
|
|
424
614
|
startScheduler();
|
|
425
615
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
616
|
+
let shuttingDown = false;
|
|
617
|
+
const shutdown = (signal) => {
|
|
618
|
+
if (shuttingDown) return;
|
|
619
|
+
shuttingDown = true;
|
|
620
|
+
console.log(`\n[orchestrator] Stopping watcher (${signal}).`);
|
|
621
|
+
try { bus.offAny(anyListener); } catch { /* best-effort */ }
|
|
622
|
+
try { stopScheduler(); } catch { /* best-effort */ }
|
|
623
|
+
try { lock.release(); } catch { /* best-effort */ }
|
|
624
|
+
if (logStream) {
|
|
625
|
+
try { logStream.end(); } catch { /* best-effort */ }
|
|
626
|
+
}
|
|
627
|
+
// process.exitCode + return-from-handler pattern: let the loop drain
|
|
628
|
+
// (so stdout flushes the goodbye line + the log stream finishes) rather
|
|
629
|
+
// than a hard process.exit() that can truncate.
|
|
630
|
+
process.exitCode = 0;
|
|
631
|
+
};
|
|
431
632
|
|
|
432
|
-
|
|
633
|
+
// Standard signal coverage. SIGINT (Ctrl+C) was the only one previously
|
|
634
|
+
// handled; SIGTERM is the conventional "graceful stop" signal sent by
|
|
635
|
+
// process managers (systemd, docker stop, kubernetes), and SIGHUP is the
|
|
636
|
+
// terminal-close signal on POSIX. SIGBREAK fires on Ctrl+Break on Windows.
|
|
637
|
+
// SIGHUP doesn't exist on Windows and registering it there is a no-op
|
|
638
|
+
// that throws on some Node versions; gate by platform.
|
|
639
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
640
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
641
|
+
if (process.platform !== 'win32') {
|
|
642
|
+
try { process.on('SIGHUP', () => shutdown('SIGHUP')); } catch { /* unsupported */ }
|
|
643
|
+
} else {
|
|
644
|
+
try { process.on('SIGBREAK', () => shutdown('SIGBREAK')); } catch { /* unsupported */ }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
console.log('Press Ctrl+C to stop. (SIGTERM / SIGHUP / SIGBREAK also honored.)\n');
|
|
433
648
|
}
|
|
434
649
|
|
|
435
650
|
async function runValidateCves(rawArgs = []) {
|
|
@@ -444,6 +659,11 @@ async function runValidateCves(rawArgs = []) {
|
|
|
444
659
|
// The cache layout is fixed by lib/prefetch.js — same one refresh-external
|
|
445
660
|
// reads from.
|
|
446
661
|
let cacheDir = null;
|
|
662
|
+
// --concurrency N — bound the number of in-flight upstream calls during
|
|
663
|
+
// live validation. Default stays 4 to match the prior implicit value;
|
|
664
|
+
// operators with faster networks can crank it up, air-gapped fleets can
|
|
665
|
+
// drop it to 1 to keep cache reads serial.
|
|
666
|
+
let concurrency = 4;
|
|
447
667
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
448
668
|
const a = rawArgs[i];
|
|
449
669
|
if (a === '--from-cache') {
|
|
@@ -452,6 +672,12 @@ async function runValidateCves(rawArgs = []) {
|
|
|
452
672
|
if (next && !next.startsWith('--')) i++;
|
|
453
673
|
} else if (a.startsWith('--from-cache=')) {
|
|
454
674
|
cacheDir = a.slice('--from-cache='.length);
|
|
675
|
+
} else if (a === '--concurrency') {
|
|
676
|
+
const next = rawArgs[i + 1];
|
|
677
|
+
if (next !== undefined) { const n = Number(next); if (Number.isFinite(n) && n >= 1) concurrency = Math.floor(n); i++; }
|
|
678
|
+
} else if (a.startsWith('--concurrency=')) {
|
|
679
|
+
const n = Number(a.slice('--concurrency='.length));
|
|
680
|
+
if (Number.isFinite(n) && n >= 1) concurrency = Math.floor(n);
|
|
455
681
|
}
|
|
456
682
|
}
|
|
457
683
|
if (cacheDir) cacheDir = path.resolve(cacheDir);
|
|
@@ -559,7 +785,7 @@ async function runValidateCves(rawArgs = []) {
|
|
|
559
785
|
if (cacheDir && fs.existsSync(cacheDir)) {
|
|
560
786
|
report = await validateAllCvesPreferCache(catalog, cacheDir);
|
|
561
787
|
} else {
|
|
562
|
-
report = await validateAllCves(catalog, { concurrency
|
|
788
|
+
report = await validateAllCves(catalog, { concurrency });
|
|
563
789
|
}
|
|
564
790
|
|
|
565
791
|
// Index results by cve_id (validateAllCves preserves insertion order, but be explicit).
|
|
@@ -850,7 +1076,12 @@ function runWatchlist(rawArgs = []) {
|
|
|
850
1076
|
process.exit(2);
|
|
851
1077
|
}
|
|
852
1078
|
|
|
853
|
-
|
|
1079
|
+
// Exclude entries that are explicitly marked `status: "deprecated"` so
|
|
1080
|
+
// operator forward-watch decisions aren't anchored to skills that are
|
|
1081
|
+
// about to come out of the manifest. The field is optional today; when
|
|
1082
|
+
// absent every skill is treated as active.
|
|
1083
|
+
const skills = (Array.isArray(manifest.skills) ? manifest.skills : [])
|
|
1084
|
+
.filter(s => s && s.status !== 'deprecated');
|
|
854
1085
|
// item -> { skills: [{name, last_threat_review}] }
|
|
855
1086
|
const itemToSkills = new Map();
|
|
856
1087
|
// skill name -> { items: [...], last_threat_review }
|
|
@@ -958,11 +1189,25 @@ async function validateAllCvesPreferCache(catalog, cacheDir) {
|
|
|
958
1189
|
const path = require('path');
|
|
959
1190
|
const { validateCve } = require('../sources/validators');
|
|
960
1191
|
|
|
1192
|
+
// 50 MB cap on any single cache file. Refuses to read past the cap to
|
|
1193
|
+
// protect against a tampered or malformed prefetch payload that would
|
|
1194
|
+
// OOM the validator on JSON.parse. The cap is well above any legitimate
|
|
1195
|
+
// NVD / EPSS / KEV file (largest in practice is the full KEV feed at
|
|
1196
|
+
// ~3 MB as of 2026); anything beyond 50 MB is almost certainly damage.
|
|
1197
|
+
const CACHE_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
1198
|
+
|
|
961
1199
|
function readCached(source, id) {
|
|
962
1200
|
const safe = id.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
963
1201
|
const p = path.join(cacheDir, source, `${safe}.json`);
|
|
964
1202
|
if (!fs.existsSync(p)) return null;
|
|
965
|
-
try {
|
|
1203
|
+
try {
|
|
1204
|
+
const st = fs.statSync(p);
|
|
1205
|
+
if (st.size > CACHE_FILE_MAX_BYTES) {
|
|
1206
|
+
console.error(`[validate-cves] cache file ${p} exceeds ${CACHE_FILE_MAX_BYTES} byte cap (${st.size}); refusing to read.`);
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
1210
|
+
}
|
|
966
1211
|
catch { return null; }
|
|
967
1212
|
}
|
|
968
1213
|
|
|
@@ -1136,7 +1381,21 @@ Examples:
|
|
|
1136
1381
|
`);
|
|
1137
1382
|
}
|
|
1138
1383
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1384
|
+
// Only run the CLI when this file is executed directly. Earlier versions
|
|
1385
|
+
// invoked main() at import time too, which meant `require('./orchestrator')`
|
|
1386
|
+
// would trigger a full CLI dispatch (and printHelp) inside the importing
|
|
1387
|
+
// process. Gating on require.main keeps the module safely importable from
|
|
1388
|
+
// tests and from `bin/exceptd.js`'s programmatic entrypoints.
|
|
1389
|
+
if (require.main === module) {
|
|
1390
|
+
main().catch(err => {
|
|
1391
|
+
console.error('[orchestrator] Fatal:', err.message);
|
|
1392
|
+
process.exit(1);
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
module.exports = {
|
|
1397
|
+
// Export the main + parseFlags helpers for programmatic embedders /
|
|
1398
|
+
// tests. Verb runners stay internal — call them via the CLI surface.
|
|
1399
|
+
main,
|
|
1400
|
+
parseFlags,
|
|
1401
|
+
};
|
package/orchestrator/pipeline.js
CHANGED
|
@@ -61,6 +61,18 @@ function initPipeline(triggerType, triggerPayload) {
|
|
|
61
61
|
* @returns {object} Handoff package for the next stage
|
|
62
62
|
*/
|
|
63
63
|
function buildHandoff(run, stageIndex, stageOutput) {
|
|
64
|
+
// Bounds-check the stage index up front. Without this, an out-of-range
|
|
65
|
+
// index throws an opaque "cannot read properties of undefined" deep inside
|
|
66
|
+
// validateHandoff. With it, callers see a structured error that names the
|
|
67
|
+
// exact invalid input.
|
|
68
|
+
if (!run || !Array.isArray(run.stages)) {
|
|
69
|
+
throw new TypeError('buildHandoff: run.stages must be an array');
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isInteger(stageIndex) || stageIndex < 0 || stageIndex >= run.stages.length) {
|
|
72
|
+
throw new RangeError(
|
|
73
|
+
`buildHandoff: stageIndex ${stageIndex} out of range [0, ${run.stages.length})`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
64
76
|
const currentStage = run.stages[stageIndex];
|
|
65
77
|
const nextStage = run.stages[stageIndex + 1];
|
|
66
78
|
|
|
@@ -96,8 +108,40 @@ function buildHandoff(run, stageIndex, stageOutput) {
|
|
|
96
108
|
*
|
|
97
109
|
* @returns {{ currency_report: object[], action_required: boolean }}
|
|
98
110
|
*/
|
|
111
|
+
// Manifest read cache. currencyCheck() runs on every weekly tick AND on every
|
|
112
|
+
// `exceptd currency` invocation; in `watch` mode the scheduler triggers it
|
|
113
|
+
// repeatedly. Re-reading + JSON.parse'ing the (~80 KB) manifest each time is
|
|
114
|
+
// pure waste when the file hasn't changed within the cache window. 60s TTL is
|
|
115
|
+
// short enough that a manual edit during a long-running watcher shows up by
|
|
116
|
+
// the next periodic tick.
|
|
117
|
+
const MANIFEST_CACHE_TTL_MS = 60_000;
|
|
118
|
+
let _manifestCache = { value: null, mtimeMs: 0, readAt: 0 };
|
|
119
|
+
|
|
120
|
+
function _loadManifestCached() {
|
|
121
|
+
const manifestPath = path.join(__dirname, '..', 'manifest.json');
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
if (_manifestCache.value && (now - _manifestCache.readAt) < MANIFEST_CACHE_TTL_MS) {
|
|
124
|
+
// Within TTL — return cached value. The cost of a stat() per call is
|
|
125
|
+
// ~tens of microseconds; trade it for the JSON.parse() cost saved.
|
|
126
|
+
try {
|
|
127
|
+
const st = fs.statSync(manifestPath);
|
|
128
|
+
if (st.mtimeMs === _manifestCache.mtimeMs) {
|
|
129
|
+
return _manifestCache.value;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// stat failed — fall through to re-read which will surface the error.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
136
|
+
const parsed = JSON.parse(raw);
|
|
137
|
+
let mtimeMs = 0;
|
|
138
|
+
try { mtimeMs = fs.statSync(manifestPath).mtimeMs; } catch { /* leave 0 */ }
|
|
139
|
+
_manifestCache = { value: parsed, mtimeMs, readAt: now };
|
|
140
|
+
return parsed;
|
|
141
|
+
}
|
|
142
|
+
|
|
99
143
|
function currencyCheck() {
|
|
100
|
-
const manifest =
|
|
144
|
+
const manifest = _loadManifestCached();
|
|
101
145
|
const now = new Date();
|
|
102
146
|
const report = [];
|
|
103
147
|
|
|
@@ -204,4 +248,21 @@ function _currencyLabel(score) {
|
|
|
204
248
|
return 'critical_stale';
|
|
205
249
|
}
|
|
206
250
|
|
|
207
|
-
|
|
251
|
+
// Test-only hook to reset the in-memory manifest cache. Not part of the
|
|
252
|
+
// operator surface. v0.12.14: moved out of the export object literal so
|
|
253
|
+
// the inline { mtimeMs: 0, readAt: 0 } reset values don't get detected
|
|
254
|
+
// by scripts/check-test-coverage.js's extractLibExports regex as
|
|
255
|
+
// pseudo-exports (`mtimeMs` and `readAt` are private cache fields, not
|
|
256
|
+
// module exports).
|
|
257
|
+
function _resetManifestCache() {
|
|
258
|
+
_manifestCache = { value: null, mtimeMs: 0, readAt: 0 };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
initPipeline,
|
|
263
|
+
buildHandoff,
|
|
264
|
+
currencyCheck,
|
|
265
|
+
getAgentDefinition,
|
|
266
|
+
MANIFEST_CACHE_TTL_MS,
|
|
267
|
+
_resetManifestCache,
|
|
268
|
+
};
|
package/orchestrator/scanner.js
CHANGED
|
@@ -55,7 +55,10 @@ async function scan() {
|
|
|
55
55
|
findings.push(...frameworkScan());
|
|
56
56
|
|
|
57
57
|
const summary = summarize(findings);
|
|
58
|
-
|
|
58
|
+
// _deprecation is a stderr-only banner — it MUST NOT appear in the JSON
|
|
59
|
+
// shape that downstream consumers ingest. Internal narrative belongs on
|
|
60
|
+
// stderr (the banner above), never in the structured result body.
|
|
61
|
+
return { timestamp, host, findings, summary };
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
/**
|
|
@@ -205,18 +208,34 @@ function cryptoScan() {
|
|
|
205
208
|
});
|
|
206
209
|
}
|
|
207
210
|
|
|
208
|
-
|
|
209
|
-
|
|
211
|
+
// Air-gap mode short-circuits the outbound TLS probe entirely; emit a
|
|
212
|
+
// skipped annotation so operators can see the probe was intentionally
|
|
213
|
+
// suppressed rather than failing silently.
|
|
214
|
+
if (process.env.EXCEPTD_AIR_GAP === '1') {
|
|
210
215
|
findings.push({
|
|
211
216
|
domain: 'crypto',
|
|
212
217
|
signal: 'tls_probe',
|
|
213
|
-
|
|
214
|
-
severity:
|
|
218
|
+
probe: 'skipped (air-gap)',
|
|
219
|
+
severity: 'info',
|
|
215
220
|
skill_hint: 'pqc-first',
|
|
216
|
-
action_required:
|
|
217
|
-
? 'TLS 1.3 detected — verify X25519+ML-KEM-768 hybrid for HNDL-exposed connections'
|
|
218
|
-
: 'TLS below 1.3 — upgrade to TLS 1.3 minimum'
|
|
221
|
+
action_required: 'Air-gap mode active — TLS probe suppressed. Run with EXCEPTD_AIR_GAP=0 to probe.'
|
|
219
222
|
});
|
|
223
|
+
} else {
|
|
224
|
+
const target = process.env.EXCEPTD_TLS_PROBE_TARGET || 'registry.npmjs.org:443';
|
|
225
|
+
const tlsProbe = probeTls(target);
|
|
226
|
+
if (tlsProbe) {
|
|
227
|
+
findings.push({
|
|
228
|
+
domain: 'crypto',
|
|
229
|
+
signal: 'tls_probe',
|
|
230
|
+
value: tlsProbe,
|
|
231
|
+
target,
|
|
232
|
+
severity: tlsProbe.includes('TLSv1.3') ? 'info' : 'high',
|
|
233
|
+
skill_hint: 'pqc-first',
|
|
234
|
+
action_required: tlsProbe.includes('TLSv1.3')
|
|
235
|
+
? 'TLS 1.3 detected — verify X25519+ML-KEM-768 hybrid for HNDL-exposed connections'
|
|
236
|
+
: 'TLS below 1.3 — upgrade to TLS 1.3 minimum'
|
|
237
|
+
});
|
|
238
|
+
}
|
|
220
239
|
}
|
|
221
240
|
|
|
222
241
|
return findings;
|
|
@@ -483,8 +502,11 @@ function probePqcAlgorithms() {
|
|
|
483
502
|
return result;
|
|
484
503
|
}
|
|
485
504
|
|
|
486
|
-
function probeTls() {
|
|
487
|
-
|
|
505
|
+
function probeTls(target) {
|
|
506
|
+
// Target is "host:port"; default lives at the call site so the env var
|
|
507
|
+
// is read once per scan rather than baked in here.
|
|
508
|
+
const t = typeof target === 'string' && target.length > 0 ? target : 'registry.npmjs.org:443';
|
|
509
|
+
const result = spawnSync('openssl', ['s_client', '-connect', t, '-brief'], {
|
|
488
510
|
input: '',
|
|
489
511
|
timeout: 5000,
|
|
490
512
|
stdio: ['pipe', 'pipe', 'pipe']
|