@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
@@ -3,12 +3,21 @@
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.
11
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
+ *
12
21
  * Implementation note — INT32 overflow guard. `setInterval` / `setTimeout`
13
22
  * delay values are coerced to a signed 32-bit integer; any value above
14
23
  * 2^31 - 1 ms (~24.8 days) is silently clamped to 1 ms by Node, which
@@ -19,6 +28,10 @@
19
28
  * delay — including multi-year intervals — fires exactly when due.
20
29
  */
21
30
 
31
+ const fs = require('fs');
32
+ const os = require('os');
33
+ const path = require('path');
34
+
22
35
  const { bus, EVENT_TYPES } = require('./event-bus');
23
36
  const { currencyCheck } = require('./pipeline');
24
37
 
@@ -36,9 +49,64 @@ const CURRENCY_THRESHOLDS = {
36
49
  warning: 70
37
50
  };
38
51
 
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
+
39
58
  let unschedulers = [];
40
59
  let running = false;
41
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
+
42
110
  // --- scheduling primitive ---
43
111
 
44
112
  /**
@@ -71,16 +139,56 @@ function scheduleEvery(intervalMs, handler) {
71
139
  // --- public API ---
72
140
 
73
141
  /**
74
- * 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.
75
148
  */
76
149
  function start() {
77
150
  if (running) return;
78
151
  running = true;
79
152
 
80
- runWeeklyCurrencyCheck();
81
- unschedulers.push(scheduleEvery(INTERVALS.WEEKLY_CURRENCY, runWeeklyCurrencyCheck));
82
- unschedulers.push(scheduleEvery(INTERVALS.MONTHLY_CVE_VALIDATION, runMonthlyCveValidation));
83
- unschedulers.push(scheduleEvery(INTERVALS.ANNUAL_AUDIT, runAnnualAudit));
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
+ }));
84
192
 
85
193
  console.log('[scheduler] Started. Weekly currency check, monthly CVE validation, annual audit scheduled.');
86
194
  }
@@ -113,14 +221,25 @@ function runWeeklyCurrencyCheck() {
113
221
 
114
222
  const { currency_report, action_required, critical_count } = currencyCheck();
115
223
 
116
- for (const skill of currency_report) {
117
- if (skill.currency_score < CURRENCY_THRESHOLDS.critical) {
118
- bus.skillCurrencyLow({
119
- skill_name: skill.skill,
120
- currency_score: skill.currency_score,
121
- days_since_review: skill.days_since_review
122
- });
123
- }
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
+ });
124
243
  }
125
244
 
126
245
  const result = {
@@ -129,7 +248,7 @@ function runWeeklyCurrencyCheck() {
129
248
  skills_checked: currency_report.length,
130
249
  action_required,
131
250
  critical_count,
132
- critical_skills: currency_report.filter(s => s.currency_score < CURRENCY_THRESHOLDS.critical).map(s => s.skill),
251
+ critical_skills: critical.map(s => s.skill),
133
252
  warning_skills: currency_report.filter(s =>
134
253
  s.currency_score >= CURRENCY_THRESHOLDS.critical && s.currency_score < CURRENCY_THRESHOLDS.warning
135
254
  ).map(s => s.skill)
@@ -177,4 +296,18 @@ function runAnnualAudit() {
177
296
  };
178
297
  }
179
298
 
180
- module.exports = { start, stop, runCurrencyNow, scheduleEvery, SAFE_MAX_MS, TICK_MS };
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.13",
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:4ab68946-cb1d-4bca-82a3-99dd37c31e07",
4
+ "serialNumber": "urn:uuid:0fb3661f-4f3b-4d31-98b3-d5fd759aa8c1",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-05-14T14:28:32.202Z",
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.13",
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.13",
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.13",
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.13"
32
+ "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.12.15"
33
33
  },
