@blamejs/exceptd-skills 0.12.22 → 0.12.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 (47) hide show
  1. package/AGENTS.md +18 -12
  2. package/ARCHITECTURE.md +2 -2
  3. package/CHANGELOG.md +152 -2
  4. package/CONTEXT.md +126 -69
  5. package/README.md +21 -8
  6. package/bin/exceptd.js +972 -464
  7. package/data/_indexes/_meta.json +3 -3
  8. package/data/_indexes/stale-content.json +10 -3
  9. package/data/playbooks/ai-api.json +1 -1
  10. package/data/playbooks/containers.json +1 -1
  11. package/data/playbooks/cred-stores.json +1 -1
  12. package/data/playbooks/crypto-codebase.json +1 -1
  13. package/data/playbooks/crypto.json +1 -1
  14. package/data/playbooks/framework.json +1 -1
  15. package/data/playbooks/hardening.json +1 -1
  16. package/data/playbooks/kernel.json +1 -1
  17. package/data/playbooks/library-author.json +1 -1
  18. package/data/playbooks/mcp.json +1 -1
  19. package/data/playbooks/runtime.json +1 -1
  20. package/data/playbooks/sbom.json +1 -1
  21. package/data/playbooks/secrets.json +39 -1
  22. package/lib/auto-discovery.js +28 -4
  23. package/lib/cross-ref-api.js +12 -11
  24. package/lib/cve-curation.js +18 -19
  25. package/lib/exit-codes.js +72 -0
  26. package/lib/flag-suggest.js +130 -0
  27. package/lib/id-validation.js +95 -0
  28. package/lib/lint-skills.js +73 -6
  29. package/lib/playbook-runner.js +617 -343
  30. package/lib/prefetch.js +134 -21
  31. package/lib/refresh-external.js +205 -26
  32. package/lib/refresh-network.js +64 -16
  33. package/lib/schemas/cve-catalog.schema.json +7 -1
  34. package/lib/schemas/playbook.schema.json +51 -0
  35. package/lib/scoring.js +49 -7
  36. package/lib/sign.js +10 -11
  37. package/lib/source-osv.js +7 -7
  38. package/lib/upstream-check-cli.js +16 -1
  39. package/lib/upstream-check.js +9 -0
  40. package/lib/validate-catalog-meta.js +1 -1
  41. package/lib/validate-cve-catalog.js +1 -1
  42. package/lib/verify.js +56 -30
  43. package/manifest.json +40 -40
  44. package/package.json +8 -2
  45. package/sbom.cdx.json +6 -6
  46. package/scripts/check-test-coverage.js +67 -0
  47. package/scripts/verify-shipped-tarball.js +27 -18
package/lib/prefetch.js CHANGED
@@ -18,11 +18,10 @@
18
18
  * rfc/<doc-name>.json — IETF Datatracker doc record
19
19
  * pins/<owner>__<repo>__releases.json — MITRE GitHub releases listing
20
20
  *
21
- * K: the registered source names in SOURCES below are `rfc` and
22
- * `pins`. Earlier comments + --help text said `ietf` and `github`; an
23
- * operator running `--source ietf` or `--source github` would hit "unknown
24
- * source" because no such key exists. The names below are the canonical
25
- * ones consumed by --source filtering.
21
+ * The registered source names in SOURCES below are `rfc` and `pins`.
22
+ * `--source ietf` or `--source github` would hit "unknown source"
23
+ * because no such key exists. The names below are the canonical ones
24
+ * consumed by --source filtering.
26
25
  *
27
26
  * Usage:
28
27
  * node lib/prefetch.js # fetch everything not fresh
