@blamejs/exceptd-skills 0.12.20 → 0.12.22

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 (52) hide show
  1. package/CHANGELOG.md +137 -6
  2. package/bin/exceptd.js +835 -70
  3. package/data/_indexes/_meta.json +14 -14
  4. package/data/_indexes/activity-feed.json +3 -3
  5. package/data/_indexes/catalog-summaries.json +3 -3
  6. package/data/_indexes/chains.json +15 -0
  7. package/data/_indexes/jurisdiction-map.json +3 -2
  8. package/data/_indexes/section-offsets.json +175 -175
  9. package/data/_indexes/summary-cards.json +1 -1
  10. package/data/_indexes/token-budget.json +83 -83
  11. package/data/cve-catalog.json +169 -2
  12. package/data/exploit-availability.json +16 -0
  13. package/data/playbooks/ai-api.json +18 -0
  14. package/data/playbooks/containers.json +30 -0
  15. package/data/playbooks/cred-stores.json +18 -0
  16. package/data/playbooks/crypto.json +18 -0
  17. package/data/playbooks/hardening.json +26 -1
  18. package/data/playbooks/kernel.json +22 -2
  19. package/data/playbooks/mcp.json +18 -0
  20. package/data/playbooks/runtime.json +22 -1
  21. package/data/playbooks/sbom.json +18 -0
  22. package/data/playbooks/secrets.json +6 -0
  23. package/data/zeroday-lessons.json +102 -0
  24. package/lib/auto-discovery.js +9 -9
  25. package/lib/cross-ref-api.js +43 -10
  26. package/lib/cve-curation.js +4 -4
  27. package/lib/playbook-runner.js +529 -70
  28. package/lib/prefetch.js +3 -3
  29. package/lib/refresh-external.js +13 -2
  30. package/lib/refresh-network.js +22 -17
  31. package/lib/scoring.js +22 -13
  32. package/lib/sign.js +5 -5
  33. package/lib/validate-catalog-meta.js +1 -1
  34. package/lib/validate-cve-catalog.js +2 -2
  35. package/lib/validate-indexes.js +2 -2
  36. package/lib/verify.js +63 -13
  37. package/manifest.json +47 -47
  38. package/package.json +1 -1
  39. package/sbom.cdx.json +6 -6
  40. package/scripts/check-manifest-snapshot.js +1 -1
  41. package/scripts/check-sbom-currency.js +1 -1
  42. package/scripts/predeploy.js +6 -6
  43. package/scripts/refresh-manifest-snapshot.js +2 -2
  44. package/scripts/validate-vendor-online.js +1 -1
  45. package/scripts/verify-shipped-tarball.js +15 -12
  46. package/skills/compliance-theater/skill.md +4 -1
  47. package/skills/exploit-scoring/skill.md +20 -1
  48. package/skills/framework-gap-analysis/skill.md +6 -2
  49. package/skills/kernel-lpe-triage/skill.md +50 -3
  50. package/skills/threat-model-currency/skill.md +7 -5
  51. package/skills/webapp-security/skill.md +1 -1
  52. package/skills/zeroday-gap-learn/skill.md +44 -1
package/lib/prefetch.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * rfc/<doc-name>.json — IETF Datatracker doc record
19
19
  * pins/<owner>__<repo>__releases.json — MITRE GitHub releases listing
20
20
  *
21
- * audit M P2-K: the registered source names in SOURCES below are `rfc` and
21
+ * K: the registered source names in SOURCES below are `rfc` and
22
22
  * `pins`. Earlier comments + --help text said `ietf` and `github`; an
23
23
  * operator running `--source ietf` or `--source github` would hit "unknown
24
24
  * source" because no such key exists. The names below are the canonical
