@blamejs/exceptd-skills 0.16.21 → 0.16.23

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 (47) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/CHANGELOG.md +28 -0
  3. package/CONTEXT.md +9 -9
  4. package/README.md +3 -3
  5. package/agents/skill-updater.md +1 -1
  6. package/agents/source-validator.md +3 -4
  7. package/agents/threat-researcher.md +1 -1
  8. package/bin/exceptd.js +19 -7
  9. package/data/_indexes/_meta.json +10 -10
  10. package/data/_indexes/activity-feed.json +12 -12
  11. package/data/_indexes/chains.json +70084 -3852
  12. package/data/_indexes/frequency.json +492 -163
  13. package/data/_indexes/section-offsets.json +16 -16
  14. package/data/_indexes/summary-cards.json +272 -106
  15. package/data/_indexes/token-budget.json +10 -10
  16. package/data/_indexes/trigger-table.json +15 -6
  17. package/data/_indexes/xref.json +218 -26
  18. package/data/cve-catalog.json +10 -10
  19. package/data/cwe-catalog.json +1 -0
  20. package/lib/auto-discovery.js +39 -1
  21. package/lib/collectors/scan-excludes.js +4 -1
  22. package/lib/cve-cli.js +9 -1
  23. package/lib/cve-curation.js +8 -1
  24. package/lib/exit-codes.js +2 -0
  25. package/lib/flag-suggest.js +1 -1
  26. package/lib/lint-skills.js +70 -0
  27. package/lib/playbook-runner.js +59 -11
  28. package/lib/prefetch.js +24 -1
  29. package/lib/refresh-external.js +56 -5
  30. package/lib/rfc-cli.js +8 -1
  31. package/lib/scoring.js +36 -8
  32. package/lib/validate-cve-catalog.js +36 -14
  33. package/lib/validate-package.js +8 -0
  34. package/lib/validate-playbooks.js +42 -0
  35. package/lib/verify.js +4 -3
  36. package/manifest-snapshot.json +4 -2
  37. package/manifest-snapshot.sha256 +1 -1
  38. package/manifest.json +57 -54
  39. package/orchestrator/index.js +48 -4
  40. package/orchestrator/scanner.js +53 -5
  41. package/package.json +1 -1
  42. package/sbom.cdx.json +80 -80
  43. package/scripts/build-indexes.js +42 -8
  44. package/scripts/check-sbom-currency.js +72 -0
  45. package/scripts/release.js +22 -15
  46. package/skills/exploit-scoring/skill.md +8 -8
  47. package/sources/validators/cve-validator.js +6 -1
package/lib/exit-codes.js CHANGED
@@ -27,6 +27,7 @@ const EXIT_CODES = Object.freeze({
27
27
  LOCK_CONTENTION: 8,
28
28
  STORAGE_EXHAUSTED: 9,
29
29
  UNKNOWN_COMMAND: 10,
30
+ WATCH_LOCK_CONTENTION: 75,
30
31
  });
31
32
 
