@blamejs/exceptd-skills 0.12.23 → 0.12.25

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 (70) hide show
  1. package/AGENTS.md +12 -4
  2. package/CHANGELOG.md +190 -3
  3. package/README.md +14 -1
  4. package/bin/exceptd.js +584 -166
  5. package/data/_indexes/_meta.json +31 -31
  6. package/data/_indexes/activity-feed.json +45 -45
  7. package/data/_indexes/catalog-summaries.json +19 -19
  8. package/data/_indexes/chains.json +320 -0
  9. package/data/_indexes/currency.json +9 -9
  10. package/data/_indexes/frequency.json +39 -2
  11. package/data/_indexes/jurisdiction-clocks.json +2 -2
  12. package/data/_indexes/jurisdiction-map.json +3 -1
  13. package/data/_indexes/section-offsets.json +396 -396
  14. package/data/_indexes/summary-cards.json +3 -3
  15. package/data/_indexes/token-budget.json +73 -73
  16. package/data/atlas-ttps.json +491 -19
  17. package/data/attack-techniques.json +198 -84
  18. package/data/cve-catalog.json +1309 -9
  19. package/data/exploit-availability.json +300 -10
  20. package/data/framework-control-gaps.json +395 -1
  21. package/data/global-frameworks.json +44 -19
  22. package/data/playbooks/containers.json +1 -1
  23. package/data/playbooks/crypto-codebase.json +1 -1
  24. package/data/playbooks/framework.json +1 -1
  25. package/data/playbooks/hardening.json +1 -1
  26. package/data/playbooks/library-author.json +1 -1
  27. package/data/playbooks/secrets.json +25 -1
  28. package/data/rfc-references.json +93 -1
  29. package/data/zeroday-lessons.json +475 -13
  30. package/lib/auto-discovery.js +26 -2
  31. package/lib/exit-codes.js +72 -0
  32. package/lib/flag-suggest.js +130 -0
  33. package/lib/id-validation.js +95 -0
  34. package/lib/lint-skills.js +68 -1
  35. package/lib/playbook-runner.js +321 -46
  36. package/lib/prefetch.js +113 -0
  37. package/lib/refresh-external.js +190 -8
  38. package/lib/refresh-network.js +35 -8
  39. package/lib/schemas/cve-catalog.schema.json +31 -4
  40. package/lib/schemas/playbook.schema.json +51 -0
  41. package/lib/scoring.js +41 -0
  42. package/lib/upstream-check-cli.js +16 -1
  43. package/lib/upstream-check.js +9 -0
  44. package/lib/verify.js +20 -4
  45. package/manifest-snapshot.json +1 -1
  46. package/manifest-snapshot.sha256 +1 -1
  47. package/manifest.json +59 -59
  48. package/package.json +8 -2
  49. package/sbom.cdx.json +6 -6
  50. package/scripts/check-test-coverage.js +67 -0
  51. package/scripts/verify-shipped-tarball.js +9 -0
  52. package/skills/ai-attack-surface/skill.md +11 -2
  53. package/skills/ai-c2-detection/skill.md +3 -1
  54. package/skills/ai-risk-management/skill.md +3 -1
  55. package/skills/api-security/skill.md +4 -0
  56. package/skills/attack-surface-pentest/skill.md +1 -0
  57. package/skills/container-runtime-security/skill.md +3 -1
  58. package/skills/dlp-gap-analysis/skill.md +1 -1
  59. package/skills/exploit-scoring/skill.md +2 -2
  60. package/skills/incident-response-playbook/skill.md +1 -1
  61. package/skills/kernel-lpe-triage/skill.md +6 -1
  62. package/skills/mcp-agent-trust/skill.md +7 -2
  63. package/skills/mlops-security/skill.md +1 -1
  64. package/skills/rag-pipeline-security/skill.md +4 -2
  65. package/skills/sector-financial/skill.md +1 -1
  66. package/skills/skill-update-loop/skill.md +1 -1
  67. package/skills/supply-chain-integrity/skill.md +3 -1
  68. package/skills/threat-model-currency/skill.md +1 -1
  69. package/skills/webapp-security/skill.md +2 -0
  70. package/skills/zeroday-gap-learn/skill.md +2 -2
package/lib/prefetch.js CHANGED
@@ -302,6 +302,92 @@ async function saveIndex(cacheDir, idx) {
302
302
  });
303
303
  }
304
304
 
