@blamejs/exceptd-skills 0.16.22 → 0.16.24

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 (62) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/CHANGELOG.md +42 -0
  3. package/CONTEXT.md +9 -9
  4. package/README.md +3 -3
  5. package/agents/report-generator.md +2 -2
  6. package/agents/skill-updater.md +1 -1
  7. package/agents/source-validator.md +3 -4
  8. package/agents/threat-researcher.md +1 -1
  9. package/bin/exceptd.js +91 -32
  10. package/data/_indexes/_meta.json +10 -10
  11. package/data/_indexes/activity-feed.json +12 -12
  12. package/data/_indexes/chains.json +70435 -4026
  13. package/data/_indexes/frequency.json +492 -163
  14. package/data/_indexes/section-offsets.json +51 -51
  15. package/data/_indexes/summary-cards.json +272 -106
  16. package/data/_indexes/token-budget.json +10 -10
  17. package/data/_indexes/trigger-table.json +15 -6
  18. package/data/_indexes/xref.json +218 -26
  19. package/data/cve-catalog.json +10 -10
  20. package/data/cwe-catalog.json +1 -0
  21. package/lib/auto-discovery.js +39 -1
  22. package/lib/collectors/ai-api.js +112 -7
  23. package/lib/collectors/citation-hygiene.js +27 -0
  24. package/lib/collectors/crypto-codebase.js +25 -0
  25. package/lib/collectors/kernel.js +32 -2
  26. package/lib/collectors/library-author.js +30 -0
  27. package/lib/collectors/runtime.js +38 -3
  28. package/lib/collectors/sbom.js +21 -2
  29. package/lib/collectors/scan-excludes.js +4 -1
  30. package/lib/collectors/secrets.js +125 -0
  31. package/lib/cve-cli.js +9 -1
  32. package/lib/cve-curation.js +8 -1
  33. package/lib/cve-regression-watcher.js +5 -2
  34. package/lib/exit-codes.js +2 -0
  35. package/lib/flag-suggest.js +1 -1
  36. package/lib/lint-skills.js +70 -0
  37. package/lib/playbook-runner.js +75 -14
  38. package/lib/prefetch.js +24 -1
  39. package/lib/refresh-external.js +32 -3
  40. package/lib/rfc-cli.js +8 -1
  41. package/lib/scoring.js +36 -8
  42. package/lib/validate-cve-catalog.js +36 -14
  43. package/lib/validate-package.js +8 -0
  44. package/lib/validate-playbooks.js +42 -0
  45. package/lib/verify.js +4 -3
  46. package/manifest-snapshot.json +4 -2
  47. package/manifest-snapshot.sha256 +1 -1
  48. package/manifest.json +57 -54
  49. package/orchestrator/README.md +1 -1
  50. package/orchestrator/index.js +65 -7
  51. package/orchestrator/scanner.js +53 -5
  52. package/package.json +1 -1
  53. package/sbom.cdx.json +110 -110
  54. package/scripts/build-indexes.js +42 -8
  55. package/scripts/builders/cwe-chains.js +1 -0
  56. package/scripts/builders/section-offsets.js +10 -2
  57. package/scripts/builders/token-budget.js +3 -3
  58. package/scripts/check-changelog-extract.js +38 -1
  59. package/scripts/check-sbom-currency.js +72 -0
  60. package/scripts/check-version-tags.js +5 -0
  61. package/scripts/release.js +22 -15
  62. package/skills/exploit-scoring/skill.md +8 -8
@@ -742,7 +742,14 @@ async function cli(argv) {
742
742
  }
743
743
 
744
744
  if (require.main === module) {
745
- cli(process.argv.slice(2));
745
+ cli(process.argv.slice(2)).catch((err) => {
746
+ // An apply-path write failure (disk full / read-only FS / permission
747
+ // denied) throws out of cli(). Emit the same {ok:false,verb,mode,error}
748
+ // envelope every other error path in cli() produces instead of crashing
749
+ // with a raw stack trace, and set exitCode so stderr drains before exit.
750
+ process.stderr.write(JSON.stringify({ ok: false, verb: "refresh", mode: "cve-curation", error: String((err && err.message) || err) }) + "\n");
751
+ process.exitCode = 2;
752
+ });
746
753
  }
747
754
 
748
755
  module.exports = { curate, keywordOverlapScore, resolveCatalogPath, autoImportedFrom, severityWord, residualWarnings };
@@ -63,7 +63,6 @@ const path = require('path');
63
63
  const fs = require('fs');
64
64
 
65
65
  const CVE_ID_RE = /^CVE-((?:19|20)\d{2})-\d{4,7}$/;