32
33
  /**
@@ -45,6 +46,7 @@ const EXIT_CODE_DESCRIPTIONS = Object.freeze({
45
46
  8: { name: 'LOCK_CONTENTION', summary: 'Concurrent invocation holds the per-playbook attestation lock; retry after the busy run releases.' },
46
47
  9: { name: 'STORAGE_EXHAUSTED', summary: 'Disk full, quota exceeded, or read-only filesystem prevented attestation write (ENOSPC, EDQUOT, EROFS).' },
47
48
  10: { name: 'UNKNOWN_COMMAND', summary: 'Unknown verb / dispatcher refused the requested command. Distinct from DETECTED_ESCALATE (2) which means a verb ran and detected an escalation-worthy finding.' },
49
+ 75: { name: 'WATCH_LOCK_CONTENTION', summary: 'Watch daemon lock is held by a live process; retry after it exits (sysexits EX_TEMPFAIL). Distinct from LOCK_CONTENTION (8), the per-playbook attestation lock.' },
48
50
  });
49
51
 
50
52
  /**
@@ -75,7 +75,7 @@ const VERB_FLAG_ALLOWLIST = Object.freeze({
75
75
  'publisher-namespace', 'vex', 'diff-from-latest', 'all', 'scope',
76
76
  'strict-preconditions', 'ci', 'block-on-jurisdiction-clock', 'upstream-check',
77
77
  'session-key', 'tlp', 'bundle-deterministic', 'bundle-epoch',
78
- 'include-judgement-shaped', 'format',
78
+ 'include-judgement-shaped', 'format', 'directive', 'explain', 'signal-list',
79
79
  ],
80
80
  ci: [
81
81
  'evidence', 'evidence-dir', 'session-id', 'force-overwrite', 'attestation-root',
@@ -44,6 +44,8 @@ const { safeExit } = require('./exit-codes');
44
44
 
45
45
  const REPO_ROOT = path.resolve(__dirname, '..');
46
46
  const MANIFEST_PATH = path.join(REPO_ROOT, 'manifest.json');
47
+ const FRONTMATTER_SCHEMA_PATH = path.join(__dirname, 'schemas', 'skill-frontmatter.schema.json');
48
+ const FRONTMATTER_SCHEMA = JSON.parse(fs.readFileSync(FRONTMATTER_SCHEMA_PATH, 'utf8'));
47
49
  const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
48
50
  const DATA_DIR = path.join(REPO_ROOT, 'data');
49
51
  const ATLAS_PATH = path.join(DATA_DIR, 'atlas-ttps.json');
@@ -234,6 +236,20 @@ function unquote(s) {
234
236
  if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
235
237
  return s.slice(1, -1);
236
238
  }
239
+ // Quoted scalar followed by an inline comment, e.g.
240
+ // "standalone" # why this skill is standalone
241
+ // The value is the quoted content; everything after the closing quote is a
242
+ // YAML comment. Only applied when the trailing segment is whitespace + `#`
243
+ // so a mid-string `#` inside an unquoted value is never mistaken for one.
244
+ if (first === '"' || first === "'") {
245
+ const close = s.indexOf(first, 1);
246
+ if (close > 0) {
247
+ const tail = s.slice(close + 1);
248
+ if (/^\s+#/.test(tail)) {
249
+ return s.slice(1, close);
250
+ }
251
+ }
252
+ }
237
253
  }
238
254
  return s;
239
255
  }
@@ -253,6 +269,53 @@ function extractFrontmatterBlock(content) {
253
269
  return { frontmatter: raw.replace(/^\r?\n/, ''), body: bodyStart, frontmatterRaw: raw };
254
270
  }
255
271
 
272
+ // Array-item pattern constraints already enforced above by dedicated regexes
273
+ // (ATLAS_ID_RE / ATTACK_ID_RE / JSON_FILENAME_RE) with their own error wording.
274
+ // The schema-driven pass below skips these so a field is never reported twice.
275
+ const SCHEMA_PATTERN_HANDLED_ELSEWHERE = new Set(['atlas_refs', 'attack_refs', 'data_deps']);
276
+
277
+ /* Enforce the enum and array-item pattern constraints declared in
278
+ * lib/schemas/skill-frontmatter.schema.json, so the shipped schema is the
279
+ * source of truth rather than a decorative artifact. Covers the schema's
280
+ * `enum` constraints (e.g. discovery_mode) and the `items.pattern` format
281
+ * checks on ref arrays (cwe_refs / d3fend_refs / dlp_refs / rfc_refs) that the
282
+ * hand-coded checks did not apply. Returns errors in the existing
283
+ * `frontmatter.<field> ...` voice. */
284
+ function schemaConstraintErrors(fm, schema) {
285
+ const errors = [];
286
+ const props = (schema && schema.properties) || {};
287
+ for (const [field, spec] of Object.entries(props)) {
288
+ if (!(field in fm)) continue;
289
+ const value = fm[field];
290
+
291
+ if (Array.isArray(spec.enum) && typeof value === 'string') {
292
+ if (!spec.enum.includes(value)) {
293
+ errors.push(
294
+ `frontmatter.${field} "${value}" is not one of ${JSON.stringify(spec.enum)}`,
295
+ );
296
+ }
297
+ }
298
+
299
+ if (
300
+ spec.type === 'array' &&
301
+ spec.items &&
302
+ typeof spec.items.pattern === 'string' &&
303
+ !SCHEMA_PATTERN_HANDLED_ELSEWHERE.has(field) &&
304
+ Array.isArray(value)
305
+ ) {
306
+ const itemRe = new RegExp(spec.items.pattern); // allow:dynamic-regex — bundled schema.pattern, not operator input
307
+ for (const item of value) {
308
+ if (typeof item !== 'string' || !itemRe.test(item)) {
309
+ errors.push(
310
+ `frontmatter.${field} entry ${JSON.stringify(item)} does not match the schema pattern /${spec.items.pattern}/`,
311
+ );
312
+ }
313
+ }
314
+ }
315
+ }
316
+ return errors;
317
+ }
318
+
256
319
  /* Validate frontmatter object against the codified schema rules. */
257
320
  function validateFrontmatter(fm, skillName) {
258
321
  const errors = [];
@@ -387,6 +450,10 @@ function validateFrontmatter(fm, skillName) {
387
450
  }
388
451
  }
389
452
 
453
+ // Drive enum + ref-pattern enforcement from the published schema so the
454
+ // shipped artifact and this gate cannot diverge.
455
+ errors.push(...schemaConstraintErrors(fm, FRONTMATTER_SCHEMA));
456
+
390
457
  return { errors, warnings };
391
458
  }
392
459
 
@@ -886,6 +953,9 @@ module.exports = {
886
953
  COUNTERMEASURE_SECTION,
887
954
  COUNTERMEASURE_CUTOFF,
888
955
  MIN_SECTION_BODY_WORDS,
956
+ validateFrontmatter,
957
+ schemaConstraintErrors,
958
+ FRONTMATTER_SCHEMA,
889
959
  };
890
960
 
