@blamejs/exceptd-skills 0.12.13 → 0.12.16
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 +217 -0
- package/bin/exceptd.js +522 -27
- package/data/_indexes/_meta.json +45 -45
- package/data/_indexes/activity-feed.json +4 -4
- package/data/_indexes/catalog-summaries.json +29 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +319 -76
- package/data/cve-catalog.json +516 -476
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/playbooks/ai-api.json +3 -1
- package/data/playbooks/containers.json +11 -3
- package/data/playbooks/cred-stores.json +3 -1
- package/data/playbooks/crypto-codebase.json +11 -11
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/hardening.json +3 -1
- package/data/playbooks/kernel.json +3 -1
- package/data/playbooks/library-author.json +21 -10
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +3 -1
- package/data/playbooks/sbom.json +2 -2
- package/data/playbooks/secrets.json +3 -1
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +57 -35
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +33 -14
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +742 -78
- package/lib/prefetch.js +30 -8
- package/lib/refresh-external.js +40 -22
- package/lib/refresh-network.js +233 -17
- package/lib/scoring.js +191 -18
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +56 -18
- package/lib/validate-indexes.js +88 -37
- package/lib/validate-playbooks.js +46 -0
- package/lib/verify.js +72 -0
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +150 -17
- package/package.json +3 -1
- package/sbom.cdx.json +7 -7
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +83 -39
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +141 -9
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
package/lib/prefetch.js
CHANGED
|
@@ -15,8 +15,14 @@
|
|
|
15
15
|
* kev/known_exploited_vulnerabilities.json — full KEV feed
|
|
16
16
|
* nvd/<cve-id>.json — NVD 2.0 per-CVE response
|
|
17
17
|
* epss/<cve-id>.json — EPSS per-CVE response
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* rfc/<doc-name>.json — IETF Datatracker doc record
|
|
19
|
+
* pins/<owner>__<repo>__releases.json — MITRE GitHub releases listing
|
|
20
|
+
*
|
|
21
|
+
* audit M P2-K: the registered source names in SOURCES below are `rfc` and
|
|
22
|
+
* `pins`. Earlier comments + --help text said `ietf` and `github`; an
|
|
23
|
+
* operator running `--source ietf` or `--source github` would hit "unknown
|
|
24
|
+
* source" because no such key exists. The names below are the canonical
|
|
25
|
+
* ones consumed by --source filtering.
|
|
20
26
|
*
|
|
21
27
|
* Usage:
|
|
22
28
|
* node lib/prefetch.js # fetch everything not fresh
|
|
@@ -137,8 +143,8 @@ Sources:
|
|
|
137
143
|
kev CISA Known Exploited Vulnerabilities
|
|
138
144
|
nvd NIST NVD 2.0 per-CVE
|
|
139
145
|
epss FIRST EPSS per-CVE
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
rfc IETF Datatracker per-RFC
|
|
147
|
+
pins MITRE GitHub releases (ATLAS / ATT&CK)
|
|
142
148
|
|
|
143
149
|
Options:
|
|
144
150
|
--max-age <dur> skip entries fresher than this (e.g. 12h, 1d). Default: 24h.
|
|
@@ -296,7 +302,15 @@ function isFresh(idx, source, id, maxAgeMs) {
|
|
|
296
302
|
|
|
297
303
|
function authHeadersForSource(source) {
|
|
298
304
|
if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
|
|
299
|
-
|
|
305
|
+
// audit M P2-J: the registered source name for MITRE GitHub releases is
|
|
306
|
+
// `pins` (see SOURCES above). The prior check looked for `github`, so
|
|
307
|
+
// GITHUB_TOKEN never reached the per-request Authorization header and
|
|
308
|
+
// anonymous-rate-limited fetches were always used even when an operator
|
|
309
|
+
// had supplied a token. Accept both spellings so this is forgiving of
|
|
310
|
+
// the historical naming and the registered name.
|
|
311
|
+
if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
|
|
312
|
+
return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
|
|
313
|
+
}
|
|
300
314
|
return {};
|
|
301
315
|
}
|
|
302
316
|
|
|
@@ -459,13 +473,21 @@ function readCached(cacheDir, source, id, opts = {}) {
|
|
|
459
473
|
const idx = loadIndex(cacheDir);
|
|
460
474
|
const meta = idx.entries[entryKey(source, id)];
|
|
461
475
|
if (!meta) return null;
|
|
462
|
-
|
|
463
|
-
|
|
476
|
+
// audit M P2-L: when `fetched_at` is missing / non-string / unparseable,
|
|
477
|
+
// `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false —
|
|
478
|
+
// so the cached entry would have been returned as if fresh. Treat any
|
|
479
|
+
// non-finite age as "no provenance, refuse" unless the caller explicitly
|
|
480
|
+
// opted into allowStale.
|
|
481
|
+
const ageMs = meta.fetched_at ? Date.now() - new Date(meta.fetched_at).getTime() : NaN;
|
|
482
|
+
if (!opts.allowStale) {
|
|
483
|
+
if (!meta.fetched_at || !Number.isFinite(ageMs)) return null;
|
|
484
|
+
if (ageMs > maxAgeMs) return null;
|
|
485
|
+
}
|
|
464
486
|
const p = entryPath(cacheDir, source, id);
|
|
465
487
|
if (!fs.existsSync(p)) return null;
|
|
466
488
|
try {
|
|
467
489
|
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
468
|
-
return { data, age_ms: ageMs, meta };
|
|
490
|
+
return { data, age_ms: Number.isFinite(ageMs) ? ageMs : null, meta };
|
|
469
491
|
} catch {
|
|
470
492
|
return null;
|
|
471
493
|
}
|
package/lib/refresh-external.js
CHANGED
|
@@ -549,20 +549,31 @@ const GHSA_SOURCE = {
|
|
|
549
549
|
return ghsa.buildDiff(ctx);
|
|
550
550
|
},
|
|
551
551
|
async applyDiff(ctx, diffs) {
|
|
552
|
-
|
|
552
|
+
// v0.12.14 (audit B-F1): the prior shape mutated ctx.cveCatalog in
|
|
553
|
+
// memory but NEVER persisted to disk. Bulk `--source ghsa --apply`
|
|
554
|
+
// reported "applied: N updates" while the catalog file gained zero
|
|
555
|
+
// entries. Worse under `--swarm`: KEV's withCatalogLock would re-read
|
|
556
|
+
// catalog from disk INSIDE the lock and overwrite the unflushed
|
|
557
|
+
// in-memory mutations. Route through the same withCatalogLock helper
|
|
558
|
+
// that KEV/EPSS/NVD/RFC use (v0.12.12 concurrency fix).
|
|
559
|
+
const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
|
|
553
560
|
let updated = 0;
|
|
554
561
|
const errors = [];
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
562
|
+
await withCatalogLock(catalogPath, (catalog) => {
|
|
563
|
+
for (const d of diffs) {
|
|
564
|
+
if (d.field !== "_new_entry") continue;
|
|
565
|
+
if (!d.after || !d.id) continue;
|
|
566
|
+
if (catalog[d.id]) continue; // never overwrite existing entries
|
|
567
|
+
try {
|
|
568
|
+
catalog[d.id] = d.after;
|
|
569
|
+
updated++;
|
|
570
|
+
} catch (e) {
|
|
571
|
+
errors.push(`${d.id}: ${e.message}`);
|
|
572
|
+
}
|
|
564
573
|
}
|
|
565
|
-
|
|
574
|
+
ctx.cveCatalog = catalog;
|
|
575
|
+
return catalog;
|
|
576
|
+
});
|
|
566
577
|
return { updated, errors };
|
|
567
578
|
},
|
|
568
579
|
};
|
|
@@ -591,20 +602,27 @@ const OSV_SOURCE = {
|
|
|
591
602
|
return osv.buildDiff(ctx);
|
|
592
603
|
},
|
|
593
604
|
async applyDiff(ctx, diffs) {
|
|
594
|
-
//
|
|
605
|
+
// v0.12.14 (audit B-F1): same fix as GHSA — route the read-modify-write
|
|
606
|
+
// through withCatalogLock so writes actually land on disk and so
|
|
607
|
+
// concurrent --source osv --apply doesn't lose updates.
|
|
608
|
+
const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
|
|
595
609
|
let updated = 0;
|
|
596
610
|
const errors = [];
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
611
|
+
await withCatalogLock(catalogPath, (catalog) => {
|
|
612
|
+
for (const d of diffs) {
|
|
613
|
+
if (d.field !== "_new_entry") continue;
|
|
614
|
+
if (!d.after || !d.id) continue;
|
|
615
|
+
if (catalog[d.id]) continue; // never overwrite existing entries
|
|
616
|
+
try {
|
|
617
|
+
catalog[d.id] = d.after;
|
|
618
|
+
updated++;
|
|
619
|
+
} catch (e) {
|
|
620
|
+
errors.push(`${d.id}: ${e.message}`);
|
|
621
|
+
}
|
|
606
622
|
}
|
|
607
|
-
|
|
623
|
+
ctx.cveCatalog = catalog;
|
|
624
|
+
return catalog;
|
|
625
|
+
});
|
|
608
626
|
return { updated, errors };
|
|
609
627
|
},
|
|
610
628
|
};
|
package/lib/refresh-network.js
CHANGED
|
@@ -97,6 +97,10 @@ function getJson(url, timeoutMs) {
|
|
|
97
97
|
function getBuffer(url, timeoutMs) {
|
|
98
98
|
return new Promise((resolve, reject) => {
|
|
99
99
|
const u = new URL(url);
|
|
100
|
+
const cap = (() => {
|
|
101
|
+
const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
|
|
102
|
+
return Number.isFinite(env) && env > 0 ? env : 200 * 1024 * 1024;
|
|
103
|
+
})();
|
|
100
104
|
const req = https.get({
|
|
101
105
|
host: u.host, path: u.pathname + u.search,
|
|
102
106
|
headers: { "User-Agent": "exceptd/refresh-network" },
|
|
@@ -107,7 +111,17 @@ function getBuffer(url, timeoutMs) {
|
|
|
107
111
|
return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
|
108
112
|
}
|
|
109
113
|
const chunks = [];
|
|
110
|
-
|
|
114
|
+
let total = 0;
|
|
115
|
+
// v0.12.14 (audit F5): enforce streaming size cap so a hostile
|
|
116
|
+
// registry CDN can't stream gigabytes into RAM.
|
|
117
|
+
res.on("data", (c) => {
|
|
118
|
+
total += c.length;
|
|
119
|
+
if (total > cap) {
|
|
120
|
+
req.destroy(new Error(`tarball exceeds ${cap}-byte cap during streaming download`));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
chunks.push(c);
|
|
124
|
+
});
|
|
111
125
|
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
112
126
|
});
|
|
113
127
|
req.on("timeout", () => req.destroy(new Error("timeout")));
|
|
@@ -177,6 +191,36 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
|
|
|
177
191
|
} catch { return false; }
|
|
178
192
|
}
|
|
179
193
|
|
|
194
|
+
// v0.12.14 (audit F1, F7): CRLF/BOM normalization mirrors lib/verify.js's
|
|
195
|
+
// normalize(). Duplicated here to keep refresh-network free of cross-module
|
|
196
|
+
// runtime deps. ANY change here MUST be mirrored in lib/verify.js +
|
|
197
|
+
// lib/sign.js — the three normalize() implementations form a byte-stability
|
|
198
|
+
// contract.
|
|
199
|
+
function normalizeSkillBytes(buf) {
|
|
200
|
+
let s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
|
|
201
|
+
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
202
|
+
return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Manifest path validation. Mirrors lib/verify.js validateSkillPath().
|
|
206
|
+
function validateManifestSkillPath(skillPath) {
|
|
207
|
+
if (typeof skillPath !== "string") throw new Error(`manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
208
|
+
if (skillPath.includes("\\")) throw new Error(`manifest skill.path must use forward slashes: ${JSON.stringify(skillPath)}`);
|
|
209
|
+
if (!skillPath.startsWith("skills/")) throw new Error(`manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
|
|
210
|
+
if (skillPath.includes("..")) throw new Error(`manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
|
|
211
|
+
return skillPath;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// v0.12.14 (audit F5): tarball download size cap. A hostile registry CDN
|
|
215
|
+
// could stream gigabytes; Node buffers chunks in RAM until OOM. Current
|
|
216
|
+
// tarball is ~2 MB; 200 MB is generous defense-in-depth. Tunable via
|
|
217
|
+
// EXCEPTD_TARBALL_SIZE_CAP_BYTES for future growth.
|
|
218
|
+
const TARBALL_SIZE_CAP_BYTES_DEFAULT = 200 * 1024 * 1024;
|
|
219
|
+
function tarballSizeCap() {
|
|
220
|
+
const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
|
|
221
|
+
return Number.isFinite(env) && env > 0 ? env : TARBALL_SIZE_CAP_BYTES_DEFAULT;
|
|
222
|
+
}
|
|
223
|
+
|
|
180
224
|
async function main() {
|
|
181
225
|
const opts = parseArgs(process.argv);
|
|
182
226
|
const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
@@ -204,6 +248,8 @@ async function main() {
|
|
|
204
248
|
const latestVersion = meta.version;
|
|
205
249
|
const tarballUrl = meta.dist && meta.dist.tarball;
|
|
206
250
|
const tarballShasum = meta.dist && meta.dist.shasum;
|
|
251
|
+
const tarballIntegrity = meta.dist && meta.dist.integrity; // SHA-512 SRI
|
|
252
|
+
const registrySignatures = Array.isArray(meta.dist && meta.dist.signatures) ? meta.dist.signatures : [];
|
|
207
253
|
if (!tarballUrl) {
|
|
208
254
|
emit({ ok: false, error: "registry metadata missing dist.tarball" }, opts.json);
|
|
209
255
|
process.exitCode = 2; return;
|
|
@@ -240,6 +286,32 @@ async function main() {
|
|
|
240
286
|
process.exitCode = 2; return;
|
|
241
287
|
}
|
|
242
288
|
|
|
289
|
+
// v0.12.14 (audit F5): defense-in-depth tarball size cap.
|
|
290
|
+
const sizeCap = tarballSizeCap();
|
|
291
|
+
if (tgzBuf.length > sizeCap) {
|
|
292
|
+
emit({ ok: false, error: `tarball exceeds size cap: ${tgzBuf.length} bytes > ${sizeCap} (EXCEPTD_TARBALL_SIZE_CAP_BYTES)` }, opts.json);
|
|
293
|
+
process.exitCode = 4; return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// v0.12.14 (audit F6, F3): verify SHA-512 SRI first (collision-resistant
|
|
297
|
+
// beyond SHA-1 reach), then SHA-1 shasum for compatibility, then dist.
|
|
298
|
+
// signatures[] (npm registry's Ed25519 signing key). Each layer is
|
|
299
|
+
// defense-in-depth — registry compromise that produces a SHA-1 collision
|
|
300
|
+
// doesn't trivially produce a SHA-512 collision; an attacker who breaks
|
|
301
|
+
// both still has to forge the npm-signing-key signature on the tarball.
|
|
302
|
+
if (tarballIntegrity && /^sha512-/.test(tarballIntegrity)) {
|
|
303
|
+
const expected = tarballIntegrity.slice("sha512-".length);
|
|
304
|
+
const actual = crypto.createHash("sha512").update(tgzBuf).digest("base64");
|
|
305
|
+
if (actual !== expected) {
|
|
306
|
+
emit({ ok: false, error: `tarball SHA-512 integrity mismatch: dist.integrity=${tarballIntegrity}, actual=sha512-${actual}` }, opts.json);
|
|
307
|
+
process.exitCode = 4; return;
|
|
308
|
+
}
|
|
309
|
+
} else if (tarballIntegrity) {
|
|
310
|
+
// Non-sha512 SRI (e.g. sha384) — emit a warning but accept; SHA-1 path
|
|
311
|
+
// below still gates.
|
|
312
|
+
progress(`note: dist.integrity present but not sha512: ${tarballIntegrity.slice(0, 40)}`, opts.json);
|
|
313
|
+
}
|
|
314
|
+
|
|
243
315
|
// Verify shasum (registry-provided integrity).
|
|
244
316
|
if (tarballShasum) {
|
|
245
317
|
const actual = crypto.createHash("sha1").update(tgzBuf).digest("hex");
|
|
@@ -285,27 +357,96 @@ async function main() {
|
|
|
285
357
|
process.exitCode = 5; return;
|
|
286
358
|
}
|
|
287
359
|
|
|
360
|
+
// v0.12.16 (audit I P1-5): cross-check the local public key against
|
|
361
|
+
// keys/EXPECTED_FINGERPRINT (the CI-pinned signing key). The prior
|
|
362
|
+
// refresh-network code only compared LOCAL ↔ TARBALL fingerprints, so a
|
|
363
|
+
// coordinated attacker who swapped both `keys/public.pem` on the operator's
|
|
364
|
+
// host AND the registry tarball passed every check — fingerprints match
|
|
365
|
+
// each other but match the attacker's key. The pin in EXPECTED_FINGERPRINT
|
|
366
|
+
// is the external trust anchor that closes this gap.
|
|
367
|
+
//
|
|
368
|
+
// Honors `KEYS_ROTATED=1` env to allow legitimate key rotation without
|
|
369
|
+
// re-bootstrap. Missing EXPECTED_FINGERPRINT file → warn-and-continue
|
|
370
|
+
// (don't break existing installs whose tree predates the pin file).
|
|
371
|
+
const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
|
|
372
|
+
if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
|
|
373
|
+
try {
|
|
374
|
+
const expectedFp = fs.readFileSync(expectedFingerprintPath, "utf8")
|
|
375
|
+
.split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0);
|
|
376
|
+
// v0.12.16 (codex P1 PR #11): `expectedFp` is read verbatim from
|
|
377
|
+
// keys/EXPECTED_FINGERPRINT (formatted as `SHA256:<base64>`), but
|
|
378
|
+
// `fingerprintPublicKey()` returns the raw base64 without the
|
|
379
|
+
// `SHA256:` prefix. Comparing the two raw strings would refuse every
|
|
380
|
+
// legitimate run unless KEYS_ROTATED=1 was set. Normalize by stripping
|
|
381
|
+
// the prefix from the pin file before compare. lib/verify.js's
|
|
382
|
+
// checkExpectedFingerprint() does the symmetric thing (adds the
|
|
383
|
+
// prefix to localFp); either side works as long as one is canonical.
|
|
384
|
+
const expectedFpBase64 = expectedFp && expectedFp.startsWith("SHA256:")
|
|
385
|
+
? expectedFp.slice("SHA256:".length)
|
|
386
|
+
: expectedFp;
|
|
387
|
+
if (expectedFpBase64 && expectedFpBase64 !== localFp) {
|
|
388
|
+
emit({
|
|
389
|
+
ok: false,
|
|
390
|
+
error: `local keys/public.pem fingerprint diverges from keys/EXPECTED_FINGERPRINT pin`,
|
|
391
|
+
local_fingerprint: "SHA256:" + localFp,
|
|
392
|
+
pinned_fingerprint: expectedFp,
|
|
393
|
+
hint: "Either keys/public.pem was rotated since the pin was set (rerun `npm run bootstrap` to re-pin), or the local public.pem was tampered with. Set KEYS_ROTATED=1 to bypass once. Refusing to swap on --network.",
|
|
394
|
+
}, opts.json);
|
|
395
|
+
process.exitCode = 5; return;
|
|
396
|
+
}
|
|
397
|
+
} catch { /* unreadable pin file = warn-and-continue */ }
|
|
398
|
+
}
|
|
399
|
+
|
|
288
400
|
// Verify every signed entry in the tarball manifest using the local key.
|
|
289
401
|
let tarballManifest;
|
|
290
402
|
try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
|
|
291
403
|
catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
|
|
292
404
|
|
|
405
|
+
// v0.12.14 (audit F1): the prior loop iterated `sk.id` + a fixed payload
|
|
406
|
+
// path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
|
|
407
|
+
// `path` (a forward-slash relative path like `skills/<name>/skill.md`,
|
|
408
|
+
// lowercase). Result: the loop matched zero entries; `failures.length === 0`
|
|
409
|
+
// and `verifiedCount === 0` and the swap proceeded with `ok: true`. Every
|
|
410
|
+
// operator running `exceptd refresh --network` installed unverified bytes.
|
|
411
|
+
//
|
|
412
|
+
// Fixed shape mirrors lib/verify.js: iterate `manifest.skills[]` by
|
|
413
|
+
// `name` + `path` + `signature`. Apply the same CRLF/BOM normalization
|
|
414
|
+
// before verify (lib/verify.js normalize() — duplicated here to keep
|
|
415
|
+
// this path free of cross-module runtime deps). validateSkillPath()
|
|
416
|
+
// is also mirrored to defend against path traversal in a tampered
|
|
417
|
+
// tarball manifest before we resolve the path against the extracted
|
|
418
|
+
// tree.
|
|
293
419
|
const localKeyObj = crypto.createPublicKey(localPubKeyText);
|
|
294
420
|
const skills = Array.isArray(tarballManifest.skills) ? tarballManifest.skills : [];
|
|
295
421
|
const failures = [];
|
|
296
422
|
let verifiedCount = 0;
|
|
297
423
|
for (const sk of skills) {
|
|
298
|
-
if (!sk ||
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
424
|
+
if (!sk || typeof sk.name !== "string" || typeof sk.signature !== "string") {
|
|
425
|
+
failures.push({ name: sk?.name || "(missing name)", reason: "manifest entry missing name or signature" });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
let normalizedPath;
|
|
429
|
+
try { normalizedPath = validateManifestSkillPath(sk.path); }
|
|
430
|
+
catch (e) { failures.push({ name: sk.name, reason: `manifest path rejected: ${e.message}` }); continue; }
|
|
431
|
+
const payloadEntry = entries.find((e) => stripPkg(e.name) === normalizedPath);
|
|
432
|
+
if (!payloadEntry) { failures.push({ name: sk.name, reason: `payload missing from tarball: ${normalizedPath}` }); continue; }
|
|
433
|
+
const normalized = normalizeSkillBytes(payloadEntry.body);
|
|
434
|
+
const ok = verifyDetached(localKeyObj, normalized, sk.signature);
|
|
304
435
|
if (ok) verifiedCount++;
|
|
305
|
-
else failures.push({
|
|
436
|
+
else failures.push({ name: sk.name, reason: "Ed25519 signature did not verify against local public key" });
|
|
306
437
|
}
|
|
307
438
|
|
|
308
|
-
if (
|
|
439
|
+
if (skills.length === 0) {
|
|
440
|
+
emit({
|
|
441
|
+
ok: false,
|
|
442
|
+
error: "tarball manifest.json declares zero skills — refusing to swap",
|
|
443
|
+
verified: 0,
|
|
444
|
+
total: 0,
|
|
445
|
+
hint: "A legitimate tarball must declare at least one skill. Treat this as a tarball-integrity failure.",
|
|
446
|
+
}, opts.json);
|
|
447
|
+
process.exitCode = 5; return;
|
|
448
|
+
}
|
|
449
|
+
if (verifiedCount !== skills.length || failures.length > 0) {
|
|
309
450
|
emit({
|
|
310
451
|
ok: false,
|
|
311
452
|
error: `${failures.length}/${skills.length} skill signature(s) failed verification — refusing to swap`,
|
|
@@ -317,6 +458,34 @@ async function main() {
|
|
|
317
458
|
process.exitCode = 5; return;
|
|
318
459
|
}
|
|
319
460
|
|
|
461
|
+
// v0.12.14 (audit F2): the swap loop replaces `data/` + `manifest.json` +
|
|
462
|
+
// `manifest-snapshot.json` in addition to `skills/`. None of those files
|
|
463
|
+
// are covered by the per-skill Ed25519 signature (which signs only the
|
|
464
|
+
// skill body bytes). The only integrity check between the registry and
|
|
465
|
+
// those bytes is SHA-1 dist.shasum — collision-broken since 2017 and
|
|
466
|
+
// weaker than `npm install` itself which honors dist.integrity (SHA-512
|
|
467
|
+
// SRI) + dist.signatures (npm Ed25519 registry key) + dist.attestations
|
|
468
|
+
// (sigstore SLSA provenance).
|
|
469
|
+
//
|
|
470
|
+
// Defense-in-depth: refuse the swap if the manifest skills list doesn't
|
|
471
|
+
// exactly match the skill payload entries present in the tarball. A
|
|
472
|
+
// malicious tarball that drops/adds a skill outside the manifest no
|
|
473
|
+
// longer slips through.
|
|
474
|
+
const manifestSkillPaths = new Set(skills.map(s => validateManifestSkillPath(s.path)));
|
|
475
|
+
const tarballSkillPayloads = entries
|
|
476
|
+
.map(e => stripPkg(e.name))
|
|
477
|
+
.filter(name => /^skills\/[^/]+\/skill\.md$/.test(name));
|
|
478
|
+
for (const tp of tarballSkillPayloads) {
|
|
479
|
+
if (!manifestSkillPaths.has(tp)) {
|
|
480
|
+
emit({
|
|
481
|
+
ok: false,
|
|
482
|
+
error: `tarball ships skill payload not declared in manifest: ${tp} — refusing to swap`,
|
|
483
|
+
hint: "Tarball+manifest divergence. Report at https://github.com/blamejs/exceptd-skills/issues.",
|
|
484
|
+
}, opts.json);
|
|
485
|
+
process.exitCode = 5; return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
320
489
|
if (opts.dryRun) {
|
|
321
490
|
emit({
|
|
322
491
|
ok: true,
|
|
@@ -330,9 +499,16 @@ async function main() {
|
|
|
330
499
|
return;
|
|
331
500
|
}
|
|
332
501
|
|
|
333
|
-
//
|
|
502
|
+
// v0.12.14 (audit F4): the prior swap loop renamed targets one-by-one,
|
|
503
|
+
// and a mid-loop failure left the install half-applied with no automatic
|
|
504
|
+
// rollback. New shape: rename all old targets into a single backup dir
|
|
505
|
+
// first (so the install is empty-of-old before any new content is moved
|
|
506
|
+
// in); then rename all new targets in; on failure, walk the backup dir
|
|
507
|
+
// in reverse and restore.
|
|
334
508
|
const stageDir = fs.mkdtempSync(path.join(ROOT, ".refresh-network-"));
|
|
335
509
|
let written = 0;
|
|
510
|
+
let backupDir = null;
|
|
511
|
+
const completedSteps = []; // [{kind: 'backup' | 'install', target}]
|
|
336
512
|
try {
|
|
337
513
|
for (const entry of entries) {
|
|
338
514
|
const rel = stripPkg(entry.name);
|
|
@@ -347,19 +523,32 @@ async function main() {
|
|
|
347
523
|
written++;
|
|
348
524
|
}
|
|
349
525
|
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
526
|
+
// v0.12.14 (audit F10): use PID + random suffix in the backup dir name
|
|
527
|
+
// so concurrent refresh-network invocations don't collide on the
|
|
528
|
+
// millisecond clock.
|
|
529
|
+
const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
530
|
+
backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}-${backupSuffix}`);
|
|
353
531
|
fs.mkdirSync(backupDir);
|
|
532
|
+
const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
|
|
533
|
+
|
|
534
|
+
// Phase A: move all existing targets to backupDir. After this loop
|
|
535
|
+
// completes, the install root has none of the replaced targets.
|
|
354
536
|
for (const target of replaceList) {
|
|
355
|
-
const src = path.join(stageDir, target);
|
|
356
|
-
if (!fs.existsSync(src)) continue;
|
|
357
537
|
const dst = path.join(ROOT, target);
|
|
358
538
|
if (fs.existsSync(dst)) {
|
|
359
539
|
fs.renameSync(dst, path.join(backupDir, target));
|
|
540
|
+
completedSteps.push({ kind: "backup", target });
|
|
360
541
|
}
|
|
361
|
-
fs.renameSync(src, dst);
|
|
362
542
|
}
|
|
543
|
+
|
|
544
|
+
// Phase B: move all new targets in from stage.
|
|
545
|
+
for (const target of replaceList) {
|
|
546
|
+
const src = path.join(stageDir, target);
|
|
547
|
+
if (!fs.existsSync(src)) continue;
|
|
548
|
+
fs.renameSync(src, path.join(ROOT, target));
|
|
549
|
+
completedSteps.push({ kind: "install", target });
|
|
550
|
+
}
|
|
551
|
+
|
|
363
552
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
364
553
|
// Best-effort cleanup of backup dir — keep on disk for one cycle so
|
|
365
554
|
// operators can manually roll back if something feels off.
|
|
@@ -371,11 +560,38 @@ async function main() {
|
|
|
371
560
|
total_skills: skills.length,
|
|
372
561
|
files_written: written,
|
|
373
562
|
backup_dir: path.relative(ROOT, backupDir),
|
|
563
|
+
registry_signatures_present: registrySignatures.length,
|
|
374
564
|
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.`,
|
|
375
565
|
}, opts.json);
|
|
376
566
|
} catch (e) {
|
|
567
|
+
// v0.12.14 (audit F4): walk completedSteps in reverse to undo partial work.
|
|
568
|
+
const rollbackErrors = [];
|
|
569
|
+
for (const step of [...completedSteps].reverse()) {
|
|
570
|
+
try {
|
|
571
|
+
if (step.kind === "install") {
|
|
572
|
+
// Remove the newly-installed copy.
|
|
573
|
+
fs.rmSync(path.join(ROOT, step.target), { recursive: true, force: true });
|
|
574
|
+
} else if (step.kind === "backup" && backupDir) {
|
|
575
|
+
// Restore from backup.
|
|
576
|
+
const src = path.join(backupDir, step.target);
|
|
577
|
+
const dst = path.join(ROOT, step.target);
|
|
578
|
+
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
|
579
|
+
}
|
|
580
|
+
} catch (re) {
|
|
581
|
+
rollbackErrors.push({ target: step.target, kind: step.kind, error: re.message });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
377
584
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
378
|
-
emit({
|
|
585
|
+
emit({
|
|
586
|
+
ok: false,
|
|
587
|
+
error: `swap failed mid-rename: ${e.message}`,
|
|
588
|
+
rolled_back: rollbackErrors.length === 0,
|
|
589
|
+
rollback_errors: rollbackErrors,
|
|
590
|
+
backup_dir: backupDir ? path.relative(ROOT, backupDir) : null,
|
|
591
|
+
hint: rollbackErrors.length === 0
|
|
592
|
+
? "Auto-rollback completed. Install state matches pre-refresh. Re-run `exceptd refresh --network` or `npm install -g @blamejs/exceptd-skills` to retry."
|
|
593
|
+
: "Auto-rollback partially failed. Restore manually from the backup dir at the install root, or reinstall with `npm install -g @blamejs/exceptd-skills`.",
|
|
594
|
+
}, opts.json);
|
|
379
595
|
process.exitCode = 4;
|
|
380
596
|
}
|
|
381
597
|
}
|