66
- const TODAY = new Date().toISOString().slice(0, 10);
67
66
 
68
67
  /**
69
68
  * Extract the year from a CVE-YYYY-NNN identifier. Returns null for non-CVE
@@ -274,7 +273,11 @@ function findRegressionCandidates(diffs, catalog, opts) {
274
273
  candidates,
275
274
  historical_id_threshold_year: thresholdYear,
276
275
  evaluated_diffs: evaluated,
277
- generated_at: TODAY,
276
+ // Stamp from the same clock that derives the threshold year, so both
277
+ // date-derived report fields share one instant. Falls back to call-time
278
+ // `new Date()` (via the `now` default) when no clock is injected — never
279
+ // a module-load constant, which would go stale in a long-lived process.
280
+ generated_at: now.toISOString().slice(0, 10),
278
281
  control_ref: 'NEW-CTRL-074',
279
282
  };
280
283
  }
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;
@@ -3380,9 +3425,22 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3380
3425
  if (runOpts.session_id) {
3381
3426
  sessionId = runOpts.session_id;
3382
3427
  } else if (runOpts.bundleDeterministic) {
3383
- const submissionDigest = crypto.createHash('sha256')
3384
- .update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
3385
- .digest('hex');
3428
+ let submissionDigest;
3429
+ try {
3430
+ submissionDigest = crypto.createHash('sha256')
3431
+ .update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
3432
+ .digest('hex');
3433
+ } catch (e) {
3434
+ // canonicalStringify deliberately throws (EVIDENCE_TOO_DEEP) on
3435
+ // pathological nesting. The mutex lockfile and the _activeRuns entry
3436
+ // are already held here, but the protecting try/finally below has
3437
+ // not opened yet — release both before rethrowing, or the leaked
3438
+ // lockfile blocks every subsequent run of this playbook for as long
3439
+ // as this PID lives.
3440
+ _activeRuns.delete(playbookId);
3441
+ releaseLock(lockPath);
3442
+ throw e;
3443
+ }
3386
3444
  sessionId = crypto.createHash('sha256')
3387
3445
  .update(`${playbookId}\0${submissionDigest}\0${getEngineVersion()}`)
3388
3446
  .digest('hex')
@@ -3741,7 +3799,7 @@ function evalCondition(expr, ctx, playbook) {
3741
3799
  // analyze() can surface analyze.runtime_errors[] without losing the
3742
3800
  // diagnostic.
3743
3801
  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
3802
+ 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
3803
  } catch (e) {
3746
3804
  const errorRec = { _regex_eval_error: { source: m[1], expr: m[2], message: e && e.message ? String(e.message) : String(e) } };
3747
3805
  // Two sites where ctx may carry an accumulator: runOpts._runErrors
@@ -3955,4 +4013,7 @@ module.exports = {
3955
4013
  _lockFilePath: lockFilePath,
3956
4014
  _vulnIdToUrn: vulnIdToUrn,
3957
4015
  _worstActiveExploitation: worstActiveExploitation,
4016
+ // Re-exported from scoring so parity between the catalog scorer and the
4017
+ // runtime evaluator is checkable (and enforced by a test) at the seam.
4018
+ _activeExploitationLadder: scoring.ACTIVE_EXPLOITATION_LADDER,
3958
4019
  };
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}` }; }
@@ -311,6 +311,15 @@ const KEV_SOURCE = {
311
311
  : (d.after ? scoring.RWEP_WEIGHTS.cisa_kev : 0);
312
312
  entry.rwep_score = scoring.deriveRwepFromFactors(entry.rwep_factors);
313
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
+ }
314
323
  }
315
324
  catalog[d.id].last_verified = TODAY;
316
325
  updated++;
@@ -613,9 +622,17 @@ const GHSA_SOURCE = {
613
622
  async fetchDiff(ctx) {
614
623
  if (ctx.fixtures?.ghsa) return synthesizeFromFixture(ctx, "ghsa");
615
624
  if (ctx.cacheDir) {
616
- // Cache parity: ghsa cache layout is .cache/upstream/ghsa/<published-date>.json
617
- // GHSA has no cache layer yet fall through to live fetch. (Unlike NVD
618
- // 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
+ };
619
636
  }
620
637
  const ghsa = require("./source-ghsa");
621
638
  return ghsa.buildDiff(ctx);
@@ -670,6 +687,18 @@ const OSV_SOURCE = {
670
687
  applies_to: "data/cve-catalog.json",
671
688
  async fetchDiff(ctx) {
672
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
+ }
673
702
  const osv = require("./source-osv");
674
703
  return osv.buildDiff(ctx);
675
704
  },
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