891
961
  if (require.main === module) {
@@ -48,6 +48,7 @@ const path = require('path');
48
48
  const os = require('os');
49
49
  const crypto = require('crypto');
50
50
  const scoring = require('./scoring');
51
+ const { assertIdComponent } = require('./id-validation');
51
52
  const codepointClass = require('../vendor/blamejs/codepoint-class.js');
52
53
 
53
54
  // cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
@@ -170,6 +171,9 @@ function listPlaybooks() {
170
171
  }
171
172
 
172
173
  function loadPlaybook(playbookId) {
174
+ // Traversal defense co-located with the path.join — every caller gets it,
175
+ // not just the CLI dispatcher's pre-validation wrapper.
176
+ assertIdComponent(playbookId, 'playbook');
173
177
  const p = path.join(PLAYBOOK_DIR, `${playbookId}.json`);
174
178
  if (!fs.existsSync(p)) throw new Error(`Playbook not found: ${playbookId} (expected ${p})`);
175
179
  return JSON.parse(fs.readFileSync(p, 'utf8'));
@@ -1104,7 +1108,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1104
1108
  // Aliasing: playbooks ship rwep_factor values `public_poc` and
1105
1109
  // `ai_weaponization` for what F5 calls `poc_available` and `ai_factor`.
1106
1110
  // Both spellings resolve here.
1107
- const _activeExploitationLadder = { confirmed: 1.0, suspected: 0.5, unknown: 0.25, theoretical: 0, none: 0 };
1111
+ const _activeExploitationLadder = scoring.ACTIVE_EXPLOITATION_LADDER;
1108
1112
  const _factorScale = (factorName, cve, blastScore) => {
1109
1113
  if (!cve) return 0;
1110
1114
  switch (factorName) {
@@ -1288,17 +1292,16 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1288
1292
  // not compute new gaps here, just attaches the playbook-declared ones.
1289
1293
  const frameworkGaps = an.framework_gap_mapping || [];
1290
1294
 
1291
- // escalation criteria
1295
+ // escalation criteria are evaluated AFTER the analyze result is assembled
1296
+ // (below the result literal) so conditions can reference `analyze.*` and
1297
+ // `finding.*` paths — the same roots close()'s feeds_into context
1298
+ // resolves. The flat keys (rwep, blast_radius_score, theater_verdict,
1299
+ // agent signals) remain available unchanged.
1292
1300
  const escalations = [];
1293
1301
  const runtimeErrors = []; // E3: collect regex-eval errors during analyze
1294
1302
  const evalCtxRoot = { _runErrors: runOpts._runErrors || runtimeErrors };
1295
- for (const ec of an.escalation_criteria || []) {
1296
- if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals, ...evalCtxRoot }, playbook)) {
1297
- escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
1298
- }
1299
- }
1300
1303
 
1301
- return {
1304
+ const result = {
1302
1305
  phase: 'analyze',
1303
1306
  playbook_id: playbookId,
1304
1307
  directive_id: directiveId,
@@ -1370,6 +1373,26 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1370
1373
  // observations submitted.
1371
1374
  signal_origins_with_collisions: Array.isArray(agentSignals?._signal_origins_collisions) ? agentSignals._signal_origins_collisions.slice() : (Array.isArray(detectResult?._signal_origins_collisions) ? detectResult._signal_origins_collisions.slice() : [])
1372
1375
  };
1376
+
1377
+ // analyzeFindingShape is a pure transform but defensive-wrap it (same as
1378
+ // close()) so a malformed analyze result can't abort escalation handling.
1379
+ let findingShape;
1380
+ try { findingShape = analyzeFindingShape(result); }
1381
+ catch (e) {
1382
+ if (Array.isArray(runOpts._runErrors)) {
1383
+ pushRunError(runOpts._runErrors, { kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: x => x.message || '' });
1384
+ }
1385
+ findingShape = {};
1386
+ }
1387
+ for (const ec of an.escalation_criteria || []) {
1388
+ if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, analyze: result, finding: findingShape, ...agentSignals, ...evalCtxRoot }, playbook)) {
1389
+ escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
1390
+ }
1391
+ }
1392
+ // Escalation evaluation may have appended regex-eval diagnostics; refresh
1393
+ // the snapshot so analyze.runtime_errors reflects them.
1394
+ result.runtime_errors = (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []);
1395
+ return result;
1373
1396
  }
1374
1397
 
