@blamejs/exceptd-skills 0.12.20 → 0.12.21
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 +98 -0
- package/bin/exceptd.js +504 -41
- 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 +20 -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 +395 -69
- package/lib/prefetch.js +3 -3
- package/lib/refresh-external.js +13 -2
- package/lib/refresh-network.js +13 -13
- package/lib/scoring.js +22 -13
- package/lib/sign.js +5 -5
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-indexes.js +2 -2
- package/lib/verify.js +28 -9
- 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 +9 -10
- 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 +6 -4
- 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;
|
|
@@ -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,7 +335,7 @@ 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);
|
|
@@ -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
|
|
@@ -451,7 +451,7 @@ async function main() {
|
|
|
451
451
|
try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
|
|
452
452
|
catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
|
|
453
453
|
|
|
454
|
-
//
|
|
454
|
+
// B + Q P1: verify the top-level manifest_signature against
|
|
455
455
|
// the LOCAL public key before honoring any entry in the tarball manifest.
|
|
456
456
|
// The previous flow iterated `manifest.skills[].signature` per-skill but
|
|
457
457
|
// never authenticated the manifest envelope itself — a coordinated
|
|
@@ -478,7 +478,7 @@ async function main() {
|
|
|
478
478
|
process.exitCode = 5; return;
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
-
// v0.12.14
|
|
481
|
+
// v0.12.14: the prior loop iterated `sk.id` + a fixed payload
|
|
482
482
|
// path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
|
|
483
483
|
// `path` (a forward-slash relative path like `skills/<name>/skill.md`,
|
|
484
484
|
// lowercase). Result: the loop matched zero entries; `failures.length === 0`
|
|
@@ -534,7 +534,7 @@ async function main() {
|
|
|
534
534
|
process.exitCode = 5; return;
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
-
// v0.12.14
|
|
537
|
+
// v0.12.14: the swap loop replaces `data/` + `manifest.json` +
|
|
538
538
|
// `manifest-snapshot.json` in addition to `skills/`. None of those files
|
|
539
539
|
// are covered by the per-skill Ed25519 signature (which signs only the
|
|
540
540
|
// skill body bytes). The only integrity check between the registry and
|
|
@@ -575,7 +575,7 @@ async function main() {
|
|
|
575
575
|
return;
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
-
// v0.12.14
|
|
578
|
+
// v0.12.14: the prior swap loop renamed targets one-by-one,
|
|
579
579
|
// and a mid-loop failure left the install half-applied with no automatic
|
|
580
580
|
// rollback. New shape: rename all old targets into a single backup dir
|
|
581
581
|
// first (so the install is empty-of-old before any new content is moved
|
|
@@ -599,7 +599,7 @@ async function main() {
|
|
|
599
599
|
written++;
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
-
// v0.12.14
|
|
602
|
+
// v0.12.14: use PID + random suffix in the backup dir name
|
|
603
603
|
// so concurrent refresh-network invocations don't collide on the
|
|
604
604
|
// millisecond clock.
|
|
605
605
|
const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
@@ -640,7 +640,7 @@ async function main() {
|
|
|
640
640
|
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
641
|
}, opts.json);
|
|
642
642
|
} catch (e) {
|
|
643
|
-
// v0.12.14
|
|
643
|
+
// v0.12.14: walk completedSteps in reverse to undo partial work.
|
|
644
644
|
const rollbackErrors = [];
|
|
645
645
|
for (const step of [...completedSteps].reverse()) {
|
|
646
646
|
try {
|
|
@@ -682,12 +682,12 @@ if (require.main === module) {
|
|
|
682
682
|
module.exports = {
|
|
683
683
|
parseTar,
|
|
684
684
|
fingerprintPublicKey,
|
|
685
|
-
//
|
|
685
|
+
// A: exported for tests/normalize-contract.test.js so the
|
|
686
686
|
// byte-stability contract can be asserted across all four normalize()
|
|
687
687
|
// implementations (lib/sign.js, lib/verify.js, lib/refresh-network.js,
|
|
688
688
|
// scripts/verify-shipped-tarball.js).
|
|
689
689
|
normalizeSkillBytes,
|
|
690
|
-
//
|
|
690
|
+
// B + Q P1: exported for in-process tests of the refresh
|
|
691
691
|
// path's manifest envelope check.
|
|
692
692
|
verifyTarballManifestSignature,
|
|
693
693
|
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.
|
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
|
+
// Audit AA P1-3: 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.
|
|
@@ -675,7 +694,7 @@ if (require.main === module) {
|
|
|
675
694
|
console.log(`[verify] ${fp.sha256}`);
|
|
676
695
|
console.log(`[verify] ${fp.sha3_512}`);
|
|
677
696
|
|
|
678
|
-
//
|
|
697
|
+
// pin check. When keys/EXPECTED_FINGERPRINT exists, the
|
|
679
698
|
// live fingerprint MUST match it (or KEYS_ROTATED=1 must be set to
|
|
680
699
|
// intentionally override). When the file is absent, emit a single-line
|
|
681
700
|
// warning but continue — fresh clones / bootstrap workflows should not
|