@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/scripts/predeploy.js
CHANGED
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
* Single-source-of-truth: the GATES list below mirrors the job sequence
|
|
20
20
|
* in .github/workflows/ci.yml. Test coverage in tests/predeploy.test.js
|
|
21
21
|
* asserts the two stay in sync.
|
|
22
|
+
*
|
|
23
|
+
* Audit G F5 — when the manifest-snapshot gate fails, the fix is NOT to
|
|
24
|
+
* run `npm run refresh-snapshot` blindly. The refresh script now refuses
|
|
25
|
+
* unless the operator passes `--commit-only` or sets
|
|
26
|
+
* EXCEPTD_SNAPSHOT_AUDIT_ACK=1. This is intentional: a failing snapshot
|
|
27
|
+
* gate means a breaking change was detected, and an accidental refresh
|
|
28
|
+
* would silently rewrite the baseline. Read the breaking-change list
|
|
29
|
+
* first, then run `node scripts/refresh-manifest-snapshot.js --commit-only`
|
|
30
|
+
* if the change is intentional.
|
|
22
31
|
*/
|
|
23
32
|
|
|
24
33
|
const { execFileSync } = require("child_process");
|
|
@@ -62,28 +71,15 @@ const GATES = [
|
|
|
62
71
|
args: [path.join(ROOT, "lib", "validate-cve-catalog.js")],
|
|
63
72
|
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
64
73
|
},
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
name: "Validate offline RFC catalog state",
|
|
78
|
-
command: process.execPath,
|
|
79
|
-
args: [
|
|
80
|
-
path.join(ROOT, "orchestrator", "index.js"),
|
|
81
|
-
"validate-rfcs",
|
|
82
|
-
"--offline",
|
|
83
|
-
"--no-fail",
|
|
84
|
-
],
|
|
85
|
-
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
86
|
-
},
|
|
74
|
+
// Audit G F13 — the "validate-cves --offline --no-fail" and
|
|
75
|
+
// "validate-rfcs --offline --no-fail" gates were enumeration-only sanity
|
|
76
|
+
// checks: `--no-fail` forced them to always exit 0, so they never blocked
|
|
77
|
+
// a release on a real catalog problem. The deep catalog validation is
|
|
78
|
+
// already performed by the gate above (`lib/validate-cve-catalog.js`),
|
|
79
|
+
// including cross-catalog reference resolution after this same audit.
|
|
80
|
+
// Keeping the no-op gates as predeploy steps inflated the gate count for
|
|
81
|
+
// no marginal value and risked false confidence ("X gates passed"). They
|
|
82
|
+
// are removed in v0.12.14; document the removal in CHANGELOG.
|
|
87
83
|
{
|
|
88
84
|
name: "Manifest snapshot gate (breaking-change detector)",
|
|
89
85
|
command: process.execPath,
|
|
@@ -97,9 +93,13 @@ const GATES = [
|
|
|
97
93
|
ciJobName: "Lint skill files",
|
|
98
94
|
},
|
|
99
95
|
{
|
|
100
|
-
// Informational
|
|
101
|
-
//
|
|
102
|
-
// a
|
|
96
|
+
// Informational — surfaces the forward_watch horizon across all skills.
|
|
97
|
+
// Audit G F12: an exit code of 0 means "ok", 1 means "items present
|
|
98
|
+
// (informational)", 2+ means a runtime error in the gate itself.
|
|
99
|
+
// The runner now distinguishes the two: 0/1 stay informational, 2+
|
|
100
|
+
// surface as a real failure. Pre-fix, any non-zero exit was rolled up
|
|
101
|
+
// as informational, which hid crashes (a 137 OOM looked the same as
|
|
102
|
+
// "found 12 items to review").
|
|
103
103
|
name: "Forward-watch aggregator (informational)",
|
|
104
104
|
command: process.execPath,
|
|
105
105
|
args: [
|
|
@@ -108,6 +108,7 @@ const GATES = [
|
|
|
108
108
|
],
|
|
109
109
|
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
110
110
|
informational: true,
|
|
111
|
+
informationalMaxExitCode: 1,
|
|
111
112
|
},
|
|
112
113
|
{
|
|
113
114
|
name: "Validate catalog _meta (tlp + source_confidence + freshness_policy)",
|
|
@@ -192,25 +193,60 @@ function runGate(gate) {
|
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
const t0 = Date.now();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
196
|
+
// Audit G F21 — spawn the child with piped stdio + tee to the parent so we
|
|
197
|
+
// can count `WARN ` lines for the summary table. We still want the live
|
|
198
|
+
// output, so each chunk is forwarded as it arrives.
|
|
199
|
+
const { spawnSync } = require("child_process");
|
|
200
|
+
const r = spawnSync(gate.command, gate.args, {
|
|
201
|
+
cwd: ROOT,
|
|
202
|
+
encoding: "utf8",
|
|
203
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
204
|
+
});
|
|
205
|
+
const durationMs = Date.now() - t0;
|
|
206
|
+
if (r.stdout) process.stdout.write(r.stdout);
|
|
207
|
+
if (r.stderr) process.stderr.write(r.stderr);
|
|
208
|
+
// Count WARN-labelled lines in the combined stream so the summary table
|
|
209
|
+
// can surface them. Lint / validate output uses "WARN " at line start;
|
|
210
|
+
// count both the table form and an inline "[warn]" form.
|
|
211
|
+
const combined = (r.stdout || "") + (r.stderr || "");
|
|
212
|
+
const warnCount = (
|
|
213
|
+
combined.match(/^WARN\b/gm) || []
|
|
214
|
+
).length + (
|
|
215
|
+
combined.match(/\[warn\]/g) || []
|
|
216
|
+
).length;
|
|
217
|
+
if (r.status === 0) {
|
|
218
|
+
return { status: "passed", durationMs, warnCount };
|
|
219
|
+
}
|
|
220
|
+
// Audit G F12 — gates may declare informationalMaxExitCode to distinguish
|
|
221
|
+
// "soft signal" (exit codes 0..N) from "crash" (> N). Default behaviour
|
|
222
|
+
// for an informational gate without that field stays the same.
|
|
223
|
+
if (gate.informational) {
|
|
224
|
+
const ceil = typeof gate.informationalMaxExitCode === "number"
|
|
225
|
+
? gate.informationalMaxExitCode
|
|
226
|
+
: Infinity;
|
|
227
|
+
if (r.status !== null && r.status > ceil) {
|
|
200
228
|
return {
|
|
201
|
-
status: "
|
|
202
|
-
exitCode:
|
|
203
|
-
message:
|
|
204
|
-
durationMs
|
|
229
|
+
status: "failed",
|
|
230
|
+
exitCode: r.status,
|
|
231
|
+
message: `informational gate crashed (exit ${r.status} > informationalMaxExitCode=${ceil})`,
|
|
232
|
+
durationMs,
|
|
233
|
+
warnCount,
|
|
205
234
|
};
|
|
206
235
|
}
|
|
207
236
|
return {
|
|
208
|
-
status: "
|
|
209
|
-
exitCode:
|
|
210
|
-
|
|
211
|
-
|
|
237
|
+
status: "informational",
|
|
238
|
+
exitCode: r.status ?? null,
|
|
239
|
+
durationMs,
|
|
240
|
+
warnCount,
|
|
212
241
|
};
|
|
213
242
|
}
|
|
243
|
+
return {
|
|
244
|
+
status: "failed",
|
|
245
|
+
exitCode: r.status ?? null,
|
|
246
|
+
message: r.error ? r.error.message : `exit ${r.status}`,
|
|
247
|
+
durationMs,
|
|
248
|
+
warnCount,
|
|
249
|
+
};
|
|
214
250
|
}
|
|
215
251
|
|
|
216
252
|
function fmtMs(ms) {
|
|
@@ -258,8 +294,16 @@ function main() {
|
|
|
258
294
|
: "✗";
|
|
259
295
|
const timing = fmtMs(outcome.durationMs);
|
|
260
296
|
const timingSuffix = timing ? ` (${timing})` : "";
|
|
297
|
+
// F21 — surface WARN counts so a gate that "passed (3 warnings)" is
|
|
298
|
+
// distinguishable from one that passed cleanly. Pre-fix, warnings
|
|
299
|
+
// printed by individual gates (validate-cve-catalog, lint-skills,
|
|
300
|
+
// validate-playbooks) scrolled past invisible in the summary.
|
|
301
|
+
const warnSuffix =
|
|
302
|
+
outcome.warnCount && outcome.warnCount > 0
|
|
303
|
+
? ` (${outcome.warnCount} warning${outcome.warnCount === 1 ? "" : "s"})`
|
|
304
|
+
: "";
|
|
261
305
|
process.stdout.write(
|
|
262
|
-
` ${icon} ${gate.name.padEnd(widest)} ${outcome.status}${timingSuffix}\n`
|
|
306
|
+
` ${icon} ${gate.name.padEnd(widest)} ${outcome.status}${warnSuffix}${timingSuffix}\n`
|
|
263
307
|
);
|
|
264
308
|
}
|
|
265
309
|
|
|
@@ -11,10 +11,22 @@
|
|
|
11
11
|
* blindly — read the breaking-change list first. A breaking change is
|
|
12
12
|
* a surface narrowing every downstream consumer needs to know about.
|
|
13
13
|
*
|
|
14
|
+
* Audit G F5 — commitOnly mode. Pass `--commit-only` (or set the env
|
|
15
|
+
* EXCEPTD_SNAPSHOT_AUDIT_ACK=1) to acknowledge that the operator
|
|
16
|
+
* deliberately wants to overwrite the committed snapshot. When neither
|
|
17
|
+
* flag nor env is set AND the snapshot would actually change, the
|
|
18
|
+
* script refuses and emits a structured diff hint. This stops an
|
|
19
|
+
* accidental `npm run refresh-snapshot` (run as muscle-memory while
|
|
20
|
+
* triaging a failing gate) from masking a real breaking change.
|
|
21
|
+
*
|
|
14
22
|
* Usage:
|
|
15
|
-
* node scripts/refresh-manifest-snapshot.js
|
|
16
|
-
*
|
|
17
|
-
*
|
|
23
|
+
* node scripts/refresh-manifest-snapshot.js # dry-shows the diff
|
|
24
|
+
* EXCEPTD_SNAPSHOT_AUDIT_ACK=1 \
|
|
25
|
+
* node scripts/refresh-manifest-snapshot.js # writes the new snapshot
|
|
26
|
+
* node scripts/refresh-manifest-snapshot.js --commit-only # same thing, on argv
|
|
27
|
+
*
|
|
28
|
+
* The flag is documented in scripts/predeploy.js so contributors see it
|
|
29
|
+
* the moment the snapshot gate fails.
|
|
18
30
|
*/
|
|
19
31
|
|
|
20
32
|
const fs = require("fs");
|
|
@@ -50,8 +62,47 @@ function captureSurface(manifest) {
|
|
|
50
62
|
|
|
51
63
|
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8"));
|
|
52
64
|
const snapshot = captureSurface(manifest);
|
|
65
|
+
const newJson = JSON.stringify(snapshot, null, 2) + "\n";
|
|
66
|
+
|
|
67
|
+
// F5 — refuse to overwrite an existing snapshot unless the operator
|
|
68
|
+
// has explicitly acknowledged the rewrite (env or --commit-only flag).
|
|
69
|
+
const argv = process.argv.slice(2);
|
|
70
|
+
const commitOnly =
|
|
71
|
+
argv.includes("--commit-only") ||
|
|
72
|
+
process.env.EXCEPTD_SNAPSHOT_AUDIT_ACK === "1";
|
|
53
73
|
|
|
54
|
-
fs.
|
|
74
|
+
if (fs.existsSync(SNAPSHOT_PATH) && !commitOnly) {
|
|
75
|
+
const current = fs.readFileSync(SNAPSHOT_PATH, "utf8");
|
|
76
|
+
// Normalise the _generated_at timestamp for comparison — that field
|
|
77
|
+
// changes every run and shouldn't trigger the guard.
|
|
78
|
+
const stripGenerated = (s) => s.replace(
|
|
79
|
+
/"_generated_at":\s*"[^"]+",?\s*\n?/, ""
|
|
80
|
+
);
|
|
81
|
+
if (stripGenerated(current) === stripGenerated(newJson)) {
|
|
82
|
+
console.log("[refresh-manifest-snapshot] snapshot unchanged — nothing to do.");
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
"[refresh-manifest-snapshot] REFUSING to overwrite manifest-snapshot.json — " +
|
|
87
|
+
"the captured surface differs from the committed snapshot.\n" +
|
|
88
|
+
" Re-run with `--commit-only` (or EXCEPTD_SNAPSHOT_AUDIT_ACK=1) to confirm " +
|
|
89
|
+
"the rewrite is intentional. The check-manifest-snapshot.js gate exists to " +
|
|
90
|
+
"force a deliberate decision about removed skills / triggers / refs before " +
|
|
91
|
+
"the baseline is rewritten.\n"
|
|
92
|
+
);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fs.writeFileSync(SNAPSHOT_PATH, newJson, "utf8");
|
|
55
97
|
|
|
56
98
|
console.log(`[refresh-manifest-snapshot] wrote ${snapshot.skill_count} skills to manifest-snapshot.json`);
|
|
57
99
|
console.log("[refresh-manifest-snapshot] commit this file alongside the surface change.");
|
|
100
|
+
|
|
101
|
+
// Audit G F23 — write a tracked SHA-256 of the snapshot so the
|
|
102
|
+
// check-manifest-snapshot.js gate can verify integrity (no hand edits
|
|
103
|
+
// after refresh).
|
|
104
|
+
const crypto = require("crypto");
|
|
105
|
+
const snapshotSha = crypto.createHash("sha256").update(newJson).digest("hex");
|
|
106
|
+
const snapshotShaPath = path.join(ROOT, "manifest-snapshot.sha256");
|
|
107
|
+
fs.writeFileSync(snapshotShaPath, snapshotSha + " manifest-snapshot.json\n", "utf8");
|
|
108
|
+
console.log(`[refresh-manifest-snapshot] wrote integrity hash to manifest-snapshot.sha256`);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* scripts/validate-vendor-online.js — Audit G F6.
|
|
5
|
+
*
|
|
6
|
+
* Optional, network-touching companion to lib/validate-vendor.js. For every
|
|
7
|
+
* file recorded in vendor/blamejs/_PROVENANCE.json, fetches the upstream
|
|
8
|
+
* blob from github.com/<source_repo>/blob/<pinned_commit>/<upstream_path>
|
|
9
|
+
* (via the raw.githubusercontent.com mirror), hashes it, and compares the
|
|
10
|
+
* result against the `upstream_sha256_at_pin` recorded in _PROVENANCE.json.
|
|
11
|
+
*
|
|
12
|
+
* This catches the class where _PROVENANCE.json was hand-edited to
|
|
13
|
+
* advertise a `upstream_sha256_at_pin` that does not actually match what
|
|
14
|
+
* upstream had at that commit. lib/validate-vendor.js only checks that the
|
|
15
|
+
* local vendored file matches its own recorded hash — that's self-attesting.
|
|
16
|
+
* This script extends the check to upstream, closing the gap.
|
|
17
|
+
*
|
|
18
|
+
* Not part of `npm run predeploy` by default — the predeploy gate sequence
|
|
19
|
+
* must remain network-independent (offline gates only). Run manually:
|
|
20
|
+
*
|
|
21
|
+
* node scripts/validate-vendor-online.js
|
|
22
|
+
* node scripts/validate-vendor-online.js --timeout 30000
|
|
23
|
+
* node scripts/validate-vendor-online.js --json
|
|
24
|
+
*
|
|
25
|
+
* Exit codes:
|
|
26
|
+
* 0 every vendored file's upstream_sha256_at_pin matched upstream
|
|
27
|
+
* 1 at least one mismatch
|
|
28
|
+
* 2 runtime / network error
|
|
29
|
+
*
|
|
30
|
+
* Zero npm deps. Node 24 stdlib only.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require("fs");
|
|
34
|
+
const path = require("path");
|
|
35
|
+
const crypto = require("crypto");
|
|
36
|
+
const https = require("https");
|
|
37
|
+
|
|
38
|
+
const ROOT = path.join(__dirname, "..");
|
|
39
|
+
const PROV_PATH = path.join(ROOT, "vendor", "blamejs", "_PROVENANCE.json");
|
|
40
|
+
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const out = { timeoutMs: 15000, json: false };
|
|
43
|
+
for (let i = 2; i < argv.length; i++) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
if (a === "--timeout") out.timeoutMs = Number(argv[++i]) || out.timeoutMs;
|
|
46
|
+
else if (a === "--json") out.json = true;
|
|
47
|
+
else if (a === "--help" || a === "-h") {
|
|
48
|
+
process.stdout.write(
|
|
49
|
+
"Usage: node scripts/validate-vendor-online.js [--timeout <ms>] [--json]\n"
|
|
50
|
+
);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
} else {
|
|
53
|
+
process.stderr.write(`Unknown argument: ${a}\n`);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rawUrlForPin(sourceRepo, commit, upstreamPath) {
|
|
61
|
+
// Translate https://github.com/owner/repo → raw.githubusercontent.com/owner/repo
|
|
62
|
+
// sourceRepo may end in .git; strip it. Tolerate trailing slash.
|
|
63
|
+
const m = (sourceRepo || "").match(
|
|
64
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/
|
|
65
|
+
);
|
|
66
|
+
if (!m) return null;
|
|
67
|
+
const [, owner, repo] = m;
|
|
68
|
+
const cleanPath = String(upstreamPath || "").replace(/^\/+/, "");
|
|
69
|
+
return `https://raw.githubusercontent.com/${owner}/${repo}/${commit}/${cleanPath}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const MAX_REDIRECTS = 5;
|
|
73
|
+
|
|
74
|
+
function fetchBuffer(url, timeoutMs, redirectsLeft = MAX_REDIRECTS) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const req = https.get(url, (res) => {
|
|
77
|
+
// v0.12.14 (codex P2): cap redirect hops. A redirect loop (or a
|
|
78
|
+
// hostile / mis-configured upstream that keeps returning 3xx with
|
|
79
|
+
// Location pointing back to itself) used to recurse until stack
|
|
80
|
+
// overflow or hang. Now: count hops, fail clean on exhaustion.
|
|
81
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
82
|
+
res.resume();
|
|
83
|
+
if (redirectsLeft <= 0) {
|
|
84
|
+
return reject(new Error(`exceeded ${MAX_REDIRECTS} redirects fetching ${url}`));
|
|
85
|
+
}
|
|
86
|
+
return resolve(fetchBuffer(res.headers.location, timeoutMs, redirectsLeft - 1));
|
|
87
|
+
}
|
|
88
|
+
if (res.statusCode !== 200) {
|
|
89
|
+
res.resume();
|
|
90
|
+
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
91
|
+
}
|
|
92
|
+
const chunks = [];
|
|
93
|
+
res.on("data", (c) => chunks.push(c));
|
|
94
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
95
|
+
res.on("error", reject);
|
|
96
|
+
});
|
|
97
|
+
req.on("error", reject);
|
|
98
|
+
req.setTimeout(timeoutMs, () => {
|
|
99
|
+
req.destroy(new Error(`timeout after ${timeoutMs}ms fetching ${url}`));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function main() {
|
|
105
|
+
const opts = parseArgs(process.argv);
|
|
106
|
+
if (!fs.existsSync(PROV_PATH)) {
|
|
107
|
+
process.stderr.write(`vendor/blamejs/_PROVENANCE.json missing\n`);
|
|
108
|
+
process.exitCode = 2;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const prov = JSON.parse(fs.readFileSync(PROV_PATH, "utf8"));
|
|
112
|
+
const sourceRepo = prov.source_repo;
|
|
113
|
+
const pinnedCommit = prov.pinned_commit;
|
|
114
|
+
if (!sourceRepo || !pinnedCommit) {
|
|
115
|
+
process.stderr.write(`_PROVENANCE.json missing source_repo or pinned_commit\n`);
|
|
116
|
+
process.exitCode = 2;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const findings = [];
|
|
121
|
+
for (const [name, info] of Object.entries(prov.files || {})) {
|
|
122
|
+
const url = rawUrlForPin(sourceRepo, pinnedCommit, info.upstream_path);
|
|
123
|
+
if (!url) {
|
|
124
|
+
findings.push({ name, ok: false, reason: `cannot compute raw URL for ${sourceRepo}` });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const buf = await fetchBuffer(url, opts.timeoutMs);
|
|
129
|
+
const sha = crypto.createHash("sha256").update(buf).digest("hex");
|
|
130
|
+
if (info.upstream_sha256_at_pin && sha !== info.upstream_sha256_at_pin) {
|
|
131
|
+
findings.push({
|
|
132
|
+
name,
|
|
133
|
+
ok: false,
|
|
134
|
+
reason:
|
|
135
|
+
`upstream sha mismatch: recorded ${String(info.upstream_sha256_at_pin).slice(0, 12)}…, ` +
|
|
136
|
+
`live ${sha.slice(0, 12)}…`,
|
|
137
|
+
url,
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
findings.push({ name, ok: true, sha, url });
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
findings.push({ name, ok: false, reason: `fetch failed: ${e.message}`, url });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const failed = findings.filter((f) => !f.ok);
|
|
148
|
+
if (opts.json) {
|
|
149
|
+
process.stdout.write(JSON.stringify({ ok: failed.length === 0, findings }, null, 2) + "\n");
|
|
150
|
+
} else {
|
|
151
|
+
for (const f of findings) {
|
|
152
|
+
if (f.ok) process.stdout.write(`PASS ${f.name} ${f.sha.slice(0, 12)}…\n`);
|
|
153
|
+
else process.stdout.write(`FAIL ${f.name} ${f.reason}\n`);
|
|
154
|
+
}
|
|
155
|
+
process.stdout.write(
|
|
156
|
+
`\n${findings.length - failed.length}/${findings.length} vendored files match upstream pin.\n`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
process.exitCode = failed.length === 0 ? 0 : 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (require.main === module) {
|
|
163
|
+
main().catch((e) => {
|
|
164
|
+
process.stderr.write(`runtime error: ${e.message}\n`);
|
|
165
|
+
process.exitCode = 2;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { rawUrlForPin, fetchBuffer };
|
|
@@ -18,6 +18,20 @@
|
|
|
18
18
|
* The bug was invisible because CI's verify ran against the SOURCE tree,
|
|
19
19
|
* not the shipped tarball. This gate closes that gap.
|
|
20
20
|
*
|
|
21
|
+
* Audit G:
|
|
22
|
+
* F9 — After the first-pass extraction (using the source-tree parseTar),
|
|
23
|
+
* re-parse the tarball using the parseTar shipped INSIDE the
|
|
24
|
+
* extracted tree itself. If the two parses disagree, fail with a
|
|
25
|
+
* structured error. Catches the class where the shipped parser
|
|
26
|
+
* silently rejects entries the source parser accepts (or vice
|
|
27
|
+
* versa), which would mean operators run a different extractor
|
|
28
|
+
* than CI exercised.
|
|
29
|
+
* F15 — Invoke `npm pack --offline` so the gate cannot be blocked by
|
|
30
|
+
* registry reachability problems during predeploy.
|
|
31
|
+
* F4 — Cross-check the extracted public.pem against
|
|
32
|
+
* keys/EXPECTED_FINGERPRINT (warn-and-continue when missing, fail
|
|
33
|
+
* when present-but-mismatched and KEYS_ROTATED != 1).
|
|
34
|
+
*
|
|
21
35
|
* Exit codes:
|
|
22
36
|
* 0 verify passed against the packed tarball
|
|
23
37
|
* 1 verify failed against the packed tarball (the bug class above)
|
|
@@ -31,6 +45,19 @@ const path = require("path");
|
|
|
31
45
|
const os = require("os");
|
|
32
46
|
const { spawnSync } = require("child_process");
|
|
33
47
|
|
|
48
|
+
// v0.12.16 (audit I P1-1): mirror the byte-stability normalize() contract
|
|
49
|
+
// from lib/sign.js + lib/verify.js + lib/refresh-network.js. Duplicated
|
|
50
|
+
// (not require'd) to keep this script's dep surface minimal and to ensure
|
|
51
|
+
// a bug in the normalize() implementation in lib/ doesn't simultaneously
|
|
52
|
+
// disable both the source-tree-verify path AND the shipped-tarball-verify
|
|
53
|
+
// gate (we want at least one independent check). ANY change to normalize()
|
|
54
|
+
// in any of these four files must be mirrored in all of them.
|
|
55
|
+
function normalizeSkillBytes(buf) {
|
|
56
|
+
let s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
|
|
57
|
+
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
58
|
+
return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
const ROOT = path.resolve(__dirname, "..");
|
|
35
62
|
|
|
36
63
|
function emit(msg) { process.stdout.write(`[verify-shipped-tarball] ${msg}\n`); }
|
|
@@ -42,7 +69,10 @@ function fail(msg, code = 1) {
|
|
|
42
69
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "verify-shipped-"));
|
|
43
70
|
try {
|
|
44
71
|
emit(`packing into ${tmpRoot} ...`);
|
|
45
|
-
|
|
72
|
+
// F15 — pass --offline. Predeploy must run without registry
|
|
73
|
+
// reachability; `npm pack` does not need the network for a local
|
|
74
|
+
// package and forcing offline mode hard-locks the assumption.
|
|
75
|
+
const pack = spawnSync("npm", ["pack", "--offline", "--pack-destination", tmpRoot], {
|
|
46
76
|
cwd: ROOT,
|
|
47
77
|
encoding: "utf8",
|
|
48
78
|
shell: process.platform === "win32",
|
|
@@ -60,10 +90,10 @@ try {
|
|
|
60
90
|
const extractDir = path.join(tmpRoot, "extract");
|
|
61
91
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
62
92
|
const zlib = require("zlib");
|
|
63
|
-
const { parseTar } = require(path.join(ROOT, "lib", "refresh-network.js"));
|
|
93
|
+
const { parseTar: parseTarSource } = require(path.join(ROOT, "lib", "refresh-network.js"));
|
|
64
94
|
const tgz = fs.readFileSync(tarballPath);
|
|
65
95
|
const tarBuf = zlib.gunzipSync(tgz);
|
|
66
|
-
const entries =
|
|
96
|
+
const entries = parseTarSource(tarBuf);
|
|
67
97
|
for (const e of entries) {
|
|
68
98
|
if (!e.name) continue;
|
|
69
99
|
const dst = path.join(extractDir, e.name);
|
|
@@ -77,6 +107,65 @@ try {
|
|
|
77
107
|
}
|
|
78
108
|
emit(`extracted to ${pkgRoot}`);
|
|
79
109
|
|
|
110
|
+
// Audit G F9 — load the extracted tree's OWN parseTar and re-parse the
|
|
111
|
+
// tarball. If the two parsers diverge on entry list or content, the
|
|
112
|
+
// gate trips: this means CI exercised a different parser than operators
|
|
113
|
+
// will. Defense against drift between source and shipped tarball when
|
|
114
|
+
// someone edits lib/refresh-network.js without re-vendoring or vice
|
|
115
|
+
// versa.
|
|
116
|
+
const shippedParserPath = path.join(pkgRoot, "lib", "refresh-network.js");
|
|
117
|
+
if (!fs.existsSync(shippedParserPath)) {
|
|
118
|
+
fail(`extracted tree missing lib/refresh-network.js (cannot run F9 cross-parse check)`, 2);
|
|
119
|
+
}
|
|
120
|
+
let parseTarShipped;
|
|
121
|
+
try {
|
|
122
|
+
parseTarShipped = require(shippedParserPath).parseTar;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
fail(`failed to load extracted parseTar: ${e.message}`, 2);
|
|
125
|
+
}
|
|
126
|
+
if (typeof parseTarShipped !== "function") {
|
|
127
|
+
fail(`extracted lib/refresh-network.js does not export parseTar`, 2);
|
|
128
|
+
}
|
|
129
|
+
const shippedEntries = parseTarShipped(tarBuf);
|
|
130
|
+
// Compare counts first — fast bailout.
|
|
131
|
+
const divergences = [];
|
|
132
|
+
if (shippedEntries.length !== entries.length) {
|
|
133
|
+
divergences.push(
|
|
134
|
+
`entry count divergence: source-tree parser produced ${entries.length}, ` +
|
|
135
|
+
`shipped parser produced ${shippedEntries.length}`
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
// Walk in parallel; tarball entry order is deterministic so positional
|
|
139
|
+
// compare is correct. Compare name + byte length + body bytes.
|
|
140
|
+
for (let i = 0; i < entries.length; i++) {
|
|
141
|
+
const a = entries[i];
|
|
142
|
+
const b = shippedEntries[i];
|
|
143
|
+
if (a.name !== b.name) {
|
|
144
|
+
divergences.push(`entry[${i}] name mismatch: source=${a.name} shipped=${b.name}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const aBuf = Buffer.isBuffer(a.body) ? a.body : Buffer.from(a.body);
|
|
148
|
+
const bBuf = Buffer.isBuffer(b.body) ? b.body : Buffer.from(b.body);
|
|
149
|
+
if (aBuf.length !== bBuf.length || !aBuf.equals(bBuf)) {
|
|
150
|
+
divergences.push(
|
|
151
|
+
`entry[${i}] (${a.name}) body bytes differ between source-tree and shipped parser ` +
|
|
152
|
+
`(source ${aBuf.length} bytes vs shipped ${bBuf.length} bytes)`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (divergences.length > 0) {
|
|
158
|
+
emit(`*** F9: parseTar divergence between source-tree and shipped tree ***`);
|
|
159
|
+
for (const d of divergences.slice(0, 5)) emit(` - ${d}`);
|
|
160
|
+
if (divergences.length > 5) emit(` ... and ${divergences.length - 5} more`);
|
|
161
|
+
fail(
|
|
162
|
+
`parseTar implementations diverge between source tree and shipped tarball. ` +
|
|
163
|
+
`Operators will run a different extractor than CI exercised. Refusing to publish.`,
|
|
164
|
+
1
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
emit(`F9: source-tree and shipped parseTar agree on ${entries.length} entries`);
|
|
168
|
+
|
|
80
169
|
// Run the verifier inline against the extracted package tree. This avoids
|
|
81
170
|
// having to spawn a separate process whose cwd resolution differs across
|
|
82
171
|
// platforms.
|
|
@@ -108,6 +197,33 @@ try {
|
|
|
108
197
|
emit(`*** Something between sign and pack is swapping the key. Verify will fail below. ***`);
|
|
109
198
|
}
|
|
110
199
|
|
|
200
|
+
// Audit G F4 — key-pin cross-check against the EXTRACTED tree. The pin
|
|
201
|
+
// is consumed from keys/EXPECTED_FINGERPRINT in the extracted package —
|
|
202
|
+
// that's the file operators will actually receive on `npm install`.
|
|
203
|
+
// Warn when absent, fail when present-but-mismatched (unless KEYS_ROTATED).
|
|
204
|
+
const expectedFpPath = path.join(pkgRoot, "keys", "EXPECTED_FINGERPRINT");
|
|
205
|
+
if (fs.existsSync(expectedFpPath)) {
|
|
206
|
+
const raw = fs.readFileSync(expectedFpPath, "utf8").trim();
|
|
207
|
+
const firstLine = raw.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || "";
|
|
208
|
+
const liveFpLine = `SHA256:${pubFp}`;
|
|
209
|
+
if (firstLine !== liveFpLine) {
|
|
210
|
+
if (process.env.KEYS_ROTATED === "1") {
|
|
211
|
+
emit(`WARN: extracted public.pem fingerprint ${liveFpLine} differs from pin ${firstLine}; KEYS_ROTATED=1 accepted`);
|
|
212
|
+
} else {
|
|
213
|
+
fail(
|
|
214
|
+
`keys/EXPECTED_FINGERPRINT (${firstLine}) does not match the extracted ` +
|
|
215
|
+
`public.pem fingerprint (${liveFpLine}). If this is an intentional rotation ` +
|
|
216
|
+
`set KEYS_ROTATED=1 and commit the new pin.`,
|
|
217
|
+
1
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
emit(`F4: key pin verified — ${liveFpLine} matches keys/EXPECTED_FINGERPRINT`);
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
emit(`WARN: keys/EXPECTED_FINGERPRINT not in extracted tree — key-pin check skipped`);
|
|
225
|
+
}
|
|
226
|
+
|
|
111
227
|
let pass = 0, miss = 0, fail_count = 0;
|
|
112
228
|
const failures = [];
|
|
113
229
|
for (const s of (manifest.skills || [])) {
|
|
@@ -118,22 +234,38 @@ try {
|
|
|
118
234
|
failures.push(`${s.name}: file not found at ${s.path}`);
|
|
119
235
|
continue;
|
|
120
236
|
}
|
|
121
|
-
|
|
122
|
-
|
|
237
|
+
// v0.12.16 (audit I P1-1): the prior code passed the raw file bytes
|
|
238
|
+
// directly to crypto.verify. lib/sign.js + lib/verify.js both NORMALIZE
|
|
239
|
+
// bytes (strip UTF-8 BOM, convert CRLF -> LF) before sign/verify, per
|
|
240
|
+
// the byte-stability contract in lib/verify.js's normalize() header.
|
|
241
|
+
// Without the same normalization here, this gate (which was added
|
|
242
|
+
// specifically to catch the v0.11.x signature regression class!) would
|
|
243
|
+
// itself report 0/38 on any tree where line-ending normalization
|
|
244
|
+
// touched the source between sign and pack — a Windows contributor
|
|
245
|
+
// with `core.autocrlf=true`, or a tool like Prettier between sign and
|
|
246
|
+
// pack. CLAUDE.md flags this as the recurring CRLF-bypass class.
|
|
247
|
+
const rawContent = fs.readFileSync(skillPath);
|
|
248
|
+
const normalizedContent = normalizeSkillBytes(rawContent);
|
|
249
|
+
const ok = crypto.verify(null, normalizedContent, pubKey, Buffer.from(s.signature, "base64"));
|
|
123
250
|
if (ok) pass++;
|
|
124
251
|
else {
|
|
125
252
|
fail_count++;
|
|
126
253
|
// Forensic detail: log size + sha256 of tarball-extracted content vs source-tree content
|
|
127
254
|
// so we can pinpoint which bytes changed between npm pack and what was signed.
|
|
128
|
-
|
|
255
|
+
// v0.12.16: forensic logging uses rawContent (pre-normalization
|
|
256
|
+
// bytes) so an operator inspecting failures sees the actual on-disk
|
|
257
|
+
// shape, but tarSha is computed over the NORMALIZED bytes that
|
|
258
|
+
// were actually fed to crypto.verify — making the comparison to
|
|
259
|
+
// sign-time bytes meaningful.
|
|
260
|
+
const tarSha = crypto.createHash("sha256").update(normalizedContent).digest("hex").slice(0, 16);
|
|
129
261
|
let srcSha = "<missing>", srcSize = 0, srcContent;
|
|
130
262
|
if (fs.existsSync(sourceSkillPath)) {
|
|
131
263
|
srcContent = fs.readFileSync(sourceSkillPath);
|
|
132
264
|
srcSize = srcContent.length;
|
|
133
|
-
srcSha = crypto.createHash("sha256").update(srcContent).digest("hex").slice(0, 16);
|
|
265
|
+
srcSha = crypto.createHash("sha256").update(normalizeSkillBytes(srcContent)).digest("hex").slice(0, 16);
|
|
134
266
|
}
|
|
135
|
-
const equal = srcContent &&
|
|
136
|
-
failures.push(`${s.name}: signature did not verify (tarball size=${
|
|
267
|
+
const equal = srcContent && rawContent.equals(srcContent) ? "equal" : "DIFFER";
|
|
268
|
+
failures.push(`${s.name}: signature did not verify (tarball size=${rawContent.length} sha-normalized=${tarSha}; source size=${srcSize} sha-normalized=${srcSha}; raw bytes ${equal})`);
|
|
137
269
|
}
|
|
138
270
|
}
|
|
139
271
|
|