@blamejs/exceptd-skills 0.12.11 → 0.12.15
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 +243 -0
- package/bin/exceptd.js +299 -48
- package/data/_indexes/_meta.json +49 -48
- package/data/_indexes/activity-feed.json +13 -5
- package/data/_indexes/catalog-summaries.json +51 -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 +339 -0
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +505 -47
- package/lib/lint-skills.js +217 -15
- package/lib/playbook-runner.js +1224 -183
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +261 -95
- package/lib/refresh-network.js +208 -18
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +83 -7
- package/lib/sign.js +112 -3
- 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 +213 -7
- package/lib/validate-indexes.js +88 -37
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +313 -16
- 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 +196 -20
- package/package.json +3 -1
- package/sbom.cdx.json +9 -9
- 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 +110 -40
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- 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/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")));
|
|
@@ -125,6 +139,18 @@ function parseTar(buf) {
|
|
|
125
139
|
const entries = [];
|
|
126
140
|
let offset = 0;
|
|
127
141
|
let pendingLongName = null;
|
|
142
|
+
// v0.12.12: tarballs from a compromised registry CDN could ship entries
|
|
143
|
+
// with `..`-bearing names targeting paths outside the install root. The
|
|
144
|
+
// immediate callers (verify-shipped-tarball.js + the network update path)
|
|
145
|
+
// do hash + signature checks before honoring entries, so this is
|
|
146
|
+
// defense-in-depth — drop the entry rather than handing a path-traversal
|
|
147
|
+
// string downstream.
|
|
148
|
+
const isSafeName = (n) => {
|
|
149
|
+
if (typeof n !== "string" || n.length === 0) return false;
|
|
150
|
+
// Reject absolute paths AND any segment that is exactly ".."
|
|
151
|
+
if (/^[\\/]/.test(n) || /^[A-Za-z]:[\\/]/.test(n)) return false;
|
|
152
|
+
return !n.split(/[\\/]/).some((seg) => seg === "..");
|
|
153
|
+
};
|
|
128
154
|
while (offset + 512 <= buf.length) {
|
|
129
155
|
const block = buf.subarray(offset, offset + 512);
|
|
130
156
|
// empty block = end-of-archive marker
|
|
@@ -141,7 +167,9 @@ function parseTar(buf) {
|
|
|
141
167
|
if (type === "L") {
|
|
142
168
|
pendingLongName = buf.subarray(dataStart, dataEnd).toString("utf8").replace(/\0.*$/, "");
|
|
143
169
|
} else if (type === "0" || type === "" || type === "\0") {
|
|
144
|
-
|
|
170
|
+
if (isSafeName(name)) {
|
|
171
|
+
entries.push({ name, body: buf.subarray(dataStart, dataEnd) });
|
|
172
|
+
}
|
|
145
173
|
}
|
|
146
174
|
// round up to 512
|
|
147
175
|
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
@@ -163,6 +191,36 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
|
|
|
163
191
|
} catch { return false; }
|
|
164
192
|
}
|
|
165
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
|
+
|
|
166
224
|
async function main() {
|
|
167
225
|
const opts = parseArgs(process.argv);
|
|
168
226
|
const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
@@ -190,6 +248,8 @@ async function main() {
|
|
|
190
248
|
const latestVersion = meta.version;
|
|
191
249
|
const tarballUrl = meta.dist && meta.dist.tarball;
|
|
192
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 : [];
|
|
193
253
|
if (!tarballUrl) {
|
|
194
254
|
emit({ ok: false, error: "registry metadata missing dist.tarball" }, opts.json);
|
|
195
255
|
process.exitCode = 2; return;
|
|
@@ -226,6 +286,32 @@ async function main() {
|
|
|
226
286
|
process.exitCode = 2; return;
|
|
227
287
|
}
|
|
228
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
|
+
|
|
229
315
|
// Verify shasum (registry-provided integrity).
|
|
230
316
|
if (tarballShasum) {
|
|
231
317
|
const actual = crypto.createHash("sha1").update(tgzBuf).digest("hex");
|
|
@@ -276,22 +362,51 @@ async function main() {
|
|
|
276
362
|
try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
|
|
277
363
|
catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
|
|
278
364
|
|
|
365
|
+
// v0.12.14 (audit F1): the prior loop iterated `sk.id` + a fixed payload
|
|
366
|
+
// path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
|
|
367
|
+
// `path` (a forward-slash relative path like `skills/<name>/skill.md`,
|
|
368
|
+
// lowercase). Result: the loop matched zero entries; `failures.length === 0`
|
|
369
|
+
// and `verifiedCount === 0` and the swap proceeded with `ok: true`. Every
|
|
370
|
+
// operator running `exceptd refresh --network` installed unverified bytes.
|
|
371
|
+
//
|
|
372
|
+
// Fixed shape mirrors lib/verify.js: iterate `manifest.skills[]` by
|
|
373
|
+
// `name` + `path` + `signature`. Apply the same CRLF/BOM normalization
|
|
374
|
+
// before verify (lib/verify.js normalize() — duplicated here to keep
|
|
375
|
+
// this path free of cross-module runtime deps). validateSkillPath()
|
|
376
|
+
// is also mirrored to defend against path traversal in a tampered
|
|
377
|
+
// tarball manifest before we resolve the path against the extracted
|
|
378
|
+
// tree.
|
|
279
379
|
const localKeyObj = crypto.createPublicKey(localPubKeyText);
|
|
280
380
|
const skills = Array.isArray(tarballManifest.skills) ? tarballManifest.skills : [];
|
|
281
381
|
const failures = [];
|
|
282
382
|
let verifiedCount = 0;
|
|
283
383
|
for (const sk of skills) {
|
|
284
|
-
if (!sk ||
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
384
|
+
if (!sk || typeof sk.name !== "string" || typeof sk.signature !== "string") {
|
|
385
|
+
failures.push({ name: sk?.name || "(missing name)", reason: "manifest entry missing name or signature" });
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
let normalizedPath;
|
|
389
|
+
try { normalizedPath = validateManifestSkillPath(sk.path); }
|
|
390
|
+
catch (e) { failures.push({ name: sk.name, reason: `manifest path rejected: ${e.message}` }); continue; }
|
|
391
|
+
const payloadEntry = entries.find((e) => stripPkg(e.name) === normalizedPath);
|
|
392
|
+
if (!payloadEntry) { failures.push({ name: sk.name, reason: `payload missing from tarball: ${normalizedPath}` }); continue; }
|
|
393
|
+
const normalized = normalizeSkillBytes(payloadEntry.body);
|
|
394
|
+
const ok = verifyDetached(localKeyObj, normalized, sk.signature);
|
|
290
395
|
if (ok) verifiedCount++;
|
|
291
|
-
else failures.push({
|
|
396
|
+
else failures.push({ name: sk.name, reason: "Ed25519 signature did not verify against local public key" });
|
|
292
397
|
}
|
|
293
398
|
|
|
294
|
-
if (
|
|
399
|
+
if (skills.length === 0) {
|
|
400
|
+
emit({
|
|
401
|
+
ok: false,
|
|
402
|
+
error: "tarball manifest.json declares zero skills — refusing to swap",
|
|
403
|
+
verified: 0,
|
|
404
|
+
total: 0,
|
|
405
|
+
hint: "A legitimate tarball must declare at least one skill. Treat this as a tarball-integrity failure.",
|
|
406
|
+
}, opts.json);
|
|
407
|
+
process.exitCode = 5; return;
|
|
408
|
+
}
|
|
409
|
+
if (verifiedCount !== skills.length || failures.length > 0) {
|
|
295
410
|
emit({
|
|
296
411
|
ok: false,
|
|
297
412
|
error: `${failures.length}/${skills.length} skill signature(s) failed verification — refusing to swap`,
|
|
@@ -303,6 +418,34 @@ async function main() {
|
|
|
303
418
|
process.exitCode = 5; return;
|
|
304
419
|
}
|
|
305
420
|
|
|
421
|
+
// v0.12.14 (audit F2): the swap loop replaces `data/` + `manifest.json` +
|
|
422
|
+
// `manifest-snapshot.json` in addition to `skills/`. None of those files
|
|
423
|
+
// are covered by the per-skill Ed25519 signature (which signs only the
|
|
424
|
+
// skill body bytes). The only integrity check between the registry and
|
|
425
|
+
// those bytes is SHA-1 dist.shasum — collision-broken since 2017 and
|
|
426
|
+
// weaker than `npm install` itself which honors dist.integrity (SHA-512
|
|
427
|
+
// SRI) + dist.signatures (npm Ed25519 registry key) + dist.attestations
|
|
428
|
+
// (sigstore SLSA provenance).
|
|
429
|
+
//
|
|
430
|
+
// Defense-in-depth: refuse the swap if the manifest skills list doesn't
|
|
431
|
+
// exactly match the skill payload entries present in the tarball. A
|
|
432
|
+
// malicious tarball that drops/adds a skill outside the manifest no
|
|
433
|
+
// longer slips through.
|
|
434
|
+
const manifestSkillPaths = new Set(skills.map(s => validateManifestSkillPath(s.path)));
|
|
435
|
+
const tarballSkillPayloads = entries
|
|
436
|
+
.map(e => stripPkg(e.name))
|
|
437
|
+
.filter(name => /^skills\/[^/]+\/skill\.md$/.test(name));
|
|
438
|
+
for (const tp of tarballSkillPayloads) {
|
|
439
|
+
if (!manifestSkillPaths.has(tp)) {
|
|
440
|
+
emit({
|
|
441
|
+
ok: false,
|
|
442
|
+
error: `tarball ships skill payload not declared in manifest: ${tp} — refusing to swap`,
|
|
443
|
+
hint: "Tarball+manifest divergence. Report at https://github.com/blamejs/exceptd-skills/issues.",
|
|
444
|
+
}, opts.json);
|
|
445
|
+
process.exitCode = 5; return;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
306
449
|
if (opts.dryRun) {
|
|
307
450
|
emit({
|
|
308
451
|
ok: true,
|
|
@@ -316,9 +459,16 @@ async function main() {
|
|
|
316
459
|
return;
|
|
317
460
|
}
|
|
318
461
|
|
|
319
|
-
//
|
|
462
|
+
// v0.12.14 (audit F4): the prior swap loop renamed targets one-by-one,
|
|
463
|
+
// and a mid-loop failure left the install half-applied with no automatic
|
|
464
|
+
// rollback. New shape: rename all old targets into a single backup dir
|
|
465
|
+
// first (so the install is empty-of-old before any new content is moved
|
|
466
|
+
// in); then rename all new targets in; on failure, walk the backup dir
|
|
467
|
+
// in reverse and restore.
|
|
320
468
|
const stageDir = fs.mkdtempSync(path.join(ROOT, ".refresh-network-"));
|
|
321
469
|
let written = 0;
|
|
470
|
+
let backupDir = null;
|
|
471
|
+
const completedSteps = []; // [{kind: 'backup' | 'install', target}]
|
|
322
472
|
try {
|
|
323
473
|
for (const entry of entries) {
|
|
324
474
|
const rel = stripPkg(entry.name);
|
|
@@ -333,19 +483,32 @@ async function main() {
|
|
|
333
483
|
written++;
|
|
334
484
|
}
|
|
335
485
|
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
486
|
+
// v0.12.14 (audit F10): use PID + random suffix in the backup dir name
|
|
487
|
+
// so concurrent refresh-network invocations don't collide on the
|
|
488
|
+
// millisecond clock.
|
|
489
|
+
const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
490
|
+
backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}-${backupSuffix}`);
|
|
339
491
|
fs.mkdirSync(backupDir);
|
|
492
|
+
const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
|
|
493
|
+
|
|
494
|
+
// Phase A: move all existing targets to backupDir. After this loop
|
|
495
|
+
// completes, the install root has none of the replaced targets.
|
|
340
496
|
for (const target of replaceList) {
|
|
341
|
-
const src = path.join(stageDir, target);
|
|
342
|
-
if (!fs.existsSync(src)) continue;
|
|
343
497
|
const dst = path.join(ROOT, target);
|
|
344
498
|
if (fs.existsSync(dst)) {
|
|
345
499
|
fs.renameSync(dst, path.join(backupDir, target));
|
|
500
|
+
completedSteps.push({ kind: "backup", target });
|
|
346
501
|
}
|
|
347
|
-
fs.renameSync(src, dst);
|
|
348
502
|
}
|
|
503
|
+
|
|
504
|
+
// Phase B: move all new targets in from stage.
|
|
505
|
+
for (const target of replaceList) {
|
|
506
|
+
const src = path.join(stageDir, target);
|
|
507
|
+
if (!fs.existsSync(src)) continue;
|
|
508
|
+
fs.renameSync(src, path.join(ROOT, target));
|
|
509
|
+
completedSteps.push({ kind: "install", target });
|
|
510
|
+
}
|
|
511
|
+
|
|
349
512
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
350
513
|
// Best-effort cleanup of backup dir — keep on disk for one cycle so
|
|
351
514
|
// operators can manually roll back if something feels off.
|
|
@@ -357,11 +520,38 @@ async function main() {
|
|
|
357
520
|
total_skills: skills.length,
|
|
358
521
|
files_written: written,
|
|
359
522
|
backup_dir: path.relative(ROOT, backupDir),
|
|
523
|
+
registry_signatures_present: registrySignatures.length,
|
|
360
524
|
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.`,
|
|
361
525
|
}, opts.json);
|
|
362
526
|
} catch (e) {
|
|
527
|
+
// v0.12.14 (audit F4): walk completedSteps in reverse to undo partial work.
|
|
528
|
+
const rollbackErrors = [];
|
|
529
|
+
for (const step of [...completedSteps].reverse()) {
|
|
530
|
+
try {
|
|
531
|
+
if (step.kind === "install") {
|
|
532
|
+
// Remove the newly-installed copy.
|
|
533
|
+
fs.rmSync(path.join(ROOT, step.target), { recursive: true, force: true });
|
|
534
|
+
} else if (step.kind === "backup" && backupDir) {
|
|
535
|
+
// Restore from backup.
|
|
536
|
+
const src = path.join(backupDir, step.target);
|
|
537
|
+
const dst = path.join(ROOT, step.target);
|
|
538
|
+
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
|
539
|
+
}
|
|
540
|
+
} catch (re) {
|
|
541
|
+
rollbackErrors.push({ target: step.target, kind: step.kind, error: re.message });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
363
544
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
364
|
-
emit({
|
|
545
|
+
emit({
|
|
546
|
+
ok: false,
|
|
547
|
+
error: `swap failed mid-rename: ${e.message}`,
|
|
548
|
+
rolled_back: rollbackErrors.length === 0,
|
|
549
|
+
rollback_errors: rollbackErrors,
|
|
550
|
+
backup_dir: backupDir ? path.relative(ROOT, backupDir) : null,
|
|
551
|
+
hint: rollbackErrors.length === 0
|
|
552
|
+
? "Auto-rollback completed. Install state matches pre-refresh. Re-run `exceptd refresh --network` or `npm install -g @blamejs/exceptd-skills` to retry."
|
|
553
|
+
: "Auto-rollback partially failed. Restore manually from the backup dir at the install root, or reinstall with `npm install -g @blamejs/exceptd-skills`.",
|
|
554
|
+
}, opts.json);
|
|
365
555
|
process.exitCode = 4;
|
|
366
556
|
}
|
|
367
557
|
}
|
|
@@ -82,6 +82,22 @@
|
|
|
82
82
|
"type": "array",
|
|
83
83
|
"items": { "type": "string", "minLength": 1 }
|
|
84
84
|
},
|
|
85
|
+
"rfc_refs": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"items": { "type": "string", "minLength": 1 }
|
|
88
|
+
},
|
|
89
|
+
"cwe_refs": {
|
|
90
|
+
"type": "array",
|
|
91
|
+
"items": { "type": "string", "minLength": 1 }
|
|
92
|
+
},
|
|
93
|
+
"d3fend_refs": {
|
|
94
|
+
"type": "array",
|
|
95
|
+
"items": { "type": "string", "minLength": 1 }
|
|
96
|
+
},
|
|
97
|
+
"dlp_refs": {
|
|
98
|
+
"type": "array",
|
|
99
|
+
"items": { "type": "string", "minLength": 1 }
|
|
100
|
+
},
|
|
85
101
|
"last_threat_review": {
|
|
86
102
|
"type": "string",
|
|
87
103
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
package/lib/scoring.js
CHANGED
|
@@ -34,7 +34,62 @@ function score(cveId, catalog) {
|
|
|
34
34
|
return entry.rwep_score;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* E10: Validate an RWEP factor bag. Returns an array of warning strings
|
|
39
|
+
* for missing-but-defaultable fields and out-of-range values. Does NOT
|
|
40
|
+
* throw — operators wanting hard enforcement should treat a non-empty
|
|
41
|
+
* return as a failure themselves.
|
|
42
|
+
*
|
|
43
|
+
* Range expectations:
|
|
44
|
+
* - cisa_kev, poc_available, ai_assisted_weapon, ai_discovered,
|
|
45
|
+
* patch_available, live_patch_available, reboot_required: boolean
|
|
46
|
+
* (or null, treated as false with a missing-field warning).
|
|
47
|
+
* - active_exploitation: 'none' | 'unknown' | 'suspected' | 'confirmed'.
|
|
48
|
+
* - blast_radius: integer in [0, 30] (clamped at the weight ceiling but
|
|
49
|
+
* flagged when out-of-range — out-of-range usually means a unit error).
|
|
50
|
+
*/
|
|
51
|
+
function validateFactors(factors) {
|
|
52
|
+
const warnings = [];
|
|
53
|
+
if (!factors || typeof factors !== 'object') {
|
|
54
|
+
return ['factors: expected object, got ' + (factors === null ? 'null' : typeof factors)];
|
|
55
|
+
}
|
|
56
|
+
const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
57
|
+
'patch_available', 'live_patch_available', 'reboot_required'];
|
|
58
|
+
for (const f of boolFields) {
|
|
59
|
+
if (factors[f] === undefined || factors[f] === null) {
|
|
60
|
+
warnings.push(`${f}: missing (treated as false; explicit value recommended)`);
|
|
61
|
+
} else if (typeof factors[f] !== 'boolean') {
|
|
62
|
+
warnings.push(`${f}: expected boolean, got ${typeof factors[f]} (${JSON.stringify(factors[f])})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const aeAllowed = ['none', 'unknown', 'suspected', 'confirmed'];
|
|
66
|
+
if (factors.active_exploitation === undefined || factors.active_exploitation === null) {
|
|
67
|
+
warnings.push("active_exploitation: missing (treated as 'none')");
|
|
68
|
+
} else if (!aeAllowed.includes(factors.active_exploitation)) {
|
|
69
|
+
warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
|
|
70
|
+
}
|
|
71
|
+
if (factors.blast_radius === undefined || factors.blast_radius === null) {
|
|
72
|
+
warnings.push('blast_radius: missing (treated as 0)');
|
|
73
|
+
} else if (typeof factors.blast_radius !== 'number' || Number.isNaN(factors.blast_radius)) {
|
|
74
|
+
warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
|
|
75
|
+
} else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
|
|
76
|
+
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)`);
|
|
77
|
+
}
|
|
78
|
+
return warnings;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* scoreCustom — compute the RWEP for a factor bag. Returns a number
|
|
83
|
+
* (clamped to [0, 100]).
|
|
84
|
+
*
|
|
85
|
+
* Backward-compat note: this function has always returned a number;
|
|
86
|
+
* callers in lib/auto-discovery.js etc. rely on that. E10 surfaces
|
|
87
|
+
* warnings via the optional `opts.collectWarnings` flag — when true,
|
|
88
|
+
* scoreCustom returns `{ score, _scoring_warnings }` instead of a bare
|
|
89
|
+
* number. Operators wanting validation without the score can call
|
|
90
|
+
* `validateFactors(factors)` directly.
|
|
91
|
+
*/
|
|
92
|
+
function scoreCustom(factors, opts) {
|
|
38
93
|
const {
|
|
39
94
|
cisa_kev = false,
|
|
40
95
|
poc_available = false,
|
|
@@ -44,8 +99,15 @@ function scoreCustom(factors) {
|
|
|
44
99
|
blast_radius = 0,
|
|
45
100
|
patch_available = false,
|
|
46
101
|
live_patch_available = false,
|
|
47
|
-
reboot_required = false
|
|
48
|
-
|
|
102
|
+
reboot_required = false,
|
|
103
|
+
// v0.12.15 (audit J F9): the CVE catalog field is `patch_required_reboot`
|
|
104
|
+
// but scoreCustom historically expected `reboot_required`. validate()
|
|
105
|
+
// already aliases at the call site; accept either spelling here so a
|
|
106
|
+
// direct caller passing the catalog entry doesn't silently lose the
|
|
107
|
+
// reboot factor.
|
|
108
|
+
patch_required_reboot,
|
|
109
|
+
} = factors || {};
|
|
110
|
+
const rebootFactor = (reboot_required === true) || (patch_required_reboot === true);
|
|
49
111
|
|
|
50
112
|
let score = 0;
|
|
51
113
|
score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
|
|
@@ -53,12 +115,26 @@ function scoreCustom(factors) {
|
|
|
53
115
|
score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
|
|
54
116
|
score += active_exploitation === 'confirmed' ? RWEP_WEIGHTS.active_exploitation : 0;
|
|
55
117
|
score += active_exploitation === 'suspected' ? Math.floor(RWEP_WEIGHTS.active_exploitation / 2) : 0;
|
|
56
|
-
|
|
118
|
+
// v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
|
|
119
|
+
// NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
|
|
120
|
+
// check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
|
|
121
|
+
// which propagates NaN through the final clamp, defeating the [0,100]
|
|
122
|
+
// contract. Number.isFinite + Number() coercion catches all four classes:
|
|
123
|
+
// NaN, Infinity, undefined, stringified-number.
|
|
124
|
+
const brRaw = Number.isFinite(Number(blast_radius)) ? Number(blast_radius) : 0;
|
|
125
|
+
const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, brRaw));
|
|
126
|
+
score += brClamped;
|
|
57
127
|
score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
|
|
58
128
|
score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
|
|
59
|
-
score +=
|
|
129
|
+
score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
|
|
60
130
|
|
|
61
|
-
|
|
131
|
+
// v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
|
|
132
|
+
// NaN production above (negative weight + Infinity + math edge case).
|
|
133
|
+
const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
|
|
134
|
+
if (opts && opts.collectWarnings) {
|
|
135
|
+
return { score: clamped, _scoring_warnings: validateFactors(factors) };
|
|
136
|
+
}
|
|
137
|
+
return clamped;
|
|
62
138
|
}
|
|
63
139
|
|
|
64
140
|
function timeline(rwepScore) {
|
|
@@ -146,4 +222,4 @@ function validate(catalog) {
|
|
|
146
222
|
return errors;
|
|
147
223
|
}
|
|
148
224
|
|
|
149
|
-
module.exports = { score, scoreCustom, timeline, compare, validate, RWEP_WEIGHTS };
|
|
225
|
+
module.exports = { score, scoreCustom, timeline, compare, validate, validateFactors, RWEP_WEIGHTS };
|
package/lib/sign.js
CHANGED
|
@@ -8,6 +8,22 @@
|
|
|
8
8
|
* which is gitignored. The public key at keys/public.pem is tracked and used
|
|
9
9
|
* by lib/verify.js for signature verification.
|
|
10
10
|
*
|
|
11
|
+
* Byte-stability contract (must mirror lib/verify.js):
|
|
12
|
+
* Skill content is normalized BEFORE the bytes are signed:
|
|
13
|
+
* 1. Strip a UTF-8 BOM (U+FEFF) if present.
|
|
14
|
+
* 2. Convert CRLF line endings to LF.
|
|
15
|
+
* The same normalization runs in lib/verify.js. A skill file checked
|
|
16
|
+
* out with core.autocrlf=true on Windows therefore signs to the SAME
|
|
17
|
+
* signature as the LF copy on Linux CI — closing the regression class
|
|
18
|
+
* that broke v0.11.x signatures across the Windows/CI line-ending
|
|
19
|
+
* boundary. ANY change to normalize() requires the matching change in
|
|
20
|
+
* lib/verify.js; round-trip stability is a hard contract.
|
|
21
|
+
*
|
|
22
|
+
* Manifest entries are also validated before iteration: skill.path must
|
|
23
|
+
* begin with "skills/" and must not contain ".." or backslashes (see
|
|
24
|
+
* validateSkillPath() below). Without this a tampered manifest could
|
|
25
|
+
* sign or verify arbitrary files outside the skills/ tree.
|
|
26
|
+
*
|
|
11
27
|
* Signing ceremony:
|
|
12
28
|
* 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
|
|
13
29
|
* 2. node lib/sign.js sign-all — sign all skills (after any content change)
|
|
@@ -80,10 +96,20 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
80
96
|
/**
|
|
81
97
|
* Sign all skills in manifest.json using the private key.
|
|
82
98
|
* Updates manifest.json with Ed25519 signatures.
|
|
99
|
+
*
|
|
100
|
+
* Each manifest entry's `path` is validated through validateSkillPath()
|
|
101
|
+
* BEFORE the file is read — a tampered manifest with an out-of-tree
|
|
102
|
+
* path will reject the whole run.
|
|
83
103
|
*/
|
|
84
104
|
function signAll() {
|
|
85
105
|
const privateKey = loadPrivateKey();
|
|
86
106
|
const manifest = loadManifest();
|
|
107
|
+
// Validate every entry's path before doing any I/O. Reject the whole
|
|
108
|
+
// manifest on the first traversal attempt — we never want to sign
|
|
109
|
+
// half a manifest then exit non-zero with a partial mutation.
|
|
110
|
+
for (const skill of manifest.skills) {
|
|
111
|
+
validateSkillPath(skill.path);
|
|
112
|
+
}
|
|
87
113
|
let signed = 0;
|
|
88
114
|
let errors = 0;
|
|
89
115
|
|
|
@@ -103,7 +129,16 @@ function signAll() {
|
|
|
103
129
|
}
|
|
104
130
|
|
|
105
131
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
106
|
-
|
|
132
|
+
|
|
133
|
+
// S5: verdict line FIRST, fingerprint banner after. An operator
|
|
134
|
+
// scrolling output should not be able to see "fingerprint: SHA256..."
|
|
135
|
+
// and assume success when errors > 0.
|
|
136
|
+
if (errors > 0) {
|
|
137
|
+
console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
|
|
138
|
+
} else {
|
|
139
|
+
console.log(`\n[sign] ${signed} skills signed.`);
|
|
140
|
+
}
|
|
141
|
+
printFingerprintBanner();
|
|
107
142
|
|
|
108
143
|
if (errors > 0) process.exit(1);
|
|
109
144
|
}
|
|
@@ -118,6 +153,7 @@ function signOne(skillName) {
|
|
|
118
153
|
const skill = manifest.skills.find(s => s.name === skillName);
|
|
119
154
|
if (!skill) { console.error(`Skill not found: ${skillName}`); process.exit(1); }
|
|
120
155
|
|
|
156
|
+
validateSkillPath(skill.path);
|
|
121
157
|
const skillPath = path.join(ROOT, skill.path);
|
|
122
158
|
const content = fs.readFileSync(skillPath, 'utf8');
|
|
123
159
|
skill.signature = signContent(content, privateKey);
|
|
@@ -126,12 +162,69 @@ function signOne(skillName) {
|
|
|
126
162
|
|
|
127
163
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
128
164
|
console.log(`[sign] Signed: ${skillName}`);
|
|
165
|
+
printFingerprintBanner();
|
|
129
166
|
}
|
|
130
167
|
|
|
131
168
|
// --- helpers ---
|
|
132
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Normalize skill content for byte-stable signing.
|
|
172
|
+
*
|
|
173
|
+
* Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
|
|
174
|
+
* line endings to LF. lib/verify.js applies the exact same transform.
|
|
175
|
+
*
|
|
176
|
+
* Without this, a Windows checkout with core.autocrlf=true reads a
|
|
177
|
+
* skill with \r\n while CI reads the same skill with \n — same bytes
|
|
178
|
+
* on disk in git, different bytes in the working tree, different
|
|
179
|
+
* signature. v0.11.x shipped 0/38 verifies for exactly this reason.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} content
|
|
182
|
+
* @returns {string}
|
|
183
|
+
*/
|
|
184
|
+
function normalize(content) {
|
|
185
|
+
let s = content;
|
|
186
|
+
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
187
|
+
return s.replace(/\r\n/g, '\n');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validate a manifest skill.path entry to prevent path traversal.
|
|
192
|
+
*
|
|
193
|
+
* skill.path MUST be a string.
|
|
194
|
+
* skill.path MUST start with "skills/".
|
|
195
|
+
* skill.path MUST NOT contain "..".
|
|
196
|
+
* skill.path MUST NOT contain backslashes (POSIX-style forward slashes
|
|
197
|
+
* only — manifest paths are not platform-specific).
|
|
198
|
+
*
|
|
199
|
+
* A tampered manifest with "../../../etc/passwd" or
|
|
200
|
+
* "skills/foo/../../.keys/private.pem" is refused; the whole run
|
|
201
|
+
* aborts before any file I/O.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} skillPath
|
|
204
|
+
* @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
function validateSkillPath(skillPath) {
|
|
207
|
+
if (typeof skillPath !== 'string') {
|
|
208
|
+
throw new Error(`[sign] manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
209
|
+
}
|
|
210
|
+
// Backslash check runs BEFORE the prefix check so a Windows-style
|
|
211
|
+
// path ("skills\foo\skill.md") returns the clearer "use forward
|
|
212
|
+
// slashes" diagnostic, not the misleading "must start with skills/".
|
|
213
|
+
if (skillPath.includes('\\')) {
|
|
214
|
+
throw new Error(`[sign] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
|
|
215
|
+
}
|
|
216
|
+
if (!skillPath.startsWith('skills/')) {
|
|
217
|
+
throw new Error(`[sign] manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
|
|
218
|
+
}
|
|
219
|
+
if (skillPath.includes('..')) {
|
|
220
|
+
throw new Error(`[sign] manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
|
|
221
|
+
}
|
|
222
|
+
return skillPath;
|
|
223
|
+
}
|
|
224
|
+
|
|
133
225
|
function signContent(content, privateKey) {
|
|
134
|
-
const
|
|
226
|
+
const normalized = normalize(content);
|
|
227
|
+
const signature = crypto.sign(null, Buffer.from(normalized, 'utf8'), {
|
|
135
228
|
key: privateKey,
|
|
136
229
|
dsaEncoding: 'ieee-p1363'
|
|
137
230
|
});
|
|
@@ -151,6 +244,22 @@ function loadManifest() {
|
|
|
151
244
|
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
152
245
|
}
|
|
153
246
|
|
|
247
|
+
function printFingerprintBanner() {
|
|
248
|
+
if (!fs.existsSync(PUBLIC_KEY_PATH)) return;
|
|
249
|
+
try {
|
|
250
|
+
const pem = fs.readFileSync(PUBLIC_KEY_PATH, 'utf8');
|
|
251
|
+
const keyObj = crypto.createPublicKey(pem);
|
|
252
|
+
const der = keyObj.export({ type: 'spki', format: 'der' });
|
|
253
|
+
const sha256 = 'SHA256:' + crypto.createHash('sha256').update(der).digest('base64');
|
|
254
|
+
const sha3_512 = 'SHA3-512:' + crypto.createHash('sha3-512').update(der).digest('base64');
|
|
255
|
+
console.log(`[sign] Public key: keys/public.pem`);
|
|
256
|
+
console.log(`[sign] ${sha256}`);
|
|
257
|
+
console.log(`[sign] ${sha3_512}`);
|
|
258
|
+
} catch (_) {
|
|
259
|
+
// Best-effort banner — never let a fingerprint failure poison the run.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
154
263
|
// --- CLI ---
|
|
155
264
|
|
|
156
265
|
if (require.main === module) {
|
|
@@ -194,4 +303,4 @@ Signing ceremony (first time):
|
|
|
194
303
|
}
|
|
195
304
|
}
|
|
196
305
|
|
|
197
|
-
module.exports = { generateKeypair, signAll, signOne };
|
|
306
|
+
module.exports = { generateKeypair, signAll, signOne, normalize, validateSkillPath };
|