@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.
- package/AGENTS.md +18 -12
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +152 -2
- package/CONTEXT.md +126 -69
- package/README.md +21 -8
- package/bin/exceptd.js +972 -464
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/stale-content.json +10 -3
- package/data/playbooks/ai-api.json +1 -1
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/cred-stores.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/framework.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/library-author.json +1 -1
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +1 -1
- package/data/playbooks/sbom.json +1 -1
- package/data/playbooks/secrets.json +39 -1
- package/lib/auto-discovery.js +28 -4
- package/lib/cross-ref-api.js +12 -11
- package/lib/cve-curation.js +18 -19
- package/lib/exit-codes.js +72 -0
- package/lib/flag-suggest.js +130 -0
- package/lib/id-validation.js +95 -0
- package/lib/lint-skills.js +73 -6
- package/lib/playbook-runner.js +617 -343
- package/lib/prefetch.js +134 -21
- package/lib/refresh-external.js +205 -26
- package/lib/refresh-network.js +64 -16
- package/lib/schemas/cve-catalog.schema.json +7 -1
- package/lib/schemas/playbook.schema.json +51 -0
- package/lib/scoring.js +49 -7
- package/lib/sign.js +10 -11
- package/lib/source-osv.js +7 -7
- package/lib/upstream-check-cli.js +16 -1
- package/lib/upstream-check.js +9 -0
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +1 -1
- package/lib/verify.js +56 -30
- package/manifest.json +40 -40
- package/package.json +8 -2
- package/sbom.cdx.json +6 -6
- package/scripts/check-test-coverage.js +67 -0
- 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
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
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
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
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
|
-
//
|
|
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
|
package/lib/refresh-external.js
CHANGED
|
@@ -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
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
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
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
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
|
-
|
|
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
|
|
package/lib/refresh-network.js
CHANGED
|
@@ -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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
486
|
+
const expectedFpBase64 = expectedFp.startsWith("SHA256:")
|
|
439
487
|
? expectedFp.slice("SHA256:".length)
|
|
440
488
|
: expectedFp;
|
|
441
|
-
if (expectedFpBase64
|
|
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
|
-
}
|
|
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
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
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
|
-
//
|
|
696
|
-
//
|
|
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": {
|