1375
1398
  /**
@@ -1638,7 +1661,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1638
1661
  // upstream `govern.jurisdiction_obligations` has the real data — carry it
1639
1662
  // forward. `notification_deadline` is published as an alias for `deadline`
1640
1663
  // (matches the field name compliance teams expect on a notification record).
1641
- const notificationActions = (c.notification_actions || []).map(na => {
1664
+ const enrichNotification = (na) => {
1642
1665
  const obligation = (g.jurisdiction_obligations || []).find(o =>
1643
1666
  `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
1644
1667
  );
@@ -1704,7 +1727,29 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1704
1727
  return { draft_notification: draft, missing_interpolation_vars: missing };
1705
1728
  })(),
1706
1729
  };
1707
- });
1730
+ };
1731
+ const notificationActions = (c.notification_actions || []).map(enrichNotification);
1732
+
1733
+ // A govern obligation that declares a notification duty but has no
1734
+ // matching close.notification_actions entry would otherwise never surface
1735
+ // in the phase-7 record set — its regulatory clock stays invisible to
1736
+ // operators reading jurisdiction_notifications. Synthesize a record for
1737
+ // each uncovered notify-type obligation so the deadline is computed and
1738
+ // visible. The synthesized entry carries no draft template (the playbook
1739
+ // declared none) and is marked synthesized_from_obligation so operators
1740
+ // can tell it apart from a playbook-authored action.
1741
+ const coveredObligationRefs = new Set((c.notification_actions || []).map(na => na.obligation_ref));
1742
+ for (const o of (g.jurisdiction_obligations || [])) {
1743
+ if (!String(o.obligation || '').startsWith('notify')) continue;
1744
+ const ref = `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
1745
+ if (coveredObligationRefs.has(ref)) continue;
1746
+ notificationActions.push(enrichNotification({
1747
+ obligation_ref: ref,
1748
+ recipient: null,
1749
+ draft_notification: null,
1750
+ synthesized_from_obligation: true,
1751
+ }));
1752
+ }
1708
1753
 
1709
1754
  // exception_generation — evaluate trigger.
1710
1755
  let exception = null;
@@ -3741,7 +3786,7 @@ function evalCondition(expr, ctx, playbook) {
3741
3786
  // analyze() can surface analyze.runtime_errors[] without losing the
3742
3787
  // diagnostic.
3743
3788
  try {
3744
- return new RegExp(m[2], 'i').test(val); // allow:dynamic-regex — m[2] is the pattern from a signed-catalog playbook condition (/…/), and construction + .test() are already wrapped in this try/catch to neutralize a malformed/pathological pattern
3789
+ return new RegExp(m[2], 'i').test(val); // allow:dynamic-regex — m[2] comes from an Ed25519-signed catalog playbook condition (/…/), so the pattern cannot be attacker-controlled without breaking the signature; the try/catch covers construction-time syntax errors only (it does NOT defend against catastrophic backtracking — do not reuse this shape for operator-supplied patterns)
3745
3790
  } catch (e) {
3746
3791
  const errorRec = { _regex_eval_error: { source: m[1], expr: m[2], message: e && e.message ? String(e.message) : String(e) } };
3747
3792
  // Two sites where ctx may carry an accumulator: runOpts._runErrors
@@ -3955,4 +4000,7 @@ module.exports = {
3955
4000
  _lockFilePath: lockFilePath,
3956
4001
  _vulnIdToUrn: vulnIdToUrn,
3957
4002
  _worstActiveExploitation: worstActiveExploitation,
4003
+ // Re-exported from scoring so parity between the catalog scorer and the
4004
+ // runtime evaluator is checkable (and enforced by a test) at the seam.
4005
+ _activeExploitationLadder: scoring.ACTIVE_EXPLOITATION_LADDER,
3958
4006
  };
package/lib/prefetch.js CHANGED
@@ -386,9 +386,32 @@ function verifyIndexSignature(cacheDir) {
386
386
  }
387
387
  const pubPath = path.join(ROOT, "keys", "public.pem");
388
388
  if (!fs.existsSync(pubPath)) return { status: "invalid", reason: "keys/public.pem absent — cannot verify cache signature" };
389
+ const pubPem = fs.readFileSync(pubPath, "utf8");
390
+ // Consult keys/EXPECTED_FINGERPRINT BEFORE crypto.verify, the same external
391
+ // trust anchor every other signature-verifying ingest site enforces. A
392
+ // host-local keys/public.pem swap paired with an attacker-signed
393
+ // _index.json.sig would otherwise authenticate against the attacker's own
394
+ // key (the signature verifies against whatever public.pem is present). The
395
+ // pin is the off-host anchor that closes that gap; honor KEYS_ROTATED=1 for
396
+ // legitimate rotations and warn-and-continue when no pin file is present.
397
+ try {
398
+ const { publicKeyFingerprint, checkExpectedFingerprint } = require("./verify.js");
399
+ const pinResult = checkExpectedFingerprint(publicKeyFingerprint(pubPem));
400
+ if (pinResult.status === "mismatch" && !pinResult.rotationOverride) {
401
+ return {
402
+ status: "invalid",
403
+ reason: `fingerprint-mismatch: live=${pinResult.actual} pin=${pinResult.expected} — keys/public.pem does not match keys/EXPECTED_FINGERPRINT. If this is an intentional rotation, set KEYS_ROTATED=1 and update the pin.`,
404
+ };
405
+ }
406
+ } catch {
407
+ // verify.js unavailable (partial install). The caller (loadCtx) already
408
+ // treats a verifier-unavailable signature path as a hard refusal unless
409
+ // --force-stale, so falling through to the signature check below keeps
410
+ // behavior no weaker than before the pin was added.
411
+ }
389
412
  const idx = JSON.parse(fs.readFileSync(indexPath, "utf8"));
390
413
  const bytes = canonicalIndexBytes(idx);
391
- const pubKey = crypto.createPublicKey(fs.readFileSync(pubPath, "utf8"));
414
+ const pubKey = crypto.createPublicKey(pubPem);
392
415
  let sigBytes;
393
416
  try { sigBytes = Buffer.from(sidecar.signature_base64, "base64"); }
394
417
  catch (e) { return { status: "invalid", reason: `signature_base64 decode: ${e.message}` }; }
@@ -294,6 +294,33 @@ const KEV_SOURCE = {
294
294
  continue;
295
295
  }
296
296
  catalog[d.id][d.field] = d.after;
297
+ // A cisa_kev flip changes the entry's RWEP: the KEV factor carries
298
+ // RWEP_WEIGHTS.cisa_kev points, and the catalog invariant requires
299
+ // rwep_score to equal the factor sum. Writing the flag without the
300
+ // factor + score left entries failing scoring.validate() (stored 45
301
+ // vs computed 70 on the first real KEV listing the refresh applied).
302
+ if (d.field === "cisa_kev") {
303
+ const entry = catalog[d.id];
304
+ if (entry.rwep_factors && typeof entry.rwep_factors === "object") {
305
+ const scoring = require("./scoring");
306
+ // Match the stored factor shape: Shape A keeps the boolean,
307
+ // Shape B (the catalog norm) stores the post-weight contribution.
308
+ entry.rwep_factors.cisa_kev =
309
+ typeof entry.rwep_factors.cisa_kev === "boolean"
310
+ ? !!d.after
311
+ : (d.after ? scoring.RWEP_WEIGHTS.cisa_kev : 0);
312
+ entry.rwep_score = scoring.deriveRwepFromFactors(entry.rwep_factors);
313
+ }
314
+ // A de-listing (true→false) leaves the listing date orphaned: the
315
+ // CVE is no longer KEV-listed, so its dateAdded is stale intel.
316
+ // The upstream diff producer only emits a cisa_kev_date diff when
317
+ // upstream has a date, which a de-listed CVE no longer does — so
318
+ // nothing else clears it. Drop the now-meaningless date fields here.
319
+ if (d.after === false) {
320
+ if ("cisa_kev_date" in entry) entry.cisa_kev_date = null;
321
+ if ("cisa_kev_due_date" in entry) entry.cisa_kev_due_date = null;
322
+ }
323
+ }
297
324
  catalog[d.id].last_verified = TODAY;
298
325
  updated++;
299
326
  }
@@ -595,9 +622,17 @@ const GHSA_SOURCE = {
595
622
  async fetchDiff(ctx) {
596
623
  if (ctx.fixtures?.ghsa) return synthesizeFromFixture(ctx, "ghsa");
597
624
  if (ctx.cacheDir) {
598
- // Cache parity: ghsa cache layout is .cache/upstream/ghsa/<published-date>.json
599
- // GHSA has no cache layer yet fall through to live fetch. (Unlike NVD
600
- // and RFC, GHSA does not honor the air-gap --from-cache path.)
625
+ // --from-cache is the offline ingest path: every source reads only
626
+ // local cache files, never the network. GHSA has no cache layer, so
627
+ // there is nothing to read offline. Skip it with a structured status
628
+ // instead of falling through to the live api.github.com fetch, which
629
+ // would silently egress on a host the operator believes is isolated.
630
+ return {
631
+ status: "unreachable",
632
+ diffs: [],
633
+ errors: 0,
634
+ summary: "GHSA: no cache layer; skipped in --from-cache mode (would require a live network call)",
635
+ };
601
636
  }
602
637
  const ghsa = require("./source-ghsa");
603
638
  return ghsa.buildDiff(ctx);
@@ -652,6 +687,18 @@ const OSV_SOURCE = {
652
687
  applies_to: "data/cve-catalog.json",
653
688
  async fetchDiff(ctx) {
654
689
  if (ctx.fixtures?.osv) return synthesizeFromFixture(ctx, "osv");
690
+ if (ctx.cacheDir) {
691
+ // --from-cache is the offline ingest path. OSV resolves advisories by
692
+ // live id lookup (ctx.osv_ids) and has no cache layer, so skip it with
693
+ // a structured status rather than risk a live osv.dev fetch on a host
694
+ // the operator believes is isolated.
695
+ return {
696
+ status: "unreachable",
697
+ diffs: [],
698
+ errors: 0,
699
+ summary: "OSV: no cache layer; skipped in --from-cache mode (would require a live network call)",
700
+ };
701
+ }
655
702
  const osv = require("./source-osv");
656
703
  return osv.buildDiff(ctx);
657
704
  },
@@ -830,8 +877,12 @@ function kevDiffFromCache(ctx) {
830
877
  diffs.push({ id, field: "cisa_kev", before: entry.cisa_kev, after: upstream, severity: "high" });
831
878
  }
832
879
  const upDate = kevDates.get(id) || null;
833
- if (upDate && entry.cisa_kev_date && entry.cisa_kev_date !== upDate) {
834
- diffs.push({ id, field: "cisa_kev_date", before: entry.cisa_kev_date, after: upDate, severity: "low" });
880
+ // First listings arrive with a null local date — emit the date diff
881
+ // whenever upstream has one that the local entry lacks or contradicts,
882
+ // so the flag flip and its listing date apply together (the strict
883
+ // catalog validator requires KEV-listed entries to carry the date).
884
+ if (upDate && (entry.cisa_kev_date || null) !== upDate) {
885
+ diffs.push({ id, field: "cisa_kev_date", before: entry.cisa_kev_date ?? null, after: upDate, severity: "low" });
835
886
  }
836
887
  }
837
888
  return { status: "ok", diffs, errors: 0, summary: `${diffs.length} KEV diffs (from cache)` };
package/lib/rfc-cli.js CHANGED
@@ -83,4 +83,11 @@ const { resolveRfc } = require("./citation-resolve.js");
83
83
  }
84
84
  // A mismatched or nonexistent citation is a non-zero exit for gates.
85
85
  if (fails) process.exitCode = 2;
86
- })();
86
+ })().catch((err) => {
87
+ // A corrupt/unreadable RFC index (or any unexpected throw inside the async
88
+ // body) becomes a rejected promise. Emit the documented {ok:false,error}
89
+ // envelope rather than crashing with a raw stack trace, and signal failure
90
+ // via exitCode so the event loop drains stderr before exit.
91
+ process.stderr.write(JSON.stringify({ ok: false, verb: "rfc", error: String((err && err.message) || err) }) + "\n");
92
+ process.exitCode = 1;
93
+ });
package/lib/scoring.js CHANGED
@@ -39,13 +39,12 @@
39
39
  * ----------------------------------------------------------------------------
40
40
  */
