@blamejs/exceptd-skills 0.12.18 → 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 +224 -52
- package/README.md +1 -1
- package/bin/exceptd.js +841 -68
- 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 +20 -1
- 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 +68 -15
- package/lib/cross-ref-api.js +43 -10
- package/lib/cve-curation.js +4 -4
- package/lib/playbook-runner.js +545 -63
- package/lib/prefetch.js +65 -18
- package/lib/refresh-external.js +40 -2
- package/lib/refresh-network.js +100 -12
- package/lib/scoring.js +22 -13
- package/lib/sign.js +14 -6
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-indexes.js +2 -2
- package/lib/verify.js +51 -10
- package/manifest.json +47 -48
- package/orchestrator/scheduler.js +10 -0
- 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 +10 -5
- package/scripts/refresh-manifest-snapshot.js +2 -2
- package/scripts/validate-vendor-online.js +1 -1
- package/scripts/verify-shipped-tarball.js +94 -6
- 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
|
|
@@ -237,6 +237,26 @@ async function withIndexLock(cacheDir, mutator) {
|
|
|
237
237
|
// raised when the other process is mid-unlink). Treat both as
|
|
238
238
|
// "lock held, back off" rather than a fatal error.
|
|
239
239
|
if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
|
|
240
|
+
// T P1-1: PID-liveness check. Same pattern as withCatalogLock in
|
|
241
|
+
// lib/refresh-external.js — read the lockfile's PID, probe with
|
|
242
|
+
// process.kill(pid, 0); ESRCH → holder dead, reclaim immediately;
|
|
243
|
+
// EPERM → holder alive (different user), keep waiting. The mtime
|
|
244
|
+
// fallback below covers malformed / unreadable lockfiles.
|
|
245
|
+
let reclaimedByPid = false;
|
|
246
|
+
try {
|
|
247
|
+
const raw = fs.readFileSync(lockPath, "utf8").trim();
|
|
248
|
+
const pid = Number.parseInt(raw, 10);
|
|
249
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
|
|
250
|
+
try {
|
|
251
|
+
process.kill(pid, 0);
|
|
252
|
+
} catch (probeErr) {
|
|
253
|
+
if (probeErr && probeErr.code === "ESRCH") {
|
|
254
|
+
try { fs.unlinkSync(lockPath); reclaimedByPid = true; } catch {}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch {}
|
|
259
|
+
if (reclaimedByPid) continue;
|
|
240
260
|
try {
|
|
241
261
|
const stat = fs.statSync(lockPath);
|
|
242
262
|
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
|
|
@@ -302,7 +322,7 @@ function isFresh(idx, source, id, maxAgeMs) {
|
|
|
302
322
|
|
|
303
323
|
function authHeadersForSource(source) {
|
|
304
324
|
if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
|
|
305
|
-
//
|
|
325
|
+
// J: the registered source name for MITRE GitHub releases is
|
|
306
326
|
// `pins` (see SOURCES above). The prior check looked for `github`, so
|
|
307
327
|
// GITHUB_TOKEN never reached the per-request Authorization header and
|
|
308
328
|
// anonymous-rate-limited fetches were always used even when an operator
|
|
@@ -394,11 +414,20 @@ async function prefetch(options = {}) {
|
|
|
394
414
|
const targetPath = entryPath(opts.cacheDir, item.source, item.id);
|
|
395
415
|
const dir = path.dirname(targetPath);
|
|
396
416
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
417
|
+
const body = JSON.stringify(res.json, null, 2) + "\n";
|
|
418
|
+
// T P1-3: stage the payload to a same-volume tmp file BEFORE
|
|
419
|
+
// attempting to acquire the index lock. If withIndexLock fails
|
|
420
|
+
// (timeout after MAX_RETRIES), we want the partially-completed
|
|
421
|
+
// download discarded — not left on disk as an orphan payload
|
|
422
|
+
// with no index entry. Air-gap operators feed off `readCached`,
|
|
423
|
+
// which consults the index; an unindexed payload silently becomes
|
|
424
|
+
// junk taking cache space. Pattern: stage → lock → rename+index
|
|
425
|
+
// → release. The rename is atomic same-volume; if it fails inside
|
|
426
|
+
// the lock we clean up the tmp file. If we never reach the rename
|
|
427
|
+
// (lock acquisition throws), the tmp file is unlinked in the
|
|
428
|
+
// catch block below.
|
|
429
|
+
const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
430
|
+
fs.writeFileSync(tmpPath, body);
|
|
402
431
|
const meta = {
|
|
403
432
|
fetched_at: new Date().toISOString(),
|
|
404
433
|
etag: res.etag,
|
|
@@ -406,16 +435,34 @@ async function prefetch(options = {}) {
|
|
|
406
435
|
url: item.url,
|
|
407
436
|
sha256: crypto.createHash("sha256").update(JSON.stringify(res.json)).digest("hex"),
|
|
408
437
|
};
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
438
|
+
try {
|
|
439
|
+
// v0.12.12 C2: persist this entry's metadata to _index.json under
|
|
440
|
+
// lock immediately, merging with whatever the on-disk index has
|
|
441
|
+
// (another concurrent prefetch may have written sibling entries).
|
|
442
|
+
// Inside the lock we also rename the staged tmp → final path so
|
|
443
|
+
// a concurrent reader sees the new payload + new index entry as
|
|
444
|
+
// an atomic pair.
|
|
445
|
+
await withIndexLock(opts.cacheDir, (current) => {
|
|
446
|
+
try {
|
|
447
|
+
fs.renameSync(tmpPath, targetPath);
|
|
448
|
+
} catch (renameErr) {
|
|
449
|
+
// Surface as a failure to mutator: throwing here aborts the
|
|
450
|
+
// lock's write step. We re-throw to the outer catch which
|
|
451
|
+
// will increment errors.
|
|
452
|
+
throw renameErr;
|
|
453
|
+
}
|
|
454
|
+
current.entries[entryKey(item.source, item.id)] = meta;
|
|
455
|
+
return current;
|
|
456
|
+
});
|
|
457
|
+
// Mirror the entry into the in-memory idx for callers that read
|
|
458
|
+
// it later in this run (e.g. the final saveIndex merge).
|
|
459
|
+
idx.entries[entryKey(item.source, item.id)] = meta;
|
|
460
|
+
} catch (lockErr) {
|
|
461
|
+
// Lock failure OR rename-inside-lock failure — unlink the staged
|
|
462
|
+
// tmp so the cache directory does not accumulate orphans.
|
|
463
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
464
|
+
throw lockErr;
|
|
465
|
+
}
|
|
419
466
|
result.fetched++;
|
|
420
467
|
result.by_source[item.source].fetched++;
|
|
421
468
|
log(` [${item.source}] ${item.id} — ok`);
|
|
@@ -473,7 +520,7 @@ function readCached(cacheDir, source, id, opts = {}) {
|
|
|
473
520
|
const idx = loadIndex(cacheDir);
|
|
474
521
|
const meta = idx.entries[entryKey(source, id)];
|
|
475
522
|
if (!meta) return null;
|
|
476
|
-
//
|
|
523
|
+
// L: when `fetched_at` is missing / non-string / unparseable,
|
|
477
524
|
// `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false —
|
|
478
525
|
// so the cached entry would have been returned as if fresh. Treat any
|
|
479
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 };
|
|
@@ -938,6 +949,33 @@ async function withCatalogLock(catalogPath, mutator) {
|
|
|
938
949
|
// Windows the same race surfaces as EPERM (sharing-violation raised
|
|
939
950
|
// when the holder is mid-unlink). Treat both as "lock held, back off."
|
|
940
951
|
if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
|
|
952
|
+
// T P1-1: PID-liveness check before falling back to mtime. The
|
|
953
|
+
// lockfile already contains String(process.pid) of the holder; parse
|
|
954
|
+
// it and probe with `process.kill(pid, 0)`. ESRCH means the holder is
|
|
955
|
+
// dead — reclaim immediately rather than waiting STALE_LOCK_MS for
|
|
956
|
+
// the mtime gate to expire. EPERM (holder alive, different user) is
|
|
957
|
+
// treated as "alive, keep waiting." The mtime gate remains as a
|
|
958
|
+
// belt-and-suspenders for the case where the lockfile content is
|
|
959
|
+
// missing / malformed / belongs to a recycled PID. Matches the PID
|
|
960
|
+
// pattern in orchestrator/index.js _acquireWatchLock and
|
|
961
|
+
// lib/playbook-runner.js pidAlive().
|
|
962
|
+
let reclaimedByPid = false;
|
|
963
|
+
try {
|
|
964
|
+
const raw = fs.readFileSync(lockPath, "utf8").trim();
|
|
965
|
+
const pid = Number.parseInt(raw, 10);
|
|
966
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
|
|
967
|
+
try {
|
|
968
|
+
process.kill(pid, 0);
|
|
969
|
+
// holder alive
|
|
970
|
+
} catch (probeErr) {
|
|
971
|
+
if (probeErr && probeErr.code === "ESRCH") {
|
|
972
|
+
try { fs.unlinkSync(lockPath); reclaimedByPid = true; } catch {}
|
|
973
|
+
}
|
|
974
|
+
// EPERM and anything else: treat as alive, fall through to mtime/sleep.
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
} catch {} // unreadable lockfile — proceed to mtime fallback
|
|
978
|
+
if (reclaimedByPid) continue;
|
|
941
979
|
// Stale-lock check before sleeping — a long-dead holder shouldn't keep
|
|
942
980
|
// us waiting MAX_RETRIES * backoff before we recover.
|
|
943
981
|
try {
|
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;
|
|
@@ -194,14 +194,63 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
|
|
|
194
194
|
// v0.12.14 (audit F1, F7): 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
|
-
// lib/sign.js — the
|
|
198
|
-
// contract
|
|
197
|
+
// lib/sign.js + scripts/verify-shipped-tarball.js — the four normalize()
|
|
198
|
+
// implementations form a byte-stability contract enforced by
|
|
199
|
+
// tests/normalize-contract.test.js.
|
|
199
200
|
function normalizeSkillBytes(buf) {
|
|
200
201
|
let s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
|
|
201
202
|
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
202
203
|
return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
// B + Q P1: in-line manifest-signature verifier. Kept here
|
|
207
|
+
// rather than imported from lib/verify.js so refresh-network.js retains
|
|
208
|
+
// its no-cross-module-dep posture (mirrors the per-skill verify path).
|
|
209
|
+
// ANY change to canonical-bytes computation here MUST stay in lockstep
|
|
210
|
+
// with lib/sign.js canonicalManifestBytes() / lib/verify.js
|
|
211
|
+
// canonicalManifestBytes() — tests/normalize-contract.test.js enforces.
|
|
212
|
+
function canonicalizeForRefresh(value) {
|
|
213
|
+
if (Array.isArray(value)) return value.map(canonicalizeForRefresh);
|
|
214
|
+
if (value && typeof value === "object") {
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const key of Object.keys(value).sort()) {
|
|
217
|
+
out[key] = canonicalizeForRefresh(value[key]);
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
function canonicalManifestBytesForRefresh(manifest) {
|
|
224
|
+
const clone = Object.assign({}, manifest);
|
|
225
|
+
delete clone.manifest_signature;
|
|
226
|
+
const json = JSON.stringify(canonicalizeForRefresh(clone), null, 2);
|
|
227
|
+
return normalizeSkillBytes(Buffer.from(json, "utf8"));
|
|
228
|
+
}
|
|
229
|
+
function verifyTarballManifestSignature(manifest, publicKeyPem) {
|
|
230
|
+
const sig = manifest && manifest.manifest_signature;
|
|
231
|
+
if (!sig || typeof sig !== "object") return { status: "missing" };
|
|
232
|
+
if (typeof sig.signature_base64 !== "string") {
|
|
233
|
+
return { status: "invalid", reason: "manifest_signature.signature_base64 missing or not a string" };
|
|
234
|
+
}
|
|
235
|
+
if (sig.algorithm !== "Ed25519") {
|
|
236
|
+
return { status: "invalid", reason: `manifest_signature.algorithm must be 'Ed25519' (got ${JSON.stringify(sig.algorithm)})` };
|
|
237
|
+
}
|
|
238
|
+
let signatureBytes;
|
|
239
|
+
try { signatureBytes = Buffer.from(sig.signature_base64, "base64"); }
|
|
240
|
+
catch (e) { return { status: "invalid", reason: `malformed base64: ${e.message}` }; }
|
|
241
|
+
const bytes = canonicalManifestBytesForRefresh(manifest);
|
|
242
|
+
let ok = false;
|
|
243
|
+
try {
|
|
244
|
+
ok = crypto.verify(null, bytes, {
|
|
245
|
+
key: publicKeyPem,
|
|
246
|
+
dsaEncoding: "ieee-p1363",
|
|
247
|
+
}, signatureBytes);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return { status: "invalid", reason: `crypto.verify threw: ${e.message}` };
|
|
250
|
+
}
|
|
251
|
+
return ok ? { status: "valid" } : { status: "invalid", reason: "Ed25519 manifest signature did not verify against local public.pem" };
|
|
252
|
+
}
|
|
253
|
+
|
|
205
254
|
// Manifest path validation. Mirrors lib/verify.js validateSkillPath().
|
|
206
255
|
function validateManifestSkillPath(skillPath) {
|
|
207
256
|
if (typeof skillPath !== "string") throw new Error(`manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
@@ -211,7 +260,7 @@ function validateManifestSkillPath(skillPath) {
|
|
|
211
260
|
return skillPath;
|
|
212
261
|
}
|
|
213
262
|
|
|
214
|
-
// v0.12.14
|
|
263
|
+
// v0.12.14: tarball download size cap. A hostile registry CDN
|
|
215
264
|
// could stream gigabytes; Node buffers chunks in RAM until OOM. Current
|
|
216
265
|
// tarball is ~2 MB; 200 MB is generous defense-in-depth. Tunable via
|
|
217
266
|
// EXCEPTD_TARBALL_SIZE_CAP_BYTES for future growth.
|
|
@@ -286,7 +335,7 @@ async function main() {
|
|
|
286
335
|
process.exitCode = 2; return;
|
|
287
336
|
}
|
|
288
337
|
|
|
289
|
-
// v0.12.14
|
|
338
|
+
// v0.12.14: defense-in-depth tarball size cap.
|
|
290
339
|
const sizeCap = tarballSizeCap();
|
|
291
340
|
if (tgzBuf.length > sizeCap) {
|
|
292
341
|
emit({ ok: false, error: `tarball exceeds size cap: ${tgzBuf.length} bytes > ${sizeCap} (EXCEPTD_TARBALL_SIZE_CAP_BYTES)` }, opts.json);
|
|
@@ -357,7 +406,7 @@ async function main() {
|
|
|
357
406
|
process.exitCode = 5; return;
|
|
358
407
|
}
|
|
359
408
|
|
|
360
|
-
// v0.12.16
|
|
409
|
+
// v0.12.16: cross-check the local public key against
|
|
361
410
|
// keys/EXPECTED_FINGERPRINT (the CI-pinned signing key). The prior
|
|
362
411
|
// refresh-network code only compared LOCAL ↔ TARBALL fingerprints, so a
|
|
363
412
|
// coordinated attacker who swapped both `keys/public.pem` on the operator's
|
|
@@ -402,7 +451,34 @@ async function main() {
|
|
|
402
451
|
try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
|
|
403
452
|
catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
|
|
404
453
|
|
|
405
|
-
//
|
|
454
|
+
// B + Q P1: verify the top-level manifest_signature against
|
|
455
|
+
// the LOCAL public key before honoring any entry in the tarball manifest.
|
|
456
|
+
// The previous flow iterated `manifest.skills[].signature` per-skill but
|
|
457
|
+
// never authenticated the manifest envelope itself — a coordinated
|
|
458
|
+
// attacker who flipped paths/names/atlas_refs on entries already covered
|
|
459
|
+
// by per-skill signatures (which sign only the skill body bytes, not the
|
|
460
|
+
// metadata around them) could re-shape catalog routing without breaking
|
|
461
|
+
// any per-skill signature. The manifest signature closes that gap.
|
|
462
|
+
//
|
|
463
|
+
// Unlike post-install verify (which warns-and-continues on missing
|
|
464
|
+
// signature for legacy-tarball compat), refresh-network REQUIRES the
|
|
465
|
+
// signature: this code path is publishing fresh content into the local
|
|
466
|
+
// tree, and the tarball must already be ≥ v0.12.17 to have reached the
|
|
467
|
+
// registry through the sign-all gate.
|
|
468
|
+
const manifestSigResult = verifyTarballManifestSignature(tarballManifest, localPubKeyText);
|
|
469
|
+
if (manifestSigResult.status !== "valid") {
|
|
470
|
+
emit({
|
|
471
|
+
ok: false,
|
|
472
|
+
error: `tarball manifest_signature ${manifestSigResult.status} — refusing to swap`,
|
|
473
|
+
reason: manifestSigResult.reason || null,
|
|
474
|
+
hint: manifestSigResult.status === "missing"
|
|
475
|
+
? "Tarball predates v0.12.17 manifest signing. Run `npm update -g @blamejs/exceptd-skills` instead so the full provenance-verified install path runs."
|
|
476
|
+
: "Tarball manifest envelope failed Ed25519 verification against the LOCAL public key. Run `npm update -g @blamejs/exceptd-skills` for the full provenance-verified path, or report this tarball at https://github.com/blamejs/exceptd-skills/issues.",
|
|
477
|
+
}, opts.json);
|
|
478
|
+
process.exitCode = 5; return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// v0.12.14: the prior loop iterated `sk.id` + a fixed payload
|
|
406
482
|
// path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
|
|
407
483
|
// `path` (a forward-slash relative path like `skills/<name>/skill.md`,
|
|
408
484
|
// lowercase). Result: the loop matched zero entries; `failures.length === 0`
|
|
@@ -458,7 +534,7 @@ async function main() {
|
|
|
458
534
|
process.exitCode = 5; return;
|
|
459
535
|
}
|
|
460
536
|
|
|
461
|
-
// v0.12.14
|
|
537
|
+
// v0.12.14: the swap loop replaces `data/` + `manifest.json` +
|
|
462
538
|
// `manifest-snapshot.json` in addition to `skills/`. None of those files
|
|
463
539
|
// are covered by the per-skill Ed25519 signature (which signs only the
|
|
464
540
|
// skill body bytes). The only integrity check between the registry and
|
|
@@ -499,7 +575,7 @@ async function main() {
|
|
|
499
575
|
return;
|
|
500
576
|
}
|
|
501
577
|
|
|
502
|
-
// v0.12.14
|
|
578
|
+
// v0.12.14: the prior swap loop renamed targets one-by-one,
|
|
503
579
|
// and a mid-loop failure left the install half-applied with no automatic
|
|
504
580
|
// rollback. New shape: rename all old targets into a single backup dir
|
|
505
581
|
// first (so the install is empty-of-old before any new content is moved
|
|
@@ -523,7 +599,7 @@ async function main() {
|
|
|
523
599
|
written++;
|
|
524
600
|
}
|
|
525
601
|
|
|
526
|
-
// v0.12.14
|
|
602
|
+
// v0.12.14: use PID + random suffix in the backup dir name
|
|
527
603
|
// so concurrent refresh-network invocations don't collide on the
|
|
528
604
|
// millisecond clock.
|
|
529
605
|
const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
@@ -564,7 +640,7 @@ async function main() {
|
|
|
564
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.`,
|
|
565
641
|
}, opts.json);
|
|
566
642
|
} catch (e) {
|
|
567
|
-
// v0.12.14
|
|
643
|
+
// v0.12.14: walk completedSteps in reverse to undo partial work.
|
|
568
644
|
const rollbackErrors = [];
|
|
569
645
|
for (const step of [...completedSteps].reverse()) {
|
|
570
646
|
try {
|
|
@@ -603,4 +679,16 @@ if (require.main === module) {
|
|
|
603
679
|
});
|
|
604
680
|
}
|
|
605
681
|
|
|
606
|
-
module.exports = {
|
|
682
|
+
module.exports = {
|
|
683
|
+
parseTar,
|
|
684
|
+
fingerprintPublicKey,
|
|
685
|
+
// A: exported for tests/normalize-contract.test.js so the
|
|
686
|
+
// byte-stability contract can be asserted across all four normalize()
|
|
687
|
+
// implementations (lib/sign.js, lib/verify.js, lib/refresh-network.js,
|
|
688
|
+
// scripts/verify-shipped-tarball.js).
|
|
689
|
+
normalizeSkillBytes,
|
|
690
|
+
// B + Q P1: exported for in-process tests of the refresh
|
|
691
|
+
// path's manifest envelope check.
|
|
692
|
+
verifyTarballManifestSignature,
|
|
693
|
+
canonicalManifestBytesForRefresh,
|
|
694
|
+
};
|
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,9 +343,18 @@ function canonicalManifestBytes(manifest) {
|
|
|
343
343
|
* Returns the manifest_signature object literal to splice into the
|
|
344
344
|
* manifest top level.
|
|
345
345
|
*
|
|
346
|
+
* A: the previous shape included a `signed_at` ISO timestamp.
|
|
347
|
+
* That field was stripped from the canonical bytes before signing (via
|
|
348
|
+
* `delete clone.manifest_signature`), so it was NOT covered by the
|
|
349
|
+
* signature — an attacker who replayed a known-valid signature could
|
|
350
|
+
* rewrite `signed_at` to any value, lending false freshness authority to
|
|
351
|
+
* a stale signature. The field is now omitted entirely. Freshness signal
|
|
352
|
+
* lives outside the signed bytes (git-log mtime of manifest.json, npm
|
|
353
|
+
* publish timestamp).
|
|
354
|
+
*
|
|
346
355
|
* @param {object} manifest
|
|
347
356
|
* @param {string} privateKey PEM-encoded Ed25519 private key
|
|
348
|
-
* @returns {{algorithm:'Ed25519', signature_base64:string
|
|
357
|
+
* @returns {{algorithm:'Ed25519', signature_base64:string}}
|
|
349
358
|
*/
|
|
350
359
|
function signCanonicalManifest(manifest, privateKey) {
|
|
351
360
|
const bytes = canonicalManifestBytes(manifest);
|
|
@@ -356,12 +365,11 @@ function signCanonicalManifest(manifest, privateKey) {
|
|
|
356
365
|
return {
|
|
357
366
|
algorithm: 'Ed25519',
|
|
358
367
|
signature_base64: sig.toString('base64'),
|
|
359
|
-
signed_at: new Date().toISOString(),
|
|
360
368
|
};
|
|
361
369
|
}
|
|
362
370
|
|
|
363
371
|
/**
|
|
364
|
-
*
|
|
372
|
+
* tighten Windows ACL on the private key.
|
|
365
373
|
*
|
|
366
374
|
* fs.writeFileSync({mode: 0o600}) on win32 only affects read-only
|
|
367
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.
|