34
34
  {
35
35
  "type": "vcs",
@@ -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 };
@@ -89,10 +89,49 @@ function git(args, cwd) {
89
89
  // findings masked. Codex P1 flag on PR #2 of v0.12.8.
90
90
  function resolveBaseRef(opts, cwd) {
91
91
  if (opts.staged) return null; // staged mode uses --cached / HEAD throughout
92
+ // F14 — fall back gracefully when origin/main is unreachable. The
93
+ // original implementation tried `merge-base HEAD <opts.base>` and, on
94
+ // failure, returned opts.base verbatim — which then failed every
95
+ // subsequent git invocation, surfacing as a runner-level error. In CI
96
+ // (full clones) the original ref usually resolves; on a developer
97
+ // laptop without `origin/main` configured (fresh clone, detached
98
+ // worktree, alternative remote name) the gate would fail entirely.
99
+ //
100
+ // Order of preference:
101
+ // 1. merge-base against the requested base
102
+ // 2. requested base verbatim, if `git rev-parse --verify` resolves it
103
+ // 3. local `main` HEAD if it exists
104
+ // 4. HEAD~1 as a last resort (single-commit diff)
105
+ const tryResolve = (ref) => {
106
+ try {
107
+ git(["merge-base", "HEAD", ref], cwd).trim();
108
+ return ref;
109
+ } catch { /* not resolvable */ }
110
+ try {
111
+ git(["rev-parse", "--verify", ref], cwd).trim();
112
+ return ref;
113
+ } catch { return null; }
114
+ };
92
115
  try {
93
116
  const mb = git(["merge-base", "HEAD", opts.base], cwd).trim();
94
117
  if (mb) return mb;
95
- } catch { /* base may not exist locally; fall back to ref literal */ }
118
+ } catch { /* fall through */ }
119
+ const direct = tryResolve(opts.base);
120
+ if (direct) return direct;
121
+ const local = tryResolve("main");
122
+ if (local) {
123
+ process.stderr.write(
124
+ `[check-test-coverage] WARN: ${opts.base} unreachable; falling back to local main\n`
125
+ );
126
+ return local;
127
+ }
128
+ const parent = tryResolve("HEAD~1");
129
+ if (parent) {
130
+ process.stderr.write(
131
+ `[check-test-coverage] WARN: ${opts.base} unreachable and no local main; falling back to HEAD~1\n`
132
+ );
133
+ return parent;
134
+ }
96
135
  return opts.base;
97
136
  }
98
137
 
@@ -165,6 +204,23 @@ function categorize(file) {
165
204
  if (norm.startsWith("scripts/") && norm.endsWith(".js")) return "lib";
166
205
  if (norm.startsWith("data/playbooks/") && norm.endsWith(".json")) return "playbook";
167
206
  if (norm === "data/cve-catalog.json") return "cve-catalog";
207
+ // F11 — files matching catalog/schema/SBOM shapes are surfaced for manual
208
+ // review rather than silent allowlist. These changes (manifest.json,
209
+ // schemas/*, data/*.json, sbom.cdx.json, manifest-snapshot.*) can carry
210
+ // semantic surface but the analyzer has no syntactic surface extractor
211
+ // for them — humans should look.
212
+ if (norm === "manifest.json") return "manual-review";
213
+ if (norm === "manifest-snapshot.json") return "manual-review";
214
+ if (norm === "manifest-snapshot.sha256") return "manual-review";
215
+ if (norm === "sbom.cdx.json") return "manual-review";
216
+ if (norm.startsWith("lib/schemas/")) return "manual-review";
217
+ // v0.12.14: data/_indexes/ is auto-regenerated from data/ + manifest by
218
+ // `npm run build-indexes`; the source-of-truth diff is in the data/
219
+ // files themselves. Allowlist the derived index files so they don't
220
+ // perpetually surface as manual-review on every release commit.
221
+ if (norm.startsWith("data/_indexes/")) return "allowlist-derived";
222
+ if (norm.startsWith("data/") && norm.endsWith(".json")) return "manual-review";
223
+ if (norm === "package.json") return "manual-review";
168
224
  return "other";
169
225
  }
170
226
 
@@ -271,15 +327,20 @@ function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return
271
327
 
