@blamejs/exceptd-skills 0.12.11 → 0.12.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/bin/exceptd.js +299 -48
  3. package/data/_indexes/_meta.json +49 -48
  4. package/data/_indexes/activity-feed.json +13 -5
  5. package/data/_indexes/catalog-summaries.json +51 -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 +339 -0
  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 +505 -47
  23. package/lib/lint-skills.js +217 -15
  24. package/lib/playbook-runner.js +1224 -183
  25. package/lib/prefetch.js +121 -8
  26. package/lib/refresh-external.js +261 -95
  27. package/lib/refresh-network.js +208 -18
  28. package/lib/schemas/manifest.schema.json +16 -0
  29. package/lib/scoring.js +83 -7
  30. package/lib/sign.js +112 -3
  31. package/lib/source-ghsa.js +219 -37
  32. package/lib/source-osv.js +381 -122
  33. package/lib/validate-catalog-meta.js +64 -9
  34. package/lib/validate-cve-catalog.js +213 -7
  35. package/lib/validate-indexes.js +88 -37
  36. package/lib/validate-playbooks.js +469 -0
  37. package/lib/verify.js +313 -16
  38. package/manifest-snapshot.json +1 -1
  39. package/manifest-snapshot.sha256 +1 -0
  40. package/manifest.json +73 -73
  41. package/orchestrator/dispatcher.js +21 -1
  42. package/orchestrator/event-bus.js +52 -8
  43. package/orchestrator/index.js +279 -20
  44. package/orchestrator/pipeline.js +63 -2
  45. package/orchestrator/scanner.js +32 -10
  46. package/orchestrator/scheduler.js +196 -20
  47. package/package.json +3 -1
  48. package/sbom.cdx.json +9 -9
  49. package/scripts/check-manifest-snapshot.js +32 -0
  50. package/scripts/check-sbom-currency.js +65 -3
  51. package/scripts/check-test-coverage.js +142 -19
  52. package/scripts/predeploy.js +110 -40
  53. package/scripts/refresh-manifest-snapshot.js +55 -4
  54. package/scripts/validate-vendor-online.js +169 -0
  55. package/scripts/verify-shipped-tarball.js +106 -3
  56. package/skills/ai-attack-surface/skill.md +18 -10
  57. package/skills/ai-c2-detection/skill.md +7 -2
  58. package/skills/ai-risk-management/skill.md +5 -4
  59. package/skills/api-security/skill.md +3 -3
  60. package/skills/attack-surface-pentest/skill.md +5 -5
  61. package/skills/cloud-security/skill.md +1 -1
  62. package/skills/compliance-theater/skill.md +8 -8
  63. package/skills/container-runtime-security/skill.md +1 -1
  64. package/skills/dlp-gap-analysis/skill.md +5 -1
  65. package/skills/email-security-anti-phishing/skill.md +1 -1
  66. package/skills/exploit-scoring/skill.md +18 -18
  67. package/skills/framework-gap-analysis/skill.md +6 -6
  68. package/skills/global-grc/skill.md +3 -2
  69. package/skills/identity-assurance/skill.md +2 -2
  70. package/skills/incident-response-playbook/skill.md +4 -4
  71. package/skills/kernel-lpe-triage/skill.md +21 -2
  72. package/skills/mcp-agent-trust/skill.md +17 -10
  73. package/skills/mlops-security/skill.md +2 -1
  74. package/skills/ot-ics-security/skill.md +1 -1
  75. package/skills/policy-exception-gen/skill.md +3 -3
  76. package/skills/pqc-first/skill.md +1 -1
  77. package/skills/rag-pipeline-security/skill.md +7 -3
  78. package/skills/researcher/skill.md +20 -3
  79. package/skills/sector-energy/skill.md +1 -1
  80. package/skills/sector-federal-government/skill.md +1 -1
  81. package/skills/sector-financial/skill.md +3 -3
  82. package/skills/sector-healthcare/skill.md +2 -2
  83. package/skills/security-maturity-tiers/skill.md +7 -7
  84. package/skills/skill-update-loop/skill.md +19 -3
  85. package/skills/supply-chain-integrity/skill.md +1 -1
  86. package/skills/threat-model-currency/skill.md +11 -11
  87. package/skills/threat-modeling-methodology/skill.md +3 -3
  88. package/skills/webapp-security/skill.md +1 -1
  89. package/skills/zeroday-gap-learn/skill.md +51 -7
  90. package/vendor/blamejs/_PROVENANCE.json +4 -1
  91. package/vendor/blamejs/worker-pool.js +38 -0
