@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.
- package/AGENTS.md +12 -4
- package/CHANGELOG.md +190 -3
- package/README.md +14 -1
- package/bin/exceptd.js +584 -166
- package/data/_indexes/_meta.json +31 -31
- package/data/_indexes/activity-feed.json +45 -45
- package/data/_indexes/catalog-summaries.json +19 -19
- package/data/_indexes/chains.json +320 -0
- package/data/_indexes/currency.json +9 -9
- package/data/_indexes/frequency.json +39 -2
- package/data/_indexes/jurisdiction-clocks.json +2 -2
- package/data/_indexes/jurisdiction-map.json +3 -1
- package/data/_indexes/section-offsets.json +396 -396
- package/data/_indexes/summary-cards.json +3 -3
- package/data/_indexes/token-budget.json +73 -73
- package/data/atlas-ttps.json +491 -19
- package/data/attack-techniques.json +198 -84
- package/data/cve-catalog.json +1309 -9
- package/data/exploit-availability.json +300 -10
- package/data/framework-control-gaps.json +395 -1
- package/data/global-frameworks.json +44 -19
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/framework.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/library-author.json +1 -1
- package/data/playbooks/secrets.json +25 -1
- package/data/rfc-references.json +93 -1
- package/data/zeroday-lessons.json +475 -13
- package/lib/auto-discovery.js +26 -2
- 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 +68 -1
- package/lib/playbook-runner.js +321 -46
- package/lib/prefetch.js +113 -0
- package/lib/refresh-external.js +190 -8
- package/lib/refresh-network.js +35 -8
- package/lib/schemas/cve-catalog.schema.json +31 -4
- package/lib/schemas/playbook.schema.json +51 -0
- package/lib/scoring.js +41 -0
- package/lib/upstream-check-cli.js +16 -1
- package/lib/upstream-check.js +9 -0
- package/lib/verify.js +20 -4
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +59 -59
- 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 +9 -0
- package/skills/ai-attack-surface/skill.md +11 -2
- package/skills/ai-c2-detection/skill.md +3 -1
- package/skills/ai-risk-management/skill.md +3 -1
- package/skills/api-security/skill.md +4 -0
- package/skills/attack-surface-pentest/skill.md +1 -0
- package/skills/container-runtime-security/skill.md +3 -1
- package/skills/dlp-gap-analysis/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +1 -1
- package/skills/kernel-lpe-triage/skill.md +6 -1
- package/skills/mcp-agent-trust/skill.md +7 -2
- package/skills/mlops-security/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +4 -2
- package/skills/sector-financial/skill.md +1 -1
- package/skills/skill-update-loop/skill.md +1 -1
- package/skills/supply-chain-integrity/skill.md +3 -1
- package/skills/threat-model-currency/skill.md +1 -1
- package/skills/webapp-security/skill.md +2 -0
- 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
|
package/lib/refresh-external.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/refresh-network.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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":
|
|
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": {
|
|
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,
|