272
328
  function loadTestCorpus(cwd) {
273
329
  const root = path.join(cwd, "tests");
274
- if (!fs.existsSync(root)) return "";
330
+ if (!fs.existsSync(root)) return { joined: "", files: [] };
275
331
  const acc = [];
332
+ const files = [];
276
333
  walk(root, p => {
277
334
  const norm = p.replace(/\\/g, "/");
278
335
  if (/\.(js|json)$/.test(norm)) {
279
- try { acc.push(fs.readFileSync(p, "utf8")); } catch { /* ignore unreadable */ }
336
+ try {
337
+ const content = fs.readFileSync(p, "utf8");
338
+ acc.push(content);
339
+ files.push({ path: norm, content });
340
+ } catch { /* ignore unreadable */ }
280
341
  }
281
342
  });
282
- return acc.join("\n\x00\n");
343
+ return { joined: acc.join("\n\x00\n"), files };
283
344
  }
284
345
 
285
346
  function walk(dir, fn) {
@@ -302,22 +363,76 @@ function coversCliFlag(corpus, flag) {
302
363
  return corpus.includes(flag);
303
364
  }
304
365
 
366
+ // F10 — same-file context check. A test corpus is no longer treated as
367
+ // one giant string for lib-export coverage: the identifier must appear
368
+ // inside a real test block (`test(`, `it(`, `describe(`, or an `assert(`
369
+ // argument) within the SAME file that issues the matching require().
370
+ // Pre-fix: an `assert.equal(...)` mention in one test file plus a stray
371
+ // `require('../lib/x')` in a completely different test file counted as
372
+ // coverage. That's not coverage — it's textual coincidence.
373
+ //
374
+ // `corpus` may be either a string (legacy joined corpus, used by
375
+ // CLI/playbook/CVE coverage probes) or the structured shape
376
+ // `{ joined, files }` produced by loadTestCorpus().
305
377
  function coversLibExport(corpus, libRel, ident) {
306
378
  const baseName = path.basename(libRel).replace(/\.js$/, "");
307
379
  const baseFile = path.basename(libRel); // e.g. "check-sbom-currency.js"
308
380
  const identRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
309
- // Primary: a `require()` that mentions the module name AND a reference
310
- // to the identifier anywhere in the test corpus. Catches the canonical
311
- // `const { foo } = require('../lib/x')` test shape.
312
381
  const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
313
- if (requireRe.test(corpus) && identRe.test(corpus)) return true;
314
- // v0.12.9: a test that spawns the script under test (e.g.
315
- // `spawnSync(node, [".../scripts/check-sbom-currency.js", ...])`) is
316
- // real coverage too. Accept that shape when the corpus references the
317
- // full filename AND the identifier elsewhere. The `.js` suffix is what
318
- // distinguishes a real spawn-path from an arbitrary mention of the
319
- // module base name.
320
- if (corpus.includes(baseFile) && identRe.test(corpus)) return true;
382
+ // Accept the structured shape (preferred). Walk files individually.
383
+ if (corpus && Array.isArray(corpus.files)) {
384
+ for (const f of corpus.files) {
385
+ const hasRequire = requireRe.test(f.content);
386
+ const mentionsSpawnPath = f.content.includes(baseFile);
387
+ if (!hasRequire && !mentionsSpawnPath) continue;
388
+ if (!identRe.test(f.content)) continue;
389
+ // F10 require the identifier appears inside a test block in this
390
+ // file. Recognise `test(`, `it(`, `describe(`, or `assert(` (or any
391
+ // `assert.<member>(`) bracketed argument that mentions the ident.
392
+ if (mentionsIdentInTestContext(f.content, ident)) return true;
393
+ }
394
+ return false;
395
+ }
396
+ // Fallback: legacy joined-string corpus.
397
+ const joined = typeof corpus === "string" ? corpus : (corpus && corpus.joined) || "";
398
+ if (requireRe.test(joined) && identRe.test(joined)) return true;
399
+ if (joined.includes(baseFile) && identRe.test(joined)) return true;
400
+ return false;
401
+ }
402
+
403
+ // Returns true when `ident` appears as a token inside the body of any
404
+ // `test( ... )`, `it( ... )`, `describe( ... )`, `assert( ... )` or
405
+ // `assert.<member>( ... )` call in the file. We approximate "the body of
406
+ // the call" by finding the opening paren after the keyword, then walking
407
+ // matched parens until the call closes. This is a syntactic-enough check
408
+ // for vanilla JavaScript tests; the goal is to refuse "ident only appears
409
+ // in a top-level comment" while still accepting `assert.deepEqual(foo, ...)`.
410
+ function mentionsIdentInTestContext(content, ident) {
411
+ const tokenRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
412
+ // Quick reject: file does not mention the identifier at all.
413
+ if (!tokenRe.test(content)) return false;
414
+ const callRe = /\b(test|it|describe|assert(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g;
415
+ let m;
416
+ while ((m = callRe.exec(content)) !== null) {
417
+ const start = m.index + m[0].length; // pointer to first char inside (
418
+ let depth = 1;
419
+ let i = start;
420
+ let inStr = null;
421
+ while (i < content.length && depth > 0) {
422
+ const c = content[i];
423
+ if (inStr) {
424
+ if (c === "\\") { i += 2; continue; }
425
+ if (c === inStr) inStr = null;
426
+ } else {
427
+ if (c === '"' || c === "'" || c === "`") inStr = c;
428
+ else if (c === "(") depth++;
429
+ else if (c === ")") depth--;
430
+ }
431
+ i++;
432
+ }
433
+ const body = content.slice(start, i - 1);
434
+ if (tokenRe.test(body)) return true;
435
+ }
321
436
  return false;
322
437
  }
323
438
 
@@ -341,7 +456,8 @@ function analyze(opts) {
341
456
  // between calls produces false add/remove findings.
342
457
  const resolvedBase = resolveBaseRef(opts, cwd);
343
458
  const changed = listChangedFiles(opts, cwd, resolvedBase);
344
- const corpus = loadTestCorpus(cwd);
459
+ const corpusObj = loadTestCorpus(cwd);
460
+ const corpus = corpusObj.joined;
345
461
 
346
462
  const findings = [];
347
463
  const allowlisted = [];
@@ -355,7 +471,12 @@ function analyze(opts) {
355
471
  }
356
472
  if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
357
473
  if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
358
- if (cat === "other") { allowlisted.push({ file: ch.file, reason: "out-of-scope" }); continue; }
474
+ // F11 data catalogs, schemas, manifests, SBOM go to manual review
475
+ // instead of being silently allowlisted. They show up in CI output.
476
+ if (cat === "manual-review") { manualReview.push({ file: ch.file, reason: "manual-review" }); continue; }
477
+ // v0.12.14: derived index files allowlist (auto-regenerated artifacts).
478
+ if (cat === "allowlist-derived") { allowlisted.push({ file: ch.file, reason: "derived-artifact" }); continue; }
479
+ if (cat === "other") { manualReview.push({ file: ch.file, reason: "unclassified" }); continue; }
359
480
  if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
360
481
  allowlisted.push({ file: ch.file, reason: "whitespace-only" });
361
482
  continue;
@@ -381,9 +502,11 @@ function analyze(opts) {
381
502
  const b = extractLibExports(before);
382
503
  const a = extractLibExports(after);
383
504
  const d = diffSets(b, a);
384
- for (const id of d.added) if (!coversLibExport(corpus, ch.file, id))
505
+ // F10 pass the structured corpus so coversLibExport can enforce
506
+ // same-file require()+identifier-in-test-context coverage.
507
+ for (const id of d.added) if (!coversLibExport(corpusObj, ch.file, id))
385
508
  findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
386
- for (const id of d.removed) if (coversLibExport(corpus, ch.file, id))
509
+ for (const id of d.removed) if (coversLibExport(corpusObj, ch.file, id))
387
510
  findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "removed-but-test-remains" });
388
511
  } else if (cat === "playbook") {
389
512
  const b = extractPlaybookIds(before);