305
+ // Canonical bytes for _index.json signing. Mirrors the manifest-signing
306
+ // contract (lib/sign.js + lib/verify.js canonicalManifestBytes): deep-sort
307
+ // keys, JSON.stringify with no formatting overhead the verifier can drift
308
+ // against. Any change here must be mirrored in verifyIndexSignature() below.
309
+ // The signature covers the index AS PERSISTED — `index_signature` is
310
+ // excluded from the canonical bytes (the signature cannot sign itself).
311
+ function canonicalizeIndex(value) {
312
+ if (Array.isArray(value)) return value.map(canonicalizeIndex);
313
+ if (value && typeof value === "object") {
314
+ const out = {};
315
+ for (const key of Object.keys(value).sort()) {
316
+ out[key] = canonicalizeIndex(value[key]);
317
+ }
318
+ return out;
319
+ }
320
+ return value;
321
+ }
322
+ function canonicalIndexBytes(idx) {
323
+ const clone = Object.assign({}, idx);
324
+ delete clone.index_signature;
325
+ return Buffer.from(JSON.stringify(canonicalizeIndex(clone)), "utf8");
326
+ }
327
+
328
+ // Sign _index.json with the Ed25519 private key (.keys/private.pem). The
329
+ // signature is written as a sidecar `_index.json.sig` containing
330
+ // { algorithm: "Ed25519", signature_base64, signed_at }. readCachedJson /
331
+ // loadCtx --from-cache verify this against keys/public.pem.
332
+ //
333
+ // Behavior on missing private key: emit a warning and return; the cache is
334
+ // left unsigned. Operators on connected hosts where prefetch runs without
335
+ // the maintainer keypair will see this warning. The verify side treats a
336
+ // missing sidecar as "unsigned cache" and refuses unless --force-stale.
337
+ function signIndex(cacheDir) {
338
+ const privPath = path.join(ROOT, ".keys", "private.pem");
339
+ if (!fs.existsSync(privPath)) {
340
+ console.warn(
341
+ `[prefetch] WARN: .keys/private.pem absent — _index.json written unsigned. ` +
342
+ `Downstream consumers reading this cache via --from-cache will refuse it ` +
343
+ `unless they pass --force-stale.`
344
+ );
345
+ return { signed: false };
346
+ }
347
+ const indexPath = path.join(cacheDir, "_index.json");
348
+ if (!fs.existsSync(indexPath)) return { signed: false };
349
+ const idx = JSON.parse(fs.readFileSync(indexPath, "utf8"));
350
+ const bytes = canonicalIndexBytes(idx);
351
+ const privKey = crypto.createPrivateKey(fs.readFileSync(privPath, "utf8"));
352
+ const sig = crypto.sign(null, bytes, privKey);
353
+ const sidecar = {
354
+ algorithm: "Ed25519",
355
+ signature_base64: sig.toString("base64"),
356
+ signed_at: new Date().toISOString(),
357
+ };
358
+ writeFileAtomic(path.join(cacheDir, "_index.json.sig"), JSON.stringify(sidecar, null, 2) + "\n");
359
+ return { signed: true };
360
+ }
361
+
362
+ // Verify _index.json against its sidecar signature using keys/public.pem.
363
+ // Returns { status: "valid" | "missing" | "invalid", reason? }. Callers
364
+ // decide policy: typically refuse unless --force-stale on "missing" /
365
+ // "invalid".
366
+ function verifyIndexSignature(cacheDir) {
367
+ const indexPath = path.join(cacheDir, "_index.json");
368
+ const sigPath = path.join(cacheDir, "_index.json.sig");
369
+ if (!fs.existsSync(indexPath)) return { status: "missing", reason: "_index.json not present" };
370
+ if (!fs.existsSync(sigPath)) return { status: "missing", reason: "_index.json.sig not present (cache was prefetched without a signing key)" };
371
+ let sidecar;
372
+ try { sidecar = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
373
+ catch (e) { return { status: "invalid", reason: `_index.json.sig parse: ${e.message}` }; }
374
+ if (!sidecar || sidecar.algorithm !== "Ed25519" || typeof sidecar.signature_base64 !== "string") {
375
+ return { status: "invalid", reason: "_index.json.sig missing algorithm or signature_base64" };
376
+ }
377
+ const pubPath = path.join(ROOT, "keys", "public.pem");
378
+ if (!fs.existsSync(pubPath)) return { status: "invalid", reason: "keys/public.pem absent — cannot verify cache signature" };
379
+ const idx = JSON.parse(fs.readFileSync(indexPath, "utf8"));
380
+ const bytes = canonicalIndexBytes(idx);
381
+ const pubKey = crypto.createPublicKey(fs.readFileSync(pubPath, "utf8"));
382
+ let sigBytes;
383
+ try { sigBytes = Buffer.from(sidecar.signature_base64, "base64"); }
384
+ catch (e) { return { status: "invalid", reason: `signature_base64 decode: ${e.message}` }; }
385
+ let ok = false;
386
+ try { ok = crypto.verify(null, bytes, pubKey, sigBytes); }
387
+ catch (e) { return { status: "invalid", reason: `crypto.verify threw: ${e.message}` }; }
388
+ return ok ? { status: "valid" } : { status: "invalid", reason: "Ed25519 signature did not verify against keys/public.pem" };
389
+ }
390
+
305
391
  function entryKey(source, id) {
306
392
  return `${source}/${id}`;
307
393
  }
@@ -482,6 +568,19 @@ async function prefetch(options = {}) {
482
568
  // run's writes would be silently overwritten here at the end of our run.
483
569
  await saveIndex(opts.cacheDir, idx);
484
570
 
571
+ // Sign the freshly-written _index.json with the Ed25519 private key
572
+ // (.keys/private.pem). The signature is a sidecar `_index.json.sig`;
573
+ // consumers reading via --from-cache verify it against keys/public.pem
574
+ // before trusting any entry. If the private key is absent (typical on
575
+ // operator-side prefetch runs where the maintainer keypair isn't
576
+ // present), signIndex() warns-and-returns and the cache is left
577
+ // unsigned — downstream verify will then refuse it without --force-stale.
578
+ try {
579
+ signIndex(opts.cacheDir);
580
+ } catch (err) {
581
+ console.warn(`[prefetch] WARN: _index.json signing failed: ${err && err.message}; cache left unsigned.`);
582
+ }
583
+
485
584
  // Final summary is unconditional — --quiet suppresses per-entry chatter
486
585
  // (the noisy part) but the operator still needs one line confirming success.
487
586
  // Without this, --quiet + --no-network was zero output even on dry-run
@@ -526,6 +625,13 @@ function readCached(cacheDir, source, id, opts = {}) {
526
625
  // non-finite age as "no provenance, refuse" unless the caller explicitly
527
626
  // opted into allowStale.
528
627
  const ageMs = meta.fetched_at ? Date.now() - new Date(meta.fetched_at).getTime() : NaN;
628
+ // Future-dated `fetched_at` (ageMs < 0) is a poisoning signal: either the
629
+ // host clock jumped backwards mid-fetch, or an attacker rewrote the index
630
+ // to inflate apparent freshness past the maxAge gate. Either way the
631
+ // entry's provenance is no longer trustworthy. Treat as missing — refuse
632
+ // even when allowStale is set, because allowStale loosens the upper bound,
633
+ // not the lower one.
634
+ if (Number.isFinite(ageMs) && ageMs < 0) return null;
529
635
  if (!opts.allowStale) {
530
636
  if (!meta.fetched_at || !Number.isFinite(ageMs)) return null;
531
637
  if (ageMs > maxAgeMs) return null;
@@ -575,6 +681,13 @@ module.exports = {
575
681
  parseArgs,
576
682
  SOURCES,
577
683
  DEFAULT_CACHE,
684
+ // Ed25519 _index.json signing + verification. Exported so
685
+ // lib/refresh-external.js (which consumes --from-cache) can verify the
686
+ // sidecar before trusting any cached entry, and so test harnesses can
687
+ // exercise the signing path without running the full prefetch pipeline.
688
+ signIndex,
689
+ verifyIndexSignature,
690
+ canonicalIndexBytes,
578
691
  // v0.12.12 C2: exported for the concurrent-writer regression test.
579
692
  // Not part of the operator-facing API — internal contract for tests
580
693
  // that need to exercise the lockfile path without spawning the full
@@ -98,7 +98,13 @@ function parseArgs(argv) {
98
98
  // EXCEPTD_AIR_GAP=1 still works as an env-var fallback so existing
99
99
  // automation isn't broken.
100
100
  else if (a === "--air-gap") out.airGap = true;
101
+ // `--force-stale` bypasses cache-freshness + cache-signature refusals.
102
+ // Required when the operator intentionally wants to consume a cache
103
+ // older than 7d or one that was prefetched without a signing keypair.
104
+ // EXCEPTD_FORCE_STALE=1 mirrors for non-interactive automation.
105
+ else if (a === "--force-stale") out.forceStale = true;
101
106
  }
107
+ if (process.env.EXCEPTD_FORCE_STALE === "1") out.forceStale = true;
102
108
  return out;
103
109
  }
104
110
 
@@ -654,16 +660,95 @@ const ALL_SOURCES = {
654
660
  // readCachedJson returns null on miss; callers report it as "unreachable"
655
661
  // for that entry rather than failing the whole source.
656
662
 
657
- function readCachedJson(cacheDir, source, id) {
663
+ // sha256 of on-disk cache entries is recorded in _index.json at fetch time
664
+ // but was never verified on consume. A coordinated tamper that rewrote
665
+ // e.g. `.cache/upstream/kev/known_exploited_vulnerabilities.json` between
666
+ // prefetch and refresh would silently feed false intelligence into the
667
+ // applied catalog. We now recompute the sha256 inside readCachedJson and
668
+ // refuse on mismatch.
669
+ //
670
+ // The sha256 stored at prefetch time is computed over JSON.stringify(payload)
671
+ // — unindented. The on-disk bytes use JSON.stringify(payload, null, 2)+"\n",
672
+ // so we round-trip parse and re-canonicalize to compute the comparable hash.
673
+ function readCachedJson(cacheDir, source, id, opts) {
674
+ const forceStale = !!(opts && opts.forceStale);
658
675
  const safe = id.replace(/[^A-Za-z0-9._-]/g, "_");
659
676
  const p = path.join(cacheDir, source, `${safe}.json`);
660
677
  if (!fs.existsSync(p)) return null;
661
- try { return JSON.parse(fs.readFileSync(p, "utf8")); }
678
+ let parsed;
679
+ try { parsed = JSON.parse(fs.readFileSync(p, "utf8")); }
662
680
  catch { return null; }
681
+ // Look up the index entry. If the index is absent or doesn't have an
682
+ // entry for this source/id, we cannot verify integrity — refuse rather
683
+ // than fail-open. The cache invariant is: every payload on disk has a
684
+ // signed entry in _index.json. `--force-stale` is the operator escape
685
+ // hatch for pre-v0.12.24 caches that lack the per-entry sha256 records;
686
+ // we proceed without integrity checking but emit a warning so the gap
687
+ // is visible in logs.
688
+ const indexPath = path.join(cacheDir, "_index.json");
689
+ if (!fs.existsSync(indexPath)) {
690
+ if (forceStale) {
691
+ process.emitWarning(
692
+ `cache-integrity: _index.json missing under ${cacheDir}; proceeding unverified (--force-stale)`,
693
+ { code: "EXCEPTD_CACHE_UNVERIFIED" },
694
+ );
695
+ return parsed;
696
+ }
697
+ const err = new Error(`cache-integrity: _index.json missing under ${cacheDir}; refusing to consume unindexed payload for ${source}/${id}`);
698
+ err._exceptd_cache_integrity = true;
699
+ err._exceptd_hint = true;
700
+ err._exceptd_exit_code = 4;
701
+ throw err;
702
+ }
703
+ let idx;
704
+ try { idx = JSON.parse(fs.readFileSync(indexPath, "utf8")); }
705
+ catch (e) {
706
+ if (forceStale) {
707
+ process.emitWarning(
708
+ `cache-integrity: _index.json parse failed (${e.message}); proceeding unverified (--force-stale)`,
709
+ { code: "EXCEPTD_CACHE_UNVERIFIED" },
710
+ );
711
+ return parsed;
712
+ }
713
+ const err = new Error(`cache-integrity: _index.json parse failed: ${e.message}`);
714
+ err._exceptd_cache_integrity = true;
715
+ err._exceptd_hint = true;
716
+ err._exceptd_exit_code = 4;
717
+ throw err;
718
+ }
719
+ const meta = idx && idx.entries && idx.entries[`${source}/${id}`];
720
+ if (!meta || typeof meta.sha256 !== "string") {
721
+ if (forceStale) {
722
+ process.emitWarning(
723
+ `cache-integrity: _index.json has no sha256 entry for ${source}/${id}; proceeding unverified (--force-stale)`,
724
+ { code: "EXCEPTD_CACHE_UNVERIFIED" },
725
+ );
726
+ return parsed;
727
+ }
728
+ const err = new Error(`cache-integrity: _index.json has no sha256 entry for ${source}/${id}; cache may have been tampered or partially populated`);
729
+ err._exceptd_cache_integrity = true;
730
+ err._exceptd_hint = true;
731
+ err._exceptd_exit_code = 4;
732
+ throw err;
733
+ }
734
+ const expected = meta.sha256;
735
+ const cryptoMod = require("crypto");
736
+ const actual = cryptoMod.createHash("sha256").update(JSON.stringify(parsed)).digest("hex");
737
+ if (expected !== actual) {
738
+ // sha256 mismatch is a hard tamper signal — `--force-stale` does NOT
739
+ // bypass it. An operator who knows the cache is stale can re-prefetch;
740
+ // an operator whose cache has been tampered should not proceed.
741
+ const err = new Error(`cache-integrity: sha256 mismatch for ${source}/${id} (expected ${expected.slice(0, 16)}..., got ${actual.slice(0, 16)}...)`);
742
+ err._exceptd_cache_integrity = true;
743
+ err._exceptd_hint = true;
744
+ err._exceptd_exit_code = 4;
745
+ throw err;
746
+ }
747
+ return parsed;
663
748
  }
664
749
 
665
750
  function kevDiffFromCache(ctx) {
666
- const feed = readCachedJson(ctx.cacheDir, "kev", "known_exploited_vulnerabilities");
751
+ const feed = readCachedJson(ctx.cacheDir, "kev", "known_exploited_vulnerabilities", { forceStale: ctx.forceStale });
667
752
  if (!feed) {
668
753
  return { status: "unreachable", diffs: [], errors: 1, summary: "KEV: no cached feed" };
669
754
  }
@@ -696,7 +781,7 @@ function epssDiffFromCache(ctx) {
696
781
  let errors = 0;
697
782
  const drift = 0.05;
698
783
  for (const id of cves) {
699
- const payload = readCachedJson(ctx.cacheDir, "epss", id);
784
+ const payload = readCachedJson(ctx.cacheDir, "epss", id, { forceStale: ctx.forceStale });
700
785
  if (!payload) { errors++; continue; }
701
786
  const row = (payload.data || []).find((r) => r?.cve === id) || (payload.data || [])[0];
702
787
  if (!row) continue;
@@ -724,7 +809,7 @@ function nvdDiffFromCache(ctx) {
724
809
  const diffs = [];
725
810
  let errors = 0;
726
811
  for (const id of cves) {
727
- const payload = readCachedJson(ctx.cacheDir, "nvd", id);
812
+ const payload = readCachedJson(ctx.cacheDir, "nvd", id, { forceStale: ctx.forceStale });
728
813
  if (!payload) { errors++; continue; }
729
814
  const vuln = payload.vulnerabilities?.[0]?.cve;
730
815
  if (!vuln) continue;
@@ -759,7 +844,7 @@ function rfcDiffFromCache(ctx) {
759
844
  if (id.startsWith("RFC-")) docName = `rfc${id.slice(4)}`;
760
845
  else if (id.startsWith("DRAFT-")) docName = `draft-${id.slice(6).toLowerCase()}`;
761
846
  if (!docName) continue;
762
- const payload = readCachedJson(ctx.cacheDir, "rfc", docName);
847
+ const payload = readCachedJson(ctx.cacheDir, "rfc", docName, { forceStale: ctx.forceStale });
763
848
  if (!payload) { errors++; continue; }
764
849
  const obj = payload.objects?.[0];
765
850
  if (!obj) continue;
@@ -793,7 +878,7 @@ function pinsDiffFromCache(ctx) {
793
878
  const diffs = [];
794
879
  let errors = 0;
795
880
  for (const [pinName, file] of Object.entries(PIN_REPOS)) {
796
- const payload = readCachedJson(ctx.cacheDir, "pins", file);
881
+ const payload = readCachedJson(ctx.cacheDir, "pins", file, { forceStale: ctx.forceStale });
797
882
  if (!payload || !Array.isArray(payload)) { errors++; continue; }
798
883
  const stable = payload.find((r) => !r.draft && !r.prerelease);
799
884
  if (!stable) { errors++; continue; }
@@ -854,8 +939,26 @@ function loadCtx(opts) {
854
939
  // GHSA + OSV source modules (lib/source-ghsa.js, lib/source-osv.js)
855
940
  // branch on it and refuse network egress.
856
941
  airGap: !!(opts && opts.airGap) || process.env.EXCEPTD_AIR_GAP === "1",
942
+ // Thread --force-stale through so readCachedJson can downgrade cache-
943
+ // integrity refusals to warnings when an operator explicitly opts out.
944
+ forceStale: !!(opts && opts.forceStale),
857
945
  };
858
946
  if (opts.fromFixture) {
947
+ // `--from-fixture` injects frozen test payloads as if they were live
948
+ // upstream responses. Allowing this on an operator's host would let any
949
+ // caller forge KEV / NVD / EPSS / pin diffs into the applied catalog.
950
+ // Gate the flag behind EXCEPTD_TEST_HARNESS=1 so it only activates in
951
+ // explicit test contexts.
952
+ if (process.env.EXCEPTD_TEST_HARNESS !== "1") {
953
+ const err = new Error(
954
+ `refresh: --from-fixture is disabled outside the test harness.\n` +
955
+ `Hint: Set EXCEPTD_TEST_HARNESS=1 to use --from-fixture; this flag is intended for test harnesses only and would otherwise allow forged upstream payloads.`
956
+ );
957
+ err._exceptd_hint = true;
958
+ err._exceptd_exit_code = 4;
959
+ err._exceptd_error_code = "from-fixture-disabled";
960
+ throw err;
961
+ }
859
962
  ctx.fixtures = { dir: path.resolve(opts.fromFixture), kev: true, epss: true, nvd: true, rfc: true, pins: true, ghsa: true, osv: true };
860
963
  } else if (opts.fromCache) {
861
964
  const abs = path.resolve(opts.fromCache);
@@ -875,6 +978,81 @@ function loadCtx(opts) {
875
978
  err._exceptd_hint = true;
876
979
  throw err;
877
980
  }
981
+ // _index.json signature verification. The cache was signed at
982
+ // prefetch time with the Ed25519 private key. Refuse to consume any
983
+ // cache whose sidecar signature does not verify against keys/public.pem,
984
+ // unless the operator explicitly accepts the risk via --force-stale.
985
+ // A missing sidecar (cache prefetched on a host without the signing
986
+ // keypair) is treated identically: same refusal, same override.
987
+ try {
988
+ const { verifyIndexSignature } = require("./prefetch.js");
989
+ const sigResult = verifyIndexSignature(abs);
990
+ if (sigResult.status !== "valid" && !opts.forceStale) {
991
+ const err = new Error(
992
+ `refresh: --from-cache signature verification failed (${sigResult.status}): ${sigResult.reason || "(no reason)"}.\n` +
993
+ `Hint: The cache at ${abs} was prefetched without a signing key, or its _index.json / _index.json.sig was tampered. ` +
994
+ `Re-run \`exceptd prefetch\` on a host with .keys/private.pem, or pass --force-stale to consume the cache anyway.`
995
+ );
996
+ err._exceptd_hint = true;
997
+ err._exceptd_exit_code = 4;
998
+ err._exceptd_error_code = "cache-signature";
999
+ throw err;
1000
+ }
1001
+ } catch (e) {
1002
+ if (e && e._exceptd_hint) throw e;
1003
+ // Loader error (prefetch.js missing exports, etc.) — treat as a hard
1004
+ // refusal rather than fail-open. Operators on --force-stale still
1005
+ // pass through.
1006
+ if (!opts.forceStale) {
1007
+ const err = new Error(
1008
+ `refresh: --from-cache signature verifier unavailable: ${e && e.message}.\n` +
1009
+ `Hint: Pass --force-stale to consume the cache without signature verification, or reinstall the package.`
1010
+ );
1011
+ err._exceptd_hint = true;
1012
+ err._exceptd_exit_code = 4;
1013
+ err._exceptd_error_code = "cache-signature";
1014
+ throw err;
1015
+ }
1016
+ }
1017
+ // Max-age check. Cache entries whose freshest fetched_at is older
1018
+ // than 7 days are refused outright; intel that stale is more likely
1019
+ // to be misleading than helpful (KEV gains entries weekly; EPSS shifts
1020
+ // daily). --force-stale overrides for genuine air-gap workflows.
1021
+ try {
1022
+ const idxPath = path.join(abs, "_index.json");
1023
+ if (fs.existsSync(idxPath)) {
1024
+ const idx = JSON.parse(fs.readFileSync(idxPath, "utf8"));
1025
+ const entries = (idx && idx.entries) || {};
1026
+ let maxFetchedMs = 0;
1027
+ for (const k of Object.keys(entries)) {
1028
+ const t = entries[k] && entries[k].fetched_at ? new Date(entries[k].fetched_at).getTime() : NaN;
1029
+ if (Number.isFinite(t) && t > maxFetchedMs) maxFetchedMs = t;
1030
+ }
1031
+ if (maxFetchedMs > 0) {
1032
+ const ageMs = Date.now() - maxFetchedMs;
1033
+ const ageDays = ageMs / (24 * 3600 * 1000);
1034
+ if (ageDays > 7 && !opts.forceStale) {
1035
+ const err = new Error(
1036
+ `refresh: --from-cache freshest entry is ${ageDays.toFixed(1)} days old (>7d cutoff).\n` +
1037
+ `Hint: Re-run \`exceptd prefetch\` to refresh the cache, or pass --force-stale to consume it anyway.`
1038
+ );
1039
+ err._exceptd_hint = true;
1040
+ err._exceptd_exit_code = 4;
1041
+ err._exceptd_error_code = "cache-stale";
1042
+ err._exceptd_max_age_days = Number(ageDays.toFixed(2));
1043
+ err._exceptd_refresh_command = "exceptd prefetch";
1044
+ throw err;
1045
+ }
1046
+ }
1047
+ }
1048
+ } catch (e) {
1049
+ if (e && e._exceptd_hint) throw e;
1050
+ // Index parse error — bubble up as a hint
1051
+ const err = new Error(`refresh: --from-cache _index.json unreadable: ${e && e.message}`);
1052
+ err._exceptd_hint = true;
1053
+ err._exceptd_exit_code = 4;
1054
+ throw err;
1055
+ }
878
1056
  }
879
1057
  return ctx;
880
1058
  }
@@ -1288,7 +1466,11 @@ if (require.main === module) {
1288
1466
  // v0.12.12 C3: exitCode + return rather than process.exit(2) — the
1289
1467
  // event loop has no further work after main()'s rejection, so this
1290
1468
  // ends the process with code 2 but lets stderr drain first.
1291
- process.exitCode = 2;
1469
+ // Cache-integrity / cache-stale / from-fixture-disabled refusals carry
1470
+ // an explicit exit code (4) via _exceptd_exit_code; honor that so
1471
+ // downstream automation can distinguish "blocked by precondition"
1472
+ // (exit 4) from "fatal/unhandled" (exit 2).
1473
+ process.exitCode = (err && Number.isInteger(err._exceptd_exit_code)) ? err._exceptd_exit_code : 2;
1292
1474
  });
1293
1475
  }
1294
1476
 
@@ -426,21 +426,48 @@ async function main() {
426
426
  );
427
427
  }
428
428
  if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
429
+ // Route through the shared lib/verify loader so a BOM-prefixed pin
430
+ // file (Notepad with files.encoding=utf8bom) is tolerated identically
431
+ // across every verify site. An inline split-trim-find would retain
432
+ // the BOM as part of the first line, which would never match a live
433
+ // fingerprint and would block every legitimate refresh-network run.
434
+ //
435
+ // Narrowed try/catch: only swallow ENOENT / EACCES from the loader
436
+ // call (the pin file may have been deleted between existsSync and
437
+ // read, or be unreadable by the current uid — both are "warn and
438
+ // continue" cases). Any OTHER error (loader bug, unexpected throw)
439
+ // must NOT silently fall through to an unpinned refresh — the prior
440
+ // catch-all flow was a fail-open vector. The emit({ok:false,...}) +
441
+ // exitCode + return block is now OUTSIDE the catch so a parsing
442
+ // failure cannot be silently swallowed.
443
+ let expectedFp;
444
+ let loaderFailedSoftly = false;
429
445
  try {
430
- // Route through the shared lib/verify loader so a BOM-prefixed pin
431
- // file (Notepad with files.encoding=utf8bom) is tolerated identically
432
- // across every verify site. An inline split-trim-find would retain
433
- // the BOM as part of the first line, which would never match a live
434
- // fingerprint and would block every legitimate refresh-network run.
435
446
  const { loadExpectedFingerprintFirstLine } = require("./verify.js");
436
- const expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
447
+ expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
448
+ } catch (err) {
449
+ if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
450
+ // Pin file unreadable (race with deletion / permission denied) —
451
+ // warn-and-continue is the original behavior for this class.
452
+ loaderFailedSoftly = true;
453
+ } else {
454
+ emit({
455
+ ok: false,
456
+ error: `keys/EXPECTED_FINGERPRINT loader threw unexpectedly: ${err && err.message}`,
457
+ pin_path: expectedFingerprintPath,
458
+ hint: "The pin file exists but the loader hit a non-IO error. Refusing to swap on --network; investigate the loader failure before retrying.",
459
+ }, opts.json);
460
+ process.exitCode = 5; return;
461
+ }
462
+ }
463
+ if (!loaderFailedSoftly) {
437
464
  // Pin file present but the loader returned null. The loader refuses
438
465
  // UTF-16LE / UTF-16BE pin files and any other shape that cannot be
439
466
  // safely decoded. Treat this as a hard fail: the operator placed a
440
467
  // pin file but the bytes are not consumable, so we must not fall
441
468
  // through to an unpinned refresh. Re-save the pin as UTF-8 (with or
442
469
  // without BOM) and retry.
443
- if (expectedFp === null) {
470
+ if (expectedFp === null || expectedFp === undefined) {
444
471
  emit({
445
472
  ok: false,
446
473
  error: `keys/EXPECTED_FINGERPRINT exists but its bytes could not be parsed (likely UTF-16 BOM or non-UTF-8 encoding)`,
@@ -469,7 +496,7 @@ async function main() {
469
496
  }, opts.json);
470
497
  process.exitCode = 5; return;
471
498
  }
472
- } catch { /* unreadable pin file = warn-and-continue */ }
499
+ }
473
500
  }
474
501
 
475
502
  // Verify every signed entry in the tarball manifest using the local key.
@@ -13,6 +13,7 @@
13
13
  "cisa_kev",
14
14
  "poc_available",
15
15
  "ai_discovered",
16
+ "ai_assisted_weaponization",
16
17
  "active_exploitation",
17
18
  "affected",
18
19
  "affected_versions",
@@ -60,11 +61,31 @@
60
61
  "poc_available": { "type": "boolean" },
61
62
  "poc_description": { "type": "string" },
62
63
  "ai_discovered": {
63
- "description": "Whether the vulnerability was discovered with AI assistance.",
64
- "type": ["boolean", "string"]
64
+ "description": "Whether the vulnerability was discovered with AI assistance. Boolean-only — strings can mask tagging bugs in RWEP's truthy branching.",
65
+ "type": "boolean"
66
+ },
67
+ "ai_discovery_source": {
68
+ "description": "Provenance category for the AI-assisted discovery (or absence thereof). Paired with ai_discovered: a true value should be backed by one of vendor_research / bug_bounty_ai_augmented / academic_ai_fuzzing / threat_actor_ai_built; false values may use human_researcher or unknown.",
69
+ "type": "string",
70
+ "enum": [
71
+ "vendor_research",
72
+ "bug_bounty_ai_augmented",
73
+ "academic_ai_fuzzing",
74
+ "threat_actor_ai_built",
75
+ "human_researcher",
76
+ "unknown"
77
+ ]
78
+ },
79
+ "ai_discovery_date": {
80
+ "type": "string",
81
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
82
+ "description": "ISO date of the AI-discovery event (typically disclosure date if discovery date is not separately published)."
65
83
  },
66
84
  "ai_discovery_notes": { "type": "string" },
67
- "ai_assisted_weaponization": { "type": "boolean" },
85
+ "ai_assisted_weaponization": {
86
+ "type": "boolean",
87
+ "description": "Distinct from ai_discovered. ai_discovered=AI found the bug; ai_assisted_weaponization=AI was used in exploit development. The two fields are independent and BOTH are required so operators cannot conflate them."
88
+ },
68
89
  "ai_assisted_notes": { "type": "string" },
69
90
  "active_exploitation": {
70
91
  "type": "string",
@@ -88,7 +109,13 @@
88
109
  "live_patch_available": { "type": "boolean" },
89
110
  "live_patch_tools": {
90
111
  "type": "array",
91
- "items": { "type": "string" }
112
+ "items": { "type": "string" },
113
+ "description": "Tools/mechanisms that apply a runtime live-patch WITHOUT restart (e.g. kpatch, ksplice, kgraft). Distinct from vendor_update_paths — a non-empty list here is what feeds the RWEP live_patch_available factor in lib/scoring.js."
114
+ },
115
+ "vendor_update_paths": {
116
+ "type": "array",
117
+ "items": { "type": "string" },
118
+ "description": "Vendor-supplied update mechanisms that require service restart / reboot (e.g. apt upgrade, dnf update, package-manager pins). Separate from live_patch_tools so the RWEP live_patch_available factor only fires when a true zero-downtime live-patch path exists."
92
119
  },
93
120
  "live_patch_notes": { "type": "string" },
94
121
  "framework_control_gaps": {
@@ -6,6 +6,57 @@
6
6
  "type": "object",
7
7
  "required": ["_meta", "domain", "phases", "directives"],
8
8
  "additionalProperties": false,
9
+ "allOf": [
10
+ {
11
+ "if": {
12
+ "type": "object",
13
+ "properties": {
14
+ "_meta": {
15
+ "type": "object",
16
+ "properties": { "air_gap_mode": { "const": true } },
17
+ "required": ["air_gap_mode"]
18
+ }
19
+ },
20
+ "required": ["_meta"]
21
+ },
22
+ "then": {
23
+ "description": "When _meta.air_gap_mode is true, every artifact whose source contains a network-call substring (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry an air_gap_alternative. The runner refuses to use the network when --air-gap is set, so an artifact with no offline fallback cannot be collected and the run is incomplete.",
24
+ "type": "object",
25
+ "properties": {
26
+ "phases": {
27
+ "type": "object",
28
+ "properties": {
29
+ "look": {
30
+ "type": "object",
31
+ "properties": {
32
+ "artifacts": {
33
+ "type": "array",
34
+ "items": {
35
+ "anyOf": [
36
+ {
37
+ "not": {
38
+ "type": "object",
39
+ "properties": {
40
+ "source": {
41
+ "type": "string",
42
+ "pattern": "(https://|http://|gh api|gh release|curl |wget |fetch )"
43
+ }
44
+ },
45
+ "required": ["source"]
46
+ }
47
+ },
48
+ { "required": ["air_gap_alternative"] }
49
+ ]
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ],
9
60
  "properties": {
10
61
 
11
62
  "_meta": {
package/lib/scoring.js CHANGED
@@ -381,6 +381,46 @@ function validate(catalog) {
381
381
  return errors;
382
382
  }
383
383
 
384
+ /**
385
+ * Strict CVSS 3.1 vector parse. Returns `{ ok, version, reason? }`.
386
+ *
387
+ * The CSAF 2.0 cvss_v3 score block requires a canonical CVSS 3.1 vector
388
+ * string. Strict validators (BSI CSAF Validator, ENISA dashboard) reject
389
+ * documents that emit a cvss_v3 block keyed off a malformed vector — the
390
+ * pre-fix permissive `^CVSS:(\d+\.\d+)/` regex let through 3.0 vectors,
391
+ * truncated metric sets, and unknown environmental-metric values, which
392
+ * downstream tooling then rejected wholesale.
393
+ *
394
+ * Required metric set (in order): AV / AC / PR / UI / S / C / I / A.
395
+ * Optional temporal metrics: E / RL / RC.
396
+ * Optional environmental metrics: CR / IR / AR / MAV / MAC / MPR / MUI /
397
+ * MS / MC / MI / MA.
398
+ */
399
+ // CVSS 3.0 and 3.1 share an identical vector grammar (metric set, value enums,
400
+ // and metric order are the same; only the `CVSS:X.Y/` prefix differs). CSAF
401
+ // 2.0 §3.2.4.3 accepts both versions in the cvss_v3 block. The strict regex
402
+ // matches either prefix; the parser records which version the vector declared
403
+ // so the emitter can stamp the right `version` field.
404
+ const CVSS_3X_RE = /^CVSS:3\.[01]\/AV:[NALP]\/AC:[LH]\/PR:[NLH]\/UI:[NR]\/S:[UC]\/C:[NLH]\/I:[NLH]\/A:[NLH](\/E:[XUPFH])?(\/RL:[XOTWU])?(\/RC:[XURC])?(\/CR:[XLMH])?(\/IR:[XLMH])?(\/AR:[XLMH])?(\/MAV:[XNALP])?(\/MAC:[XLH])?(\/MPR:[XNLH])?(\/MUI:[XNR])?(\/MS:[XUC])?(\/MC:[XNLH])?(\/MI:[XNLH])?(\/MA:[XNLH])?$/;
405
+
406
+ function parseCvss31Vector(v) {
407
+ if (typeof v !== 'string' || v.length === 0) {
408
+ return { ok: false, version: null, reason: 'cvss_vector is not a non-empty string' };
409
+ }
410
+ const versionMatch = v.match(/^CVSS:(\d+\.\d+)\//);
411
+ if (!versionMatch) {
412
+ return { ok: false, version: null, reason: 'cvss_vector does not start with a CVSS:X.Y/ version prefix' };
413
+ }
414
+ const version = versionMatch[1];
415
+ if (version !== '3.0' && version !== '3.1') {
416
+ return { ok: false, version, reason: `cvss_vector declares version ${version}; CSAF 2.0 cvss_v3 accepts 3.0 and 3.1 only. Backfill a CVSS 3.x vector against this CVE in the catalog, or wait for CSAF 2.1 (cvss_v4 support).` };
417
+ }
418
+ if (!CVSS_3X_RE.test(v)) {
419
+ return { ok: false, version, reason: 'cvss_vector does not match the strict CVSS 3.x grammar (missing/invalid mandatory metric, unknown metric value, or out-of-order metric)' };
420
+ }
421
+ return { ok: true, version };
422
+ }
423
+
384
424
  module.exports = {
385
425
  score,
386
426
  scoreCustom,
@@ -389,6 +429,7 @@ module.exports = {
389
429
  validate,
390
430
  validateFactors,
391
431
  deriveRwepFromFactors,
432
+ parseCvss31Vector,
392
433
  RWEP_WEIGHTS,
393
434
  ACTIVE_EXPLOITATION_LADDER,
394
435
  RECOGNISED_FACTOR_KEYS,