@blamejs/exceptd-skills 0.19.34 → 0.19.36

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/exceptd.js +0 -5
  3. package/data/_indexes/_meta.json +4 -4
  4. package/data/cve-catalog.json +24 -24
  5. package/data/zeroday-lessons.json +1249 -1249
  6. package/lib/collectors/ai-api.js +150 -22
  7. package/lib/collectors/cicd-pipeline-compromise.js +73 -28
  8. package/lib/collectors/library-author.js +141 -5
  9. package/lib/collectors/sbom.js +96 -12
  10. package/lib/collectors/scan-excludes.js +3 -2
  11. package/lib/cve-regression-watcher.js +0 -3
  12. package/lib/framework-gap.js +5 -0
  13. package/lib/lint-skills.js +24 -4
  14. package/lib/playbook-runner.js +68 -14
  15. package/lib/prefetch.js +3 -2
  16. package/lib/refresh-external.js +0 -6
  17. package/lib/refresh-network.js +3 -4
  18. package/lib/scoring.js +8 -1
  19. package/lib/ttp-mapper.js +14 -3
  20. package/lib/upstream-check-cli.js +26 -1
  21. package/lib/validate-cve-catalog.js +9 -2
  22. package/lib/validate-playbooks.js +9 -11
  23. package/manifest.json +53 -53
  24. package/orchestrator/index.js +0 -1
  25. package/package.json +1 -1
  26. package/sbom.cdx.json +82 -82
  27. package/scripts/audit-perf.js +24 -13
  28. package/scripts/builders/theater-fingerprints.js +9 -4
  29. package/scripts/check-agents-md-collectors.js +15 -3
  30. package/scripts/check-codebase-patterns.js +16 -3
  31. package/scripts/check-manifest-snapshot.js +49 -8
  32. package/scripts/check-test-coverage.js +17 -1
  33. package/scripts/refresh-mitre-atlas.js +5 -1
  34. package/scripts/refresh-mitre-ics-attack.js +5 -1
  35. package/scripts/refresh-rfc-index.js +6 -1
  36. package/scripts/refresh-upstream-catalogs.js +25 -13
  37. package/scripts/release.js +0 -2
  38. package/scripts/run-e2e-scenarios.js +2 -2
  39. package/scripts/verify-shipped-tarball.js +0 -1
@@ -172,13 +172,24 @@ function captureLockfile(p, ecosystem, parser, label) {
172
172
  path: p,
173
173
  size_bytes: Buffer.byteLength(content, "utf8"),
174
174
  ...stats,
175
+ // A parser that could not decode the structure returns `error`; the read
176
+ // itself succeeded, so the two failures carry different kinds downstream.
177
+ ...(stats && stats.error ? { error_kind: "lockfile_parse_failed" } : {}),
175
178
  };
176
179
  } catch (e) {
177
- return { file: label, ecosystem, path: p, error: e.message };
180
+ return { file: label, ecosystem, path: p, error: e.message, error_kind: "lockfile_read_failed" };
178
181
  }
179
182
  }
180
183
 
181
- function findLockfiles(cwd) {
184
+ // `errors` is the collector_errors channel: a directory the walk could not read
185
+ // is a scan gap the operator has to see, not a silent zero. Required, not
186
+ // defaulted: a default array would restore the discard-into-the-void behaviour
187
+ // for any caller that forgets it, and neither this function nor
188
+ // findSbomDocuments is exported, so no test could reach that caller.
189
+ function findLockfiles(cwd, errors) {
190
+ if (!Array.isArray(errors)) {
191
+ throw new TypeError("findLockfiles: `errors` must be the collector_errors array — scan gaps have nowhere else to go");
192
+ }
182
193
  const found = [];
183
194
  for (const lf of LOCKFILES) {
184
195
  const p = path.join(cwd, lf.file);
@@ -196,7 +207,13 @@ function findLockfiles(cwd) {
196
207
  }
197
208
  }
198
209
  }
199
- } catch { /* swallow */ }
210
+ } catch (e) {
211
+ errors.push({
212
+ artifact_id: "lockfile-inventory",
213
+ kind: "readdir_failed",
214
+ reason: `cwd root: ${e.message} — requirements*.txt variants were not enumerated`,
215
+ });
216
+ }
200
217
 