41
41
 
42
- const CVE_SCHEMA_REQUIRED = [
43
- 'type', 'cvss_score', 'cvss_vector', 'cisa_kev', 'poc_available',
44
- 'ai_discovered', 'active_exploitation', 'affected', 'patch_available',
45
- 'patch_required_reboot', 'live_patch_available', 'live_patch_tools',
46
- 'rwep_score', 'rwep_factors', 'atlas_refs', 'attack_refs',
47
- 'source_verified', 'verification_sources', 'last_updated'
48
- ];
42
+ // Required-field list is loaded from the catalog schema's entry-level
43
+ // `required` array so the two can never drift. (live_patch_tools is
44
+ // deliberately NOT hard-required here — it is schema-optional, and the
45
+ // live_patch_available => live_patch_tools implication is enforced
46
+ // separately below.)
47
+ const CVE_SCHEMA_REQUIRED = require('./schemas/cve-catalog.schema.json').required;
49
48
 
50
49
  // blast_radius range is 0-30; represents breadth of affected population.
51
50
  // AI-discovered and AI-assisted-weaponization both contribute the ai_factor (+15).
@@ -383,7 +382,12 @@ function compare(cveId, catalog, opts) {
383
382
  */
384
383
  function detectFactorShape(factors) {
385
384
  if (!factors || typeof factors !== 'object') return 'unknown';
386
- const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weaponization', 'ai_discovered', 'active_exploitation', 'patch_available', 'live_patch_available', 'patch_required_reboot'];
385
+ // Keys inspected for shape evidence. Covers BOTH spellings per factor:
386
+ // the Shape A (raw boolean) names AND the Shape B (post-weight) names the
387
+ // schema requires on rwep_factors — ai_factor and reboot_required — so a
388
+ // post-weight integer on either canonical key registers as Shape B
389
+ // evidence instead of slipping past the mixed-shape detector.
390
+ const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weaponization', 'ai_discovered', 'ai_factor', 'active_exploitation', 'patch_available', 'live_patch_available', 'patch_required_reboot', 'reboot_required'];
387
391
  let sawBool = false;
388
392
  let sawWeightedInt = false;
389
393
  for (const [k, v] of Object.entries(factors)) {
@@ -440,6 +444,30 @@ function validate(catalog) {
440
444
  if (shape === 'mixed') {
441
445
  errors.push(`${cveId}: rwep_factors mixes Shape A (booleans) with Shape B (post-weight integers) — sum invariant cannot hold. Convert factors to a single shape.`);
442
446
  }
447
+ // Per-factor coherence: in a Shape B (post-weight) block every stored
448
+ // contribution must equal the weight implied by its source field.
449
+ // Without this, two compensating per-factor errors cancel inside the
450
+ // ±5 aggregate tolerance below and a factor block that contradicts the
451
+ // entry's own flags ships unnoticed. blast_radius is exempt — it is the
452
+ // one judgment-set factor with no deriving source field.
453
+ if (shape === 'B') {
454
+ const f = entry.rwep_factors;
455
+ const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[entry.active_exploitation] ?? 0;
456
+ const implied = {
457
+ cisa_kev: entry.cisa_kev === true ? RWEP_WEIGHTS.cisa_kev : 0,
458
+ poc_available: entry.poc_available === true ? RWEP_WEIGHTS.poc_available : 0,
459
+ ai_factor: (entry.ai_assisted_weaponization === true || entry.ai_discovered === true) ? RWEP_WEIGHTS.ai_factor : 0,
460
+ active_exploitation: RWEP_WEIGHTS.active_exploitation * aeMultiplier,
461
+ patch_available: entry.patch_available === true ? RWEP_WEIGHTS.patch_available : 0,
462
+ live_patch_available: entry.live_patch_available === true ? RWEP_WEIGHTS.live_patch_available : 0,
463
+ reboot_required: entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0,
464
+ };
465
+ for (const [k, want] of Object.entries(implied)) {
466
+ if (k in f && typeof f[k] === 'number' && f[k] !== want) {
467
+ errors.push(`${cveId}: rwep_factors.${k} is ${f[k]} but the entry's source fields imply ${want}`);
468
+ }
469
+ }
470
+ }
443
471
  const calculatedRwep = scoreCustom({
444
472
  cisa_kev: entry.cisa_kev,
445
473
  poc_available: entry.poc_available,
@@ -313,6 +313,23 @@ function additionalChecks(key, entry, ctx) {
313
313
  return warnings;
314
314
  }
315
315
 
316
+ // Return the list of catalogs whose hand-maintained _meta.entry_count disagrees
317
+ // with the live count of non-metadata top-level keys. Catalogs without a numeric
318
+ // _meta.entry_count (or absent/null catalogs) are skipped, so any loaded catalog
319
+ // that adopts the field is covered without editing this function.
320
+ function entryCountMismatches(catalogs) {
321
+ const failures = [];
322
+ for (const { name, catalog: cat } of catalogs) {
323
+ if (cat && cat._meta && typeof cat._meta.entry_count === 'number') {
324
+ const actual = Object.keys(cat).filter((k) => !k.startsWith('_')).length;
325
+ if (cat._meta.entry_count !== actual) {
326
+ failures.push({ name, declared: cat._meta.entry_count, actual });
327
+ }
328
+ }
329
+ }
330
+ return failures;
331
+ }
332
+
316
333
  function main() {
317
334
  const opts = parseArgs(process.argv);
318
335
  if (opts === null) return; // parseArgs handled --help / bad-arg and set the exit code
@@ -374,24 +391,28 @@ function main() {
374
391
  // Guard hand-maintained _meta.entry_count fields against silent drift. The
375
392
  // framework-control-gaps counter once declared 184 while the file held 192
376
393
  // and nothing caught it; the zeroday-lessons counter drifted to 68 while the
377
- // file held 422 because only framework-control-gaps was gated. This now
378
- // checks EVERY loaded catalog that declares a numeric _meta.entry_count, so a
379
- // new catalog with the field is covered automatically.
394
+ // file held 422 because only framework-control-gaps was gated. This checks
395
+ // EVERY loaded catalog that declares a numeric _meta.entry_count, so a new
396
+ // catalog with the field is covered automatically. The covered set is derived
397
+ // from the catalogs main() already loaded, not a fixed allowlist.
380
398
  const ENTRY_COUNT_CATALOGS = [
381
- { name: 'framework-control-gaps', catalog: frameworks },
399
+ { name: 'cve-catalog', catalog },
382
400
  { name: 'zeroday-lessons', catalog: lessons },
401
+ { name: 'atlas-ttps', catalog: atlas },
402
+ { name: 'cwe-catalog', catalog: cwe },
403
+ { name: 'attack-techniques', catalog: attack },
404
+ { name: 'd3fend-catalog', catalog: d3fend },
405
+ { name: 'framework-control-gaps', catalog: frameworks },
383
406
  ];
384
- for (const { name, catalog: cat } of ENTRY_COUNT_CATALOGS) {
385
- if (cat && cat._meta && typeof cat._meta.entry_count === 'number') {
386
- const actual = Object.keys(cat).filter((k) => !k.startsWith('_')).length;
387
- if (cat._meta.entry_count !== actual) {
388
- process.stderr.write(
389
- `[validate-cve-catalog] FAIL: ${name} _meta.entry_count (${cat._meta.entry_count}) ` +
390
- `!= actual entry count (${actual}). Update _meta.entry_count to ${actual}.\n`,
391
- );
392
- process.exit(1);
393
- }
407
+ const entryCountFailures = entryCountMismatches(ENTRY_COUNT_CATALOGS);
408
+ if (entryCountFailures.length > 0) {
409
+ for (const f of entryCountFailures) {
410
+ process.stderr.write(
411
+ `[validate-cve-catalog] FAIL: ${f.name} _meta.entry_count (${f.declared}) ` +
412
+ `!= actual entry count (${f.actual}). Update _meta.entry_count to ${f.actual}.\n`,
413
+ );
394
414
  }
415
+ process.exit(1);
395
416
  }
396
417
 
397
418
  let failed = 0;
@@ -496,6 +517,7 @@ module.exports = {
496
517
  looksLikePublicExploitSource,
497
518
  isUsableDate,
498
519
  additionalChecks,
520
+ entryCountMismatches,
499
521
  PUBLIC_EXPLOIT_URL_PATTERNS,
500
522
  STRICT_CVSS_PATTERN,
501
523
  };
@@ -40,6 +40,14 @@ const REQUIRED_PATHS = [
40
40
  "manifest-snapshot.json",
41
41
  "sbom.cdx.json",
42
42
  "bin/exceptd.js",
43
+ // Modules require()d at the top of bin/exceptd.js on every invocation —
44
+ // dropping any of these from the tarball bricks the CLI at launch with a
45
+ // module-not-found, so they are pinned explicitly rather than relied on via
46
+ // the directory-level files[] entries.
47
+ "lib/exit-codes.js",
48
+ "lib/id-validation.js",
49
+ "lib/flag-suggest.js",
50
+ "vendor/blamejs/codepoint-class.js",
43
51
  "lib/refresh-external.js",
44
52
  "lib/job-queue.js",
45
53
  "lib/prefetch.js",
@@ -475,6 +475,48 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
475
475
  }
476
476
  }
477
477
 
478
+ // Escalation / feeds_into condition path-root resolvability. The
479
+ // analyze-phase escalation context resolves the flat keys (rwep,
480
+ // blast_radius_score, theater_verdict, agent signals) plus the `analyze`
481
+ // and `finding` roots; close()'s feeds_into context additionally resolves
482
+ // `validate` and `theater_score`. A dotted path rooted at any OTHER phase
483
+ // name can never resolve at evaluation time — the condition would
484
+ // silently never fire — so it is rejected here. Bare (un-dotted)
485
+ // identifiers are agent-signal names, an open vocabulary, and are not
486
+ // checked.
487
+ const conditionPathRoots = (cond) => {
488
+ if (typeof cond !== 'string') return [];
489
+ const stripped = cond.replace(/'[^']*'|"[^"]*"|\/[^/\n]*\//g, ' ');
490
+ const roots = [];
491
+ const re = /(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)(?:\.[A-Za-z_][A-Za-z0-9_]*)+/g;
492
+ let m;
493
+ while ((m = re.exec(stripped)) !== null) roots.push(m[1]);
494
+ return roots;
495
+ };
496
+ const PHASE_NAME_ROOTS = new Set(['govern', 'direct', 'look', 'detect', 'analyze', 'validate', 'close']);
497
+ const ESCALATION_OK_ROOTS = new Set(['analyze', 'finding']);
498
+ const FEEDS_OK_ROOTS = new Set(['analyze', 'validate', 'finding']);
499
+ for (const [i, ec] of (((phases.analyze || {}).escalation_criteria) || []).entries()) {
500
+ if (!ec || typeof ec !== 'object') continue;
501
+ for (const root of conditionPathRoots(ec.condition)) {
502
+ if (PHASE_NAME_ROOTS.has(root) && !ESCALATION_OK_ROOTS.has(root)) {
503
+ err(
504
+ `phases.analyze.escalation_criteria[${i}].condition: path root "${root}." is not resolvable in the escalation context (phase-result roots available there: analyze, finding) — the condition would never fire`,
505
+ );
506
+ }
507
+ }
508
+ }
509
+ for (const [i, f] of (meta.feeds_into || []).entries()) {
510
+ if (!f || typeof f !== 'object') continue;
511
+ for (const root of conditionPathRoots(f.condition)) {
512
+ if (PHASE_NAME_ROOTS.has(root) && !FEEDS_OK_ROOTS.has(root)) {
513
+ err(
514
+ `_meta.feeds_into[${i}].condition: path root "${root}." is not resolvable in the feeds_into context (phase-result roots available there: analyze, validate, finding) — the condition would never fire`,
515
+ );
516
+ }
517
+ }
518
+ }
519
+
478
520
  // Air-gap completeness. When _meta.air_gap_mode is true the runner refuses
479
521
  // to touch the network, so every artifact whose source is a network call
480
522
  // (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry a
package/lib/verify.js CHANGED
@@ -602,13 +602,14 @@ function validateAgainstSchema(value, schema, here, root) {
602
602
  * and returns the first non-comment / non-empty line. Returns null if
603
603
  * the file is unreadable / empty.
604
604
  *
605
- * Shared across four sites so every loader normalises identically:
605
+ * Shared across five sites so every loader normalises identically:
606
606
  * - lib/verify.js (manifest signature gate)
607
607
  * - lib/refresh-network.js (refresh-network pre-swap gate)
608
608
  * - scripts/verify-shipped-tarball.js (predeploy gate)
609
609
  * - bin/exceptd.js (attestation pin)
610
- * tests/normalize-contract.test.js asserts byte-identical output across all
611
- * four sites under a BOM + CRLF fuzz corpus.
610
+ * - lib/prefetch.js (cache-consume index signature pin)
611
+ * tests/normalize-contract.test.js asserts byte-identical output across the
612
+ * sites under a BOM + CRLF fuzz corpus.
612
613
  */
613
614
  function loadExpectedFingerprintFirstLine(pinPath) {
614
615
  let buf;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
3
- "_generated_at": "2026-06-02T19:58:16.286Z",
3
+ "_generated_at": "2026-06-05T18:14:35.343Z",
4
4
  "atlas_version": "5.6.0",
5
5
  "skill_count": 51,
6
6
  "skills": [
@@ -155,7 +155,9 @@
155
155
  "RFC-9421",
156
156
  "RFC-9458"
157
157
  ],
158
- "cwe_refs": [],
158
+ "cwe_refs": [
159
+ "CWE-918"
160
+ ],
159
161
  "d3fend_refs": [
160
162
  "D3-CA",
161
163
  "D3-CSPP",