@@ -3,16 +3,41 @@
3
3
  /**
4
4
  * Scheduled task coordinator for skill currency maintenance.
5
5
  *
6
- * Schedules: weekly currency check, monthly CVE validation, annual full audit.
7
- * Emits events via event-bus.js when currency thresholds are breached.
6
+ * Schedules: weekly currency check, monthly CVE validation reminder, annual
7
+ * full audit reminder. Emits events via event-bus.js when currency thresholds
8
+ * are breached.
8
9
  *
9
10
  * This is a simple interval-based scheduler. For production use, swap for a
10
11
  * proper cron daemon or cloud scheduler without changing the task definitions.
12
+ *
13
+ * Bootstrap-fire policy. Long-cadence tasks (monthly, annual) are also
14
+ * evaluated on `start()` so a freshly-restarted watcher does not silently
15
+ * skip a due interval. Whether a task fires on bootstrap is gated by a
16
+ * persisted "last fired" timestamp store at
17
+ * `~/.exceptd/scheduler-last-fired.json` — the task fires only when the
18
+ * elapsed time since the persisted timestamp exceeds the interval. The
19
+ * weekly currency check always fires on bootstrap (legacy behavior).
20
+ *
21
+ * Implementation note — INT32 overflow guard. `setInterval` / `setTimeout`
22
+ * delay values are coerced to a signed 32-bit integer; any value above
23
+ * 2^31 - 1 ms (~24.8 days) is silently clamped to 1 ms by Node, which
24
+ * causes the handler to fire ~1000×/sec. The MONTHLY_CVE_VALIDATION and
25
+ * ANNUAL_AUDIT intervals both exceed that limit. `scheduleEvery` wraps the
26
+ * underlying timer with a short tick interval (capped at SAFE_MAX_MS) and
27
+ * compares wall-clock elapsed time against the requested interval, so any
28
+ * delay — including multi-year intervals — fires exactly when due.
11
29
  */
12
30
 
31
+ const fs = require('fs');
32
+ const os = require('os');
33
+ const path = require('path');
34
+
13
35
  const { bus, EVENT_TYPES } = require('./event-bus');
14
36
  const { currencyCheck } = require('./pipeline');
15
37
 
