@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/bin/exceptd.js +522 -27
  3. package/data/_indexes/_meta.json +45 -45
  4. package/data/_indexes/activity-feed.json +4 -4
  5. package/data/_indexes/catalog-summaries.json +29 -29
  6. package/data/_indexes/chains.json +3238 -3210
  7. package/data/_indexes/frequency.json +3 -0
  8. package/data/_indexes/jurisdiction-map.json +5 -3
  9. package/data/_indexes/section-offsets.json +712 -685
  10. package/data/_indexes/theater-fingerprints.json +1 -1
  11. package/data/_indexes/token-budget.json +355 -340
  12. package/data/atlas-ttps.json +144 -129
  13. package/data/attack-techniques.json +319 -76
  14. package/data/cve-catalog.json +516 -476
  15. package/data/cwe-catalog.json +1081 -759
  16. package/data/exploit-availability.json +63 -15
  17. package/data/framework-control-gaps.json +867 -843
  18. package/data/playbooks/ai-api.json +3 -1
  19. package/data/playbooks/containers.json +11 -3
  20. package/data/playbooks/cred-stores.json +3 -1
  21. package/data/playbooks/crypto-codebase.json +11 -11
  22. package/data/playbooks/crypto.json +1 -1
  23. package/data/playbooks/hardening.json +3 -1
  24. package/data/playbooks/kernel.json +3 -1
  25. package/data/playbooks/library-author.json +21 -10
  26. package/data/playbooks/mcp.json +1 -1
  27. package/data/playbooks/runtime.json +3 -1
  28. package/data/playbooks/sbom.json +2 -2
  29. package/data/playbooks/secrets.json +3 -1
  30. package/data/rfc-references.json +276 -276
  31. package/keys/EXPECTED_FINGERPRINT +1 -0
  32. package/lib/auto-discovery.js +57 -35
  33. package/lib/cross-ref-api.js +39 -6
  34. package/lib/cve-curation.js +33 -14
  35. package/lib/lint-skills.js +6 -1
  36. package/lib/playbook-runner.js +742 -78
  37. package/lib/prefetch.js +30 -8
  38. package/lib/refresh-external.js +40 -22
  39. package/lib/refresh-network.js +233 -17
  40. package/lib/scoring.js +191 -18
  41. package/lib/source-ghsa.js +219 -37
  42. package/lib/source-osv.js +381 -122
  43. package/lib/validate-catalog-meta.js +64 -9
  44. package/lib/validate-cve-catalog.js +56 -18
  45. package/lib/validate-indexes.js +88 -37
  46. package/lib/validate-playbooks.js +46 -0
  47. package/lib/verify.js +72 -0
  48. package/manifest-snapshot.json +1 -1
  49. package/manifest-snapshot.sha256 +1 -0
  50. package/manifest.json +73 -73
  51. package/orchestrator/dispatcher.js +21 -1
  52. package/orchestrator/event-bus.js +52 -8
  53. package/orchestrator/index.js +279 -20
  54. package/orchestrator/pipeline.js +63 -2
  55. package/orchestrator/scanner.js +32 -10
  56. package/orchestrator/scheduler.js +150 -17
  57. package/package.json +3 -1
  58. package/sbom.cdx.json +7 -7
  59. package/scripts/check-manifest-snapshot.js +32 -0
  60. package/scripts/check-sbom-currency.js +65 -3
  61. package/scripts/check-test-coverage.js +142 -19
  62. package/scripts/predeploy.js +83 -39
  63. package/scripts/refresh-manifest-snapshot.js +55 -4
  64. package/scripts/validate-vendor-online.js +169 -0
  65. package/scripts/verify-shipped-tarball.js +141 -9
  66. package/skills/ai-attack-surface/skill.md +18 -10
  67. package/skills/ai-c2-detection/skill.md +7 -2
  68. package/skills/ai-risk-management/skill.md +5 -4
  69. package/skills/api-security/skill.md +3 -3
  70. package/skills/attack-surface-pentest/skill.md +5 -5
  71. package/skills/cloud-security/skill.md +1 -1
  72. package/skills/compliance-theater/skill.md +8 -8
  73. package/skills/container-runtime-security/skill.md +1 -1
  74. package/skills/dlp-gap-analysis/skill.md +5 -1
  75. package/skills/email-security-anti-phishing/skill.md +1 -1
  76. package/skills/exploit-scoring/skill.md +18 -18
  77. package/skills/framework-gap-analysis/skill.md +6 -6
  78. package/skills/global-grc/skill.md +3 -2
  79. package/skills/identity-assurance/skill.md +2 -2
  80. package/skills/incident-response-playbook/skill.md +4 -4
  81. package/skills/kernel-lpe-triage/skill.md +21 -2
  82. package/skills/mcp-agent-trust/skill.md +17 -10
  83. package/skills/mlops-security/skill.md +2 -1
  84. package/skills/ot-ics-security/skill.md +1 -1
  85. package/skills/policy-exception-gen/skill.md +3 -3
  86. package/skills/pqc-first/skill.md +1 -1
  87. package/skills/rag-pipeline-security/skill.md +7 -3
  88. package/skills/researcher/skill.md +20 -3
  89. package/skills/sector-energy/skill.md +1 -1
  90. package/skills/sector-federal-government/skill.md +1 -1
  91. package/skills/sector-financial/skill.md +3 -3
  92. package/skills/sector-healthcare/skill.md +2 -2
  93. package/skills/security-maturity-tiers/skill.md +7 -7
  94. package/skills/skill-update-loop/skill.md +19 -3
  95. package/skills/supply-chain-integrity/skill.md +1 -1
  96. package/skills/threat-model-currency/skill.md +11 -11
  97. package/skills/threat-modeling-methodology/skill.md +3 -3
  98. package/skills/webapp-security/skill.md +1 -1
  99. package/skills/zeroday-gap-learn/skill.md +51 -7
  100. package/vendor/blamejs/_PROVENANCE.json +4 -1
  101. package/vendor/blamejs/worker-pool.js +38 -0
@@ -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
- name: "Validate offline CVE catalog state",
67
- command: process.execPath,
68
- args: [
69
- path.join(ROOT, "orchestrator", "index.js"),
70
- "validate-cves",
71
- "--offline",
72
- "--no-fail",
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 only — surfaces the forward_watch horizon across all
101
- // skills as a sanity signal. Emits the count but never fails the run;
102
- // a parse problem is reported, not blocking.
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
- try {
196
- execFileSync(gate.command, gate.args, { stdio: "inherit", cwd: ROOT });
197
- return { status: "passed", durationMs: Date.now() - t0 };
198
- } catch (e) {
199
- if (gate.informational) {
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: "informational",
202
- exitCode: e.status ?? null,
203
- message: e.message,
204
- durationMs: Date.now() - t0,
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: "failed",
209
- exitCode: e.status ?? null,
210
- message: e.message,
211
- durationMs: Date.now() - t0,
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
- * git add manifest-snapshot.json
17
- * git commit -m "refresh manifest snapshot: <what changed>"
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.writeFileSync(SNAPSHOT_PATH, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
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
- const pack = spawnSync("npm", ["pack", "--pack-destination", tmpRoot], {
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 = parseTar(tarBuf);
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
- const content = fs.readFileSync(skillPath);
122
- const ok = crypto.verify(null, content, pubKey, Buffer.from(s.signature, "base64"));
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
- const tarSha = crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
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 && content.equals(srcContent) ? "equal" : "DIFFER";
136
- failures.push(`${s.name}: signature did not verify (tarball size=${content.length} sha=${tarSha}; source size=${srcSize} sha=${srcSha}; bytes ${equal})`);
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