@blamejs/exceptd-skills 0.12.13 → 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.
Files changed (87) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/bin/exceptd.js +147 -9
  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 +515 -475
  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/rfc-references.json +276 -276
  19. package/keys/EXPECTED_FINGERPRINT +1 -0
  20. package/lib/auto-discovery.js +21 -4
  21. package/lib/cross-ref-api.js +39 -6
  22. package/lib/cve-curation.js +18 -5
  23. package/lib/lint-skills.js +6 -1
  24. package/lib/playbook-runner.js +742 -78
  25. package/lib/refresh-external.js +40 -22
  26. package/lib/refresh-network.js +193 -17
  27. package/lib/scoring.js +20 -7
  28. package/lib/source-ghsa.js +219 -37
  29. package/lib/source-osv.js +381 -122
  30. package/lib/validate-catalog-meta.js +64 -9
  31. package/lib/validate-cve-catalog.js +56 -18
  32. package/lib/validate-indexes.js +88 -37
  33. package/lib/verify.js +72 -0
  34. package/manifest-snapshot.json +1 -1
  35. package/manifest-snapshot.sha256 +1 -0
  36. package/manifest.json +73 -73
  37. package/orchestrator/dispatcher.js +21 -1
  38. package/orchestrator/event-bus.js +52 -8
  39. package/orchestrator/index.js +279 -20
  40. package/orchestrator/pipeline.js +63 -2
  41. package/orchestrator/scanner.js +32 -10
  42. package/orchestrator/scheduler.js +150 -17
  43. package/package.json +3 -1
  44. package/sbom.cdx.json +7 -7
  45. package/scripts/check-manifest-snapshot.js +32 -0
  46. package/scripts/check-sbom-currency.js +65 -3
  47. package/scripts/check-test-coverage.js +142 -19
  48. package/scripts/predeploy.js +83 -39
  49. package/scripts/refresh-manifest-snapshot.js +55 -4
  50. package/scripts/validate-vendor-online.js +169 -0
  51. package/scripts/verify-shipped-tarball.js +106 -3
  52. package/skills/ai-attack-surface/skill.md +18 -10
  53. package/skills/ai-c2-detection/skill.md +7 -2
  54. package/skills/ai-risk-management/skill.md +5 -4
  55. package/skills/api-security/skill.md +3 -3
  56. package/skills/attack-surface-pentest/skill.md +5 -5
  57. package/skills/cloud-security/skill.md +1 -1
  58. package/skills/compliance-theater/skill.md +8 -8
  59. package/skills/container-runtime-security/skill.md +1 -1
  60. package/skills/dlp-gap-analysis/skill.md +5 -1
  61. package/skills/email-security-anti-phishing/skill.md +1 -1
  62. package/skills/exploit-scoring/skill.md +18 -18
  63. package/skills/framework-gap-analysis/skill.md +6 -6
  64. package/skills/global-grc/skill.md +3 -2
  65. package/skills/identity-assurance/skill.md +2 -2
  66. package/skills/incident-response-playbook/skill.md +4 -4
  67. package/skills/kernel-lpe-triage/skill.md +21 -2
  68. package/skills/mcp-agent-trust/skill.md +17 -10
  69. package/skills/mlops-security/skill.md +2 -1
  70. package/skills/ot-ics-security/skill.md +1 -1
  71. package/skills/policy-exception-gen/skill.md +3 -3
  72. package/skills/pqc-first/skill.md +1 -1
  73. package/skills/rag-pipeline-security/skill.md +7 -3
  74. package/skills/researcher/skill.md +20 -3
  75. package/skills/sector-energy/skill.md +1 -1
  76. package/skills/sector-federal-government/skill.md +1 -1
  77. package/skills/sector-financial/skill.md +3 -3
  78. package/skills/sector-healthcare/skill.md +2 -2
  79. package/skills/security-maturity-tiers/skill.md +7 -7
  80. package/skills/skill-update-loop/skill.md +19 -3
  81. package/skills/supply-chain-integrity/skill.md +1 -1
  82. package/skills/threat-model-currency/skill.md +11 -11
  83. package/skills/threat-modeling-methodology/skill.md +3 -3
  84. package/skills/webapp-security/skill.md +1 -1
  85. package/skills/zeroday-gap-learn/skill.md +51 -7
  86. package/vendor/blamejs/_PROVENANCE.json +4 -1
  87. package/vendor/blamejs/worker-pool.js +38 -0