38
+ const SAFE_MAX_MS = 2_147_483_647; // INT32 max — Node's setTimeout/setInterval ceiling.
39
+ const TICK_MS = Math.min(SAFE_MAX_MS, 24 * 60 * 60 * 1000); // 24h tick by default.
40
+
16
41
  const INTERVALS = {
17
42
  WEEKLY_CURRENCY: 7 * 24 * 60 * 60 * 1000,
18
43
  MONTHLY_CVE_VALIDATION: 30 * 24 * 60 * 60 * 1000,
@@ -24,22 +49,146 @@ const CURRENCY_THRESHOLDS = {
24
49
  warning: 70
25
50
  };
26
51
 
27
- let timers = [];
52
+ const LAST_FIRED_KEYS = {
53
+ WEEKLY_CURRENCY: 'weekly_currency_check',
54
+ MONTHLY_CVE_VALIDATION: 'monthly_cve_validation',
55
+ ANNUAL_AUDIT: 'annual_full_audit'
56
+ };
57
+
58
+ let unschedulers = [];
28
59
  let running = false;
29
60
 
61
+ // --- persistent last-fired store ---
62
+
63
+ /**
64
+ * Resolve the path of the last-fired persistence file. Defaults to
65
+ * `~/.exceptd/scheduler-last-fired.json`. Honors EXCEPTD_HOME for the test
66
+ * suite so unit tests stay isolated from the maintainer's real home dir.
67
+ */
68
+ function _lastFiredStorePath() {
69
+ const root = process.env.EXCEPTD_HOME || path.join(os.homedir(), '.exceptd');
70
+ return path.join(root, 'scheduler-last-fired.json');
71
+ }
72
+
73
+ function _loadLastFired() {
74
+ const p = _lastFiredStorePath();
75
+ try {
76
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
82
+ function _saveLastFired(store) {
83
+ const p = _lastFiredStorePath();
84
+ try {
85
+ fs.mkdirSync(path.dirname(p), { recursive: true });
86
+ fs.writeFileSync(p, JSON.stringify(store, null, 2));
87
+ } catch (err) {
88
+ // Persistence is best-effort. A failed write only means the next start
89
+ // can't tell whether the task fired; it does not break the running
90
+ // scheduler.
91
+ console.error('[scheduler] could not persist last-fired:', err.message);
92
+ }
93
+ }
94
+
95
+ function _markFired(key, when) {
96
+ const store = _loadLastFired();
97
+ store[key] = when || new Date().toISOString();
98
+ _saveLastFired(store);
99
+ }
100
+
101
+ function _shouldBootstrapFire(key, intervalMs) {
102
+ const store = _loadLastFired();
103
+ const stamp = store[key];
104
+ if (!stamp) return true;
105
+ const last = Date.parse(stamp);
106
+ if (!Number.isFinite(last)) return true;
107
+ return (Date.now() - last) >= intervalMs;
108
+ }
109
+
110
+ // --- scheduling primitive ---
111
+
112
+ /**
113
+ * Schedule `handler` to fire every `intervalMs`, safely for intervals that
114
+ * exceed Node's INT32 setTimeout ceiling. Returns an unschedule function.
115
+ *
116
+ * @param {number} intervalMs Desired interval in milliseconds (any positive value).
117
+ * @param {Function} handler Function to invoke on each interval.
118
+ * @returns {Function} Call to stop further firings.
119
+ */
120
+ function scheduleEvery(intervalMs, handler) {
121
+ const startedAt = Date.now();
122
+ let lastFired = startedAt;
123
+ const tick = () => {
124
+ const now = Date.now();
125
+ if (now - lastFired >= intervalMs) {
126
+ lastFired = now;
127
+ try { handler(); } catch (e) { console.error('[scheduler]', e); }
128
+ }
129
+ };
130
+ const id = setInterval(tick, Math.min(intervalMs, TICK_MS));
131
+ // Deliberately NOT calling id.unref(): the `watch` orchestrator verb
132
+ // is long-running and relies on the scheduler timers to keep the event
133
+ // loop alive (the event bus has no I/O of its own). Callers that don't
134
+ // want the timer to hold the loop open should call the returned
135
+ // unschedule function in their teardown path.
136
+ return () => clearInterval(id);
137
+ }
138
+
30
139
  // --- public API ---
31
140
 
32
141
  /**
33
- * Start the scheduler. Runs all tasks immediately on start, then on schedule.
142
+ * Start the scheduler. Runs the weekly task immediately, then schedules all
143
+ * three on their intervals. Monthly and annual tasks are also "bootstrap
144
+ * fired" on start when the persisted last-fired timestamp is older than the
145
+ * interval (or absent), so a restarted watcher does not silently skip a due
146
+ * window. Bootstrap-fire happens inside the same try/catch the periodic
147
+ * wrapper uses so a single thrown task cannot crash the watcher.
34
148
  */
35
149
  function start() {
36
150
  if (running) return;
37
151
  running = true;
38
152
 
39
- runWeeklyCurrencyCheck();
40
- timers.push(setInterval(runWeeklyCurrencyCheck, INTERVALS.WEEKLY_CURRENCY));
41
- timers.push(setInterval(runMonthlyCveValidation, INTERVALS.MONTHLY_CVE_VALIDATION));
42
- timers.push(setInterval(runAnnualAudit, INTERVALS.ANNUAL_AUDIT));
153
+ const safeRun = (label, fn) => {
154
+ try { fn(); }
155
+ catch (e) { console.error('[scheduler] ' + label + ' failed:', e); }
156
+ };
157
+
158
+ // Weekly always fires on bootstrap (legacy behavior).
159
+ safeRun('weekly currency bootstrap', () => {
160
+ runWeeklyCurrencyCheck();
161
+ _markFired(LAST_FIRED_KEYS.WEEKLY_CURRENCY);
162
+ });
163
+
164
+ // Monthly + annual bootstrap-fire only when the persisted timestamp is
165
+ // older than the interval. This closes the "freshly-restarted watcher
166
+ // never fires the long-cadence task" gap.
167
+ if (_shouldBootstrapFire(LAST_FIRED_KEYS.MONTHLY_CVE_VALIDATION, INTERVALS.MONTHLY_CVE_VALIDATION)) {
168
+ safeRun('monthly CVE bootstrap', () => {
169
+ runMonthlyCveValidation();
170
+ _markFired(LAST_FIRED_KEYS.MONTHLY_CVE_VALIDATION);
171
+ });
172
+ }
173
+ if (_shouldBootstrapFire(LAST_FIRED_KEYS.ANNUAL_AUDIT, INTERVALS.ANNUAL_AUDIT)) {
174
+ safeRun('annual audit bootstrap', () => {
175
+ runAnnualAudit();
176
+ _markFired(LAST_FIRED_KEYS.ANNUAL_AUDIT);
177
+ });
178
+ }
179
+
180
+ unschedulers.push(scheduleEvery(INTERVALS.WEEKLY_CURRENCY, () => {
181
+ runWeeklyCurrencyCheck();
182
+ _markFired(LAST_FIRED_KEYS.WEEKLY_CURRENCY);
183
+ }));
184
+ unschedulers.push(scheduleEvery(INTERVALS.MONTHLY_CVE_VALIDATION, () => {
185
+ runMonthlyCveValidation();
186
+ _markFired(LAST_FIRED_KEYS.MONTHLY_CVE_VALIDATION);
187
+ }));
188
+ unschedulers.push(scheduleEvery(INTERVALS.ANNUAL_AUDIT, () => {
189
+ runAnnualAudit();
190
+ _markFired(LAST_FIRED_KEYS.ANNUAL_AUDIT);
191
+ }));
43
192
 
44
193
  console.log('[scheduler] Started. Weekly currency check, monthly CVE validation, annual audit scheduled.');
45
194
  }
@@ -48,8 +197,10 @@ function start() {
48
197
  * Stop the scheduler and clear all timers.
49
198
  */
50
199
  function stop() {
51
- for (const t of timers) clearInterval(t);
52
- timers = [];
200
+ for (const off of unschedulers) {
201
+ try { off(); } catch { /* ignore */ }
202
+ }
203
+ unschedulers = [];
53
204
  running = false;
54
205
  console.log('[scheduler] Stopped.');
55
206
  }
@@ -70,14 +221,25 @@ function runWeeklyCurrencyCheck() {
70
221
 
71
222
  const { currency_report, action_required, critical_count } = currencyCheck();
72
223
 
73
- for (const skill of currency_report) {
74
- if (skill.currency_score < CURRENCY_THRESHOLDS.critical) {
75
- bus.skillCurrencyLow({
76
- skill_name: skill.skill,
77
- currency_score: skill.currency_score,
78
- days_since_review: skill.days_since_review
79
- });
80
- }
224
+ // Emit ONE aggregated SKILL_CURRENCY_LOW_AGGREGATE event per run instead
225
+ // of N per-skill events. Per-run aggregate prevents downstream consumers
226
+ // (`watch`, dashboards, alerting webhooks) from receiving an event storm
227
+ // when N skills are simultaneously stale — common after a long pause
228
+ // between runs or on first bootstrap. The aggregate payload carries
229
+ // critical_count + the full array of stale skills so consumers can still
230
+ // drill in. The legacy per-skill SKILL_CURRENCY_LOW signature is preserved
231
+ // for callers (and tests) that consume bus.skillCurrencyLow() directly.
232
+ const critical = currency_report.filter(s => s.currency_score < CURRENCY_THRESHOLDS.critical);
233
+ if (critical.length > 0) {
234
+ bus.emit(EVENT_TYPES.SKILL_CURRENCY_LOW_AGGREGATE, {
235
+ critical_count: critical.length,
236
+ skills: critical.map(s => ({
237
+ skill_name: s.skill,
238
+ currency_score: s.currency_score,
239
+ days_since_review: s.days_since_review
240
+ })),
241
+ timestamp
242
+ });
81
243
  }
82
244
 
83
245
  const result = {
@@ -86,7 +248,7 @@ function runWeeklyCurrencyCheck() {
86
248
  skills_checked: currency_report.length,
87
249
  action_required,
88
250
  critical_count,
89
- critical_skills: currency_report.filter(s => s.currency_score < CURRENCY_THRESHOLDS.critical).map(s => s.skill),
251
+ critical_skills: critical.map(s => s.skill),
90
252
  warning_skills: currency_report.filter(s =>
91
253
  s.currency_score >= CURRENCY_THRESHOLDS.critical && s.currency_score < CURRENCY_THRESHOLDS.warning
92
254
  ).map(s => s.skill)
@@ -134,4 +296,18 @@ function runAnnualAudit() {
134
296
  };
135
297
  }
136
298
 
137
- module.exports = { start, stop, runCurrencyNow };
299
+ module.exports = {
300
+ start,
301
+ stop,
302
+ runCurrencyNow,
303
+ scheduleEvery,
304
+ SAFE_MAX_MS,
305
+ TICK_MS,
306
+ INTERVALS,
307
+ LAST_FIRED_KEYS,
308
+ // Internal hooks exposed for tests; not part of the operator surface.
309
+ _lastFiredStorePath,
310
+ _shouldBootstrapFire,
311
+ _markFired,
312
+ _loadLastFired,
313
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/exceptd-skills",
3
- "version": "0.12.11",
3
+ "version": "0.12.15",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
5
5
  "keywords": [
6
6
  "ai-security",
@@ -54,8 +54,10 @@
54
54
  "data/",
55
55
  "skills/",
56
56
  "keys/public.pem",
57
+ "keys/EXPECTED_FINGERPRINT",
57
58
  "manifest.json",
58
59
  "manifest-snapshot.json",
60
+ "manifest-snapshot.sha256",
59
61
  "sbom.cdx.json",
60
62
  "AGENTS.md",
61
63
  "ARCHITECTURE.md",
package/sbom.cdx.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.6",
4
- "serialNumber": "urn:uuid:dac8abdb-9a54-4d36-9f4b-21e8dde200ea",
4
+ "serialNumber": "urn:uuid:0fb3661f-4f3b-4d31-98b3-d5fd759aa8c1",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-05-13T21:19:35.259Z",
7
+ "timestamp": "2026-05-14T16:47:04.535Z",
8
8
  "tools": [
9
9
  {
10
10
  "name": "hand-written",
@@ -13,10 +13,10 @@
13
13
  }
14
14
  ],
15
15
  "component": {
16
- "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.12.11",
16
+ "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.12.15",
17
17
  "type": "application",
18
18
  "name": "@blamejs/exceptd-skills",
19
- "version": "0.12.11",
19
+ "version": "0.12.15",
20
20
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
21
21
  "licenses": [
22
22
  {
@@ -25,11 +25,11 @@
25
25
  }
26
26
  }
27
27
  ],
28
- "purl": "pkg:npm/%40blamejs/exceptd-skills@0.12.11",
28
+ "purl": "pkg:npm/%40blamejs/exceptd-skills@0.12.15",
29
29
  "externalReferences": [
30
30
  {
31
31
  "type": "distribution",
32
- "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.12.11"
32
+ "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.12.15"
33
33
  },
34
34
  {
35
35
  "type": "vcs",
@@ -40,11 +40,11 @@
40
40
  "properties": [
41
41
  {
42
42
  "name": "cyclonedx:dataflow:input",
43
- "value": "data/atlas-ttps.json,data/cve-catalog.json,data/cwe-catalog.json,data/d3fend-catalog.json,data/dlp-controls.json,data/exploit-availability.json,data/framework-control-gaps.json,data/global-frameworks.json,data/rfc-references.json,data/zeroday-lessons.json"
43
+ "value": "data/atlas-ttps.json,data/attack-techniques.json,data/cve-catalog.json,data/cwe-catalog.json,data/d3fend-catalog.json,data/dlp-controls.json,data/exploit-availability.json,data/framework-control-gaps.json,data/global-frameworks.json,data/rfc-references.json,data/zeroday-lessons.json"
44
44
  },
45
45
  {
46
46
  "name": "exceptd:catalog:count",
47
- "value": "10"
47
+ "value": "11"
48
48
  },
49
49
  {
50
50
  "name": "exceptd:skill:count",
@@ -129,7 +129,7 @@
129
129
  "hashes": [
130
130
  {
131
131
  "alg": "SHA-256",
132
- "content": "57d8f5c3bca23cd4ef2a16adf4107bc60c4da3fbc9bb861580edc76ed9a2d132"
132
+ "content": "fa9814c2d18db221a2dc552cf2a5047467d87a2590cbc9d64b8a0a340e545b55"
133
133
  }
134
134
  ],
135
135
  "externalReferences": [
@@ -32,10 +32,12 @@
32
32
 
33
33
  const fs = require("fs");
34
34
  const path = require("path");
35
+ const crypto = require("crypto");
35
36
 
36
37
  const ROOT = path.join(__dirname, "..");
37
38
  const MANIFEST_PATH = path.join(ROOT, "manifest.json");
38
39
  const SNAPSHOT_PATH = path.join(ROOT, "manifest-snapshot.json");
40
+ const SNAPSHOT_SHA_PATH = path.join(ROOT, "manifest-snapshot.sha256");
39
41
 
40
42
  function captureSurface(manifest) {
41
43
  // Public surface = the set of facts downstream consumers may have
@@ -188,6 +190,36 @@ if (require.main === module) {
188
190
  process.exit(2);
189
191
  }
190
192
 
193
+ // Audit G F23 — when manifest-snapshot.sha256 is present, validate that
194
+ // the on-disk snapshot still hashes to the recorded value. Catches a
195
+ // hand-edit of manifest-snapshot.json that bypassed refresh-manifest-
196
+ // snapshot.js (so the F5 commit-only guard never had a chance to fire).
197
+ // The file is OPTIONAL: when absent, the gate warns-and-continues so
198
+ // pre-v0.12.14 trees still work.
199
+ if (fs.existsSync(SNAPSHOT_SHA_PATH)) {
200
+ const expectedLine = fs.readFileSync(SNAPSHOT_SHA_PATH, "utf8").trim();
201
+ const expectedSha = expectedLine.split(/\s+/)[0];
202
+ const liveSha = crypto
203
+ .createHash("sha256")
204
+ .update(fs.readFileSync(SNAPSHOT_PATH))
205
+ .digest("hex");
206
+ if (expectedSha !== liveSha) {
207
+ console.error(
208
+ "[check-manifest-snapshot] manifest-snapshot.json integrity check FAILED " +
209
+ `(expected ${expectedSha.slice(0, 12)}…, live ${liveSha.slice(0, 12)}…). ` +
210
+ "Someone edited manifest-snapshot.json without running refresh-manifest-snapshot.js. " +
211
+ "Re-run `node scripts/refresh-manifest-snapshot.js --commit-only` to regenerate."
212
+ );
213
+ process.exit(1);
214
+ }
215
+ } else {
216
+ console.warn(
217
+ "[check-manifest-snapshot] WARN: manifest-snapshot.sha256 missing — " +
218
+ "integrity check skipped. Run `node scripts/refresh-manifest-snapshot.js --commit-only` " +
219
+ "to generate it."
220
+ );
221
+ }
222
+
191
223
  const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8"));
192
224
  const current = captureSurface(manifest);
193
225
  const result = diff(baseline, current);
@@ -62,11 +62,67 @@ function checkSbomCurrency(root) {
62
62
  if (sbom.bomFormat !== "CycloneDX" || sbom.specVersion !== "1.6") {
63
63
  errors.push("SBOM is not CycloneDX 1.6");
64
64
  }
65
+
66
+ // Audit G F2 — component-level cross-check. A renamed or version-bumped
67
+ // skill that never made it into the SBOM refresh will pass the count
68
+ // check (the cardinality is unchanged) but the per-component name +
69
+ // version comparison surfaces it. Two component classes are recognised:
70
+ //
71
+ // 1. Skill components — bom-ref begins with "skill:" OR the component
72
+ // name matches a manifest.skills[].name. Each one must exist in
73
+ // manifest.skills with the same version.
74
+ // 2. Vendor components — bom-ref begins with "vendor:". Validated
75
+ // against vendor/blamejs/_PROVENANCE.json when present.
76
+ //
77
+ // Components that don't fit either pattern are surfaced as warnings
78
+ // (not errors) so the gate isn't brittle against future component types.
79
+ const components = Array.isArray(sbom.components) ? sbom.components : [];
80
+ const skillByName = new Map(
81
+ (manifest.skills || []).map((s) => [s.name, s])
82
+ );
83
+ const provPath = path.join(root, "vendor", "blamejs", "_PROVENANCE.json");
84
+ let vendorProv = null;
85
+ if (fs.existsSync(provPath)) {
86
+ try { vendorProv = JSON.parse(fs.readFileSync(provPath, "utf8")); } catch { /* leave null */ }
87
+ }
88
+ for (const comp of components) {
89
+ const bomRef = typeof comp["bom-ref"] === "string" ? comp["bom-ref"] : "";
90
+ const name = comp.name;
91
+ const version = comp.version;
92
+ if (bomRef.startsWith("skill:") || skillByName.has(name)) {
93
+ const skillName = bomRef.startsWith("skill:")
94
+ ? bomRef.slice("skill:".length)
95
+ : name;
96
+ const live = skillByName.get(skillName);
97
+ if (!live) {
98
+ errors.push(
99
+ `SBOM component "${name}" (bom-ref ${bomRef}) is not in manifest.skills — skill renamed or removed without SBOM refresh`
100
+ );
101
+ continue;
102
+ }
103
+ if (live.version && version && String(live.version) !== String(version)) {
104
+ errors.push(
105
+ `SBOM component "${name}" version ${version} != manifest.skills version ${live.version} — bump without SBOM refresh`
106
+ );
107
+ }
108
+ } else if (bomRef.startsWith("vendor:")) {
109
+ if (vendorProv && vendorProv.pinned_commit) {
110
+ const expected = vendorProv.pinned_commit.slice(0, 12);
111
+ if (version && String(version) !== expected) {
112
+ errors.push(
113
+ `SBOM vendor component "${name}" version ${version} != _PROVENANCE.json pinned_commit ${expected}`
114
+ );
115
+ }
116
+ }
117
+ }
118
+ }
119
+
65
120
  return {
66
121
  ok: errors.length === 0,
67
122
  errors,
68
123
  skills: sbomSkills,
69
124
  catalogs: sbomCatalogs,
125
+ components_validated: components.length,
70
126
  };
71
127
  }
72
128
 
@@ -76,10 +132,16 @@ function main() {
76
132
  if (!result.ok) {
77
133
  for (const e of result.errors) process.stderr.write(e + "\n");
78
134
  process.stderr.write("Run `npm run refresh-sbom` to regenerate sbom.cdx.json.\n");
79
- process.exit(1);
135
+ // v0.11.13 pattern: set exitCode + return so buffered stdout/stderr
136
+ // writes drain before the event loop exits. process.exit() can
137
+ // truncate piped output (CI log capture, JSON consumers).
138
+ process.exitCode = 1;
139
+ return;
80
140
  }
81
- process.stdout.write(`SBOM current — ${result.skills} skills, ${result.catalogs} catalogs.\n`);
82
- process.exit(0);
141
+ process.stdout.write(
142
+ `SBOM current — ${result.skills} skills, ${result.catalogs} catalogs, ` +
143
+ `${result.components_validated} components validated.\n`
144
+ );
83
145
  }
84
146
 
85
147
  module.exports = { checkSbomCurrency, resolveRoot };