@@ -322,7 +322,7 @@ function isFresh(idx, source, id, maxAgeMs) {
322
322
 
323
323
  function authHeadersForSource(source) {
324
324
  if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
325
- // audit M P2-J: the registered source name for MITRE GitHub releases is
325
+ // J: the registered source name for MITRE GitHub releases is
326
326
  // `pins` (see SOURCES above). The prior check looked for `github`, so
327
327
  // GITHUB_TOKEN never reached the per-request Authorization header and
328
328
  // anonymous-rate-limited fetches were always used even when an operator
@@ -520,7 +520,7 @@ function readCached(cacheDir, source, id, opts = {}) {
520
520
  const idx = loadIndex(cacheDir);
521
521
  const meta = idx.entries[entryKey(source, id)];
522
522
  if (!meta) return null;
523
- // audit M P2-L: when `fetched_at` is missing / non-string / unparseable,
523
+ // L: when `fetched_at` is missing / non-string / unparseable,
524
524
  // `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false —
525
525
  // so the cached entry would have been returned as if fresh. Treat any
526
526
  // non-finite age as "no provenance, refuse" unless the caller explicitly
@@ -94,6 +94,12 @@ function parseArgs(argv) {
94
94
  else if (a.startsWith("--from-fixture=")) out.fromFixture = a.slice("--from-fixture=".length);
95
95
  else if (a === "--report-out") out.reportOut = argv[++i];
96
96
  else if (a.startsWith("--report-out=")) out.reportOut = a.slice("--report-out=".length);
97
+ // FF P1-3: previously only EXCEPTD_AIR_GAP=1 reached the GHSA/OSV source
98
+ // modules — the CLI flag was undocumented in parseArgs, so a downstream
99
+ // operator following the documented `--air-gap` path silently allowed
100
+ // network calls. Now the flag is honoured; env var still works as a
101
+ // fallback so existing automation isn't broken.
102
+ else if (a === "--air-gap") out.airGap = true;
97
103
  }
98
104
  return out;
99
105
  }
@@ -549,7 +555,7 @@ const GHSA_SOURCE = {
549
555
  return ghsa.buildDiff(ctx);
550
556
  },
551
557
  async applyDiff(ctx, diffs) {
552
- // v0.12.14 (audit B-F1): the prior shape mutated ctx.cveCatalog in
558
+ // v0.12.14: the prior shape mutated ctx.cveCatalog in
553
559
  // memory but NEVER persisted to disk. Bulk `--source ghsa --apply`
554
560
  // reported "applied: N updates" while the catalog file gained zero
555
561
  // entries. Worse under `--swarm`: KEV's withCatalogLock would re-read
@@ -602,7 +608,7 @@ const OSV_SOURCE = {
602
608
  return osv.buildDiff(ctx);
603
609
  },
604
610
  async applyDiff(ctx, diffs) {
605
- // v0.12.14 (audit B-F1): same fix as GHSA — route the read-modify-write
611
+ // v0.12.14: same fix as GHSA — route the read-modify-write
606
612
  // through withCatalogLock so writes actually land on disk and so
607
613
  // concurrent --source osv --apply doesn't lose updates.
608
614
  const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
@@ -846,6 +852,11 @@ function loadCtx(opts) {
846
852
  d3fendCatalog: JSON.parse(fs.readFileSync(ABS("data/d3fend-catalog.json"), "utf8")),
847
853
  fixtures: null,
848
854
  cacheDir: null,
855
+ // FF P1-3: thread --air-gap (or EXCEPTD_AIR_GAP=1) through to ctx.airGap
856
+ // so the GHSA + OSV source modules (lib/source-ghsa.js, lib/source-osv.js)
857
+ // can branch on ctx.airGap and refuse network egress. Pre-fix the GHSA/OSV
858
+ // sources only saw `ctx?.airGap` as undefined when the CLI flag was used.
859
+ airGap: !!(opts && opts.airGap) || process.env.EXCEPTD_AIR_GAP === "1",
849
860
  };
850
861
  if (opts.fromFixture) {
851
862
  ctx.fixtures = { dir: path.resolve(opts.fromFixture), kev: true, epss: true, nvd: true, rfc: true, pins: true, ghsa: true, osv: true };
@@ -112,7 +112,7 @@ function getBuffer(url, timeoutMs) {
112
112
  }
113
113
  const chunks = [];
114
114
  let total = 0;
115
- // v0.12.14 (audit F5): enforce streaming size cap so a hostile
115
+ // v0.12.14: enforce streaming size cap so a hostile
116
116
  // registry CDN can't stream gigabytes into RAM.
117
117
  res.on("data", (c) => {
118
118
  total += c.length;
@@ -191,7 +191,7 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
191
191
  } catch { return false; }
192
192
  }
193
193
 
194
- // v0.12.14 (audit F1, F7): CRLF/BOM normalization mirrors lib/verify.js's
194
+ // v0.12.14: CRLF/BOM normalization mirrors lib/verify.js's
195
195
  // normalize(). Duplicated here to keep refresh-network free of cross-module
196
196
  // runtime deps. ANY change here MUST be mirrored in lib/verify.js +
197
197
  // lib/sign.js + scripts/verify-shipped-tarball.js — the four normalize()
@@ -203,7 +203,7 @@ function normalizeSkillBytes(buf) {
203
203
  return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
204
204
  }
205
205
 
206
- // Audit O P1-B + Q P1: in-line manifest-signature verifier. Kept here
206
+ // B + Q P1: in-line manifest-signature verifier. Kept here
207
207
  // rather than imported from lib/verify.js so refresh-network.js retains
208
208
  // its no-cross-module-dep posture (mirrors the per-skill verify path).
209
209
  // ANY change to canonical-bytes computation here MUST stay in lockstep
@@ -260,7 +260,7 @@ function validateManifestSkillPath(skillPath) {
260
260
  return skillPath;
261
261
  }
262
262
 
263
- // v0.12.14 (audit F5): tarball download size cap. A hostile registry CDN
263
+ // v0.12.14: tarball download size cap. A hostile registry CDN
264
264
  // could stream gigabytes; Node buffers chunks in RAM until OOM. Current
265
265
  // tarball is ~2 MB; 200 MB is generous defense-in-depth. Tunable via
266
266
  // EXCEPTD_TARBALL_SIZE_CAP_BYTES for future growth.
@@ -335,14 +335,14 @@ async function main() {
335
335
  process.exitCode = 2; return;
336
336
  }
337
337
 
338
- // v0.12.14 (audit F5): defense-in-depth tarball size cap.
338
+ // v0.12.14: defense-in-depth tarball size cap.
339
339
  const sizeCap = tarballSizeCap();
340
340
  if (tgzBuf.length > sizeCap) {
341
341
  emit({ ok: false, error: `tarball exceeds size cap: ${tgzBuf.length} bytes > ${sizeCap} (EXCEPTD_TARBALL_SIZE_CAP_BYTES)` }, opts.json);
342
342
  process.exitCode = 4; return;
343
343
  }
344
344
 
345
- // v0.12.14 (audit F6, F3): verify SHA-512 SRI first (collision-resistant
345
+ // v0.12.14: verify SHA-512 SRI first (collision-resistant
346
346
  // beyond SHA-1 reach), then SHA-1 shasum for compatibility, then dist.
347
347
  // signatures[] (npm registry's Ed25519 signing key). Each layer is
348
348
  // defense-in-depth — registry compromise that produces a SHA-1 collision
@@ -406,7 +406,7 @@ async function main() {
406
406
  process.exitCode = 5; return;
407
407
  }
408
408
 
409
- // v0.12.16 (audit I P1-5): cross-check the local public key against
409
+ // v0.12.16: cross-check the local public key against
410
410
  // keys/EXPECTED_FINGERPRINT (the CI-pinned signing key). The prior
411
411
  // refresh-network code only compared LOCAL ↔ TARBALL fingerprints, so a
412
412
  // coordinated attacker who swapped both `keys/public.pem` on the operator's
@@ -420,8 +420,13 @@ async function main() {
420
420
  const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
421
421
  if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
422
422
  try {
423
- const expectedFp = fs.readFileSync(expectedFingerprintPath, "utf8")
424
- .split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0);
423
+ // KK P1-5: route through the shared lib/verify loader so a BOM-prefixed
424
+ // pin file (Notepad with files.encoding=utf8bom) is tolerated identically
425
+ // across every verify site. Pre-fix the inline split-trim-find returned
426
+ // the BOM as part of the first line, which would never match a live
427
+ // fingerprint and would block every legitimate refresh-network run.
428
+ const { loadExpectedFingerprintFirstLine } = require("./verify.js");
429
+ const expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
425
430
  // v0.12.16 (codex P1 PR #11): `expectedFp` is read verbatim from
426
431
  // keys/EXPECTED_FINGERPRINT (formatted as `SHA256:<base64>`), but
427
432
  // `fingerprintPublicKey()` returns the raw base64 without the
@@ -451,7 +456,7 @@ async function main() {
451
456
  try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
452
457
  catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
453
458
 
454
- // Audit O P1-B + Q P1: verify the top-level manifest_signature against
459
+ // B + Q P1: verify the top-level manifest_signature against
455
460
  // the LOCAL public key before honoring any entry in the tarball manifest.
456
461
  // The previous flow iterated `manifest.skills[].signature` per-skill but
457
462
  // never authenticated the manifest envelope itself — a coordinated
@@ -478,7 +483,7 @@ async function main() {
478
483
  process.exitCode = 5; return;
479
484
  }
480
485
 
481
- // v0.12.14 (audit F1): the prior loop iterated `sk.id` + a fixed payload
486
+ // v0.12.14: the prior loop iterated `sk.id` + a fixed payload
482
487
  // path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
483
488
  // `path` (a forward-slash relative path like `skills/<name>/skill.md`,
484
489
  // lowercase). Result: the loop matched zero entries; `failures.length === 0`
@@ -534,7 +539,7 @@ async function main() {
534
539
  process.exitCode = 5; return;
535
540
  }
536
541
 
537
- // v0.12.14 (audit F2): the swap loop replaces `data/` + `manifest.json` +
542
+ // v0.12.14: the swap loop replaces `data/` + `manifest.json` +
538
543
  // `manifest-snapshot.json` in addition to `skills/`. None of those files
539
544
  // are covered by the per-skill Ed25519 signature (which signs only the
540
545
  // skill body bytes). The only integrity check between the registry and
@@ -575,7 +580,7 @@ async function main() {
575
580
  return;
576
581
  }
577
582
 
578
- // v0.12.14 (audit F4): the prior swap loop renamed targets one-by-one,
583
+ // v0.12.14: the prior swap loop renamed targets one-by-one,
579
584
  // and a mid-loop failure left the install half-applied with no automatic
580
585
  // rollback. New shape: rename all old targets into a single backup dir
581
586
  // first (so the install is empty-of-old before any new content is moved
@@ -599,7 +604,7 @@ async function main() {
599
604
  written++;
600
605
  }
601
606
 
602
- // v0.12.14 (audit F10): use PID + random suffix in the backup dir name
607
+ // v0.12.14: use PID + random suffix in the backup dir name
603
608
  // so concurrent refresh-network invocations don't collide on the
604
609
  // millisecond clock.
605
610
  const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
@@ -640,7 +645,7 @@ async function main() {
640
645
  message: `refreshed catalog from v${localVersion} → v${latestVersion} (${verifiedCount}/${skills.length} signatures verified). Backup at ${path.relative(ROOT, backupDir)} — safe to remove after verifying the new run.`,
641
646
  }, opts.json);
642
647
  } catch (e) {
643
- // v0.12.14 (audit F4): walk completedSteps in reverse to undo partial work.
648
+ // v0.12.14: walk completedSteps in reverse to undo partial work.
644
649
  const rollbackErrors = [];
645
650
  for (const step of [...completedSteps].reverse()) {
646
651
  try {
@@ -682,12 +687,12 @@ if (require.main === module) {
682
687
  module.exports = {
683
688
  parseTar,
684
689
  fingerprintPublicKey,
685
- // Audit P P1-A: exported for tests/normalize-contract.test.js so the
690
+ // A: exported for tests/normalize-contract.test.js so the
686
691
  // byte-stability contract can be asserted across all four normalize()
687
692
  // implementations (lib/sign.js, lib/verify.js, lib/refresh-network.js,
688
693
  // scripts/verify-shipped-tarball.js).
689
694
  normalizeSkillBytes,
690
- // Audit O P1-B + Q P1: exported for in-process tests of the refresh
695
+ // B + Q P1: exported for in-process tests of the refresh
691
696
  // path's manifest envelope check.
692
697
  verifyTarballManifestSignature,
693
698
  canonicalManifestBytesForRefresh,
package/lib/scoring.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Supplements CVSS with exploit availability, active exploitation, and operational constraints.
6
6
  *
7
7
  * ----------------------------------------------------------------------------
8
- * `rwep_factors` dual-semantics (audit J F2)
8
+ * `rwep_factors` dual-semantics
9
9
  * ----------------------------------------------------------------------------
10
10
  * Catalog entries (data/cve-catalog.json) store `rwep_factors` as an object
11
11
  * whose values are POST-WEIGHT CONTRIBUTIONS for boolean / ladder factors
@@ -62,7 +62,7 @@ const RWEP_WEIGHTS = {
62
62
  reboot_required: 5
63
63
  };
64
64
 
65
- // audit J F4: active_exploitation ladder. Aligned with playbook-runner's
65
+ // active_exploitation ladder. Aligned with playbook-runner's
66
66
  // _activeExploitationLadder so the catalog scorer and the runtime evaluator
67
67
  // produce identical results for the same string value. 'unknown' contributes
68
68
  // a quarter of the confirmed weight (5 points) — operationally "we have not
@@ -76,7 +76,7 @@ const ACTIVE_EXPLOITATION_LADDER = {
76
76
  };
77
77
 
78
78
  // The canonical set of factor keys scoreCustom recognises. Used by
79
- // validateFactors to flag unknown keys (audit J F8).
79
+ // validateFactors to flag unknown keys.
80
80
  const RECOGNISED_FACTOR_KEYS = new Set([
81
81
  'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
82
82
  'active_exploitation', 'blast_radius', 'patch_available',
@@ -125,7 +125,7 @@ function validateFactors(factors) {
125
125
  } else if (!aeAllowed.includes(factors.active_exploitation)) {
126
126
  warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
127
127
  }
128
- // audit J F6: NaN diagnostics. The prior message read "expected number,
128
+ // NaN diagnostics. The prior message read "expected number,
129
129
  // got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
130
130
  // NaN === 'number'`. Number.isFinite catches NaN + Infinity + -Infinity
131
131
  // and emits a useful message.
@@ -140,7 +140,7 @@ function validateFactors(factors) {
140
140
  } else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
141
141
  warnings.push(`blast_radius: ${factors.blast_radius} out of expected range [0, 30] (clamped to weight ceiling, but the value usually indicates a unit-of-measure mistake)`);
142
142
  }
143
- // audit J F8: surface unknown factor keys so a typo'd answer file
143
+ // surface unknown factor keys so a typo'd answer file
144
144
  // (`patch_avilable`, `cisa-kev`, etc.) doesn't silently default to false
145
145
  // with no diagnostic.
146
146
  for (const k of Object.keys(factors)) {
@@ -173,7 +173,7 @@ function scoreCustom(factors, opts) {
173
173
  patch_available = false,
174
174
  live_patch_available = false,
175
175
  reboot_required = false,
176
- // v0.12.15 (audit J F9): the CVE catalog field is `patch_required_reboot`
176
+ // v0.12.15: the CVE catalog field is `patch_required_reboot`
177
177
  // but scoreCustom historically expected `reboot_required`. validate()
178
178
  // already aliases at the call site; accept either spelling here so a
179
179
  // direct caller passing the catalog entry doesn't silently lose the
@@ -186,7 +186,7 @@ function scoreCustom(factors, opts) {
186
186
  score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
187
187
  score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
188
188
  score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
189
- // audit J F4 + F16: active_exploitation goes through the ladder rather
189
+ // active_exploitation goes through the ladder rather
190
190
  // than two hand-written branches with `Math.floor(weight/2)`. The floor
191
191
  // was a no-op for even weights (20/2 = 10) but would have silently
192
192
  // truncated to asymmetric results if a future operator bumped the
@@ -195,7 +195,7 @@ function scoreCustom(factors, opts) {
195
195
  // aligns the catalog scorer with playbook-runner._activeExploitationLadder.
196
196
  const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
197
197
  score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
198
- // v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
198
+ // v0.12.15: blast_radius numeric coercion must reject
199
199
  // NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
200
200
  // check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
201
201
  // which propagates NaN through the final clamp, defeating the [0,100]
@@ -208,12 +208,12 @@ function scoreCustom(factors, opts) {
208
208
  score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
209
209
  score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
210
210
 
211
- // audit J F10: keep the pre-clamp value so collectWarnings consumers can
211
+ // keep the pre-clamp value so collectWarnings consumers can
212
212
  // see deduction magnitude (e.g. a -25 raw score collapsed to 0 hides the
213
213
  // fact that the entry had three mitigating factors).
214
214
  const rawUnclamped = score;
215
215
 
216
- // v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
216
+ // v0.12.15: defense-in-depth clamp against any unforeseen
217
217
  // NaN production above (negative weight + Infinity + math edge case).
218
218
  const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
219
219
  if (opts && opts.collectWarnings) {
@@ -227,7 +227,7 @@ function scoreCustom(factors, opts) {
227
227
  }
228
228
 
229
229
  /**
230
- * audit J F3 + audit M P1-C bridge: derive an RWEP score from a
230
+ * Derive an RWEP score from a
231
231
  * `rwep_factors` object regardless of which shape it uses.
232
232
  *
233
233
  * - SHAPE A (boolean / string-ladder): values are booleans + an
@@ -275,7 +275,7 @@ function compare(cveId, catalog, opts) {
275
275
  const entry = catalog[cveId];
276
276
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
277
277
 
278
- // audit J F11: `--recompute` ignores the stored rwep_score and forces a
278
+ // `--recompute` ignores the stored rwep_score and forces a
279
279
  // fresh computation from rwep_factors. Useful for catching catalog drift
280
280
  // (stored score grew stale relative to current weights) and for auditing
281
281
  // the divergence between stored vs. formula-derived scores.
@@ -294,7 +294,7 @@ function compare(cveId, catalog, opts) {
294
294
  const cvssEquivalent = cvss * 10;
295
295
  const delta = rwep - cvssEquivalent;
296
296
 
297
- // audit J F15: narrow the "broadly aligned" band from ±20 to ±10. The old
297
+ // narrow the "broadly aligned" band from ±20 to ±10. The old
298
298
  // ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
299
299
  // where the operator-facing point is precisely that the CVSS-calibrated
300
300
  // SLA is insufficient. ±10 is the tightest classifier that still treats
@@ -342,6 +342,15 @@ function validate(catalog) {
342
342
  const errors = [];
343
343
  for (const [cveId, entry] of Object.entries(catalog)) {
344
344
  if (cveId.startsWith('_')) continue;
345
+ // FF P1-1: skip auto-imported drafts. KEV/GHSA/OSV-discovered drafts
346
+ // store a conservative-default rwep_score (poc=true, reboot=true, etc.)
347
+ // alongside `poc_available: null` and other null-until-curated factor
348
+ // fields, so the recomputed-vs-stored divergence check ALWAYS fires
349
+ // against them — flooding the predeploy gate. Drafts are reviewed
350
+ // separately via the `_auto_imported_meta.curation_needed` list and the
351
+ // strict catalog validator's draft-warning tier. Once curation promotes
352
+ // an entry, `_auto_imported` is cleared and full validation resumes.
353
+ if (entry && entry._auto_imported === true) continue;
345
354
  for (const field of CVE_SCHEMA_REQUIRED) {
346
355
  if (!(field in entry)) {
347
356
  errors.push(`${cveId}: missing required field '${field}'`);
package/lib/sign.js CHANGED
@@ -112,7 +112,7 @@ function generateKeypair({ rotate = false } = {}) {
112
112
  fs.writeFileSync(PRIVATE_KEY_PATH, privateKey, { encoding: 'utf8', mode: 0o600 });
113
113
  fs.writeFileSync(PUBLIC_KEY_PATH, publicKey, { encoding: 'utf8', mode: 0o644 });
114
114
 
115
- // Audit I P1-3: on win32, fs.writeFileSync `mode` does not produce
115
+ // on win32, fs.writeFileSync `mode` does not produce
116
116
  // a POSIX-style restrictive ACL. Tighten via icacls so other desktop
117
117
  // users on the same workstation / CI runner can't read the key.
118
118
  restrictWindowsAcl(PRIVATE_KEY_PATH);
@@ -166,7 +166,7 @@ function signAll() {
166
166
  signed++;
167
167
  }
168
168
 
169
- // Audit I P1-4: sign the manifest itself. Removes any existing
169
+ // sign the manifest itself. Removes any existing
170
170
  // manifest_signature field so the canonical bytes are deterministic
171
171
  // across re-runs, signs with the private key, then writes the result.
172
172
  // A coordinated attacker who rewrites the manifest (and snapshot, and
@@ -298,7 +298,7 @@ function loadManifest() {
298
298
  }
299
299
 
300
300
  /**
301
- * Audit I P1-4 — canonical byte form of the manifest, used for both
301
+ * canonical byte form of the manifest, used for both
302
302
  * signing (lib/sign.js) and verification (lib/verify.js).
303
303
  *
304
304
  * Contract: the same logical manifest content must produce the same bytes
@@ -343,7 +343,7 @@ function canonicalManifestBytes(manifest) {
343
343
  * Returns the manifest_signature object literal to splice into the
344
344
  * manifest top level.
345
345
  *
346
- * Audit O P1-A: the previous shape included a `signed_at` ISO timestamp.
346
+ * A: the previous shape included a `signed_at` ISO timestamp.
347
347
  * That field was stripped from the canonical bytes before signing (via
348
348
  * `delete clone.manifest_signature`), so it was NOT covered by the
349
349
  * signature — an attacker who replayed a known-valid signature could
@@ -369,7 +369,7 @@ function signCanonicalManifest(manifest, privateKey) {
369
369
  }
370
370
 
371
371
  /**
372
- * Audit I P1-3 — tighten Windows ACL on the private key.
372
+ * tighten Windows ACL on the private key.
373
373
  *
374
374
  * fs.writeFileSync({mode: 0o600}) on win32 only affects read-only
375
375
  * attributes; the file inherits its ACL from the parent. icacls strips
@@ -163,7 +163,7 @@ function validateMeta(catalogPath, opts) {
163
163
  }
164
164
  }
165
165
 
166
- /* Audit G F3 — freshness enforcement. When both meta.last_updated and
166
+ /* freshness enforcement. When both meta.last_updated and
167
167
  * freshness_policy.stale_after_days are present, surface a warning if
168
168
  * (now - last_updated) > stale_after_days. Patch-class release emits at
169
169
  * WARN level (does not fail validation); v0.13.0 will flip to an error.
@@ -244,8 +244,8 @@ function additionalChecks(key, entry, ctx) {
244
244
  }
245
245
 
246
246
  // V2 — Cross-catalog reference resolution. Unresolved refs are warnings
247
- // for v0.12.x; v0.13.0 will flip to hard failures. Audit D's V2 expansion
248
- // (Audit G) extends the walk from cwe_refs only to attack_refs, atlas_refs,
247
+ // for v0.12.x; v0.13.0 will flip to hard failures. V2 expansion
248
+ // extends the walk from cwe_refs only to attack_refs, atlas_refs,
249
249
  // d3fend_refs, AND framework_control_gaps.
250
250
  const REF_FIELDS = [
251
251
  { field: 'atlas_refs', set: ctx.atlasKeys, file: 'data/atlas-ttps.json' },
@@ -47,7 +47,7 @@ function main() {
47
47
  const meta = JSON.parse(fs.readFileSync(META, "utf8"));
48
48
  const recorded = meta.source_hashes || {};
49
49
 
50
- // Audit G F1 — reject an empty source_hashes table outright. The previous
50
+ // reject an empty source_hashes table outright. The previous
51
51
  // gate would silently pass when source_hashes was {} (or missing entirely)
52
52
  // because the for-loop body never executed; the resulting "0 sources" pass
53
53
  // banner falsely advertised the indexes as current. An empty source-hash
@@ -67,7 +67,7 @@ function main() {
67
67
  const manifest = JSON.parse(fs.readFileSync(ABS("manifest.json"), "utf8"));
68
68
  const liveSources = new Set();
69
69
  liveSources.add("manifest.json");
70
- // Audit G F16 — use lstat to detect symlinks. A symlinked .json under data/
70
+ // use lstat to detect symlinks. A symlinked .json under data/
71
71
  // would be hashed via the followed target, allowing a malicious checkout
72
72
  // (or a misconfigured filesystem) to swap data origin without tripping the
73
73
  // gate. Reject symlinks outright.
package/lib/verify.js CHANGED
@@ -69,7 +69,7 @@ const SKILLS_DIR = path.join(ROOT, 'skills');
69
69
  const PUBLIC_KEY_PATH = path.join(ROOT, 'keys', 'public.pem');
70
70
  const PRIVATE_KEY_PATH = path.join(ROOT, '.keys', 'private.pem');
71
71
  const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
72
- // Audit G F4 — key-pin file. When present, lib/verify.js compares the live
72
+ // key-pin file. When present, lib/verify.js compares the live
73
73
  // public-key fingerprint against the pinned one and fails the verify run
74
74
  // if they differ (unless the operator sets KEYS_ROTATED=1). The file format
75
75
  // is a single line "SHA256:<base64>" matching the publicKeyFingerprint()
@@ -164,7 +164,7 @@ function signAll() {
164
164
  const manifestSig = crypto.sign(null, canonical, {
165
165
  key: privateKey, dsaEncoding: 'ieee-p1363',
166
166
  });
167
- // Audit O P1-A: `signed_at` is intentionally OMITTED. The previous shape
167
+ // A: `signed_at` is intentionally OMITTED. The previous shape
168
168
  // emitted a `signed_at` timestamp alongside the Ed25519 signature, but
169
169
  // `signed_at` was stripped from the canonical bytes before signing — so
170
170
  // an attacker could replay a known-valid signature against the same
@@ -294,7 +294,7 @@ function loadManifest() {
294
294
  }
295
295
 
296
296
  /**
297
- * Audit I P1-4 — canonical byte form of the manifest.
297
+ * canonical byte form of the manifest.
298
298
  *
299
299
  * Mirrors lib/sign.js canonicalManifestBytes(). Any divergence here
300
300
  * breaks the verify-after-sign round trip; do not modify in isolation.
@@ -347,7 +347,7 @@ function verifyManifestSignature(manifest) {
347
347
  if (typeof sig.signature_base64 !== 'string') {
348
348
  return { status: 'invalid', reason: 'manifest_signature.signature_base64 missing or not a string' };
349
349
  }
350
- // Audit O P1-E: require the algorithm field to be present and exactly
350
+ // E: require the algorithm field to be present and exactly
351
351
  // 'Ed25519'. The previous form accepted a missing algorithm field
352
352
  // (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`) which let a
353
353
  // future downgrade attacker drop the field to bait a weaker default.
@@ -362,6 +362,25 @@ function verifyManifestSignature(manifest) {
362
362
  if (!publicKey) {
363
363
  return { status: 'no-key', reason: 'public key missing at keys/public.pem' };
364
364
  }
365
+ // consult keys/EXPECTED_FINGERPRINT BEFORE crypto.verify so
366
+ // library callers (refresh-network gate, verify-shipped-tarball gate, tests,
367
+ // downstream consumers via `require("lib/verify")`) cannot bypass the pin.
368
+ // Previously the pin only fired at the CLI tail of `node lib/verify.js`,
369
+ // letting a coordinated attacker who swapped keys/public.pem authenticate
370
+ // against the attacker key without any divergence surfaced through the
371
+ // library API. Honors KEYS_ROTATED=1 for legitimate rotations; missing
372
+ // pin file remains warn-and-continue (legacy compat).
373
+ const liveFp = publicKeyFingerprint(publicKey);
374
+ const pinResult = checkExpectedFingerprint(liveFp);
375
+ if (pinResult.status === 'mismatch' && !pinResult.rotationOverride) {
376
+ return {
377
+ status: 'invalid',
378
+ 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.`,
379
+ fingerprint_mismatch: true,
380
+ expected: pinResult.expected,
381
+ actual: pinResult.actual,
382
+ };
383
+ }
365
384
  let signatureBytes;
366
385
  try {
367
386
  signatureBytes = Buffer.from(sig.signature_base64, 'base64');
@@ -389,7 +408,7 @@ function verifyManifestSignature(manifest) {
389
408
  * is a fatal-class bug — surface it loudly rather than verify-against-
390
409
  * a-corrupt-manifest.
391
410
  *
392
- * Audit I P1-4: also verifies the top-level manifest_signature. On
411
+ * also verifies the top-level manifest_signature. On
393
412
  * invalid signature, throws a structured error blocking all skill
394
413
  * verification (a coordinated attacker who rewrote manifest.json +
395
414
  * manifest-snapshot.json + manifest-snapshot.sha256 still cannot forge
@@ -414,7 +433,7 @@ function loadManifestValidated() {
414
433
  for (const skill of manifest.skills) {
415
434
  validateSkillPath(skill.path);
416
435
  }
417
- // Audit I P1-4 — manifest signature gate. Runs after schema + path
436
+ // manifest signature gate. Runs after schema + path
418
437
  // validation so a malformed manifest reports the structural failure
419
438
  // before the cryptographic one.
420
439
  const sigResult = verifyManifestSignature(manifest);
@@ -422,7 +441,7 @@ function loadManifestValidated() {
422
441
  throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. The manifest has been modified (or signed with a different key) since last sign-all. Refusing to verify any skill against this manifest.`);
423
442
  }
424
443
  if (sigResult.status === 'missing') {
425
- // Audit O P1-D: dedupe the legacy-tarball warning. Many CLI verbs
444
+ // D: dedupe the legacy-tarball warning. Many CLI verbs
426
445
  // call loadManifestValidated() more than once per invocation; the
427
446
  // previous console.warn spammed stderr per call. Node's emitWarning()
428
447
  // with a stable `code` collapses repeated emissions automatically.
@@ -562,7 +581,7 @@ function validateAgainstSchema(value, schema, here, root) {
562
581
  * @returns {{sha256: string, sha3_512: string}|{error: string}}
563
582
  */
564
583
  /**
565
- * Audit G F4 — compare the live public-key fingerprint against the optional
584
+ * compare the live public-key fingerprint against the optional
566
585
  * pinned fingerprint in keys/EXPECTED_FINGERPRINT. Returns one of:
567
586
  * { status: 'no-pin' } — keys/EXPECTED_FINGERPRINT not present.
568
587
  * Callers should warn and continue.
@@ -574,16 +593,46 @@ function validateAgainstSchema(value, schema, here, root) {
574
593
  * @param {{sha256:string}|null} liveFp publicKeyFingerprint() output
575
594
  * @param {string} [pinPath] optional override (testability)
576
595
  */
596
+ /**
597
+ * KK P1-5: shared loader for keys/EXPECTED_FINGERPRINT. Reads the pin file,
598
+ * strips a leading UTF-8 BOM (Notepad with files.encoding=utf8bom would
599
+ * otherwise prepend U+FEFF and silently break every verify path on the host),
600
+ * tolerates CRLF line endings, ignores comment lines (`#`) and blanks, and
601
+ * returns the first non-comment / non-empty line. Returns null if the file
602
+ * is unreadable / empty.
603
+ *
604
+ * Shared across four sites so every loader normalises identically:
605
+ * - lib/verify.js (manifest signature gate)
606
+ * - lib/refresh-network.js (refresh-network pre-swap gate)
607
+ * - scripts/verify-shipped-tarball.js (predeploy gate)
608
+ * - bin/exceptd.js (attestation pin)
609
+ * tests/normalize-contract.test.js asserts byte-identical output across all
610
+ * four sites under a BOM + CRLF fuzz corpus.
611
+ */
612
+ function loadExpectedFingerprintFirstLine(pinPath) {
613
+ let raw;
614
+ try { raw = fs.readFileSync(pinPath, 'utf8'); }
615
+ catch { return null; }
616
+ if (raw.length > 0 && raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
617
+ const lines = raw
618
+ .split(/\r?\n/)
619
+ .map((l) => l.trim())
620
+ .filter((l) => l.length > 0 && !l.startsWith('#'));
621
+ return lines[0] || null;
622
+ }
623
+
577
624
  function checkExpectedFingerprint(liveFp, pinPath) {
578
625
  const p = pinPath || EXPECTED_FINGERPRINT_PATH;
579
626
  if (!fs.existsSync(p)) return { status: 'no-pin' };
580
627
  if (!liveFp || typeof liveFp.sha256 !== 'string') {
581
628
  return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
582
629
  }
583
- const expected = fs.readFileSync(p, 'utf8').trim();
584
- // Tolerate trailing comment / whitespace on the same line; the file's
585
- // first non-empty line is the canonical fingerprint.
586
- const firstLine = expected.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || '';
630
+ // KK P1-5: route through the shared loader so a BOM-prefixed pin file
631
+ // (Notepad with files.encoding=utf8bom) is tolerated identically across
632
+ // every verify site. Pre-fix the verbatim split-trim-find produced a
633
+ // first-line of "SHA256:..." (with leading BOM) that would never equal
634
+ // a live fingerprint.
635
+ const firstLine = loadExpectedFingerprintFirstLine(p) || '';
587
636
  if (firstLine === liveFp.sha256) return { status: 'match' };
588
637
  return {
589
638
  status: 'mismatch',
@@ -675,7 +724,7 @@ if (require.main === module) {
675
724
  console.log(`[verify] ${fp.sha256}`);
676
725
  console.log(`[verify] ${fp.sha3_512}`);
677
726
 
678
- // Audit G F4 — pin check. When keys/EXPECTED_FINGERPRINT exists, the
727
+ // pin check. When keys/EXPECTED_FINGERPRINT exists, the
679
728
  // live fingerprint MUST match it (or KEYS_ROTATED=1 must be set to
680
729
  // intentionally override). When the file is absent, emit a single-line
681
730
  // warning but continue — fresh clones / bootstrap workflows should not
@@ -721,6 +770,7 @@ module.exports = {
721
770
  validateAgainstSchema,
722
771
  publicKeyFingerprint,
723
772
  checkExpectedFingerprint,
773
+ loadExpectedFingerprintFirstLine,
724
774
  canonicalManifestBytes,
725
775
  verifyManifestSignature,
726
776
  EXPECTED_FINGERPRINT_PATH,