@@ -52,15 +52,17 @@ const PLACEHOLDER_TOKENS = [
52
52
  ];
53
53
 
54
54
  function parseArgs(argv) {
55
- const opts = { quiet: false };
55
+ const opts = { quiet: false, strict: false };
56
56
  for (let i = 2; i < argv.length; i++) {
57
57
  const a = argv[i];
58
58
  if (a === '--quiet' || a === '-q') opts.quiet = true;
59
+ else if (a === '--strict') opts.strict = true;
59
60
  else if (a === '--help' || a === '-h') {
60
61
  console.log(
61
- 'Usage: node lib/validate-catalog-meta.js [--quiet]\n' +
62
+ 'Usage: node lib/validate-catalog-meta.js [--quiet] [--strict]\n' +
62
63
  '\n' +
63
- ' --quiet Suppress per-catalog PASS output; show failures only.\n',
64
+ ' --quiet Suppress per-catalog PASS output; show failures only.\n' +
65
+ ' --strict Promote v0.13.0-preview warnings (freshness) to errors.\n',
64
66
  );
65
67
  process.exit(0);
66
68
  } else {
@@ -80,8 +82,9 @@ function containsPlaceholder(s) {
80
82
  return PLACEHOLDER_TOKENS.some((re) => re.test(s));
81
83
  }
82
84
 
83
- function validateMeta(catalogPath) {
85
+ function validateMeta(catalogPath, opts) {
84
86
  const errors = [];
87
+ const warnings = [];
85
88
  const data = readJson(catalogPath);
86
89
  const meta = data._meta;
87
90
 
@@ -159,8 +162,46 @@ function validateMeta(catalogPath) {
159
162
  );
160
163
  }
161
164
  }
165
+
166
+ /* Audit G F3 — freshness enforcement. When both meta.last_updated and
167
+ * freshness_policy.stale_after_days are present, surface a warning if
168
+ * (now - last_updated) > stale_after_days. Patch-class release emits at
169
+ * WARN level (does not fail validation); v0.13.0 will flip to an error.
170
+ *
171
+ * Optional `opts.strict` (or `opts.errorOnStale`) promotes the warning
172
+ * to an error today; predeploy keeps the warning posture.
173
+ */
174
+ if (
175
+ typeof meta.last_updated === 'string' &&
176
+ typeof fp.stale_after_days === 'number' &&
177
+ fp.stale_after_days > 0
178
+ ) {
179
+ const lu = new Date(meta.last_updated + (
180
+ /^\d{4}-\d{2}-\d{2}$/.test(meta.last_updated) ? 'T00:00:00Z' : ''
181
+ ));
182
+ if (!Number.isNaN(lu.getTime())) {
183
+ const ageDays = Math.floor((Date.now() - lu.getTime()) / 86400000);
184
+ if (ageDays > fp.stale_after_days) {
185
+ const msg =
186
+ `_meta freshness: last_updated ${meta.last_updated} is ${ageDays} days old ` +
187
+ `(stale_after_days = ${fp.stale_after_days}); refresh the catalog or bump _meta.last_updated. ` +
188
+ `Will hard-fail in v0.13.0.`;
189
+ if (opts && (opts.strict || opts.errorOnStale)) {
190
+ errors.push(msg);
191
+ } else {
192
+ warnings.push(msg);
193
+ }
194
+ }
195
+ }
196
+ }
162
197
  }
163
198
 
199
+ // Warnings are appended after errors when callers ask for the combined
200
+ // shape via opts.includeWarnings. Default return is errors only so the
201
+ // public function signature is unchanged for existing callers.
202
+ if (opts && opts.includeWarnings) {
203
+ return { errors, warnings };
204
+ }
164
205
  return errors;
165
206
  }
166
207
 
@@ -172,23 +213,37 @@ function main() {
172
213
  .sort();
173
214
 
174
215
  let failed = 0;
216
+ let warned = 0;
175
217
  for (const f of files) {
176
- const errors = validateMeta(path.join(DATA_DIR, f));
177
- if (errors.length === 0) {
218
+ const result = validateMeta(path.join(DATA_DIR, f), {
219
+ includeWarnings: true,
220
+ strict: opts.strict,
221
+ });
222
+ const errors = result.errors;
223
+ const warnings = result.warnings || [];
224
+ if (errors.length === 0 && warnings.length === 0) {
178
225
  if (!opts.quiet) console.log(`PASS ${f}`);
226
+ } else if (errors.length === 0) {
227
+ warned++;
228
+ if (!opts.quiet) console.log(`WARN ${f}`);
229
+ for (const w of warnings) console.log(` - [warn] ${w}`);
179
230
  } else {
180
231
  failed++;
181
232
  console.log(`FAIL ${f}`);
182
233
  for (const e of errors) console.log(` - ${e}`);
234
+ for (const w of warnings) console.log(` - [warn] ${w}`);
183
235
  }
184
236
  }
185
237
 
186
238
  const total = files.length;
187
- const passed = total - failed;
239
+ const passed = total - failed - warned;
240
+ const warnSuffix = warned ? `, ${warned} with warnings` : '';
241
+ const failSuffix = failed ? `, ${failed} failed` : '';
188
242
  console.log(
189
- `\n${passed}/${total} catalogs validated${failed ? `, ${failed} failed` : ''}.`,
243
+ `\n${passed}/${total} catalogs validated${warnSuffix}${failSuffix}.`,
190
244
  );
191
- process.exit(failed === 0 ? 0 : 1);
245
+ // F18: process.exitCode + return so buffered writes drain.
246
+ process.exitCode = failed === 0 ? 0 : 1;
192
247
  }
193
248
 
194
249
  if (require.main === module) {
@@ -33,6 +33,9 @@ const CATALOG_PATH = path.join(REPO_ROOT, 'data', 'cve-catalog.json');
33
33
  const LESSONS_PATH = path.join(REPO_ROOT, 'data', 'zeroday-lessons.json');
34
34
  const ATLAS_PATH = path.join(REPO_ROOT, 'data', 'atlas-ttps.json');
35
35
  const CWE_PATH = path.join(REPO_ROOT, 'data', 'cwe-catalog.json');
36
+ const ATTACK_PATH = path.join(REPO_ROOT, 'data', 'attack-techniques.json');
37
+ const D3FEND_PATH = path.join(REPO_ROOT, 'data', 'd3fend-catalog.json');
38
+ const FRAMEWORK_GAPS_PATH = path.join(REPO_ROOT, 'data', 'framework-control-gaps.json');
36
39
 
37
40
  // v0.12.12 — patterns that mark a verification_sources URL as a public exploit
38
41
  // or PoC location. When poc_available: true AND a verification source matches
@@ -67,15 +70,17 @@ const DATE_FIELDS = [
67
70
  ];
68
71
 
69
72
  function parseArgs(argv) {
70
- const opts = { quiet: false };
73
+ const opts = { quiet: false, strict: false };
71
74
  for (let i = 2; i < argv.length; i++) {
72
75
  const a = argv[i];
73
76
  if (a === '--quiet' || a === '-q') opts.quiet = true;
77
+ else if (a === '--strict') opts.strict = true;
74
78
  else if (a === '--help' || a === '-h') {
75
79
  console.log(
76
- 'Usage: node lib/validate-cve-catalog.js [--quiet]\n' +
80
+ 'Usage: node lib/validate-cve-catalog.js [--quiet] [--strict]\n' +
77
81
  '\n' +
78
- ' --quiet Suppress per-CVE PASS output; show failures only.\n',
82
+ ' --quiet Suppress per-CVE PASS output; show failures only.\n' +
83
+ ' --strict Promote v0.13.0-preview warnings to errors. Off by default.\n',
79
84
  );
80
85
  process.exit(0);
81
86
  } else {
@@ -239,19 +244,31 @@ function additionalChecks(key, entry, ctx) {
239
244
  }
240
245
 
241
246
  // V2 — Cross-catalog reference resolution. Unresolved refs are warnings
242
- // for v0.12.12; v0.13.0 will flip to hard failures.
243
- for (const ref of entry.atlas_refs || []) {
244
- if (!ctx.atlasKeys.has(ref)) {
245
- warnings.push(
246
- `${key}: atlas_refs entry "${ref}" not in data/atlas-ttps.json (will hard-fail in v0.13.0)`,
247
- );
248
- }
249
- }
250
- for (const ref of entry.cwe_refs || []) {
251
- if (!ctx.cweKeys.has(ref)) {
252
- warnings.push(
253
- `${key}: cwe_refs entry "${ref}" not in data/cwe-catalog.json (will hard-fail in v0.13.0)`,
254
- );
247
+ // for v0.12.x; v0.13.0 will flip to hard failures. Audit D's V2 expansion
248
+ // (Audit G) extends the walk from cwe_refs only to attack_refs, atlas_refs,
249
+ // d3fend_refs, AND framework_control_gaps.
250
+ const REF_FIELDS = [
251
+ { field: 'atlas_refs', set: ctx.atlasKeys, file: 'data/atlas-ttps.json' },
252
+ { field: 'cwe_refs', set: ctx.cweKeys, file: 'data/cwe-catalog.json' },
253
+ { field: 'attack_refs', set: ctx.attackKeys, file: 'data/attack-techniques.json' },
254
+ { field: 'd3fend_refs', set: ctx.d3fendKeys, file: 'data/d3fend-catalog.json' },
255
+ {
256
+ field: 'framework_control_gaps',
257
+ set: ctx.frameworkKeys,
258
+ file: 'data/framework-control-gaps.json',
259
+ },
260
+ ];
261
+ for (const { field, set, file } of REF_FIELDS) {
262
+ if (!set) continue; // catalog absent — skip silently (defense-in-depth)
263
+ const refs = entry[field];
264
+ if (!Array.isArray(refs)) continue;
265
+ for (const ref of refs) {
266
+ if (typeof ref !== 'string') continue;
267
+ if (!set.has(ref)) {
268
+ warnings.push(
269
+ `${key}: ${field} entry "${ref}" not in ${file} (will hard-fail in v0.13.0)`,
270
+ );
271
+ }
255
272
  }
256
273
  }
257
274
 
@@ -285,10 +302,24 @@ function main() {
285
302
  const lessons = readJson(LESSONS_PATH);
286
303
  const atlas = fs.existsSync(ATLAS_PATH) ? readJson(ATLAS_PATH) : {};
287
304
  const cwe = fs.existsSync(CWE_PATH) ? readJson(CWE_PATH) : {};
305
+ const attack = fs.existsSync(ATTACK_PATH) ? readJson(ATTACK_PATH) : null;
306
+ const d3fend = fs.existsSync(D3FEND_PATH) ? readJson(D3FEND_PATH) : null;
307
+ const frameworks = fs.existsSync(FRAMEWORK_GAPS_PATH)
308
+ ? readJson(FRAMEWORK_GAPS_PATH)
309
+ : null;
288
310
 
289
311
  const ctx = {
290
312
  atlasKeys: new Set(Object.keys(atlas).filter((k) => !k.startsWith('_'))),
291
313
  cweKeys: new Set(Object.keys(cwe).filter((k) => !k.startsWith('_'))),
314
+ attackKeys: attack
315
+ ? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
316
+ : null,
317
+ d3fendKeys: d3fend
318
+ ? new Set(Object.keys(d3fend).filter((k) => !k.startsWith('_')))
319
+ : null,
320
+ frameworkKeys: frameworks
321
+ ? new Set(Object.keys(frameworks).filter((k) => !k.startsWith('_')))
322
+ : null,
292
323
  };
293
324
 
294
325
  const cveKeys = Object.keys(catalog).filter((k) => !k.startsWith('_'));
@@ -325,12 +356,18 @@ function main() {
325
356
  // `exceptd run cve-curation --advisory <id>`.
326
357
  const isDraft = entry && (entry._auto_imported === true || entry._draft === true);
327
358
  const errors = validate(entry, schema, 'cve', key);
328
- const warnings = additionalChecks(key, entry, ctx);
359
+ let warnings = additionalChecks(key, entry, ctx);
329
360
  if (!lessonKeys.has(key) && !isDraft) {
330
361
  errors.push(
331
362
  `${key}: missing matching entry in data/zeroday-lessons.json (rule #6: zero-day learning is live)`,
332
363
  );
333
364
  }
365
+ // F20 — --strict promotes per-CVE warnings to errors. Drafts are
366
+ // exempt (drafts already exit non-fail).
367
+ if (opts.strict && !isDraft) {
368
+ errors.push(...warnings);
369
+ warnings = [];
370
+ }
334
371
  if (isDraft) {
335
372
  drafts++;
336
373
  if (!opts.quiet) {
@@ -381,7 +418,8 @@ function main() {
381
418
  (warned ? `, ${warned} with warnings` : '') +
382
419
  (failed ? `, ${failed} failed` : '') + '.';
383
420
  console.log(summary);
384
- process.exit(failed === 0 ? 0 : 1);
421
+ // F18: process.exitCode + return so buffered output drains.
422
+ process.exitCode = failed === 0 ? 0 : 1;
385
423
  }
386
424
 
387
425
  module.exports = {
@@ -12,6 +12,8 @@
12
12
  * 3. Fail if any hash diverges (the indexes are stale).
13
13
  * 4. Fail if a new source file exists that's not in the index (the
14
14
  * index doesn't reflect current state).
15
+ * 5. Fail if source_hashes is empty (build-indexes never ran).
16
+ * 6. Fail if any data/*.json or listed source is a symlink.
15
17
  *
16
18
  * Exit 0 on success, 1 on staleness.
17
19
  *
@@ -34,50 +36,99 @@ function sha256(buf) {
34
36
  return crypto.createHash("sha256").update(buf).digest("hex");
35
37
  }
36
38
 
37
- if (!fs.existsSync(META)) {
38
- console.error("[validate-indexes] data/_indexes/_meta.json missing — run `npm run build-indexes`.");
39
- process.exit(1);
40
- }
39
+ function main() {
40
+ if (!fs.existsSync(META)) {
41
+ console.error("[validate-indexes] data/_indexes/_meta.json missing — run `npm run build-indexes`.");
42
+ // v0.11.13 pattern: exitCode + return so async stdout/stderr writes drain.
43
+ process.exitCode = 1;
44
+ return;
45
+ }
41
46
 
42
- const meta = JSON.parse(fs.readFileSync(META, "utf8"));
43
- const recorded = meta.source_hashes || {};
47
+ const meta = JSON.parse(fs.readFileSync(META, "utf8"));
48
+ const recorded = meta.source_hashes || {};
44
49
 
45
- // Discover the current canonical source set.
46
- const manifest = JSON.parse(fs.readFileSync(ABS("manifest.json"), "utf8"));
47
- const liveSources = new Set();
48
- liveSources.add("manifest.json");
49
- for (const f of fs.readdirSync(ABS("data"))) {
50
- if (f.endsWith(".json")) liveSources.add("data/" + f);
51
- }
52
- for (const s of manifest.skills) liveSources.add(s.path);
50
+ // Audit G F1 reject an empty source_hashes table outright. The previous
51
+ // gate would silently pass when source_hashes was {} (or missing entirely)
52
+ // because the for-loop body never executed; the resulting "0 sources" pass
53
+ // banner falsely advertised the indexes as current. An empty source-hash
54
+ // table means build-indexes was never run, or was run against an empty
55
+ // repo, and the index files themselves are not trustworthy.
56
+ if (Object.keys(recorded).length === 0) {
57
+ console.error(
58
+ "[validate-indexes] data/_indexes/_meta.json source_hashes is empty — " +
59
+ "this means build-indexes did not populate the index. " +
60
+ "Regenerate with: npm run build-indexes"
61
+ );
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ // Discover the current canonical source set.
67
+ const manifest = JSON.parse(fs.readFileSync(ABS("manifest.json"), "utf8"));
68
+ const liveSources = new Set();
69
+ liveSources.add("manifest.json");
70
+ // Audit G F16 — use lstat to detect symlinks. A symlinked .json under data/
71
+ // would be hashed via the followed target, allowing a malicious checkout
72
+ // (or a misconfigured filesystem) to swap data origin without tripping the
73
+ // gate. Reject symlinks outright.
74
+ for (const f of fs.readdirSync(ABS("data"))) {
75
+ if (!f.endsWith(".json")) continue;
76
+ const abs = ABS("data/" + f);
77
+ const st = fs.lstatSync(abs);
78
+ if (st.isSymbolicLink()) {
79
+ console.error(
80
+ `[validate-indexes] data/${f} is a symbolic link — refusing to follow. ` +
81
+ `Replace with the real file or remove the entry.`
82
+ );
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ liveSources.add("data/" + f);
87
+ }
88
+ for (const s of manifest.skills) liveSources.add(s.path);
53
89
 
54
- const drift = [];
55
- const missing = [];
56
- const recordedKeys = new Set(Object.keys(recorded));
90
+ const drift = [];
91
+ const missing = [];
92
+ const recordedKeys = new Set(Object.keys(recorded));
57
93
 
58
- for (const p of liveSources) {
59
- if (!recordedKeys.has(p)) {
60
- missing.push(`new source not in index: ${p}`);
61
- continue;
94
+ for (const p of liveSources) {
95
+ if (!recordedKeys.has(p)) {
96
+ missing.push(`new source not in index: ${p}`);
97
+ continue;
98
+ }
99
+ const abs = ABS(p);
100
+ // F16 — also check listed-but-symlinked sources. lstatSync on a missing
101
+ // file throws; mirror the existsSync semantics by guarding it.
102
+ if (fs.existsSync(abs)) {
103
+ const st = fs.lstatSync(abs);
104
+ if (st.isSymbolicLink()) {
105
+ missing.push(`source ${p} is a symbolic link — refusing to follow`);
106
+ continue;
107
+ }
108
+ }
109
+ const live = sha256(fs.readFileSync(abs));
110
+ if (live !== recorded[p]) {
111
+ drift.push(`hash drift: ${p} (recorded ${recorded[p].slice(0, 12)}…, live ${live.slice(0, 12)}…)`);
112
+ }
62
113
  }
63
- const live = sha256(fs.readFileSync(ABS(p)));
64
- if (live !== recorded[p]) {
65
- drift.push(`hash drift: ${p} (recorded ${recorded[p].slice(0, 12)}…, live ${live.slice(0, 12)}…)`);
114
+ for (const p of recordedKeys) {
115
+ if (!liveSources.has(p)) {
116
+ missing.push(`stale source in index (file removed): ${p}`);
117
+ }
66
118
  }
67
- }
68
- for (const p of recordedKeys) {
69
- if (!liveSources.has(p)) {
70
- missing.push(`stale source in index (file removed): ${p}`);
119
+
120
+ const issues = [...drift, ...missing];
121
+ if (issues.length === 0) {
122
+ console.log(`[validate-indexes] indexes current ${recordedKeys.size} sources hashed at ${meta.generated_at}.`);
123
+ return;
71
124
  }
72
- }
73
125
 
74
- const issues = [...drift, ...missing];
75
- if (issues.length === 0) {
76
- console.log(`[validate-indexes] indexes current ${recordedKeys.size} sources hashed at ${meta.generated_at}.`);
77
- process.exit(0);
126
+ console.error("[validate-indexes] indexes STALE:");
127
+ for (const i of issues) console.error(" • " + i);
128
+ console.error("[validate-indexes] regenerate with: npm run build-indexes");
129
+ process.exitCode = 1;
78
130
  }
79
131
 
80
- console.error("[validate-indexes] indexes STALE:");
81
- for (const i of issues) console.error(" • " + i);
82
- console.error("[validate-indexes] regenerate with: npm run build-indexes");
83
- process.exit(1);
132
+ if (require.main === module) main();
133
+
134
+ module.exports = { main };
package/lib/verify.js CHANGED
@@ -51,6 +51,14 @@ const SKILLS_DIR = path.join(ROOT, 'skills');
51
51
  const PUBLIC_KEY_PATH = path.join(ROOT, 'keys', 'public.pem');
52
52
  const PRIVATE_KEY_PATH = path.join(ROOT, '.keys', 'private.pem');
53
53
  const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
54
+ // Audit G F4 — key-pin file. When present, lib/verify.js compares the live
55
+ // public-key fingerprint against the pinned one and fails the verify run
56
+ // if they differ (unless the operator sets KEYS_ROTATED=1). The file format
57
+ // is a single line "SHA256:<base64>" matching the publicKeyFingerprint()
58
+ // shape. The file is OPTIONAL: when missing, the gate warns-and-continues
59
+ // rather than failing — this preserves bootstrap compatibility on fresh
60
+ // clones / new key ceremonies. Patch-class semantics.
61
+ const EXPECTED_FINGERPRINT_PATH = path.join(ROOT, 'keys', 'EXPECTED_FINGERPRINT');
54
62
 
55
63
  // --- public API ---
56
64
 
@@ -386,6 +394,38 @@ function validateAgainstSchema(value, schema, here, root) {
386
394
  * @param {string|null} pemKey PEM-encoded public key (or null)
387
395
  * @returns {{sha256: string, sha3_512: string}|{error: string}}
388
396
  */
397
+ /**
398
+ * Audit G F4 — compare the live public-key fingerprint against the optional
399
+ * pinned fingerprint in keys/EXPECTED_FINGERPRINT. Returns one of:
400
+ * { status: 'no-pin' } — keys/EXPECTED_FINGERPRINT not present.
401
+ * Callers should warn and continue.
402
+ * { status: 'match' } — live fingerprint matches the pin.
403
+ * { status: 'mismatch', — divergence; caller should fail unless
404
+ * expected, actual, KEYS_ROTATED=1 is set in the environment.
405
+ * rotationOverride }
406
+ *
407
+ * @param {{sha256:string}|null} liveFp publicKeyFingerprint() output
408
+ * @param {string} [pinPath] optional override (testability)
409
+ */
410
+ function checkExpectedFingerprint(liveFp, pinPath) {
411
+ const p = pinPath || EXPECTED_FINGERPRINT_PATH;
412
+ if (!fs.existsSync(p)) return { status: 'no-pin' };
413
+ if (!liveFp || typeof liveFp.sha256 !== 'string') {
414
+ return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
415
+ }
416
+ const expected = fs.readFileSync(p, 'utf8').trim();
417
+ // Tolerate trailing comment / whitespace on the same line; the file's
418
+ // first non-empty line is the canonical fingerprint.
419
+ const firstLine = expected.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || '';
420
+ if (firstLine === liveFp.sha256) return { status: 'match' };
421
+ return {
422
+ status: 'mismatch',
423
+ expected: firstLine,
424
+ actual: liveFp.sha256,
425
+ rotationOverride: process.env.KEYS_ROTATED === '1',
426
+ };
427
+ }
428
+
389
429
  function publicKeyFingerprint(pemKey) {
390
430
  if (!pemKey) return { sha256: '(no key)', sha3_512: '(no key)' };
391
431
  try {
@@ -468,6 +508,35 @@ if (require.main === module) {
468
508
  console.log(`[verify] ${fp.sha256}`);
469
509
  console.log(`[verify] ${fp.sha3_512}`);
470
510
 
511
+ // Audit G F4 — pin check. When keys/EXPECTED_FINGERPRINT exists, the
512
+ // live fingerprint MUST match it (or KEYS_ROTATED=1 must be set to
513
+ // intentionally override). When the file is absent, emit a single-line
514
+ // warning but continue — fresh clones / bootstrap workflows should not
515
+ // fail the gate before the operator has committed a fingerprint.
516
+ const pinResult = checkExpectedFingerprint(fp);
517
+ if (pinResult.status === 'no-pin') {
518
+ console.warn(
519
+ `[verify] WARN: keys/EXPECTED_FINGERPRINT not present — key-pin check skipped. ` +
520
+ `Create it with the current ${fp.sha256} line to enable pinning.`
521
+ );
522
+ } else if (pinResult.status === 'mismatch') {
523
+ if (pinResult.rotationOverride) {
524
+ console.warn(
525
+ `[verify] WARN: live key fingerprint ${pinResult.actual} differs from pin ` +
526
+ `${pinResult.expected}. KEYS_ROTATED=1 set — accepting rotation. ` +
527
+ `Update keys/EXPECTED_FINGERPRINT to lock the new pin.`
528
+ );
529
+ } else {
530
+ console.error(
531
+ `[verify] FAIL: live key fingerprint ${pinResult.actual} does not match ` +
532
+ `keys/EXPECTED_FINGERPRINT ${pinResult.expected}. ` +
533
+ `If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
534
+ `then commit the new fingerprint to keys/EXPECTED_FINGERPRINT.`
535
+ );
536
+ process.exit(1);
537
+ }
538
+ }
539
+
471
540
  if (result.invalid.length > 0) process.exit(1);
472
541
  if (result.missing_sig.length > 0) process.exit(1);
473
542
  if (result.missing_file.length > 0) process.exit(1);
@@ -483,4 +552,7 @@ module.exports = {
483
552
  validateSkillPath,
484
553
  loadManifestValidated,
485
554
  validateAgainstSchema,
555
+ publicKeyFingerprint,
556
+ checkExpectedFingerprint,
557
+ EXPECTED_FINGERPRINT_PATH,
486
558
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
3
- "_generated_at": "2026-05-14T14:28:31.798Z",
3
+ "_generated_at": "2026-05-14T15:55:39.383Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [
@@ -0,0 +1 @@
1
+ ca9d31e533c9d494e1ac5875e0a45176101438c3d75d44387187e367ccae21ad manifest-snapshot.json