@blamejs/exceptd-skills 0.19.33 → 0.19.34
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 +10 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +241 -241
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
package/orchestrator/index.js
CHANGED
|
@@ -2,20 +2,8 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* exceptd orchestrator — CLI entry point.
|
|
6
|
-
*
|
|
7
|
-
* Commands:
|
|
8
|
-
* scan Scan current environment and produce findings
|
|
9
|
-
* dispatch Route findings to relevant skills
|
|
10
|
-
* skill <name> Show context for a specific skill
|
|
11
|
-
* pipeline Initialize and describe a pipeline run
|
|
12
|
-
* currency Check skill currency scores
|
|
13
|
-
* report Print dispatch plan as a report
|
|
14
|
-
* watch Start event bus watcher (long-running)
|
|
15
|
-
* validate-cves Remind to validate CVE entries against NVD
|
|
16
|
-
* validate-rfcs Cross-check the RFC catalog against IETF Datatracker
|
|
17
|
-
* watchlist Aggregate forward_watch entries across all skills
|
|
18
|
-
* help Show this help
|
|
5
|
+
* exceptd orchestrator — CLI entry point. printHelp() below is the
|
|
6
|
+
* authoritative verb and flag list.
|
|
19
7
|
*/
|
|
20
8
|
|
|
21
9
|
const fsMod = require('fs');
|
|
@@ -34,16 +22,9 @@ const cmd = process.argv[2];
|
|
|
34
22
|
const args = process.argv.slice(3);
|
|
35
23
|
|
|
36
24
|
/**
|
|
37
|
-
*
|
|
38
|
-
* `
|
|
39
|
-
* `
|
|
40
|
-
* where boolean flags (e.g. `--json`) land in `flags`, option flags with a
|
|
41
|
-
* value (e.g. `--log-file path.log` or `--log-file=path.log`) land in
|
|
42
|
-
* `options`, and the rest are positionals.
|
|
43
|
-
*
|
|
44
|
-
* Kept inline rather than reaching into `lib/refresh-external.js#parseArgs`
|
|
45
|
-
* because that helper is hard-coded to the `refresh` flag set; this one
|
|
46
|
-
* stays generic. Both follow the same convention so verbs stay consistent.
|
|
25
|
+
* Returns `{ flags: Set<string>, options: Map<string,string>, positionals:
|
|
26
|
+
* string[] }`. A flag named in `optionFlags` consumes the following token (or an
|
|
27
|
+
* `=value`) into `options`; every other `--flag` is a boolean in `flags`.
|
|
47
28
|
*/
|
|
48
29
|
function parseFlags(argv, optionFlags) {
|
|
49
30
|
const optSet = new Set(optionFlags || []);
|
|
@@ -90,10 +71,8 @@ async function main() {
|
|
|
90
71
|
runSkillContext(args);
|
|
91
72
|
break;
|
|
92
73
|
case 'pipeline': {
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// malformed input emits a structured ok:false envelope instead of an
|
|
96
|
-
// uncaught SyntaxError stack trace.
|
|
74
|
+
// Reachable only through a direct orchestrator invocation. The guarded
|
|
75
|
+
// parse turns malformed findings into an ok:false envelope.
|
|
97
76
|
let findings = {};
|
|
98
77
|
if (args[1]) {
|
|
99
78
|
try {
|
|
@@ -115,9 +94,8 @@ async function main() {
|
|
|
115
94
|
runCurrency();
|
|
116
95
|
break;
|
|
117
96
|
case 'report':
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
// an invalid format and exiting 1.
|
|
97
|
+
// The first non-flag positional, so `report --json` does not read "--json"
|
|
98
|
+
// as the format.
|
|
121
99
|
await runReport(args.find((a) => typeof a === 'string' && !a.startsWith('--')) || 'technical');
|
|
122
100
|
break;
|
|
123
101
|
case 'watch':
|
|
@@ -142,26 +120,12 @@ async function main() {
|
|
|
142
120
|
}
|
|
143
121
|
}
|
|
144
122
|
|
|
145
|
-
// Programmatic runner for the framework-gap-analysis skill. Closes the
|
|
146
|
-
// dispatch loop — previously `exceptd dispatch` would say "run
|
|
147
|
-
// framework-gap-analysis" but the only thing the CLI could actually do
|
|
148
|
-
// was print the skill body. This subcommand executes the analytical
|
|
149
|
-
// path in lib/framework-gap.js so an operator (or a CI gate) can pipe
|
|
150
|
-
// the JSON into another tool.
|
|
151
|
-
//
|
|
152
|
-
// Usage:
|
|
153
|
-
// exceptd framework-gap <FRAMEWORK_ID|all> <SCENARIO|CVE-ID>
|
|
154
|
-
// exceptd framework-gap NIST-800-53 CVE-2026-31431
|
|
155
|
-
// exceptd framework-gap PCI-DSS-4.0 "prompt injection"
|
|
156
|
-
// exceptd framework-gap all CVE-2025-53773 --json
|
|
157
123
|
function runFrameworkGap(rawArgs) {
|
|
158
124
|
const fs = require('fs');
|
|
159
125
|
const path = require('path');
|
|
160
126
|
const { gapReport, theaterCheck } = require('../lib/framework-gap');
|
|
161
127
|
|
|
162
|
-
//
|
|
163
|
-
// consumes only --json; the global air-gap flags are accepted-and-ignored
|
|
164
|
-
// (the analytical path reads local catalogs, no egress).
|
|
128
|
+
// Air-gap flags are accepted and ignored: this path reads local catalogs only.
|
|
165
129
|
if (rejectUnknownFlags('framework-gap', rawArgs, ['--json', '--air-gap', '--offline', '--no-network'])) return;
|
|
166
130
|
const args = rawArgs.filter(a => !a.startsWith('--'));
|
|
167
131
|
const flags = new Set(rawArgs.filter(a => a.startsWith('--')));
|
|
@@ -173,8 +137,7 @@ Examples:
|
|
|
173
137
|
exceptd framework-gap NIST-800-53 CVE-2026-31431
|
|
174
138
|
exceptd framework-gap PCI-DSS-4.0 "prompt injection"
|
|
175
139
|
exceptd framework-gap all CVE-2025-53773 --json`;
|
|
176
|
-
//
|
|
177
|
-
// structured ok:false envelope, not plain usage text on stderr.
|
|
140
|
+
// A JSON consumer needs the ok:false envelope, not usage text on stderr.
|
|
178
141
|
if (jsonOut) {
|
|
179
142
|
process.stdout.write(JSON.stringify({
|
|
180
143
|
ok: false,
|
|
@@ -185,10 +148,8 @@ Examples:
|
|
|
185
148
|
} else {
|
|
186
149
|
console.error(usage);
|
|
187
150
|
}
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// exit 2 for usage errors, colliding with CI gates that branch on
|
|
191
|
-
// exit 2 to mean "verb ran + detected escalation-worthy finding".
|
|
151
|
+
// A usage error is GENERIC_FAILURE, never DETECTED_ESCALATE — exit 2 tells a
|
|
152
|
+
// CI gate the verb ran and found something.
|
|
192
153
|
safeExit(EXIT_CODES.GENERIC_FAILURE);
|
|
193
154
|
return;
|
|
194
155
|
}
|
|
@@ -200,8 +161,6 @@ Examples:
|
|
|
200
161
|
cveCatalog = JSON.parse(fs.readFileSync(path.join(root, 'data', 'cve-catalog.json'), 'utf8'));
|
|
201
162
|
lessons = JSON.parse(fs.readFileSync(path.join(root, 'data', 'zeroday-lessons.json'), 'utf8'));
|
|
202
163
|
} catch (err) {
|
|
203
|
-
// Honor --json on the catalog-read failure path too: a JSON consumer must
|
|
204
|
-
// get the same structured ok:false envelope the other error exits emit.
|
|
205
164
|
const msg = `framework-gap: cannot read catalog: ${err.message}`;
|
|
206
165
|
if (jsonOut) {
|
|
207
166
|
process.stdout.write(JSON.stringify({ ok: false, verb: 'framework-gap', error: msg }) + '\n');
|
|
@@ -212,8 +171,6 @@ Examples:
|
|
|
212
171
|
return;
|
|
213
172
|
}
|
|
214
173
|
|
|
215
|
-
// The set of framework IDs the catalog actually carries gaps for. Used both
|
|
216
|
-
// to expand `all` and to validate an explicit framework argument.
|
|
217
174
|
const knownFrameworks = [...new Set(Object.values(controlGaps).flatMap(g =>
|
|
218
175
|
Array.isArray(g.framework) ? g.framework : [g.framework]
|
|
219
176
|
).filter(f => f && f !== 'ALL'))];
|
|
@@ -222,18 +179,9 @@ Examples:
|
|
|
222
179
|
? knownFrameworks
|
|
223
180
|
: [args[0]];
|
|
224
181
|
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// so an operator could read a typo as proof the framework covers the
|
|
229
|
-
// scenario. Refuse with the known-framework list instead.
|
|
230
|
-
//
|
|
231
|
-
// Match exactly as gapReport does: normalize (strip case + spaces + hyphens)
|
|
232
|
-
// and accept a substring hit against either a gap's `framework` or a prefix
|
|
233
|
-
// hit against a gap KEY — so the documented short forms ("NIST-800-53"
|
|
234
|
-
// matching "NIST 800-53 Rev 5") still resolve. A framework is "known" when at
|
|
235
|
-
// least one catalog gap matches it, independent of the scenario; a known
|
|
236
|
-
// framework with no scenario gaps remains a legitimate empty result.
|
|
182
|
+
// An unknown framework reports zero matching gaps — indistinguishable from a
|
|
183
|
+
// real "no gaps" result, so a typo could read as proof of coverage. Matching is
|
|
184
|
+
// as gapReport does it, so the short forms still resolve.
|
|
237
185
|
if (args[0].toLowerCase() !== 'all') {
|
|
238
186
|
const normalize = (s) => String(s).toLowerCase().replace(/[\s_-]/g, '');
|
|
239
187
|
const idNorm = normalize(args[0]);
|
|
@@ -263,7 +211,6 @@ Examples:
|
|
|
263
211
|
return;
|
|
264
212
|
}
|
|
265
213
|
|
|
266
|
-
// Human-readable output.
|
|
267
214
|
console.log(`\nFramework gap analysis — ${new Date().toISOString().slice(0, 10)}`);
|
|
268
215
|
console.log(`Scenario: ${scenario}`);
|
|
269
216
|
console.log(`Frameworks: ${requested.join(', ')}\n`);
|
|
@@ -292,8 +239,6 @@ Examples:
|
|
|
292
239
|
for (const c of report.new_control_requirements) {
|
|
293
240
|
const req = c.requirement || '';
|
|
294
241
|
console.log(` - ${c.id} ${c.name}: ${req.length > 140 ? req.slice(0, 140) + '…' : req}`);
|
|
295
|
-
// Naming the gaps it closes is what separates this from generic advice —
|
|
296
|
-
// it ties the control back to the insufficient framework controls above.
|
|
297
242
|
if (c.closes.length) console.log(` closes: ${c.closes.join(', ')}`);
|
|
298
243
|
}
|
|
299
244
|
console.log();
|
|
@@ -310,25 +255,16 @@ Examples:
|
|
|
310
255
|
console.log(`Summary: ${report.summary.total_gaps} matching gaps, ${report.summary.universal_gaps} universal, ${report.summary.new_control_requirements} new controls required, ${report.summary.theater_risk_controls} theater-risk controls`);
|
|
311
256
|
}
|
|
312
257
|
|
|
313
|
-
// --- command implementations ---
|
|
314
|
-
|
|
315
258
|
async function runScan() {
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
// bare `process.argv.includes('--json')`, which differed in style from
|
|
319
|
-
// the verbs below and could miss `--json=true` or similar future forms.
|
|
320
|
-
// --air-gap / --offline / --no-network are global flags; scan's TLS-reachability
|
|
321
|
-
// probe is the one network touch, and --air-gap (or EXCEPTD_AIR_GAP=1) now
|
|
322
|
-
// suppresses it (see scanner.js isAirGap), so the flags are accepted here and
|
|
323
|
-
// honored downstream rather than silently ignored.
|
|
259
|
+
// scan's TLS-reachability probe is its one network touch; --air-gap (or
|
|
260
|
+
// EXCEPTD_AIR_GAP=1) suppresses it in scanner.js, so the flags are honored there.
|
|
324
261
|
if (rejectUnknownFlags('scan', args, ['--json', '--air-gap', '--offline', '--no-network'])) return;
|
|
325
262
|
const { flags } = parseFlags(process.argv.slice(2), []);
|
|
326
263
|
const jsonOut = flags.has('--json');
|
|
327
264
|
if (!jsonOut) console.log('[orchestrator] Scanning environment...\n');
|
|
328
265
|
const result = await scan();
|
|
329
266
|
if (jsonOut) {
|
|
330
|
-
//
|
|
331
|
-
// envelope the ok:false error paths emit.
|
|
267
|
+
// ok:true so one consumer branches on the same envelope as the error paths.
|
|
332
268
|
process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n');
|
|
333
269
|
return result;
|
|
334
270
|
}
|
|
@@ -359,9 +295,7 @@ async function runScan() {
|
|
|
359
295
|
}
|
|
360
296
|
|
|
361
297
|
async function runDispatch() {
|
|
362
|
-
//
|
|
363
|
-
// probe is suppressed under air-gap (see runScan / scanner.js isAirGap), so the
|
|
364
|
-
// flags are accepted here and honored downstream.
|
|
298
|
+
// dispatch runs scan(), whose TLS probe is suppressed under air-gap.
|
|
365
299
|
if (rejectUnknownFlags('dispatch', args, ['--json', '--air-gap', '--offline', '--no-network'])) return;
|
|
366
300
|
const jsonOut = process.argv.includes('--json');
|
|
367
301
|
if (!jsonOut) console.log('[orchestrator] Scanning then dispatching...\n');
|
|
@@ -380,9 +314,6 @@ async function runDispatch() {
|
|
|
380
314
|
console.log(`[${urgency}] ${item.skill_name}`);
|
|
381
315
|
console.log(` Triggered by: ${item.triggered_by} (${item.finding_domain})`);
|
|
382
316
|
console.log(` Action: ${item.action_required}`);
|
|
383
|
-
// Surface per-CVE detail when the underlying finding had a list of
|
|
384
|
-
// CVEs (e.g. cisa_kev_high_rwep). Operators need to know WHICH
|
|
385
|
-
// CVE — not just an aggregate count.
|
|
386
317
|
if (item.evidence && Array.isArray(item.evidence.items) && item.evidence.items.length > 0) {
|
|
387
318
|
console.log(` Evidence:`);
|
|
388
319
|
for (const ev of item.evidence.items) {
|
|
@@ -405,24 +336,18 @@ async function runDispatch() {
|
|
|
405
336
|
}
|
|
406
337
|
|
|
407
338
|
function runSkillContext(rawArgs) {
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
// `skill --json` passed "--json" through as args[0] and reported
|
|
411
|
-
// "Skill not found: --json".
|
|
339
|
+
// --flags are filtered out before the first positional is read as the skill
|
|
340
|
+
// name, or `skill --json` reports "Skill not found: --json".
|
|
412
341
|
const argList = Array.isArray(rawArgs) ? rawArgs : (rawArgs == null ? [] : [rawArgs]);
|
|
413
|
-
//
|
|
414
|
-
// only --json; the global air-gap flags are accepted-and-ignored (skill
|
|
415
|
-
// context is read from local skill files, no egress).
|
|
342
|
+
// Air-gap flags are accepted and ignored; skill context is read from local files.
|
|
416
343
|
if (rejectUnknownFlags('skill', argList, ['--json', '--air-gap', '--offline', '--no-network'])) return;
|
|
417
344
|
const jsonOut = argList.includes('--json');
|
|
418
345
|
const positionals = argList.filter(a => typeof a === 'string' && !a.startsWith('--'));
|
|
419
346
|
const skillName = positionals[0];
|
|
420
347
|
|
|
421
348
|
if (!skillName) {
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
// one-line descriptions straight from the signed manifest so `exceptd skill`
|
|
425
|
-
// is self-documenting.
|
|
349
|
+
// Skill IDs are not the playbook names `brief --all` lists, so listing them
|
|
350
|
+
// from the signed manifest keeps `exceptd skill` self-documenting.
|
|
426
351
|
let skills = [];
|
|
427
352
|
try {
|
|
428
353
|
skills = (require('../manifest.json').skills || [])
|
|
@@ -454,9 +379,8 @@ function runSkillContext(rawArgs) {
|
|
|
454
379
|
|
|
455
380
|
const context = getSkillContext(skillName);
|
|
456
381
|
if (!context) {
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
// verb's envelope without splitting across two streams.
|
|
382
|
+
// ok:false bodies land on stdout beside successful results, so one consumer
|
|
383
|
+
// parses the envelope without splitting across two streams.
|
|
460
384
|
process.stdout.write(JSON.stringify({ ok: false, verb: "skill", error: `Skill not found: ${skillName}`, hint: "Run `exceptd skill` with no arguments to list all available skill IDs." }) + "\n");
|
|
461
385
|
safeExit(EXIT_CODES.GENERIC_FAILURE);
|
|
462
386
|
return;
|
|
@@ -491,8 +415,7 @@ function runPipeline(triggerType, payload) {
|
|
|
491
415
|
}
|
|
492
416
|
|
|
493
417
|
function runCurrency() {
|
|
494
|
-
//
|
|
495
|
-
// (see runScan).
|
|
418
|
+
// Local-only verb; the air-gap flags are accepted and ignored.
|
|
496
419
|
if (rejectUnknownFlags('currency', args, ['--json', '--air-gap', '--offline', '--no-network'])) return;
|
|
497
420
|
const jsonOut = process.argv.includes('--json');
|
|
498
421
|
const result = runCurrencyNow();
|
|
@@ -520,22 +443,12 @@ function runCurrency() {
|
|
|
520
443
|
}
|
|
521
444
|
|
|
522
445
|
async function runReport(format) {
|
|
523
|
-
//
|
|
524
|
-
// emit. report takes only a format positional; --json is accepted for
|
|
525
|
-
// parity, and the global air-gap flags are honored downstream — the
|
|
526
|
-
// report path's scan() TLS probe is suppressed under air-gap (scanner.js).
|
|
446
|
+
// The air-gap flags are honored downstream by scan()'s TLS probe.
|
|
527
447
|
if (rejectUnknownFlags('report', args, ['--json', '--air-gap', '--offline', '--no-network'])) return;
|
|
528
|
-
// v0.11.6 (#98): validate format positional. Pre-0.11.6 unknown formats
|
|
529
|
-
// emitted a generic "# exceptd Report" header — silently accepted any
|
|
530
|
-
// string. Now: reject with structured JSON error matching other verbs.
|
|
531
448
|
const VALID_REPORT_FORMATS = ['executive', 'technical', 'compliance', 'csaf'];
|
|
532
449
|
if (!VALID_REPORT_FORMATS.includes(format)) {
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
// body went to stderr and exit was 2; both broke CI consumers
|
|
536
|
-
// that expected the dispatch-error vs verb-finding distinction.
|
|
537
|
-
// v0.13.2: did-you-mean on the unknown format value (reuses
|
|
538
|
-
// lib/flag-suggest.js for Levenshtein-≤2 typo correction).
|
|
450
|
+
// GENERIC_FAILURE, not DETECTED_ESCALATE — exit 2 is how a CI consumer reads
|
|
451
|
+
// "the verb ran and found something".
|
|
539
452
|
const { suggestFlag } = require('../lib/flag-suggest');
|
|
540
453
|
const dym = suggestFlag(String(format), VALID_REPORT_FORMATS);
|
|
541
454
|
const hint = dym ? ` Did you mean "${dym}"?` : '';
|
|
@@ -551,9 +464,7 @@ async function runReport(format) {
|
|
|
551
464
|
return;
|
|
552
465
|
}
|
|
553
466
|
|
|
554
|
-
//
|
|
555
|
-
// every scanned finding + dispatched plan + currency posture. Useful for
|
|
556
|
-
// VEX downstreams that ingest CSAF JSON.
|
|
467
|
+
// `report csaf` emits a CSAF 2.0 envelope for VEX downstreams.
|
|
557
468
|
if (format === 'csaf') {
|
|
558
469
|
const scanResult = await scan();
|
|
559
470
|
const plan = dispatch(scanResult.findings);
|
|
@@ -573,28 +484,22 @@ async function runReport(format) {
|
|
|
573
484
|
revision_history: [{ number: '1', date: new Date().toISOString(), summary: 'Initial report emission' }],
|
|
574
485
|
},
|
|
575
486
|
},
|
|
576
|
-
// CSAF vulnerabilities[]
|
|
577
|
-
//
|
|
578
|
-
// exceptd_extension.scan_summary below, not dropped.
|
|
487
|
+
// CSAF vulnerabilities[] is CVE-scoped by spec, so a signal detection
|
|
488
|
+
// without a catalogued CVE is preserved in exceptd_extension, not dropped.
|
|
579
489
|
vulnerabilities: scanResult.findings
|
|
580
490
|
.filter(f => f.cve_id)
|
|
581
491
|
.map(f => {
|
|
582
492
|
const vuln = {
|
|
583
493
|
cve: f.cve_id,
|
|
584
|
-
//
|
|
585
|
-
// requires non-empty strings, so fall back through signal to a
|
|
586
|
-
// generic label.
|
|
494
|
+
// The CSAF schema requires non-empty strings, so never emit null here.
|
|
587
495
|
notes: [{ category: 'description', text: f.action_required || f.signal || 'Vulnerability detected' }],
|
|
588
496
|
threats: f.severity === 'critical'
|
|
589
497
|
? [{ category: 'exploit_status', details: f.action_required || f.signal || 'Critical vulnerability detected' }]
|
|
590
498
|
: [],
|
|
591
499
|
};
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
// vectorString (CSAF §3.2.1.5), so emit it only when both the score
|
|
596
|
-
// and vector are known, and omit scores entirely otherwise rather
|
|
597
|
-
// than fabricate one.
|
|
500
|
+
// The real catalog CVSS, never a hardcoded base_score of 0 — that reads
|
|
501
|
+
// as "no impact". A cvss_v3 block carries vectorString (CSAF §3.2.1.5),
|
|
502
|
+
// so emit only when both are known rather than fabricate one.
|
|
598
503
|
if (typeof f.cvss_score === 'number' && Number.isFinite(f.cvss_score) &&
|
|
599
504
|
typeof f.cvss_vector === 'string' && f.cvss_vector) {
|
|
600
505
|
vuln.scores = [{ products: [], cvss_v3: { baseScore: f.cvss_score, vectorString: f.cvss_vector } }];
|
|
@@ -612,17 +517,13 @@ async function runReport(format) {
|
|
|
612
517
|
return;
|
|
613
518
|
}
|
|
614
519
|
|
|
615
|
-
//
|
|
616
|
-
// clean markdown on stdout — the first stdout line must be the report
|
|
617
|
-
// header, not this progress notice.
|
|
520
|
+
// stderr, so `report executive > out.md` opens with the report header.
|
|
618
521
|
console.error(`[orchestrator] Generating ${format} report...\n`);
|
|
619
522
|
const scanResult = await scan();
|
|
620
523
|
const plan = dispatch(scanResult.findings);
|
|
621
524
|
const { currency_report } = currencyCheck();
|
|
622
525
|
|
|
623
|
-
// The help advertises
|
|
624
|
-
// `report csaf` emitted JSON; executive/technical/compliance silently
|
|
625
|
-
// rendered Markdown even with --json. Honor the flag for those formats too.
|
|
526
|
+
// The help advertises --json for every format, not only csaf.
|
|
626
527
|
if (args.includes('--json')) {
|
|
627
528
|
process.stdout.write(JSON.stringify({
|
|
628
529
|
ok: true,
|
|
@@ -639,9 +540,7 @@ async function runReport(format) {
|
|
|
639
540
|
return;
|
|
640
541
|
}
|
|
641
542
|
|
|
642
|
-
//
|
|
643
|
-
// report carries its provenance internally. Previously only stderr
|
|
644
|
-
// (`[orchestrator] Generating <X> report`) distinguished the three.
|
|
543
|
+
// The header self-describes the flavor, so a piped report carries its provenance.
|
|
645
544
|
const flavorTitle = {
|
|
646
545
|
executive: 'Executive Report',
|
|
647
546
|
technical: 'Technical Report',
|
|
@@ -664,8 +563,6 @@ async function runReport(format) {
|
|
|
664
563
|
}
|
|
665
564
|
|
|
666
565
|
console.log('\n## Skill Currency');
|
|
667
|
-
// Two tiers, named consistently and explained inline so the reader
|
|
668
|
-
// doesn't have to mentally map two thresholds onto the same list.
|
|
669
566
|
const critical = currency_report.filter(s => s.currency_score < 50);
|
|
670
567
|
const stale = currency_report.filter(s => s.currency_score >= 50 && s.currency_score < 70);
|
|
671
568
|
if (critical.length === 0 && stale.length === 0) {
|
|
@@ -683,15 +580,13 @@ async function runReport(format) {
|
|
|
683
580
|
}
|
|
684
581
|
|
|
685
582
|
/**
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
* non-existent (CI runners, restricted shells, etc.).
|
|
583
|
+
* A writable directory for the watch lockfile: the operator's home, falling
|
|
584
|
+
* back to the OS tempdir when home is absent or non-writable.
|
|
689
585
|
*/
|
|
690
586
|
function _resolveWatchLockDir() {
|
|
691
587
|
const home = process.env.EXCEPTD_HOME || pathMod.join(osMod.homedir(), '.exceptd');
|
|
692
588
|
try {
|
|
693
589
|
fsMod.mkdirSync(home, { recursive: true });
|
|
694
|
-
// Probe writability with a small marker; remove on success.
|
|
695
590
|
const probe = pathMod.join(home, `.write-probe-${process.pid}`);
|
|
696
591
|
fsMod.writeFileSync(probe, '');
|
|
697
592
|
fsMod.unlinkSync(probe);
|
|
@@ -704,16 +599,9 @@ function _resolveWatchLockDir() {
|
|
|
704
599
|
}
|
|
705
600
|
|
|
706
601
|
/**
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
* combined: (a) the recorded PID is no longer alive, or (b) the file
|
|
711
|
-
* mtime is older than 60s. Either path reclaims the lock atomically.
|
|
712
|
-
*
|
|
713
|
-
* The PID-alive check covers Windows, where SIGTERM cannot be delivered
|
|
714
|
-
* to the prior watcher and our graceful release handler never ran (e.g.
|
|
715
|
-
* the test harness kills with taskkill /F). Without it, every second
|
|
716
|
-
* watch invocation would inherit a stale lock until the 60s mtime ages out.
|
|
602
|
+
* Returns `{ path, release }`, or throws code 'EWATCHLOCKED' when a live watcher
|
|
603
|
+
* holds the lock. Stale means the recorded PID is dead or the mtime is older than
|
|
604
|
+
* 60s; the PID check covers Windows, where the graceful release never ran.
|
|
717
605
|
*/
|
|
718
606
|
function _acquireWatchLock() {
|
|
719
607
|
const dir = _resolveWatchLockDir();
|
|
@@ -721,10 +609,8 @@ function _acquireWatchLock() {
|
|
|
721
609
|
const STALE_MS = 60_000;
|
|
722
610
|
|
|
723
611
|
function tryCreate() {
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
// create + restrictive perms, not an unguessable name. The explicit mode
|
|
727
|
-
// also clears the insecure-temporary-file static-analysis finding.
|
|
612
|
+
// The predictable lock-file name IS the mutex, so the safety is the exclusive
|
|
613
|
+
// create plus the owner-only mode, not an unguessable name.
|
|
728
614
|
const fd = fsMod.openSync(lockPath, 'wx', 0o600);
|
|
729
615
|
fsMod.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
|
|
730
616
|
fsMod.closeSync(fd);
|
|
@@ -733,9 +619,7 @@ function _acquireWatchLock() {
|
|
|
733
619
|
function _pidAlive(pid) {
|
|
734
620
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
735
621
|
try {
|
|
736
|
-
//
|
|
737
|
-
// it on Windows too via OpenProcess. Throws ESRCH (or EPERM, which
|
|
738
|
-
// also implies alive) when the PID is dead.
|
|
622
|
+
// Signal 0 is the "is it alive" probe; ESRCH means dead, EPERM alive.
|
|
739
623
|
process.kill(pid, 0);
|
|
740
624
|
return true;
|
|
741
625
|
} catch (e) {
|
|
@@ -766,11 +650,8 @@ function _acquireWatchLock() {
|
|
|
766
650
|
e.code = 'EWATCHLOCKED';
|
|
767
651
|
throw e;
|
|
768
652
|
}
|
|
769
|
-
// Stale —
|
|
770
|
-
//
|
|
771
|
-
// create throws EEXIST; that is contention, not a generic failure, so
|
|
772
|
-
// re-tag it as EWATCHLOCKED to map onto the EX_TEMPFAIL "retry later"
|
|
773
|
-
// exit rather than the GENERIC_FAILURE the bare EEXIST would fall to.
|
|
653
|
+
// Stale — unlink and re-create with O_EXCL. Losing that race is contention,
|
|
654
|
+
// so it is re-tagged EWATCHLOCKED to reach the EX_TEMPFAIL "retry later" exit.
|
|
774
655
|
try { fsMod.unlinkSync(lockPath); } catch { /* concurrent reclaim, fine */ }
|
|
775
656
|
try {
|
|
776
657
|
tryCreate();
|
|
@@ -795,27 +676,20 @@ function _acquireWatchLock() {
|
|
|
795
676
|
}
|
|
796
677
|
|
|
797
678
|
async function runWatch() {
|
|
798
|
-
// Reject unknown flags before acquiring the
|
|
799
|
-
//
|
|
800
|
-
// --json is accepted for parity; the global air-gap flags are
|
|
801
|
-
// accepted-and-ignored (watch does no egress of its own).
|
|
679
|
+
// Reject unknown flags before acquiring the lock or starting the scheduler.
|
|
680
|
+
// --log-file is the value-taking option; watch does no egress of its own.
|
|
802
681
|
if (rejectUnknownFlags('watch', args, ['--log-file', '--json', '--air-gap', '--offline', '--no-network'])) return;
|
|
803
682
|
const { flags, options } = parseFlags(args, ['--log-file']);
|
|
804
683
|
const logFilePath = options.get('--log-file');
|
|
805
684
|
let logStream = null;
|
|
806
|
-
//
|
|
807
|
-
// every console.log + raw write also lands in the log file; this is the
|
|
808
|
-
// simplest pattern that captures scheduler + event-bus + this verb's
|
|
809
|
-
// output uniformly without rewriting every console.log call site.
|
|
685
|
+
// Intercepting process.stdout.write tees the scheduler and event bus too.
|
|
810
686
|
if (logFilePath) {
|
|
811
687
|
try {
|
|
812
688
|
fsMod.mkdirSync(pathMod.dirname(pathMod.resolve(logFilePath)), { recursive: true });
|
|
813
689
|
logStream = fsMod.createWriteStream(pathMod.resolve(logFilePath), { flags: 'a' });
|
|
814
690
|
} catch (err) {
|
|
815
|
-
// A filesystem error opening the log target
|
|
816
|
-
//
|
|
817
|
-
// 2 is DETECTED_ESCALATE and would misroute a CI gate. Use the same
|
|
818
|
-
// GENERIC_FAILURE every other error site in this function uses.
|
|
691
|
+
// A filesystem error opening the log target is a generic failure, not a
|
|
692
|
+
// detected finding — exit 2 would misroute a CI gate.
|
|
819
693
|
console.error(`[orchestrator] --log-file ${logFilePath}: ${err.message}`);
|
|
820
694
|
safeExit(EXIT_CODES.GENERIC_FAILURE);
|
|
821
695
|
return;
|
|
@@ -826,21 +700,18 @@ async function runWatch() {
|
|
|
826
700
|
return origWrite(chunk, enc, cb);
|
|
827
701
|
};
|
|
828
702
|
}
|
|
829
|
-
//
|
|
703
|
+
// Parsed for symmetry with the other verbs, unused here.
|
|
830
704
|
void flags;
|
|
831
705
|
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
// double-firing scheduler bootstraps. Release happens in every shutdown
|
|
835
|
-
// path (SIGINT/SIGTERM/SIGHUP/SIGBREAK).
|
|
706
|
+
// The cross-process lock stops two watchers double-emitting events and
|
|
707
|
+
// double-firing the scheduler bootstrap. Every shutdown path releases it.
|
|
836
708
|
let lock;
|
|
837
709
|
try {
|
|
838
710
|
lock = _acquireWatchLock();
|
|
839
711
|
} catch (err) {
|
|
840
712
|
console.error(`[orchestrator] cannot start watch: ${err.message}`);
|
|
841
|
-
// sysexits EX_TEMPFAIL
|
|
842
|
-
//
|
|
843
|
-
// `doctor --exit-codes` dump and this path share one source of truth.
|
|
713
|
+
// sysexits EX_TEMPFAIL, read from the canonical table so this path and the
|
|
714
|
+
// `doctor --exit-codes` dump agree.
|
|
844
715
|
const watchLocked = EXIT_CODES.WATCH_LOCK_CONTENTION || 75;
|
|
845
716
|
process.exitCode = err.code === 'EWATCHLOCKED' ? watchLocked : 1;
|
|
846
717
|
return;
|
|
@@ -850,9 +721,8 @@ async function runWatch() {
|
|
|
850
721
|
console.log(`[orchestrator] Lockfile: ${lock.path}`);
|
|
851
722
|
console.log('Listening for: CISA KEV additions, ATLAS updates, CVE drops, framework amendments.\n');
|
|
852
723
|
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
// start/stop cycles in long-running embedders).
|
|
724
|
+
// Held so shutdown can detach it — a leaked '*' listener accumulates across
|
|
725
|
+
// start/stop cycles.
|
|
856
726
|
const anyListener = (event) => {
|
|
857
727
|
console.log(`[event] ${event.type} — ${event.timestamp}`);
|
|
858
728
|
if (event.affected_skills.length > 0) {
|
|
@@ -877,18 +747,13 @@ async function runWatch() {
|
|
|
877
747
|
if (logStream) {
|
|
878
748
|
try { logStream.end(); } catch { /* best-effort */ }
|
|
879
749
|
}
|
|
880
|
-
// process.exitCode
|
|
881
|
-
//
|
|
882
|
-
// than a hard process.exit() that can truncate.
|
|
750
|
+
// process.exitCode, not process.exit() — the loop drains so the goodbye line
|
|
751
|
+
// flushes and the log stream finishes.
|
|
883
752
|
process.exitCode = 0;
|
|
884
753
|
};
|
|
885
754
|
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
// process managers (systemd, docker stop, kubernetes), and SIGHUP is the
|
|
889
|
-
// terminal-close signal on POSIX. SIGBREAK fires on Ctrl+Break on Windows.
|
|
890
|
-
// SIGHUP doesn't exist on Windows and registering it there is a no-op
|
|
891
|
-
// that throws on some Node versions; gate by platform.
|
|
755
|
+
// SIGHUP does not exist on Windows and registering it throws on some Node
|
|
756
|
+
// versions, so the platform gate picks SIGBREAK instead.
|
|
892
757
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
893
758
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
894
759
|
if (process.platform !== 'win32') {
|
|
@@ -900,24 +765,19 @@ async function runWatch() {
|
|
|
900
765
|
console.log('Press Ctrl+C to stop. (SIGTERM / SIGHUP / SIGBREAK also honored.)\n');
|
|
901
766
|
}
|
|
902
767
|
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
// through to the default live-network path and the verb hung fetching the
|
|
906
|
-
// whole catalog from NVD until killed.
|
|
768
|
+
// An unknown flag must be rejected BEFORE any network work: a swallowed
|
|
769
|
+
// `--ofline` falls through to the live path and fetches the whole NVD catalog.
|
|
907
770
|
const VALIDATE_CVES_KNOWN_FLAGS = Object.freeze([
|
|
908
771
|
'--offline', '--no-fail', '--from-cache', '--concurrency', '--since', '--air-gap',
|
|
909
772
|
]);
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
// path; `--air-gap` forces the offline view with no egress.
|
|
773
|
+
// `--live` is the explicit opt-in to the default network path; `--air-gap`
|
|
774
|
+
// forces the offline view.
|
|
913
775
|
const VALIDATE_RFCS_KNOWN_FLAGS = Object.freeze([
|
|
914
776
|
'--offline', '--no-fail', '--from-cache', '--since', '--live', '--air-gap',
|
|
915
777
|
]);
|
|
916
778
|
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
// every flag is recognized. The base flag (text before any `=`) is what gets
|
|
920
|
-
// matched so `--from-cache=path` and `--since=2026-01-01` are accepted.
|
|
779
|
+
// True when a rejection was emitted and the caller must return. Matching is on
|
|
780
|
+
// the base flag — the text before any `=` — so `--since=2026-01-01` is accepted.
|
|
921
781
|
function rejectUnknownFlags(verb, rawArgs, knownFlags) {
|
|
922
782
|
const known = new Set(knownFlags);
|
|
923
783
|
const unknown = rawArgs
|
|
@@ -925,7 +785,6 @@ function rejectUnknownFlags(verb, rawArgs, knownFlags) {
|
|
|
925
785
|
.map(a => { const eq = a.indexOf('='); return eq === -1 ? a : a.slice(0, eq); })
|
|
926
786
|
.filter(base => !known.has(base));
|
|
927
787
|
if (unknown.length === 0) return false;
|
|
928
|
-
// Dedupe while preserving order.
|
|
929
788
|
const uniq = [...new Set(unknown)];
|
|
930
789
|
process.stdout.write(JSON.stringify({
|
|
931
790
|
ok: false,
|
|
@@ -947,15 +806,9 @@ async function runValidateCves(rawArgs = []) {
|
|
|
947
806
|
const flags = new Set(rawArgs.filter(a => a.startsWith('--')));
|
|
948
807
|
const offline = flags.has('--offline') || flags.has('--air-gap');
|
|
949
808
|
const noFail = flags.has('--no-fail');
|
|
950
|
-
// --from-cache
|
|
951
|
-
// network. Accepts an optional path; defaults to .cache/upstream when bare.
|
|
952
|
-
// The cache layout is fixed by lib/prefetch.js — same one refresh-external
|
|
953
|
-
// reads from.
|
|
809
|
+
// --from-cache takes an optional path; the layout is fixed by lib/prefetch.js.
|
|
954
810
|
let cacheDir = null;
|
|
955
|
-
// --concurrency
|
|
956
|
-
// live validation. Default stays 4 to match the prior implicit value;
|
|
957
|
-
// operators with faster networks can crank it up, air-gapped fleets can
|
|
958
|
-
// drop it to 1 to keep cache reads serial.
|
|
811
|
+
// --concurrency bounds in-flight upstream calls during live validation.
|
|
959
812
|
let concurrency = 4;
|
|
960
813
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
961
814
|
const a = rawArgs[i];
|
|
@@ -985,9 +838,7 @@ async function runValidateCves(rawArgs = []) {
|
|
|
985
838
|
return;
|
|
986
839
|
}
|
|
987
840
|
|
|
988
|
-
// --since
|
|
989
|
-
// last_updated (or cisa_kev_date when missing) is on or after the given
|
|
990
|
-
// date. Cuts upstream calls for fleet operators running cron jobs.
|
|
841
|
+
// --since scopes to CVEs whose last_updated (or cisa_kev_date) is on or after it.
|
|
991
842
|
let sinceDate = null;
|
|
992
843
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
993
844
|
if (rawArgs[i] === '--since' && rawArgs[i + 1]) sinceDate = rawArgs[i + 1];
|
|
@@ -1011,11 +862,8 @@ async function runValidateCves(rawArgs = []) {
|
|
|
1011
862
|
const modeStr = offline
|
|
1012
863
|
? 'offline (local view only)'
|
|
1013
864
|
: (cacheDir ? `live with cache (${path.relative(path.join(__dirname, '..'), cacheDir)})` : 'live (NVD + CISA KEV)');
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
// data/cve-catalog.json but are out of scope for NVD validation — they
|
|
1017
|
-
// do not have CVE IDs by design. `exceptd doctor` reports the combined
|
|
1018
|
-
// catalog total separately to avoid the "where did MAL-* go?" misread.
|
|
865
|
+
// CVE-* IDs validated against NVD, not the whole catalog: MAL-* entries live in
|
|
866
|
+
// the same file but carry no CVE ID by design.
|
|
1019
867
|
const allKeys = Object.keys(catalog).filter((k) => !k.startsWith('_'));
|
|
1020
868
|
const nonCveCount = allKeys.length - cveIds.length;
|
|
1021
869
|
const totalNote = nonCveCount > 0
|
|
@@ -1024,7 +872,6 @@ async function runValidateCves(rawArgs = []) {
|
|
|
1024
872
|
console.log(`${cveIds.length} CVE IDs queued for NVD validation${totalNote}. Mode: ${modeStr}${sinceDate ? ` · since=${sinceDate}` : ''}`);
|
|
1025
873
|
console.log(`Fail-on-drift: ${noFail ? 'disabled' : 'enabled'}\n`);
|
|
1026
874
|
|
|
1027
|
-
// --- Header (fixed-width; works with the existing currency command's style)
|
|
1028
875
|
const header = 'CVE | Local RWEP | Local CVSS | NVD CVSS | KEV Local | KEV NVD | EPSS Local | EPSS Live | EPSS Drift | Status';
|
|
1029
876
|
const rule = '-------------------|------------|------------|------------------|-----------|---------|-----------------|-----------------|------------|----------';
|
|
1030
877
|
console.log(header);
|
|
@@ -1035,7 +882,6 @@ async function runValidateCves(rawArgs = []) {
|
|
|
1035
882
|
return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length);
|
|
1036
883
|
}
|
|
1037
884
|
|
|
1038
|
-
// Format an EPSS pair as "score / percentile" with 4-decimal score, 2-decimal pct.
|
|
1039
885
|
function fmtEpss(score, pct) {
|
|
1040
886
|
if (score === null || score === undefined) return '-';
|
|
1041
887
|
const s = Number(score).toFixed(4);
|
|
@@ -1064,15 +910,8 @@ async function runValidateCves(rawArgs = []) {
|
|
|
1064
910
|
return;
|
|
1065
911
|
}
|
|
1066
912
|
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
// entries fall through to the live validator. Both paths produce the
|
|
1070
|
-
// same ValidationResult shape.
|
|
1071
|
-
//
|
|
1072
|
-
// Graceful fallback when sources/validators isn't shipped (matches the
|
|
1073
|
-
// pattern validate-rfcs uses below). Pre-v0.10.3 this crashed with
|
|
1074
|
-
// MODULE_NOT_FOUND in installed npm packages because sources/ wasn't
|
|
1075
|
-
// in the files allowlist.
|
|
913
|
+
// sources/validators is absent from some installs, hence the guarded require
|
|
914
|
+
// and the offline fallback.
|
|
1076
915
|
let validateAllCves;
|
|
1077
916
|
try {
|
|
1078
917
|
({ validateAllCves } = require('../sources/validators'));
|
|
@@ -1092,7 +931,6 @@ async function runValidateCves(rawArgs = []) {
|
|
|
1092
931
|
report = await validateAllCves(catalog, { concurrency });
|
|
1093
932
|
}
|
|
1094
933
|
|
|
1095
|
-
// Index results by cve_id (validateAllCves preserves insertion order, but be explicit).
|
|
1096
934
|
const byId = new Map(report.results.map(r => [r.cve_id, r]));
|
|
1097
935
|
let driftFound = 0;
|
|
1098
936
|
let unreachable = 0;
|
|
@@ -1111,7 +949,6 @@ async function runValidateCves(rawArgs = []) {
|
|
|
1111
949
|
const cvssMismatch = r?.discrepancies?.some(d => d.field === 'cvss_score');
|
|
1112
950
|
const kevMismatch = r?.discrepancies?.some(d => d.field === 'cisa_kev');
|
|
1113
951
|
|
|
1114
|
-
// EPSS Local / Live / Drift block
|
|
1115
952
|
const liveEpss = r?.fetched?.epss || null;
|
|
1116
953
|
const epssReachable = r?.fetched?.sources?.epss?.reachable === true;
|
|
1117
954
|
const epssMismatchScore = r?.discrepancies?.some(d => d.field === 'epss_score');
|
|
@@ -1169,23 +1006,10 @@ async function runValidateCves(rawArgs = []) {
|
|
|
1169
1006
|
}
|
|
1170
1007
|
|
|
1171
1008
|
/**
|
|
1172
|
-
*
|
|
1173
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
* Modes:
|
|
1177
|
-
* --offline Print the local view only; do not fetch. Useful for airgapped
|
|
1178
|
-
* CI runs and for fast iteration on the catalog file itself.
|
|
1179
|
-
* --live Fetch the IETF Datatracker for each RFC / draft (default if
|
|
1180
|
-
* neither flag passed).
|
|
1181
|
-
* --no-fail Report drift but exit zero. Useful when you want a quarterly
|
|
1182
|
-
* drift report without blocking CI.
|
|
1183
|
-
*
|
|
1184
|
-
* Per AGENTS.md hard rule #12 (external data version pinning), drift surfaces
|
|
1185
|
-
* are: status change (Draft → Standards Track → Internet Standard), new
|
|
1186
|
-
* errata since `last_verified`, replaced-by relationships, and obsoletion.
|
|
1187
|
-
* A local entry with no upstream is flagged. Network errors return
|
|
1188
|
-
* `unreachable` for that entry — they never fail the run.
|
|
1009
|
+
* Confirms every entry in data/rfc-references.json is current against the IETF
|
|
1010
|
+
* Datatracker. Drift is a status change, new errata since `last_verified`, a
|
|
1011
|
+
* replaced-by relationship, or obsoletion; a network error returns `unreachable`
|
|
1012
|
+
* for that entry and never fails the run.
|
|
1189
1013
|
*/
|
|
1190
1014
|
async function runValidateRfcs(rawArgs = []) {
|
|
1191
1015
|
const fs = require('fs');
|
|
@@ -1254,8 +1078,7 @@ async function runValidateRfcs(rawArgs = []) {
|
|
|
1254
1078
|
return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length);
|
|
1255
1079
|
}
|
|
1256
1080
|
|
|
1257
|
-
// Lazy-
|
|
1258
|
-
// installed still gets the offline view.
|
|
1081
|
+
// Lazy-loaded so an install without sources/validators still gets the offline view.
|
|
1259
1082
|
let validator = null;
|
|
1260
1083
|
if (!offline) {
|
|
1261
1084
|
try {
|
|
@@ -1268,9 +1091,8 @@ async function runValidateRfcs(rawArgs = []) {
|
|
|
1268
1091
|
let driftFound = 0;
|
|
1269
1092
|
let unreachable = 0;
|
|
1270
1093
|
|
|
1271
|
-
// Cache-first
|
|
1272
|
-
//
|
|
1273
|
-
// to the live validator.
|
|
1094
|
+
// Cache-first: drift is computed the way validateRfc would, and a miss falls
|
|
1095
|
+
// through to the validator.
|
|
1274
1096
|
const STATUS_MAP = {
|
|
1275
1097
|
std: 'Internet Standard', ps: 'Proposed Standard', ds: 'Draft Standard',
|
|
1276
1098
|
bcp: 'Best Current Practice', inf: 'Informational', exp: 'Experimental',
|
|
@@ -1356,19 +1178,12 @@ async function runValidateRfcs(rawArgs = []) {
|
|
|
1356
1178
|
}
|
|
1357
1179
|
|
|
1358
1180
|
/**
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
* them. Supports `--by-skill` to invert the view (per-skill watch items).
|
|
1363
|
-
*
|
|
1364
|
-
* Per AGENTS.md, `forward_watch` is the optional frontmatter field every
|
|
1365
|
-
* skill uses to flag upcoming standards changes, new TTPs, or RFC drops
|
|
1366
|
-
* that should trigger a skill update. This command surfaces the union so
|
|
1367
|
-
* maintainers can see the full horizon at a glance.
|
|
1181
|
+
* Aggregates the `forward_watch` frontmatter across every manifest skill into
|
|
1182
|
+
* one deduplicated, sorted list, carrying the skills that listed each item and
|
|
1183
|
+
* the most recent `last_threat_review` among them. --by-skill inverts the view.
|
|
1368
1184
|
*/
|
|
1369
|
-
// Every flag any watchlist mode
|
|
1370
|
-
//
|
|
1371
|
-
// typo is rejected regardless of which sub-mode it would have reached.
|
|
1185
|
+
// Every flag any watchlist mode accepts; the guard runs once at the top of
|
|
1186
|
+
// runWatchlist, whichever sub-mode a typo would have reached.
|
|
1372
1187
|
const WATCHLIST_KNOWN_FLAGS = Object.freeze([
|
|
1373
1188
|
'--json', '--by-skill', '--alerts', '--org-scan',
|
|
1374
1189
|
'--org', '--pattern', '--output-format', '--air-gap',
|
|
@@ -1387,22 +1202,11 @@ function runWatchlist(rawArgs = []) {
|
|
|
1387
1202
|
const manifestPath = path.join(__dirname, '..', 'manifest.json');
|
|
1388
1203
|
const repoRoot = path.join(__dirname, '..');
|
|
1389
1204
|
|
|
1390
|
-
// v0.13.1: --alerts re-scopes watchlist from "skills forward_watch" to
|
|
1391
|
-
// "CVE-class alert patterns" — surfaces catalog entries matching
|
|
1392
|
-
// high-priority shape rules (kernel-LPE-with-PoC, supply-chain-family,
|
|
1393
|
-
// AI-discovered-KEV, recently-disclosed-with-active-exploitation).
|
|
1394
1205
|
// The modes are mutually exclusive; the first matching flag wins.
|
|
1395
1206
|
if (alertsMode) {
|
|
1396
1207
|
return runWatchlistAlerts(rawArgs);
|
|
1397
1208
|
}
|
|
1398
1209
|
|
|
1399
|
-
// v0.13.3: --org-scan probes GitHub for repository naming patterns
|
|
1400
|
-
// associated with known threat actors (per NEW-CTRL-052 from the
|
|
1401
|
-
// MAL-2026-SHAI-HULUD-OSS zeroday-lessons entry). The Shai-Hulud
|
|
1402
|
-
// worm uses GitHub itself as the exfil channel — repos named
|
|
1403
|
-
// "A Gift From TeamPCP", "Shai-Hulud-*", or with future variants
|
|
1404
|
-
// hold the stolen credentials. Operators can run this against
|
|
1405
|
-
// their own GitHub org to surface exfil staging in their tenant.
|
|
1406
1210
|
if (orgScanMode) {
|
|
1407
1211
|
return runWatchlistOrgScan(rawArgs);
|
|
1408
1212
|
}
|
|
@@ -1416,10 +1220,7 @@ function runWatchlist(rawArgs = []) {
|
|
|
1416
1220
|
return;
|
|
1417
1221
|
}
|
|
1418
1222
|
|
|
1419
|
-
//
|
|
1420
|
-
// operator forward-watch decisions aren't anchored to skills that are
|
|
1421
|
-
// about to come out of the manifest. The field is optional today; when
|
|
1422
|
-
// absent every skill is treated as active.
|
|
1223
|
+
// Deprecated skills are excluded; the field is optional, absent means active.
|
|
1423
1224
|
const skills = (Array.isArray(manifest.skills) ? manifest.skills : [])
|
|
1424
1225
|
.filter(s => s && s.status !== 'deprecated');
|
|
1425
1226
|
// item -> { skills: [{name, last_threat_review}] }
|
|
@@ -1460,10 +1261,7 @@ function runWatchlist(rawArgs = []) {
|
|
|
1460
1261
|
|
|
1461
1262
|
const jsonOut = rawArgs.includes('--json');
|
|
1462
1263
|
if (jsonOut) {
|
|
1463
|
-
//
|
|
1464
|
-
// verb's JSON body shares the contract whether emitted by
|
|
1465
|
-
// bin/exceptd.js emit() (which auto-defaults `ok`) or by the
|
|
1466
|
-
// orchestrator dispatch (which writes stdout directly).
|
|
1264
|
+
// Top-level ok:true so every verb's JSON body shares one contract.
|
|
1467
1265
|
const out = {
|
|
1468
1266
|
ok: true,
|
|
1469
1267
|
generated_at: new Date().toISOString(),
|
|
@@ -1520,30 +1318,9 @@ function runWatchlist(rawArgs = []) {
|
|
|
1520
1318
|
}
|
|
1521
1319
|
|
|
1522
1320
|
/**
|
|
1523
|
-
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
* matching the kernel-LPE-with-public-PoC shape but had no programmatic
|
|
1527
|
-
* way for an operator to ask "what just landed that needs attention?".
|
|
1528
|
-
*
|
|
1529
|
-
* Patterns are evaluated against every catalog entry; multiple patterns
|
|
1530
|
-
* may fire on the same entry (the report carries the list). The age
|
|
1531
|
-
* filter — pattern.fresh_days — bounds the "recently disclosed" patterns;
|
|
1532
|
-
* older entries already had attention.
|
|
1533
|
-
*
|
|
1534
|
-
* Output (JSON mode):
|
|
1535
|
-
* {
|
|
1536
|
-
* ok: true,
|
|
1537
|
-
* verb: "watchlist",
|
|
1538
|
-
* mode: "alerts",
|
|
1539
|
-
* generated_at: "...",
|
|
1540
|
-
* patterns_evaluated: 5,
|
|
1541
|
-
* entries_scanned: 39,
|
|
1542
|
-
* alerts: [
|
|
1543
|
-
* { cve_id, name, rwep_score, patterns: ["kernel_lpe_class", ...],
|
|
1544
|
-
* disclosed: "...", source_verified: "...", links: [...] }
|
|
1545
|
-
* ]
|
|
1546
|
-
* }
|
|
1321
|
+
* Surfaces CVE catalog entries matching high-priority pattern rules. Every entry
|
|
1322
|
+
* is evaluated against every pattern and several may fire on one entry, so the
|
|
1323
|
+
* report carries the list rather than a single label.
|
|
1547
1324
|
*/
|
|
1548
1325
|
function runWatchlistAlerts(rawArgs = []) {
|
|
1549
1326
|
const fs = require('fs');
|
|
@@ -1559,9 +1336,8 @@ function runWatchlistAlerts(rawArgs = []) {
|
|
|
1559
1336
|
return;
|
|
1560
1337
|
}
|
|
1561
1338
|
|
|
1562
|
-
//
|
|
1563
|
-
//
|
|
1564
|
-
// narrow — false-positive flood would defeat the alert purpose.
|
|
1339
|
+
// A pattern is a predicate over one entry plus a label and severity. Kept
|
|
1340
|
+
// narrow — a false-positive flood defeats the alert.
|
|
1565
1341
|
const today = new Date();
|
|
1566
1342
|
function daysSince(iso) {
|
|
1567
1343
|
if (typeof iso !== 'string') return Infinity;
|
|
@@ -1639,8 +1415,7 @@ function runWatchlistAlerts(rawArgs = []) {
|
|
|
1639
1415
|
});
|
|
1640
1416
|
}
|
|
1641
1417
|
|
|
1642
|
-
//
|
|
1643
|
-
// each band, highest RWEP first; finally CVE-id ascending for stability.
|
|
1418
|
+
// Severity band first, then highest RWEP, then CVE id for a stable order.
|
|
1644
1419
|
const severityWeight = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
1645
1420
|
alerts.sort((a, b) => {
|
|
1646
1421
|
const sa = Math.min(...a.patterns.map((p) => severityWeight[p.severity] ?? 9));
|
|
@@ -1684,14 +1459,10 @@ function runWatchlistAlerts(rawArgs = []) {
|
|
|
1684
1459
|
}
|
|
1685
1460
|
|
|
1686
1461
|
/**
|
|
1687
|
-
* Cache-first variant of validateAllCves
|
|
1688
|
-
*
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
* produces so downstream consumers don't have to fork their logic.
|
|
1692
|
-
*
|
|
1693
|
-
* Missing cache entries fall through to the live validator for that CVE,
|
|
1694
|
-
* so partial caches still produce a complete report.
|
|
1462
|
+
* Cache-first variant of validateAllCves: builds a ValidationResult matching the
|
|
1463
|
+
* shape sources/validators/cve-validator.js produces, so a downstream consumer
|
|
1464
|
+
* needs no second code path. A missing cache entry falls through to the live
|
|
1465
|
+
* validator, so a partial cache still yields a complete report.
|
|
1695
1466
|
*/
|
|
1696
1467
|
async function validateAllCvesPreferCache(catalog, cacheDir) {
|
|
1697
1468
|
const fs = require('fs');
|
|
@@ -1699,27 +1470,20 @@ async function validateAllCvesPreferCache(catalog, cacheDir) {
|
|
|
1699
1470
|
const crypto = require('crypto');
|
|
1700
1471
|
const { validateCve } = require('../sources/validators');
|
|
1701
1472
|
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
// OOM the validator on JSON.parse. The cap is well above any legitimate
|
|
1705
|
-
// NVD / EPSS / KEV file (largest in practice is the full KEV feed at
|
|
1706
|
-
// ~3 MB as of 2026); anything beyond 50 MB is almost certainly damage.
|
|
1473
|
+
// Cap on any single cache file: a malformed prefetch payload would otherwise
|
|
1474
|
+
// OOM the validator on JSON.parse. Well above the few-MB full KEV feed.
|
|
1707
1475
|
const CACHE_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
1708
1476
|
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
// computed over JSON.stringify(payload) (unindented) at write time, so the
|
|
1713
|
-
// round-trip parse + re-stringify below produces the comparable hash.
|
|
1477
|
+
// Every prefetch payload records a per-entry sha256, so recomputing on read
|
|
1478
|
+
// catches a rewrite. It is taken over JSON.stringify(payload) unindented,
|
|
1479
|
+
// which is why the re-stringify below matches.
|
|
1714
1480
|
let cacheIndex = null;
|
|
1715
1481
|
try {
|
|
1716
1482
|
cacheIndex = JSON.parse(fs.readFileSync(path.join(cacheDir, '_index.json'), 'utf8'));
|
|
1717
1483
|
} catch { cacheIndex = null; }
|
|
1718
1484
|
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
// mismatch. The caller then refuses the cached value and re-validates the
|
|
1722
|
-
// CVE live, so forged cache contents can no longer mask drift.
|
|
1485
|
+
// False on any tamper signal — no index, no sha256 entry, or a mismatch. The
|
|
1486
|
+
// caller then validates that CVE live, so forged cache cannot mask drift.
|
|
1723
1487
|
function cacheEntryVerified(source, id, parsed) {
|
|
1724
1488
|
const meta = cacheIndex && cacheIndex.entries && cacheIndex.entries[`${source}/${id}`];
|
|
1725
1489
|
if (!meta || typeof meta.sha256 !== 'string') {
|
|
@@ -1747,10 +1511,8 @@ async function validateAllCvesPreferCache(catalog, cacheDir) {
|
|
|
1747
1511
|
console.error(`[validate-cves] cache file ${p} exceeds ${CACHE_FILE_MAX_BYTES} byte cap (${st.size}); refusing to read.`);
|
|
1748
1512
|
return null;
|
|
1749
1513
|
}
|
|
1750
|
-
// readFileSync(fd) loops
|
|
1751
|
-
//
|
|
1752
|
-
// tail NUL-filled and truncating the JSON. Reading via the open fd keeps
|
|
1753
|
-
// the open→fstat ordering TOCTOU-free.
|
|
1514
|
+
// readFileSync(fd) loops to EOF — a single readSync can come back short on
|
|
1515
|
+
// a network fd, NUL-filling the tail and truncating the JSON.
|
|
1754
1516
|
const parsed = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
1755
1517
|
if (!cacheEntryVerified(source, id, parsed)) return null;
|
|
1756
1518
|
return parsed;
|
|
@@ -1803,7 +1565,6 @@ async function validateAllCvesPreferCache(catalog, cacheDir) {
|
|
|
1803
1565
|
const epssPayload = readCached('epss', id);
|
|
1804
1566
|
|
|
1805
1567
|
if (!nvdPayload && !kevFeed && !epssPayload) {
|
|
1806
|
-
// No cache for this CVE on any source — fall through to live.
|
|
1807
1568
|
liveFallbacks++;
|
|
1808
1569
|
try {
|
|
1809
1570
|
const r = await validateCve(id, local);
|
|
@@ -1930,30 +1691,14 @@ Examples:
|
|
|
1930
1691
|
}
|
|
1931
1692
|
|
|
1932
1693
|
/**
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
*
|
|
1937
|
-
* 2099-01-01, and contributor accounts agwagwagwa / headdirt / tmechen.
|
|
1938
|
-
*
|
|
1939
|
-
* Operators run this against their GitHub org (--org <login> or
|
|
1940
|
-
* GITHUB_ORG env var) to surface exfil staging within their tenant.
|
|
1941
|
-
* Uses the GitHub Search API (unauthenticated for public-repo search;
|
|
1942
|
-
* GITHUB_TOKEN env var lifts the rate limit + enables private-repo
|
|
1943
|
-
* coverage). Report-only — never modifies repos.
|
|
1944
|
-
*
|
|
1945
|
-
* Flags / env:
|
|
1946
|
-
* --org <login> GitHub org / user to scope the search to (required)
|
|
1947
|
-
* --pattern <s> (repeatable) additional naming-pattern strings (defaults below)
|
|
1948
|
-
* --json structured JSON output
|
|
1949
|
-
* GITHUB_TOKEN env var lifts rate limit + private-repo search coverage
|
|
1694
|
+
* GitHub repo-pattern monitoring per NEW-CTRL-052: the Shai-Hulud worm uses
|
|
1695
|
+
* GitHub itself as the exfil channel, so an operator scans their own org
|
|
1696
|
+
* (--org <login>, or GITHUB_ORG) for the threat-actor naming patterns.
|
|
1697
|
+
* GITHUB_TOKEN lifts the rate limit and adds private-repo coverage.
|
|
1950
1698
|
*/
|
|
1951
1699
|
async function runWatchlistOrgScan(rawArgs = []) {
|
|
1952
1700
|
const jsonOut = rawArgs.includes('--json');
|
|
1953
|
-
//
|
|
1954
|
-
// table suitable for pasting into a PR / issue body for ops follow-up.
|
|
1955
|
-
// Accepted values: json | markdown | human (default). --json is the
|
|
1956
|
-
// legacy short form and remains accepted (treated as --output-format json).
|
|
1701
|
+
// --output-format takes json | markdown | human; --json resolves to json.
|
|
1957
1702
|
let outputFormat = jsonOut ? 'json' : 'human';
|
|
1958
1703
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
1959
1704
|
if (rawArgs[i] === '--output-format' && rawArgs[i + 1]) outputFormat = rawArgs[i + 1];
|
|
@@ -1972,7 +1717,6 @@ async function runWatchlistOrgScan(rawArgs = []) {
|
|
|
1972
1717
|
safeExit(EXIT_CODES.GENERIC_FAILURE);
|
|
1973
1718
|
return;
|
|
1974
1719
|
}
|
|
1975
|
-
// Extract --org <login>. Accept --org=foo too.
|
|
1976
1720
|
let org = null;
|
|
1977
1721
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
1978
1722
|
if (rawArgs[i] === '--org' && rawArgs[i + 1]) { org = rawArgs[i + 1]; break; }
|
|
@@ -1989,10 +1733,8 @@ async function runWatchlistOrgScan(rawArgs = []) {
|
|
|
1989
1733
|
safeExit(EXIT_CODES.GENERIC_FAILURE);
|
|
1990
1734
|
return;
|
|
1991
1735
|
}
|
|
1992
|
-
//
|
|
1993
|
-
//
|
|
1994
|
-
// any fetch rather than silently attempting egress. Mirrors the
|
|
1995
|
-
// source-ghsa air-gap refusal shape (ok:false + source + explicit error).
|
|
1736
|
+
// Every pattern query reaches api.github.com with no offline substitute, so
|
|
1737
|
+
// air-gap refuses before any fetch. Same refusal shape as source-ghsa.
|
|
1996
1738
|
if (process.env.EXCEPTD_AIR_GAP === '1' || rawArgs.includes('--air-gap')) {
|
|
1997
1739
|
process.stdout.write(JSON.stringify({
|
|
1998
1740
|
ok: false,
|
|
@@ -2004,8 +1746,7 @@ async function runWatchlistOrgScan(rawArgs = []) {
|
|
|
2004
1746
|
safeExit(EXIT_CODES.BLOCKED);
|
|
2005
1747
|
return;
|
|
2006
1748
|
}
|
|
2007
|
-
//
|
|
2008
|
-
// the MAL-2026-SHAI-HULUD-OSS catalog entry + NEW-CTRL-052 evidence.
|
|
1749
|
+
// --pattern is repeatable; the defaults come from MAL-2026-SHAI-HULUD-OSS.
|
|
2009
1750
|
const customPatterns = [];
|
|
2010
1751
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
2011
1752
|
if (rawArgs[i] === '--pattern' && rawArgs[i + 1]) customPatterns.push(rawArgs[i + 1]);
|
|
@@ -2021,9 +1762,7 @@ async function runWatchlistOrgScan(rawArgs = []) {
|
|
|
2021
1762
|
...customPatterns.map((q, i) => ({ id: `custom-${i + 1}`, q, severity: 'medium', source: 'operator --pattern' })),
|
|
2022
1763
|
];
|
|
2023
1764
|
|
|
2024
|
-
// GitHub
|
|
2025
|
-
// and GET /search/repositories?q=<query>+org:<login>. Both return
|
|
2026
|
-
// the same shape (items[] with name, html_url, owner, created_at).
|
|
1765
|
+
// Both GitHub search endpoints return items[] with name, html_url, owner, created_at.
|
|
2027
1766
|
const token = process.env.GITHUB_TOKEN || '';
|
|
2028
1767
|
const matches = [];
|
|
2029
1768
|
let rateLimited = false;
|
|
@@ -2038,19 +1777,15 @@ async function runWatchlistOrgScan(rawArgs = []) {
|
|
|
2038
1777
|
safeExit(EXIT_CODES.GENERIC_FAILURE);
|
|
2039
1778
|
return;
|
|
2040
1779
|
}
|
|
2041
|
-
// Patterns whose query exhausted retries
|
|
2042
|
-
//
|
|
2043
|
-
//
|
|
2044
|
-
// reset / timeout silently yielding "no matches found" for a threat-actor
|
|
2045
|
-
// naming pattern) is exactly what NEW-CTRL-052 exists to defend against.
|
|
1780
|
+
// Patterns whose query exhausted its retries, surfaced in the envelope: a flaky
|
|
1781
|
+
// 5xx silently yielding "no matches" for a threat-actor pattern is the false
|
|
1782
|
+
// negative NEW-CTRL-052 defends against.
|
|
2046
1783
|
const erroredPatterns = [];
|
|
2047
|
-
// A 5xx
|
|
2048
|
-
//
|
|
2049
|
-
// rate-limit codes is not. 403/429 short-circuit to the rateLimited flag.
|
|
1784
|
+
// A 5xx, timeout or reset retries with backoff and jitter; a permanent 4xx does
|
|
1785
|
+
// not, and 403/429 short-circuit to the rateLimited flag.
|
|
2050
1786
|
const retryable = (err) => {
|
|
2051
|
-
// 403/429 are the documented rate-limit signal — surface
|
|
2052
|
-
//
|
|
2053
|
-
// burning the retry budget; the operator re-runs with GITHUB_TOKEN set.
|
|
1787
|
+
// 403/429 are the documented rate-limit signal — surface at once rather than
|
|
1788
|
+
// burn the retry budget; the operator re-runs with GITHUB_TOKEN set.
|
|
2054
1789
|
if (err && err._rateLimit) return false;
|
|
2055
1790
|
if (err && typeof err.statusCode === 'number') return err.statusCode >= 500;
|
|
2056
1791
|
if (err && (err.name === 'AbortError' || err.name === 'TimeoutError')) return true;
|
|
@@ -2095,9 +1830,8 @@ async function runWatchlistOrgScan(rawArgs = []) {
|
|
|
2095
1830
|
});
|
|
2096
1831
|
}
|
|
2097
1832
|
} catch (err) {
|
|
2098
|
-
//
|
|
2099
|
-
//
|
|
2100
|
-
// operator can distinguish a dropped query from a clean no-match.
|
|
1833
|
+
// A non-rate-limit failure that exhausted its retries marks the pattern
|
|
1834
|
+
// errored, so a dropped query is distinguishable from a clean no-match.
|
|
2101
1835
|
if (err && err._rateLimit) {
|
|
2102
1836
|
rateLimited = true;
|
|
2103
1837
|
} else {
|
|
@@ -2108,8 +1842,7 @@ async function runWatchlistOrgScan(rawArgs = []) {
|
|
|
2108
1842
|
const generated_at = new Date().toISOString();
|
|
2109
1843
|
if (outputFormat === 'json') {
|
|
2110
1844
|
process.stdout.write(JSON.stringify({
|
|
2111
|
-
// A rate-limited
|
|
2112
|
-
// zero match-count is no longer trustworthy as "clean".
|
|
1845
|
+
// A rate-limited or errored pattern means a zero match-count is not "clean".
|
|
2113
1846
|
ok: !rateLimited && erroredPatterns.length === 0,
|
|
2114
1847
|
verb: 'watchlist',
|
|
2115
1848
|
mode: 'org-scan',
|
|
@@ -2126,10 +1859,8 @@ async function runWatchlistOrgScan(rawArgs = []) {
|
|
|
2126
1859
|
return;
|
|
2127
1860
|
}
|
|
2128
1861
|
if (outputFormat === 'markdown') {
|
|
2129
|
-
//
|
|
2130
|
-
//
|
|
2131
|
-
// and the unauthenticated / rate-limit caveats so the operator
|
|
2132
|
-
// who receives the paste knows how to validate the result.
|
|
1862
|
+
// Paste-friendly for a PR, issue or advisory body. Carries the unauthenticated
|
|
1863
|
+
// and rate-limit caveats, so the receiver knows how far to trust the result.
|
|
2133
1864
|
const lines = [];
|
|
2134
1865
|
lines.push(`## GitHub Org-Scan: ${org}`);
|
|
2135
1866
|
lines.push('');
|
|
@@ -2182,23 +1913,18 @@ async function runWatchlistOrgScan(rawArgs = []) {
|
|
|
2182
1913
|
}
|
|
2183
1914
|
}
|
|
2184
1915
|
|
|
2185
|
-
//
|
|
2186
|
-
//
|
|
2187
|
-
// would trigger a full CLI dispatch (and printHelp) inside the importing
|
|
2188
|
-
// process. Gating on require.main keeps the module safely importable from
|
|
2189
|
-
// tests and from `bin/exceptd.js`'s programmatic entrypoints.
|
|
1916
|
+
// Gated on require.main so `require('./orchestrator')` does not trigger a CLI
|
|
1917
|
+
// dispatch inside the importing process.
|
|
2190
1918
|
if (require.main === module) {
|
|
2191
1919
|
main().catch(err => {
|
|
2192
|
-
//
|
|
2193
|
-
// uses so a JSON consumer gets a parseable envelope on the fatal path too.
|
|
1920
|
+
// The same ok:false envelope on the fatal path, so a JSON consumer can parse it.
|
|
2194
1921
|
console.error(JSON.stringify({ ok: false, verb: cmd, error: String((err && err.message) || err) }));
|
|
2195
1922
|
process.exitCode = 1;
|
|
2196
1923
|
});
|
|
2197
1924
|
}
|
|
2198
1925
|
|
|
2199
1926
|
module.exports = {
|
|
2200
|
-
//
|
|
2201
|
-
// tests. Verb runners stay internal — call them via the CLI surface.
|
|
1927
|
+
// Verb runners stay internal — reach them through the CLI surface.
|
|
2202
1928
|
main,
|
|
2203
1929
|
parseFlags,
|
|
2204
1930
|
};
|