201
218
  for (const sub of SUBDIR_PROBE_PATHS) {
202
219
  const subDir = path.join(cwd, sub);
@@ -204,7 +221,18 @@ function findLockfiles(cwd) {
204
221
  try {
205
222
  if (!fs.statSync(subDir).isDirectory()) continue;
206
223
  entries = fs.readdirSync(subDir, { withFileTypes: true });
207
- } catch { continue; }
224
+ } catch (e) {
225
+ // An absent probe dir is the normal case, not a gap. A permission or I/O
226
+ // failure on one that IS there means the subtree went unscanned.
227
+ if (e.code !== "ENOENT") {
228
+ errors.push({
229
+ artifact_id: "lockfile-inventory",
230
+ kind: "readdir_failed",
231
+ reason: `${sub}/: ${e.message} — subtree not scanned for lockfiles`,
232
+ });
233
+ }
234
+ continue;
235
+ }
208
236
  for (const e of entries) {
209
237
  if (e.isDirectory()) {
210
238
  // packages/* gets one more level for monorepo workspaces, no deeper.
@@ -242,7 +270,11 @@ function findLockfiles(cwd) {
242
270
  return found;
243
271
  }
244
272
 
245
- function findSbomDocuments(cwd) {
273
+ // `errors` is required for the same reason it is on findLockfiles above.
274
+ function findSbomDocuments(cwd, errors) {
275
+ if (!Array.isArray(errors)) {
276
+ throw new TypeError("findSbomDocuments: `errors` must be the collector_errors array — scan gaps have nowhere else to go");
277
+ }
246
278
  const found = [];
247
279
  for (const s of SBOM_FORMATS) {
248
280
  const p = path.join(cwd, s.file);
@@ -250,24 +282,41 @@ function findSbomDocuments(cwd) {
250
282
  // Open once and fstat the descriptor rather than existsSync→statSync→read:
251
283
  // ENOENT still skips, and there is no TOCTOU window.
252
284
  try { fd = fs.openSync(p, "r"); }
253
- catch (e) { if (e.code === "ENOENT") continue; found.push({ file: s.file, format: s.format, error: e.message }); continue; }
285
+ catch (e) { if (e.code === "ENOENT") continue; found.push({ file: s.file, format: s.format, error: e.message, error_kind: "open_failed" }); continue; }
254
286
  try {
255
287
  const stat = fs.fstatSync(fd);
256
288
  let content;
257
289
  // Read from the open descriptor, never by path again. readFileSync(fd) loops
258
290
  // to EOF; a single readSync can return short on a network or FUSE mount,
259
291
  // NUL-padding the buffer and truncating otherwise-valid JSON.
260
- try { content = fs.readFileSync(fd, "utf8"); } catch { content = null; }
292
+ try { content = fs.readFileSync(fd, "utf8"); } catch (e) {
293
+ content = null;
294
+ errors.push({
295
+ artifact_id: "sbom-document",
296
+ kind: "read_failed",
297
+ reason: `${s.file}: ${e.message} — component count not derived`,
298
+ });
299
+ }
261
300
  let component_count = null;
262
301
  if (content && s.format === "cyclonedx-1.x") {
263
302
  try {
264
303
  const j = JSON.parse(content);
265
304
  component_count = (j.components || []).length;
266
- } catch {}
305
+ } catch (e) {
306
+ // A present-but-unparseable SBOM reported `null components` with no
307
+ // explanation, which reads as "no components" rather than "not read".
308
+ errors.push({
309
+ artifact_id: "sbom-document",
310
+ kind: "parse_failed",
311
+ reason: `${s.file}: ${e.message} — component count unavailable`,
312
+ });
313
+ }
267
314
  }
268
315
  found.push({ file: s.file, format: s.format, size_bytes: stat.size, component_count });
269
316
  } catch (e) {
270
- found.push({ file: s.file, format: s.format, error: e.message });
317
+ // The descriptor opened; fstat is what failed here. Reporting it as an
318
+ // open failure sends the operator after the wrong thing.
319
+ found.push({ file: s.file, format: s.format, error: e.message, error_kind: "stat_failed" });
271
320
  } finally {
272
321
  if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } }
273
322
  }
@@ -281,10 +330,31 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
281
330
  const root = path.resolve(cwd);
282
331
 
283
332
  // sbom-tool-available: a lockfile or an SBOM document is a sufficient proxy.
284
- const lockfiles = findLockfiles(root);
285
- const sbomDocuments = findSbomDocuments(root);
333
+ const lockfiles = findLockfiles(root, errors);
334
+ const sbomDocuments = findSbomDocuments(root, errors);
286
335
  const hasAnything = lockfiles.length > 0 || sbomDocuments.length > 0;
287
336
 
337
+ // A lockfile that was found but not read contributes no component count, so
338
+ // the inventory under-reports; the operator sees which one and why.
339
+ for (const l of lockfiles) {
340
+ if (!l.error) continue;
341
+ errors.push({
342
+ artifact_id: "lockfile-inventory",
343
+ kind: l.error_kind || "lockfile_read_failed",
344
+ reason: `${l.file}: ${l.error}`,
345
+ });
346
+ }
347
+ for (const s of sbomDocuments) {
348
+ if (!s.error) continue;
349
+ errors.push({
350
+ artifact_id: "sbom-document",
351
+ // Carried from the push site, mirroring the lockfile loop above: an
352
+ // fstat failure and an open failure are different operator actions.
353
+ kind: s.error_kind || "open_failed",
354
+ reason: `${s.file}: ${s.error}`,
355
+ });
356
+ }
357
+
288
358
  const artifacts = {
289
359
  "lockfile-inventory": {
290
360
  value: lockfiles.length
@@ -346,8 +416,13 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
346
416
  }
347
417
  npmLockfile.integrity_present_count = withIntegrity;
348
418
  npmLockfile.integrity_missing_count = withoutIntegrity;
349
- } catch {
419
+ } catch (e) {
350
420
  // A malformed lockfile leaves the indicator unflipped: inconclusive, not miss.
421
+ errors.push({
422
+ artifact_id: "lockfile-inventory",
423
+ kind: "lockfile_parse_failed",
424
+ reason: `${npmLockfile.file}: ${e.message} — lockfile-no-integrity left undecided (inconclusive, not miss)`,
425
+ });
351
426
  }
352
427
  }
353
428
 
@@ -378,6 +453,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
378
453
  lockfiles_found: lockfiles.length,
379
454
  sbom_documents_found: sbomDocuments.length,
380
455
  ecosystems_detected: [...new Set(lockfiles.map(l => l.ecosystem))],
456
+ // Magnitude behind the lockfile-no-integrity verdict: "hit" alone does not
457
+ // say whether one entry or nine hundred resolve without a hash. Present
458
+ // only when an npm lockfile was found AND parsed.
459
+ ...(npmLockfile && npmLockfile.integrity_present_count != null
460
+ ? {
461
+ npm_integrity_present_count: npmLockfile.integrity_present_count,
462
+ npm_integrity_missing_count: npmLockfile.integrity_missing_count,
463
+ }
464
+ : {}),
381
465
  },
382
466
  collector_errors: errors,
383
467
  };
@@ -85,8 +85,9 @@ function walkTree(root, opts = {}) {
85
85
  }
86
86
  walk(full, depth + 1);
87
87
  } else if (entry.isSymbolicLink()) {
88
- // A symlink Dirent is neither isDirectory() nor isFile(): never emitted.
89
- try { fs.realpathSync(full); } catch { /* dangling link — ignore */ }
88
+ // A symlink Dirent is neither isDirectory() nor isFile(), so it is
89
+ // never traversed and never emitted — including a dangling one.
90
+ continue;
90
91
  } else if (entry.isFile()) {
91
92
  // A regular file cannot introduce a directory cycle, so no realpath /
92
93
  // `seen` check. Windows path.relative returns backslashes, so rel is
@@ -7,9 +7,6 @@
7
7
  * it never mutates the catalog; each candidate is triage input.
8
8
  */
9
9
 
10
- const path = require('path');
11
- const fs = require('fs');
12
-
13
10
  const CVE_ID_RE = /^CVE-((?:19|20)\d{2})-\d{4,7}$/;
14
11
 
15
12
  /**
@@ -159,6 +159,11 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
159
159
  * free text. Omitting `opts.lessons` (parsed zeroday-lessons.json) yields a
160
160
  * report with no new-control section rather than an error. Returns
161
161
  * { frameworks, universal_gaps, new_control_requirements, theater_risks, summary }.
162
+ *
163
+ * `cveCatalog` is positional surface this function does not read — the scenario
164
+ * matches against `controlGaps` alone. It holds the fourth slot so `opts` stays
165
+ * fifth, and mirrors theaterCheck(), which does read its catalog. Dropping the
166
+ * parameter would silently bind every caller's `opts` argument to it instead.
162
167
  */
163
168
  function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, opts = {}) {
164
169
  const scenario = threatScenario.toLowerCase();
@@ -100,8 +100,20 @@ function isStrictIsoCalendarDate(s) {
100
100
  const KEBAB_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
101
101
  const JSON_FILENAME_RE = /^[A-Za-z0-9._-]+\.json$/;
102
102
 
103
+ // `helpExitCode` is null unless the run is over before linting starts: 0 after
104
+ // --help, 2 after an unknown argument. parseArgs never terminates the process —
105
+ // it returns the code and the caller must honour it and stop.
106
+ //
107
+ // The invariant this keeps: every exit in this file goes through safeExit(), so
108
+ // no path here can write to stdout and then terminate synchronously. That is the
109
+ // `process-exit-after-stdout-write` shape, and it is a convention held file-wide
110
+ // rather than a repair of an observed truncation: Node writes pipe stdout
111
+ // synchronously on Windows and Linux (asynchronously on macOS), and the help
112
+ // block is a few hundred bytes, so process.exit() did not in fact truncate it
113
+ // here. Holding the invariant means a future help block that grows, or a run on
114
+ // a platform with async pipe writes, cannot reintroduce the class.
103
115
  function parseArgs(argv) {
104
- const opts = { skill: null, quiet: false, strict: false };
116
+ const opts = { skill: null, quiet: false, strict: false, helpExitCode: null };
105
117
  for (let i = 2; i < argv.length; i++) {
106
118
  const a = argv[i];
107
119
  if (a === '--skill') {
@@ -115,11 +127,13 @@ function parseArgs(argv) {
115
127
  opts.strict = true;
116
128
  } else if (a === '--help' || a === '-h') {
117
129
  printHelp();
118
- process.exit(0);
130
+ opts.helpExitCode = 0;
131
+ return opts;
119
132
  } else {
120
133
  console.error(`Unknown argument: ${a}`);
121
134
  printHelp();
122
- process.exit(2);
135
+ opts.helpExitCode = 2;
136
+ return opts;
123
137
  }
124
138
  }
125
139
  return opts;
@@ -798,6 +812,10 @@ function lintPlaybookAirGap() {
798
812
 
799
813
  function main() {
800
814
  const opts = parseArgs(process.argv);
815
+ if (opts.helpExitCode !== null) {
816
+ safeExit(opts.helpExitCode);
817
+ return;
818
+ }
801
819
  const manifest = readJson(MANIFEST_PATH);
802
820
 
803
821
  let skills = manifest.skills;
@@ -805,7 +823,8 @@ function main() {
805
823
  skills = skills.filter((s) => s.name === opts.skill);
806
824
  if (skills.length === 0) {
807
825
  console.error(`No skill named "${opts.skill}" in manifest.json`);
808
- process.exit(2);
826
+ safeExit(2);
827
+ return;
809
828
  }
810
829
  }
811
830
 
@@ -888,6 +907,7 @@ function main() {
888
907
 
889
908
  // The frontmatter parser is exported so `watchlist` does not grow a second one.
890
909
  module.exports = {
910
+ parseArgs,
891
911
  parseFrontmatter,
892
912
  extractFrontmatterBlock,
893
913
  unquote,
@@ -1292,6 +1292,60 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1292
1292
  analyze_complete: !!(analyzeResult && typeof analyzeResult === 'object'),
1293
1293
  validate_complete: !!(validateResult && typeof validateResult === 'object'),
1294
1294
  };
1295
+
1296
+ // Nothing downstream guards the analyze result's container shape: every
1297
+ // bundle format maps over `analyze.matched_cves` directly, and so does
1298
+ // analyzeFindingShape, so a non-object result or a non-array matched_cves
1299
+ // throws before phase 7 produces any output at all. Normalizing once, here,
1300
+ // is what makes the whole phase survive it — the run still emits an evidence
1301
+ // bundle and the shape failure is readable on runtime_errors. The container
1302
+ // and its elements are both normalized: every bundle builder dereferences an
1303
+ // element, so one null inside an otherwise well-formed array aborts the phase
1304
+ // exactly as a non-array container does.
1305
+ const shapeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
1306
+ const analyzeIsObject = !!analyzeResult && typeof analyzeResult === 'object' && !Array.isArray(analyzeResult);
1307
+ if (!analyzeIsObject || !Array.isArray(analyzeResult.matched_cves)) {
1308
+ pushRunError(runOpts._runErrors, {
1309
+ kind: 'analyze_shape',
1310
+ message: analyzeIsObject
1311
+ ? `analyze.matched_cves is ${shapeOf(analyzeResult.matched_cves)}, expected an array; treated as empty`
1312
+ : `analyze result is ${shapeOf(analyzeResult)}, expected an object; treated as empty`,
1313
+ }, { dedupeKey: x => x.message || '' });
1314
+ // A copy, so close() reports the caller's malformed result without
1315
+ // rewriting it underneath them.
1316
+ analyzeResult = { ...(analyzeIsObject ? analyzeResult : {}), matched_cves: [] };
1317
+ } else {
1318
+ // Dropped rather than repaired: a consumer cannot read a CVE id off a null,
1319
+ // and inventing a placeholder entry would put a finding in the evidence
1320
+ // bundle that the analyze phase never produced.
1321
+ const usable = (e) => !!e && typeof e === 'object' && !Array.isArray(e);
1322
+ const dropped = analyzeResult.matched_cves.filter((e) => !usable(e));
1323
+ if (dropped.length) {
1324
+ pushRunError(runOpts._runErrors, {
1325
+ kind: 'analyze_shape',
1326
+ message: `analyze.matched_cves holds ${dropped.length} malformed element(s) (${[...new Set(dropped.map(shapeOf))].join(', ')}); they are dropped and the remaining ${analyzeResult.matched_cves.length - dropped.length} are used`,
1327
+ }, { dedupeKey: x => x.message || '' });
1328
+ analyzeResult = { ...analyzeResult, matched_cves: analyzeResult.matched_cves.filter(usable) };
1329
+ }
1330
+ }
1331
+
1332
+ // The residual guard for the four sites that interpolate the finding shape —
1333
+ // the notification draft, the auditor-ready exception language, the
1334
+ // learning-loop lesson and the feeds_into finding context. The normalization
1335
+ // above removes the shapes analyzeFindingShape is known to throw on; this
1336
+ // catches anything it still throws on, degrading those four fields to
1337
+ // `<MISSING:…>` and putting the throw on runtime_errors rather than aborting
1338
+ // the phase.
1339
+ const safeFindingShape = () => {
1340
+ try { return analyzeFindingShape(analyzeResult); }
1341
+ catch (e) {
1342
+ pushRunError(runOpts._runErrors,
1343
+ { kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) },
1344
+ { dedupeKey: x => x.message || '' });
1345
+ return {};
1346
+ }
1347
+ };
1348
+
1295
1349
  const enrichNotification = (na) => {
1296
1350
  const obligation = (g.jurisdiction_obligations || []).find(o =>
1297
1351
  o && typeof o === 'object' &&
@@ -1359,16 +1413,9 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1359
1413
  // Which template vars failed to resolve; empty when all rendered.
1360
1414
  ...(function () {
1361
1415
  const missing = [];
1362
- // Wrapped so a malformed analyze result cannot bring down close();
1363
- // the failure surfaces through runOpts._runErrors instead.
1364
- let findingShape;
1365
- try { findingShape = analyzeFindingShape(analyzeResult); }
1366
- catch (e) {
1367
- if (Array.isArray(runOpts._runErrors)) {
1368
- pushRunError(runOpts._runErrors, { kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: e => e.message || '' });
1369
- }
1370
- findingShape = {};
1371
- }
1416
+ // safeFindingShape, not analyzeFindingShape: a malformed analyze result
1417
+ // must not bring down close(); the failure surfaces on runtime_errors.
1418
+ const findingShape = safeFindingShape();
1372
1419
  const draft = interpolate(
1373
1420
  na.draft_notification,
1374
1421
  { ...agentSignals, ...findingShape },
@@ -1416,7 +1463,11 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1416
1463
  // does not supply, so the auditor-ready language routinely renders
1417
1464
  // `<MISSING:…>`. missing_interpolation_vars names which.
1418
1465
  const exMissing = [];
1419
- const findingShape = (() => { try { return analyzeFindingShape(analyzeResult); } catch { return {}; } })();
1466
+ // safeFindingShape, like the other three shape consumers. The feeds_into
1467
+ // context below reports the same throw on every close(), so this site's
1468
+ // own disposition is not separately observable — it is uniform so a
1469
+ // future reader does not read the bare catch as a deliberate exemption.
1470
+ const findingShape = safeFindingShape();
1420
1471
  exception = {
1421
1472
  scope: interpolate(t.scope, { ...agentSignals, ...findingShape }, exMissing),
1422
1473
  duration: t.duration,
@@ -1492,7 +1543,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1492
1543
 
1493
1544
  const lesson = c.learning_loop?.enabled ? {
1494
1545
  enabled: true,
1495
- attack_vector: interpolate(c.learning_loop.lesson_template.attack_vector, analyzeFindingShape(analyzeResult)),
1546
+ attack_vector: interpolate(c.learning_loop.lesson_template.attack_vector, safeFindingShape()),
1496
1547
  control_gap: c.learning_loop.lesson_template.control_gap,
1497
1548
  framework_gap: c.learning_loop.lesson_template.framework_gap,
1498
1549
  new_control_requirement: c.learning_loop.lesson_template.new_control_requirement,
@@ -1542,7 +1593,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1542
1593
  analyze: analyzeResult,
1543
1594
  validate: validateResult,
1544
1595
  // Same two-sourced finding.* shape and guards as the escalation context.
1545
- finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...analyzeFindingShape(analyzeResult) },
1596
+ finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...safeFindingShape() },
1546
1597
  // Without the accumulator, a feeds_into condition failure never reaches
1547
1598
  // analyze.runtime_errors[].
1548
1599
  ...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
@@ -2356,7 +2407,10 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2356
2407
  '@id': productPurl,
2357
2408
  subcomponents: [{ '@id': productPurl }],
2358
2409
  };
2359
- const remediationId = validate.selected_remediation?.id || (validate.remediation_paths?.[0]?.id) || null;
2410
+ // validate() selects the priority-1 path as its last rung, so a null
2411
+ // selected_remediation means the phase declares no remediation paths at all
2412
+ // and remediation_options_considered is empty too — there is no id to name.
2413
+ const remediationId = validate.selected_remediation?.id || null;
2360
2414
  const remediationDescription = validate.selected_remediation?.description || null;
2361
2415
  const actionStatementFor = (fallback) => {
2362
2416
  if (remediationId && remediationDescription) {
package/lib/prefetch.js CHANGED
@@ -487,8 +487,9 @@ function isFresh(idx, source, id, maxAgeMs) {
487
487
 
488
488
  function authHeadersForSource(source) {
489
489
  if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
490
- // `pins` is the registered source name; `github` is accepted too, so
491
- // GITHUB_TOKEN reaches the header whichever spelling an operator uses.
490
+ // `pins` is the registered name for the MITRE GitHub releases feed. The
491
+ // `github` alias is defensive: no registered source emits it, and prefetch()
492
+ // refuses `--source github` before any header is built.
492
493
  if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
493
494
  return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
494
495
  }
@@ -1701,12 +1701,6 @@ async function main() {
1701
1701
  process.exitCode = cacheIntegrityFailure ? 4 : (hadFailure ? 1 : 0);
1702
1702
  }
1703
1703
 
1704
- async function sequential(items, fn) {
1705
- const out = [];
1706
- for (const it of items) out.push(await fn(it));
1707
- return out;
1708
- }
1709
-
1710
1704
  if (require.main === module) {
1711
1705
  main().catch((err) => {
1712
1706
  // A hinted error prints its message plus a JSON line, never a stack trace.
@@ -116,10 +116,9 @@ function getBufferOnce(url, timeoutMs) {
116
116
  if (!isAllowedTarballHost(url)) {
117
117
  return reject(new Error(`refusing to fetch tarball from non-allowlisted host: ${u.hostname}`));
118
118
  }
119
- const cap = (() => {
120
- const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
121
- return Number.isFinite(env) && env > 0 ? env : 200 * 1024 * 1024;
122
- })();
119
+ // Same reader as the buffered-length check, so the streaming cap and the
120
+ // post-download cap cannot drift apart when the default changes.
121
+ const cap = tarballSizeCap();
123
122
  const req = https.get({
124
123
  host: u.host, path: u.pathname + u.search,
125
124
  headers: { "User-Agent": "exceptd/refresh-network" },
package/lib/scoring.js CHANGED
@@ -431,10 +431,17 @@ function validate(catalog) {
431
431
  const errors = [];
432
432
  for (const [cveId, entry] of Object.entries(catalog)) {
433
433
  if (cveId.startsWith('_')) continue;
434
+ // A null or non-object entry cannot be walked field by field: `field in
435
+ // entry` throws, taking down validation of the whole catalog. Report it as
436
+ // the error it is, so the remaining entries are still checked.
437
+ if (!entry || typeof entry !== 'object') {
438
+ errors.push(`${cveId}: entry is ${entry === null ? 'null' : typeof entry}, expected an object`);
439
+ continue;
440
+ }
434
441
  // Drafts carry a conservative-default rwep_score beside null-until-curated
435
442
  // factor fields, so the divergence check below fires on every one. They are
436
443
  // reviewed through `_auto_imported_meta.curation_needed` instead.
437
- if (entry && entry._auto_imported === true) continue;
444
+ if (entry._auto_imported === true) continue;
438
445
  for (const field of CVE_SCHEMA_REQUIRED) {
439
446
  if (!(field in entry)) {
440
447
  errors.push(`${cveId}: missing required field '${field}'`);
package/lib/ttp-mapper.js CHANGED
@@ -28,7 +28,20 @@ function map(controlId, gapCatalog) {
28
28
  };
29
29
  }
30
30
 
31
+ // The single source of gapsFor()'s no-match answer. Both routes to it — an
32
+ // absent catalog and a present one that matched nothing — are the same answer
33
+ // to the operator, so they must carry the same words.
34
+ const noDocumentedGaps = (attackPattern) => ({
35
+ attack_pattern: attackPattern,
36
+ found_gaps: false,
37
+ message: 'No documented gaps for this pattern — verify manually'
38
+ });
39
+
31
40
  function gapsFor(attackPattern, gapCatalog, atlasCatalog) {
41
+ // Object.entries(null) throws. An absent catalog is "nothing documented",
42
+ // which is the same answer an empty one gives — as in map(), the caller gets
43
+ // the module's no-data shape rather than an exception.
44
+ if (!gapCatalog || typeof gapCatalog !== 'object') return noDocumentedGaps(attackPattern);
32
45
  const results = [];
33
46
  for (const [controlId, entry] of Object.entries(gapCatalog)) {
34
47
  if (controlId.startsWith('_')) continue;
@@ -36,9 +49,7 @@ function gapsFor(attackPattern, gapCatalog, atlasCatalog) {
36
49
  results.push({ control_id: controlId, framework: entry.framework, control_name: entry.control_name, gap: entry.misses });
37
50
  }
38
51
  }
39
- if (results.length === 0) {
40
- return { attack_pattern: attackPattern, found_gaps: false, message: 'No documented gaps for this pattern — verify manually' };
41
- }
52
+ if (results.length === 0) return noDocumentedGaps(attackPattern);
42
53
  return { attack_pattern: attackPattern, found_gaps: true, controls_with_gap: results };
43
54
  }
44
55
 
@@ -16,14 +16,25 @@ const { safeExit } = require("./exit-codes");
16
16
  const ROOT = path.resolve(__dirname, "..");
17
17
  const { fetchLatestPublished, buildFreshnessReport } = require("./upstream-check.js");
18
18
 
19
+ // --flag base names this CLI accepts; drives the unknown-flag error message.
20
+ const KNOWN_FLAGS = Object.freeze(["--timeout", "--raw", "--air-gap"]);
21
+
19
22
  function parseArgs(argv) {
20
- const out = { timeoutMs: 5000, raw: false, airGap: false };
23
+ const out = { timeoutMs: 5000, raw: false, airGap: false, unknownFlags: [] };
21
24
  for (let i = 2; i < argv.length; i++) {
22
25
  const a = argv[i];
23
26
  if (a === "--timeout") { out.timeoutMs = parseInt(argv[++i], 10) || 5000; }
24
27
  else if (a.startsWith("--timeout=")) { out.timeoutMs = parseInt(a.slice("--timeout=".length), 10) || 5000; }
25
28
  else if (a === "--raw") out.raw = true;
26
29
  else if (a === "--air-gap") out.airGap = true;
30
+ // Any remaining --flag is a typo; refused below, before any network work.
31
+ // Only `--`-prefixed tokens are collected, matching lib/prefetch.js: a
32
+ // single-dash `-air-gap` or an en-dashed `–air-gap` is not recognized here
33
+ // and still reaches the registry probe.
34
+ else if (a.startsWith("--")) {
35
+ const eq = a.indexOf("=");
36
+ out.unknownFlags.push(eq === -1 ? a : a.slice(0, eq));
37
+ }
27
38
  }
28
39
  return out;
29
40
  }
@@ -42,6 +53,20 @@ function readManifest() {
42
53
 
43
54
  (async () => {
44
55
  const opts = parseArgs(process.argv);
56
+ // Usage errors exit 2 on stderr, keeping stdout reserved for the one report
57
+ // line callers parse. Checked before the air-gap branch and the probe.
58
+ if (opts.unknownFlags.length > 0) {
59
+ const uniq = [...new Set(opts.unknownFlags)];
60
+ process.stderr.write(JSON.stringify({
61
+ ok: false,
62
+ source: "upstream-check",
63
+ error: `upstream-check: unknown flag(s): ${uniq.join(", ")}`,
64
+ unknown_flags: uniq,
65
+ known_flags: KNOWN_FLAGS,
66
+ }) + "\n");
67
+ process.exitCode = 2;
68
+ return;
69
+ }
45
70
  // The registry probe is a network call, so air-gap mode answers with a
46
71
  // `skipped` envelope instead. `ok: null` is neither pass nor fail.
47
72
  if (process.env.EXCEPTD_AIR_GAP === "1" || opts.airGap) {
@@ -393,7 +393,11 @@ function main() {
393
393
  (missingFromSchema.length ? ` Missing from schema: ${JSON.stringify(missingFromSchema)}\n` : '') +
394
394
  ` Reconcile by updating one or the other so they agree exactly.\n`,
395
395
  );
396
- process.exit(1);
396
+ // safeExit, not process.exit(): stderr is an async pipe under CI log
397
+ // capture, and exiting synchronously discards the diagnostic above while
398
+ // still failing the gate — a red run with no reason printed.
399
+ safeExit(1);
400
+ return;
397
401
  }
398
402
  }
399
403
 
@@ -415,7 +419,10 @@ function main() {
415
419
  `!= actual entry count (${f.actual}). Update _meta.entry_count to ${f.actual}.\n`,
416
420
  );
417
421
  }
418
- process.exit(1);
422
+ // Same reason as the enum-mismatch exit above: the drift lines are the whole
423
+ // value of the failure, so the process must unwind rather than exit here.
424
+ safeExit(1);
425
+ return;
419
426
  }
420
427
 
421
428
  let failed = 0;
@@ -98,11 +98,6 @@ function readJson(p) {
98
98
  return JSON.parse(fs.readFileSync(p, 'utf8'));
99
99
  }
100
100
 
101
- function readJsonIfExists(p) {
102
- if (!fs.existsSync(p)) return null;
103
- return readJson(p);
104
- }
105
-
106
101
  function typeOf(value) {
107
102
  if (value === null) return 'null';
108
103
  if (Array.isArray(value)) return 'array';
@@ -239,6 +234,9 @@ function loadContext() {
239
234
  const d3 = readJson(D3FEND_PATH);
240
235
  // Required, not optional: an optional load skips attack_ref validation silently.
241
236
  const attack = readJson(ATTACK_PATH);
237
+ if (!attack || typeof attack !== 'object' || Array.isArray(attack)) {
238
+ throw new Error(`validate-playbooks: ${ATTACK_PATH} did not parse to a technique map (got ${attack === null ? 'null' : typeof attack}). Refusing to validate with the attack_ref checks silently disabled.`);
239
+ }
242
240
 
243
241
  // Sourced from the schema so the hard-error checks in checkCrossRefs stay in
244
242
  // lockstep with its enum lists.
@@ -266,9 +264,9 @@ function loadContext() {
266
264
  cveKeys: new Set(Object.keys(cve).filter((k) => !k.startsWith('_'))),
267
265
  cweKeys: new Set(Object.keys(cwe).filter((k) => !k.startsWith('_'))),
268
266
  d3fendKeys: new Set(Object.keys(d3).filter((k) => !k.startsWith('_'))),
269
- attackKeys: attack
270
- ? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
271
- : null,
267
+ // Never nullable: a null here silently disables every attack_ref check, the
268
+ // exact failure the required load above exists to prevent.
269
+ attackKeys: new Set(Object.keys(attack).filter((k) => !k.startsWith('_'))),
272
270
  clockStartsEnum: clockStartsEnum ? new Set(clockStartsEnum) : null,
273
271
  frameworksEnum: frameworksEnum ? new Set(frameworksEnum) : null,
274
272
  };
@@ -347,7 +345,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
347
345
  }
348
346
  // Hard Rule #4: domain-level TTPs resolve against the ATT&CK catalog.
349
347
  for (const a of domain.attack_refs || []) {
350
- if (ctx.attackKeys && !ctx.attackKeys.has(a)) {
348
+ if (!ctx.attackKeys.has(a)) {
351
349
  warn(`domain.attack_refs: unresolved "${a}" (not in data/attack-techniques.json)`);
352
350
  }
353
351
  }
@@ -381,7 +379,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
381
379
  }
382
380
  indIds.add(ind.id);
383
381
  }
384
- if (ind.attack_ref && ctx.attackKeys && !ctx.attackKeys.has(ind.attack_ref)) {
382
+ if (ind.attack_ref && !ctx.attackKeys.has(ind.attack_ref)) {
385
383
  warn(
386
384
  `phases.detect.indicators[${i}].attack_ref: unresolved "${ind.attack_ref}" (not in data/attack-techniques.json)`,
387
385
  );
@@ -581,7 +579,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
581
579
  if (at.atlas_ttp && !ctx.atlasKeys.has(at.atlas_ttp)) {
582
580
  warn(`${label}.applies_to.atlas_ttp: unresolved "${at.atlas_ttp}" (not in data/atlas-ttps.json)`);
583
581
  }
584
- if (at.attack_technique && ctx.attackKeys && !ctx.attackKeys.has(at.attack_technique)) {
582
+ if (at.attack_technique && !ctx.attackKeys.has(at.attack_technique)) {
585
583
  warn(`${label}.applies_to.attack_technique: unresolved "${at.attack_technique}" (not in data/attack-techniques.json)`);
586
584
  }
587
585
  }