@@ -237,7 +236,7 @@ async function withIndexLock(cacheDir, mutator) {
237
236
  // raised when the other process is mid-unlink). Treat both as
238
237
  // "lock held, back off" rather than a fatal error.
239
238
  if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
240
- // T P1-1: PID-liveness check. Same pattern as withCatalogLock in
239
+ // PID-liveness check. Same pattern as withCatalogLock in
241
240
  // lib/refresh-external.js — read the lockfile's PID, probe with
242
241
  // process.kill(pid, 0); ESRCH → holder dead, reclaim immediately;
243
242
  // EPERM → holder alive (different user), keep waiting. The mtime
@@ -303,6 +302,92 @@ async function saveIndex(cacheDir, idx) {
303
302
  });
304
303
  }
305
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
+
306
391
  function entryKey(source, id) {
307
392
  return `${source}/${id}`;
308
393
  }
@@ -322,11 +407,12 @@ function isFresh(idx, source, id, maxAgeMs) {
322
407
 
323
408
  function authHeadersForSource(source) {
324
409
  if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
325
- // J: the registered source name for MITRE GitHub releases is
326
- // `pins` (see SOURCES above). The prior check looked for `github`, so
327
- // GITHUB_TOKEN never reached the per-request Authorization header and
328
- // anonymous-rate-limited fetches were always used even when an operator
329
- // had supplied a token. Accept both spellings so this is forgiving of
410
+ // The registered source name for MITRE GitHub releases is `pins`
411
+ // (see SOURCES above). Accept both `pins` and `github` so GITHUB_TOKEN
412
+ // reaches the per-request Authorization header regardless of which
413
+ // spelling the operator's automation uses; without this, anonymous
414
+ // rate-limited fetches happen even when a token is configured. Be
415
+ // forgiving of
330
416
  // the historical naming and the registered name.
331
417
  if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
332
418
  return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
@@ -415,14 +501,14 @@ async function prefetch(options = {}) {
415
501
  const dir = path.dirname(targetPath);
416
502
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
417
503
  const body = JSON.stringify(res.json, null, 2) + "\n";
418
- // T P1-3: stage the payload to a same-volume tmp file BEFORE
419
- // attempting to acquire the index lock. If withIndexLock fails
420
- // (timeout after MAX_RETRIES), we want the partially-completed
421
- // download discarded — not left on disk as an orphan payload
422
- // with no index entry. Air-gap operators feed off `readCached`,
423
- // which consults the index; an unindexed payload silently becomes
424
- // junk taking cache space. Pattern: stage → lock → rename+index
425
- // release. The rename is atomic same-volume; if it fails inside
504
+ // Stage the payload to a same-volume tmp file BEFORE attempting
505
+ // to acquire the index lock. If withIndexLock fails (timeout
506
+ // after MAX_RETRIES), the partially-completed download must be
507
+ // discarded — not left on disk as an orphan payload with no
508
+ // index entry. Air-gap operators feed off `readCached`, which
509
+ // consults the index; an unindexed payload silently becomes junk
510
+ // taking cache space. Pattern: stage → lock → rename+index
511
+ // release. The rename is atomic same-volume; if it fails inside
426
512
  // the lock we clean up the tmp file. If we never reach the rename
427
513
  // (lock acquisition throws), the tmp file is unlinked in the
428
514
  // catch block below.
@@ -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
@@ -520,12 +619,19 @@ function readCached(cacheDir, source, id, opts = {}) {
520
619
  const idx = loadIndex(cacheDir);
521
620
  const meta = idx.entries[entryKey(source, id)];
522
621
  if (!meta) return null;
523
- // L: when `fetched_at` is missing / non-string / unparseable,
524
- // `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false
622
+ // When `fetched_at` is missing / non-string / unparseable,
623
+ // `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false,
525
624
  // so the cached entry would have been returned as if fresh. Treat any
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
@@ -94,13 +94,17 @@ 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.
97
+ // Honour `--air-gap` here so it reaches the GHSA/OSV source modules.
98
+ // EXCEPTD_AIR_GAP=1 still works as an env-var fallback so existing
99
+ // automation isn't broken.
102
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;
103
106
  }
107
+ if (process.env.EXCEPTD_FORCE_STALE === "1") out.forceStale = true;
104
108
  return out;
105
109
  }
106
110
 
@@ -656,16 +660,95 @@ const ALL_SOURCES = {
656
660
  // readCachedJson returns null on miss; callers report it as "unreachable"
657
661
  // for that entry rather than failing the whole source.
658
662
 
659
- 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);
660
675
  const safe = id.replace(/[^A-Za-z0-9._-]/g, "_");
661
676
  const p = path.join(cacheDir, source, `${safe}.json`);
662
677
  if (!fs.existsSync(p)) return null;
663
- try { return JSON.parse(fs.readFileSync(p, "utf8")); }
678
+ let parsed;
679
+ try { parsed = JSON.parse(fs.readFileSync(p, "utf8")); }
664
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;
665
748
  }
666
749
 
667
750
  function kevDiffFromCache(ctx) {
668
- const feed = readCachedJson(ctx.cacheDir, "kev", "known_exploited_vulnerabilities");
751
+ const feed = readCachedJson(ctx.cacheDir, "kev", "known_exploited_vulnerabilities", { forceStale: ctx.forceStale });
669
752
  if (!feed) {
670
753
  return { status: "unreachable", diffs: [], errors: 1, summary: "KEV: no cached feed" };
671
754
  }
@@ -698,7 +781,7 @@ function epssDiffFromCache(ctx) {
698
781
  let errors = 0;
699
782
  const drift = 0.05;
700
783
  for (const id of cves) {
701
- const payload = readCachedJson(ctx.cacheDir, "epss", id);
784
+ const payload = readCachedJson(ctx.cacheDir, "epss", id, { forceStale: ctx.forceStale });
702
785
  if (!payload) { errors++; continue; }
703
786
  const row = (payload.data || []).find((r) => r?.cve === id) || (payload.data || [])[0];
704
787
  if (!row) continue;
@@ -726,7 +809,7 @@ function nvdDiffFromCache(ctx) {
726
809
  const diffs = [];
727
810
  let errors = 0;
728
811
  for (const id of cves) {
729
- const payload = readCachedJson(ctx.cacheDir, "nvd", id);
812
+ const payload = readCachedJson(ctx.cacheDir, "nvd", id, { forceStale: ctx.forceStale });
730
813
  if (!payload) { errors++; continue; }
731
814
  const vuln = payload.vulnerabilities?.[0]?.cve;
732
815
  if (!vuln) continue;
@@ -761,7 +844,7 @@ function rfcDiffFromCache(ctx) {
761
844
  if (id.startsWith("RFC-")) docName = `rfc${id.slice(4)}`;
762
845
  else if (id.startsWith("DRAFT-")) docName = `draft-${id.slice(6).toLowerCase()}`;
763
846
  if (!docName) continue;
764
- const payload = readCachedJson(ctx.cacheDir, "rfc", docName);
847
+ const payload = readCachedJson(ctx.cacheDir, "rfc", docName, { forceStale: ctx.forceStale });
765
848
  if (!payload) { errors++; continue; }
766
849
  const obj = payload.objects?.[0];
767
850
  if (!obj) continue;
@@ -795,7 +878,7 @@ function pinsDiffFromCache(ctx) {
795
878
  const diffs = [];
796
879
  let errors = 0;
797
880
  for (const [pinName, file] of Object.entries(PIN_REPOS)) {
798
- const payload = readCachedJson(ctx.cacheDir, "pins", file);
881
+ const payload = readCachedJson(ctx.cacheDir, "pins", file, { forceStale: ctx.forceStale });
799
882
  if (!payload || !Array.isArray(payload)) { errors++; continue; }
800
883
  const stable = payload.find((r) => !r.draft && !r.prerelease);
801
884
  if (!stable) { errors++; continue; }
@@ -852,13 +935,30 @@ function loadCtx(opts) {
852
935
  d3fendCatalog: JSON.parse(fs.readFileSync(ABS("data/d3fend-catalog.json"), "utf8")),
853
936
  fixtures: null,
854
937
  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.
938
+ // Thread --air-gap (or EXCEPTD_AIR_GAP=1) through to ctx.airGap so the
939
+ // GHSA + OSV source modules (lib/source-ghsa.js, lib/source-osv.js)
940
+ // branch on it and refuse network egress.
859
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),
860
945
  };
861
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
+ }
862
962
  ctx.fixtures = { dir: path.resolve(opts.fromFixture), kev: true, epss: true, nvd: true, rfc: true, pins: true, ghsa: true, osv: true };
863
963
  } else if (opts.fromCache) {
864
964
  const abs = path.resolve(opts.fromCache);
@@ -878,6 +978,81 @@ function loadCtx(opts) {
878
978
  err._exceptd_hint = true;
879
979
  throw err;
880
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
+ }
881
1056
  }
882
1057
  return ctx;
883
1058
  }
@@ -949,15 +1124,15 @@ async function withCatalogLock(catalogPath, mutator) {
949
1124
  // Windows the same race surfaces as EPERM (sharing-violation raised
950
1125
  // when the holder is mid-unlink). Treat both as "lock held, back off."
951
1126
  if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
952
- // T P1-1: PID-liveness check before falling back to mtime. The
953
- // lockfile already contains String(process.pid) of the holder; parse
954
- // it and probe with `process.kill(pid, 0)`. ESRCH means the holder is
955
- // dead — reclaim immediately rather than waiting STALE_LOCK_MS for
956
- // the mtime gate to expire. EPERM (holder alive, different user) is
957
- // treated as "alive, keep waiting." The mtime gate remains as a
958
- // belt-and-suspenders for the case where the lockfile content is
959
- // missing / malformed / belongs to a recycled PID. Matches the PID
960
- // pattern in orchestrator/index.js _acquireWatchLock and
1127
+ // PID-liveness check before falling back to mtime. The lockfile
1128
+ // contains String(process.pid) of the holder; parse it and probe with
1129
+ // `process.kill(pid, 0)`. ESRCH means the holder is dead — reclaim
1130
+ // immediately rather than waiting STALE_LOCK_MS for the mtime gate
1131
+ // to expire. EPERM (holder alive, different user) is treated as
1132
+ // "alive, keep waiting." The mtime gate remains as a belt-and-
1133
+ // suspenders for cases where the lockfile content is missing /
1134
+ // malformed / belongs to a recycled PID. Matches the PID pattern in
1135
+ // orchestrator/index.js _acquireWatchLock and
961
1136
  // lib/playbook-runner.js pidAlive().
962
1137
  let reclaimedByPid = false;
963
1138
  try {
@@ -1291,7 +1466,11 @@ if (require.main === module) {
1291
1466
  // v0.12.12 C3: exitCode + return rather than process.exit(2) — the
1292
1467
  // event loop has no further work after main()'s rejection, so this
1293
1468
  // ends the process with code 2 but lets stderr drain first.
1294
- 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;
1295
1474
  });
1296
1475
  }
1297
1476
 
@@ -418,27 +418,75 @@ async function main() {
418
418
  // re-bootstrap. Missing EXPECTED_FINGERPRINT file → warn-and-continue
419
419
  // (don't break existing installs whose tree predates the pin file).
420
420
  const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
421
+ if (fs.existsSync(expectedFingerprintPath) && process.env.KEYS_ROTATED === "1") {
422
+ process.emitWarning(
423
+ `EXPECTED_FINGERPRINT pin check skipped via KEYS_ROTATED=1 during refresh-network. ` +
424
+ `Update keys/EXPECTED_FINGERPRINT to lock the new pin once rotation completes.`,
425
+ { code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
426
+ );
427
+ }
421
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;
422
445
  try {
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
446
  const { loadExpectedFingerprintFirstLine } = require("./verify.js");
429
- const expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
430
- // v0.12.16 (codex P1 PR #11): `expectedFp` is read verbatim from
431
- // keys/EXPECTED_FINGERPRINT (formatted as `SHA256:<base64>`), but
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) {
464
+ // Pin file present but the loader returned null. The loader refuses
465
+ // UTF-16LE / UTF-16BE pin files and any other shape that cannot be
466
+ // safely decoded. Treat this as a hard fail: the operator placed a
467
+ // pin file but the bytes are not consumable, so we must not fall
468
+ // through to an unpinned refresh. Re-save the pin as UTF-8 (with or
469
+ // without BOM) and retry.
470
+ if (expectedFp === null || expectedFp === undefined) {
471
+ emit({
472
+ ok: false,
473
+ error: `keys/EXPECTED_FINGERPRINT exists but its bytes could not be parsed (likely UTF-16 BOM or non-UTF-8 encoding)`,
474
+ pin_path: expectedFingerprintPath,
475
+ hint: "Re-save the pin file as UTF-8 (the loader accepts UTF-8-with-BOM). Refusing to swap on --network with an unparseable pin.",
476
+ }, opts.json);
477
+ process.exitCode = 5; return;
478
+ }
479
+ // The pin file is canonically formatted as `SHA256:<base64>`, but
432
480
  // `fingerprintPublicKey()` returns the raw base64 without the
433
481
  // `SHA256:` prefix. Comparing the two raw strings would refuse every
434
482
  // legitimate run unless KEYS_ROTATED=1 was set. Normalize by stripping
435
483
  // the prefix from the pin file before compare. lib/verify.js's
436
484
  // checkExpectedFingerprint() does the symmetric thing (adds the
437
485
  // prefix to localFp); either side works as long as one is canonical.
438
- const expectedFpBase64 = expectedFp && expectedFp.startsWith("SHA256:")
486
+ const expectedFpBase64 = expectedFp.startsWith("SHA256:")
439
487
  ? expectedFp.slice("SHA256:".length)
440
488
  : expectedFp;
441
- if (expectedFpBase64 && expectedFpBase64 !== localFp) {
489
+ if (expectedFpBase64 !== localFp) {
442
490
  emit({
443
491
  ok: false,
444
492
  error: `local keys/public.pem fingerprint diverges from keys/EXPECTED_FINGERPRINT pin`,
@@ -448,7 +496,7 @@ async function main() {
448
496
  }, opts.json);
449
497
  process.exitCode = 5; return;
450
498
  }
451
- } catch { /* unreadable pin file = warn-and-continue */ }
499
+ }
452
500
  }
453
501
 
454
502
  // Verify every signed entry in the tarball manifest using the local key.
@@ -687,13 +735,13 @@ if (require.main === module) {
687
735
  module.exports = {
688
736
  parseTar,
689
737
  fingerprintPublicKey,
690
- // A: exported for tests/normalize-contract.test.js so the
691
- // byte-stability contract can be asserted across all four normalize()
692
- // implementations (lib/sign.js, lib/verify.js, lib/refresh-network.js,
738
+ // Exported for tests/normalize-contract.test.js so the byte-stability
739
+ // contract can be asserted across all four normalize() implementations
740
+ // (lib/sign.js, lib/verify.js, lib/refresh-network.js,
693
741
  // scripts/verify-shipped-tarball.js).
694
742
  normalizeSkillBytes,
695
- // B + Q P1: exported for in-process tests of the refresh
696
- // path's manifest envelope check.
743
+ // Exported for in-process tests of the refresh path's manifest envelope
744
+ // check.
697
745
  verifyTarballManifestSignature,
698
746
  canonicalManifestBytesForRefresh,
699
747
  };
@@ -88,7 +88,13 @@
88
88
  "live_patch_available": { "type": "boolean" },
89
89
  "live_patch_tools": {
90
90
  "type": "array",
91
- "items": { "type": "string" }
91
+ "items": { "type": "string" },
92
+ "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."
93
+ },
94
+ "vendor_update_paths": {
95
+ "type": "array",
96
+ "items": { "type": "string" },
97
+ "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
98
  },
93
99
  "live_patch_notes": { "type": "string" },
94
100
  "framework_control_gaps": {