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