@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
package/lib/scoring.js CHANGED
@@ -3,6 +3,40 @@
3
3
  /**
4
4
  * RWEP — Real-World Exploit Priority scoring engine
5
5
  * Supplements CVSS with exploit availability, active exploitation, and operational constraints.
6
+ *
7
+ * ----------------------------------------------------------------------------
8
+ * `rwep_factors` dual-semantics (audit J F2)
9
+ * ----------------------------------------------------------------------------
10
+ * Catalog entries (data/cve-catalog.json) store `rwep_factors` as an object
11
+ * whose values are POST-WEIGHT CONTRIBUTIONS for boolean / ladder factors
12
+ * but the RAW BLAST RADIUS for `blast_radius`. The two shapes coexist because
13
+ * each surface has different requirements:
14
+ *
15
+ * cisa_kev: 0 OR +25 (post-weight contribution)
16
+ * poc_available: 0 OR +20 (post-weight contribution)
17
+ * ai_factor: 0 OR +15 (post-weight contribution)
18
+ * active_exploitation: 0 / 10 / 5 / 20 (post-weight contribution from ladder)
19
+ * blast_radius: 0..30 RAW (intentionally NOT post-weight —
20
+ * mirrors the weight ceiling so it
21
+ * reads as raw blast magnitude)
22
+ * patch_available: 0 OR -15 (post-weight contribution)
23
+ * live_patch_available: 0 OR -10 (post-weight contribution)
24
+ * reboot_required: 0 OR +5 (post-weight contribution)
25
+ *
26
+ * Operator-facing implication: summing `Object.values(rwep_factors)` produces
27
+ * the stored `rwep_score` for catalog entries because the blast weight is 30
28
+ * (matches the raw cap). This dual-shape is intentional but easy to misuse;
29
+ * direct boolean inputs should go through `scoreCustom()` instead.
30
+ *
31
+ * scoreCustom() input shape is DIFFERENT — it accepts BOOLEAN factors plus
32
+ * a numeric blast_radius and a string active_exploitation ladder value.
33
+ * `deriveRwepFromFactors()` is the shape-detecting bridge: if values look
34
+ * numeric (post-weighted), it sums; if values look boolean / string-ladder,
35
+ * it routes through scoreCustom.
36
+ *
37
+ * The semantic ambiguity is grandfathered. A clean rename (raw_factors vs
38
+ * weighted_contributions) is a minor-bump change and is deferred.
39
+ * ----------------------------------------------------------------------------
6
40
  */
7
41
 
8
42
  const CVE_SCHEMA_REQUIRED = [
@@ -28,6 +62,29 @@ const RWEP_WEIGHTS = {
28
62
  reboot_required: 5
29
63
  };
30
64
 
65
+ // audit J F4: active_exploitation ladder. Aligned with playbook-runner's
66
+ // _activeExploitationLadder so the catalog scorer and the runtime evaluator
67
+ // produce identical results for the same string value. 'unknown' contributes
68
+ // a quarter of the confirmed weight (5 points) — operationally "we have not
69
+ // confirmed, but absence of evidence is not evidence of absence; do not
70
+ // score zero on a fresh CVE that hasn't been triaged yet".
71
+ const ACTIVE_EXPLOITATION_LADDER = {
72
+ confirmed: 1.0,
73
+ suspected: 0.5,
74
+ unknown: 0.25,
75
+ none: 0,
76
+ };
77
+
78
+ // The canonical set of factor keys scoreCustom recognises. Used by
79
+ // validateFactors to flag unknown keys (audit J F8).
80
+ const RECOGNISED_FACTOR_KEYS = new Set([
81
+ 'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
82
+ 'active_exploitation', 'blast_radius', 'patch_available',
83
+ 'live_patch_available', 'reboot_required',
84
+ // accepted alias for the catalog field name
85
+ 'patch_required_reboot',
86
+ ]);
87
+
31
88
  function score(cveId, catalog) {
32
89
  const entry = catalog[cveId];
33
90
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
@@ -68,13 +125,29 @@ function validateFactors(factors) {
68
125
  } else if (!aeAllowed.includes(factors.active_exploitation)) {
69
126
  warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
70
127
  }
128
+ // audit J F6: NaN diagnostics. The prior message read "expected number,
129
+ // got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
130
+ // NaN === 'number'`. Number.isFinite catches NaN + Infinity + -Infinity
131
+ // and emits a useful message.
71
132
  if (factors.blast_radius === undefined || factors.blast_radius === null) {
72
133
  warnings.push('blast_radius: missing (treated as 0)');
73
- } else if (typeof factors.blast_radius !== 'number' || Number.isNaN(factors.blast_radius)) {
134
+ } else if (typeof factors.blast_radius !== 'number') {
74
135
  warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
136
+ } else if (Number.isNaN(factors.blast_radius)) {
137
+ warnings.push('blast_radius: NaN is not a valid numeric value (treated as 0)');
138
+ } else if (!Number.isFinite(factors.blast_radius)) {
139
+ warnings.push(`blast_radius: ${factors.blast_radius > 0 ? 'Infinity' : '-Infinity'} is not a finite numeric value (treated as 0)`);
75
140
  } else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
76
141
  warnings.push(`blast_radius: ${factors.blast_radius} out of expected range [0, 30] (clamped to weight ceiling, but the value usually indicates a unit-of-measure mistake)`);
77
142
  }
143
+ // audit J F8: surface unknown factor keys so a typo'd answer file
144
+ // (`patch_avilable`, `cisa-kev`, etc.) doesn't silently default to false
145
+ // with no diagnostic.
146
+ for (const k of Object.keys(factors)) {
147
+ if (!RECOGNISED_FACTOR_KEYS.has(k)) {
148
+ warnings.push(`unknown factor: ${k} (ignored — not in the recognised key set)`);
149
+ }
150
+ }
78
151
  return warnings;
79
152
  }
80
153
 
@@ -99,31 +172,96 @@ function scoreCustom(factors, opts) {
99
172
  blast_radius = 0,
100
173
  patch_available = false,
101
174
  live_patch_available = false,
102
- reboot_required = false
175
+ reboot_required = false,
176
+ // v0.12.15 (audit J F9): the CVE catalog field is `patch_required_reboot`
177
+ // but scoreCustom historically expected `reboot_required`. validate()
178
+ // already aliases at the call site; accept either spelling here so a
179
+ // direct caller passing the catalog entry doesn't silently lose the
180
+ // reboot factor.
181
+ patch_required_reboot,
103
182
  } = factors || {};
183
+ const rebootFactor = (reboot_required === true) || (patch_required_reboot === true);
104
184
 
105
185
  let score = 0;
106
186
  score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
107
187
  score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
108
188
  score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
109
- score += active_exploitation === 'confirmed' ? RWEP_WEIGHTS.active_exploitation : 0;
110
- score += active_exploitation === 'suspected' ? Math.floor(RWEP_WEIGHTS.active_exploitation / 2) : 0;
111
- // Clamp blast_radius into the weight-ceiling band [0, RWEP_WEIGHTS.blast_radius]
112
- // before adding. Out-of-range values still produce a clamped contribution but
113
- // validateFactors() surfaces the anomaly so operators see the unit error.
114
- const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, typeof blast_radius === 'number' ? blast_radius : 0));
189
+ // audit J F4 + F16: active_exploitation goes through the ladder rather
190
+ // than two hand-written branches with `Math.floor(weight/2)`. The floor
191
+ // was a no-op for even weights (20/2 = 10) but would have silently
192
+ // truncated to asymmetric results if a future operator bumped the
193
+ // weight to 21. The ladder + multiplication preserves the contribution
194
+ // exactly, including the new `unknown 0.25 × weight = 5` mapping that
195
+ // aligns the catalog scorer with playbook-runner._activeExploitationLadder.
196
+ const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
197
+ score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
198
+ // v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
199
+ // NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
200
+ // check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
201
+ // which propagates NaN through the final clamp, defeating the [0,100]
202
+ // contract. Number.isFinite + Number() coercion catches all four classes:
203
+ // NaN, Infinity, undefined, stringified-number.
204
+ const brRaw = Number.isFinite(Number(blast_radius)) ? Number(blast_radius) : 0;
205
+ const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, brRaw));
115
206
  score += brClamped;
116
207
  score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
117
208
  score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
118
- score += reboot_required ? RWEP_WEIGHTS.reboot_required : 0;
209
+ score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
210
+
211
+ // audit J F10: keep the pre-clamp value so collectWarnings consumers can
212
+ // see deduction magnitude (e.g. a -25 raw score collapsed to 0 hides the
213
+ // fact that the entry had three mitigating factors).
214
+ const rawUnclamped = score;
119
215
 
120
- const clamped = Math.min(100, Math.max(0, score));
216
+ // v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
217
+ // NaN production above (negative weight + Infinity + math edge case).
218
+ const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
121
219
  if (opts && opts.collectWarnings) {
122
- return { score: clamped, _scoring_warnings: validateFactors(factors) };
220
+ return {
221
+ score: clamped,
222
+ _rwep_raw_unclamped: Number.isFinite(rawUnclamped) ? rawUnclamped : null,
223
+ _scoring_warnings: validateFactors(factors),
224
+ };
123
225
  }
124
226
  return clamped;
125
227
  }
126
228
 
229
+ /**
230
+ * audit J F3 + audit M P1-C bridge: derive an RWEP score from a
231
+ * `rwep_factors` object regardless of which shape it uses.
232
+ *
233
+ * - SHAPE A (boolean / string-ladder): values are booleans + an
234
+ * active_exploitation string + a numeric blast_radius. Route through
235
+ * scoreCustom() — the canonical formula.
236
+ * - SHAPE B (catalog post-weight): values are numeric contributions
237
+ * (0 / ±N) plus a numeric blast_radius. Sum the numeric values and
238
+ * clamp to [0, 100]. This is how catalog `rwep_factors` are stored.
239
+ *
240
+ * Heuristic: if every value is a number, treat as Shape B (sum). If any
241
+ * value is boolean or a recognised ladder string, treat as Shape A
242
+ * (scoreCustom). This lets the curation apply-path and the auto-discovery
243
+ * builder share one canonical derivation that handles either operator
244
+ * input style without duplicating the scoring formula.
245
+ */
246
+ function deriveRwepFromFactors(factors) {
247
+ if (!factors || typeof factors !== 'object') return 0;
248
+ const values = Object.values(factors);
249
+ if (values.length === 0) return 0;
250
+ const aeAllowed = new Set(['none', 'unknown', 'suspected', 'confirmed']);
251
+ const hasBooleanOrLadder = values.some(
252
+ (v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v)),
253
+ );
254
+ if (hasBooleanOrLadder) {
255
+ return scoreCustom(factors);
256
+ }
257
+ // Shape B: catalog post-weight. Sum + clamp.
258
+ let sum = 0;
259
+ for (const v of values) {
260
+ if (typeof v === 'number' && Number.isFinite(v)) sum += v;
261
+ }
262
+ return Math.max(0, Math.min(100, sum));
263
+ }
264
+
127
265
  function timeline(rwepScore) {
128
266
  if (rwepScore >= 90) return { hours: 4, label: 'Immediate — live patch or isolate within 4 hours' };
129
267
  if (rwepScore >= 75) return { hours: 24, label: 'Urgent — patch or compensating controls within 24 hours' };
@@ -133,17 +271,36 @@ function timeline(rwepScore) {
133
271
  return { hours: null, label: 'Low — next scheduled maintenance' };
134
272
  }
135
273
 
136
- function compare(cveId, catalog) {
274
+ function compare(cveId, catalog, opts) {
137
275
  const entry = catalog[cveId];
138
276
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
139
277
 
140
- const rwep = entry.rwep_score;
278
+ // audit J F11: `--recompute` ignores the stored rwep_score and forces a
279
+ // fresh computation from rwep_factors. Useful for catching catalog drift
280
+ // (stored score grew stale relative to current weights) and for auditing
281
+ // the divergence between stored vs. formula-derived scores.
282
+ const recompute = !!(opts && opts.recompute);
283
+ let rwep;
284
+ if (recompute) {
285
+ const factors = entry.rwep_factors || {};
286
+ // The catalog's rwep_factors shape is "post-weight" (Shape B). Route
287
+ // through the shape-detecting helper so a catalog whose factors were
288
+ // hand-edited in either shape still produces a usable score.
289
+ rwep = deriveRwepFromFactors(factors);
290
+ } else {
291
+ rwep = entry.rwep_score;
292
+ }
141
293
  const cvss = entry.cvss_score;
142
294
  const cvssEquivalent = cvss * 10;
143
295
  const delta = rwep - cvssEquivalent;
144
296
 
297
+ // audit J F15: narrow the "broadly aligned" band from ±20 to ±10. The old
298
+ // ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
299
+ // where the operator-facing point is precisely that the CVSS-calibrated
300
+ // SLA is insufficient. ±10 is the tightest classifier that still treats
301
+ // ordinary CVSS rounding noise as alignment.
145
302
  let explanation = '';
146
- if (delta > 20) {
303
+ if (delta > 10) {
147
304
  explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
148
305
  const driving = [];
149
306
  if (entry.cisa_kev) driving.push('CISA KEV (+25)');
@@ -153,7 +310,7 @@ function compare(cveId, catalog) {
153
310
  if (entry.patch_required_reboot && !entry.live_patch_available) driving.push('reboot required (+5)');
154
311
  explanation += driving.join(', ');
155
312
  explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
156
- } else if (delta < -20) {
313
+ } else if (delta < -10) {
157
314
  explanation = `RWEP lower than CVSS equivalent. Mitigating factors: `;
158
315
  const mitigating = [];
159
316
  if (entry.patch_available) mitigating.push('patch available (-15)');
@@ -165,15 +322,20 @@ function compare(cveId, catalog) {
165
322
  explanation = 'CVSS and RWEP are broadly aligned for this CVE.';
166
323
  }
167
324
 
168
- return {
325
+ const out = {
169
326
  cve_id: cveId,
170
327
  cvss: cvss,
171
328
  rwep: rwep,
172
329
  cvss_framework_sla: timeline(cvssEquivalent),
173
330
  rwep_actual_sla: timeline(rwep),
174
331
  delta,
175
- explanation
332
+ explanation,
176
333
  };
334
+ if (recompute) {
335
+ out.stored_rwep_score = entry.rwep_score;
336
+ out.recomputed = true;
337
+ }
338
+ return out;
177
339
  }
178
340
 
179
341
  function validate(catalog) {
@@ -209,4 +371,15 @@ function validate(catalog) {
209
371
  return errors;
210
372
  }
211
373
 
212
- module.exports = { score, scoreCustom, timeline, compare, validate, validateFactors, RWEP_WEIGHTS };
374
+ module.exports = {
375
+ score,
376
+ scoreCustom,
377
+ timeline,
378
+ compare,
379
+ validate,
380
+ validateFactors,
381
+ deriveRwepFromFactors,
382
+ RWEP_WEIGHTS,
383
+ ACTIVE_EXPLOITATION_LADDER,
384
+ RECOGNISED_FACTOR_KEYS,
385
+ };
@@ -26,6 +26,7 @@
26
26
 
27
27
  const https = require("https");
28
28
  const fs = require("fs");
29
+ const { withRetry } = require("../vendor/blamejs/retry.js");
29
30
 
30
31
  const GHSA_HOST = "api.github.com";
31
32
  const GHSA_PATH = "/advisories?per_page=50&type=reviewed&sort=published&direction=desc";
@@ -33,40 +34,93 @@ const REQUEST_TIMEOUT_MS = 10000;
33
34
  const USER_AGENT = "exceptd-security/source-ghsa (+https://exceptd.com)";
34
35
 
35
36
  /**
36
- * Fetch a page of advisories (default: latest 50).
37
+ * Field-dropped watch set fields the buildDiff regression-detector
38
+ * watches when the upstream still has an entry but a previously-populated
39
+ * local value has gone null. Mirrors lib/source-osv.js. Finding 9.
40
+ */
41
+ const FIELD_DROPPED_WATCH = Object.freeze([
42
+ "cvss_score",
43
+ "cisa_kev_pending",
44
+ "active_exploitation",
45
+ "ai_discovered",
46
+ "poc_available",
47
+ ]);
48
+
49
+ /**
50
+ * Return true when the runtime context requests air-gap mode. Sources MUST
51
+ * refuse network calls when this is set — fall through to fixture or return
52
+ * a structured `air-gap: no fixture available` error so the operator sees
53
+ * an explicit refusal, not a silent network attempt. Mirrors source-osv.
54
+ */
55
+ function isAirGap(opts) {
56
+ if (opts && opts.airGap) return true;
57
+ if (process.env.EXCEPTD_AIR_GAP === "1") return true;
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Read EXCEPTD_GHSA_FIXTURE and return a structured envelope. Finding 5:
63
+ * mirror the OSV-source convention so a fixture file containing `null`,
64
+ * a number, or a string at its root doesn't slip through as an empty
65
+ * advisories array — the strict catalog validator would later swallow the
66
+ * resulting drift as "no advisories returned" instead of surfacing it as
67
+ * a fixture configuration error. Returns:
37
68
  *
38
- * Returns:
39
- * { ok: true, advisories: [...], source: "github-api" | "fixture", rate_limit?: { remaining, reset } }
40
- * { ok: false, error, source: "offline" }
69
+ * null when env var is unset
70
+ * { ok: true, advisories: [...], source } on success
71
+ * { ok: false, error, source: "offline" } on any failure
41
72
  */
42
- async function fetchAdvisories({ timeoutMs = REQUEST_TIMEOUT_MS, path = GHSA_PATH, token = null } = {}) {
43
- if (process.env.EXCEPTD_GHSA_FIXTURE) {
44
- try {
45
- const arr = JSON.parse(fs.readFileSync(process.env.EXCEPTD_GHSA_FIXTURE, "utf8"));
46
- return { ok: true, advisories: Array.isArray(arr) ? arr : [arr], source: "fixture" };
47
- } catch (e) {
48
- return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
49
- }
73
+ function readFixture() {
74
+ const fp = process.env.EXCEPTD_GHSA_FIXTURE;
75
+ if (!fp) return null;
76
+ let raw;
77
+ try {
78
+ raw = fs.readFileSync(fp, "utf8");
79
+ } catch (e) {
80
+ return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
81
+ }
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ } catch (e) {
86
+ return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
50
87
  }
88
+ if (parsed == null || typeof parsed !== "object") {
89
+ return { ok: false, error: `fixture: invalid root shape (got ${typeof parsed}); expected GHSA advisory object or array`, source: "offline" };
90
+ }
91
+ return { ok: true, advisories: Array.isArray(parsed) ? parsed : [parsed], source: "fixture" };
92
+ }
51
93
 
52
- return new Promise((resolve) => {
53
- const headers = {
54
- "Accept": "application/vnd.github+json",
55
- "User-Agent": USER_AGENT,
56
- "X-GitHub-Api-Version": "2022-11-28",
57
- };
58
- if (token || process.env.GITHUB_TOKEN) {
59
- headers.Authorization = `Bearer ${token || process.env.GITHUB_TOKEN}`;
60
- }
94
+ /**
95
+ * One HTTPS GET against api.github.com. Throws on retryable conditions so
96
+ * withRetry's default classifier (HTTP 408/425/429/5xx + ECONNRESET et al)
97
+ * picks them up; resolves to a structured envelope on permanent conditions.
98
+ */
99
+ function ghsaRequestOnce({ path, headers, timeoutMs }) {
100
+ return new Promise((resolve, reject) => {
61
101
  const req = https.get({
62
102
  host: GHSA_HOST,
63
103
  path,
64
104
  headers,
65
105
  timeout: timeoutMs,
66
106
  }, (res) => {
67
- if (res.statusCode !== 200) {
107
+ const status = res.statusCode;
108
+ if (status === 429 || status === 503 ||
109
+ (status >= 500 && status <= 599) ||
110
+ status === 408 || status === 425) {
68
111
  res.resume();
69
- return resolve({ ok: false, error: `GHSA returned HTTP ${res.statusCode}`, source: "offline" });
112
+ const err = new Error(`GHSA returned HTTP ${status}`);
113
+ err.statusCode = status;
114
+ const ra = res.headers["retry-after"];
115
+ if (ra) {
116
+ const secs = parseInt(ra, 10);
117
+ if (Number.isFinite(secs)) err.retryAfterMs = secs * 1000;
118
+ }
119
+ return reject(err);
120
+ }
121
+ if (status !== 200) {
122
+ res.resume();
123
+ return resolve({ ok: false, error: `GHSA returned HTTP ${status}`, source: "offline" });
70
124
  }
71
125
  const chunks = [];
72
126
  res.on("data", (c) => chunks.push(c));
@@ -88,11 +142,60 @@ async function fetchAdvisories({ timeoutMs = REQUEST_TIMEOUT_MS, path = GHSA_PAT
88
142
  }
89
143
  });
90
144
  });
91
- req.on("timeout", () => req.destroy(new Error("timeout")));
92
- req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
145
+ req.on("timeout", () => {
146
+ const err = new Error("timeout");
147
+ err.code = "ETIMEDOUT";
148
+ req.destroy(err);
149
+ });
150
+ req.on("error", (e) => {
151
+ if (e && e.code && /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|EAGAIN|ENOTFOUND|ENETUNREACH)$/.test(e.code)) {
152
+ return reject(e);
153
+ }
154
+ resolve({ ok: false, error: e.message, source: "offline" });
155
+ });
93
156
  });
94
157
  }
95
158
 
159
+ /**
160
+ * Fetch a page of advisories (default: latest 50). Wraps the underlying
161
+ * HTTPS request in withRetry so transient 429/503/5xx + net errors back off
162
+ * automatically.
163
+ *
164
+ * Returns:
165
+ * { ok: true, advisories: [...], source: "github-api" | "fixture", rate_limit?: { remaining, reset } }
166
+ * { ok: false, error, source: "offline" }
167
+ */
168
+ async function fetchAdvisories({ timeoutMs = REQUEST_TIMEOUT_MS, path = GHSA_PATH, token = null, airGap = false } = {}) {
169
+ const fixture = readFixture();
170
+ if (fixture) return fixture;
171
+ // Finding 7: air-gap hard-refuses network when no fixture is configured.
172
+ if (isAirGap({ airGap })) {
173
+ return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_GHSA_FIXTURE)", source: "offline" };
174
+ }
175
+ const headers = {
176
+ "Accept": "application/vnd.github+json",
177
+ "User-Agent": USER_AGENT,
178
+ "X-GitHub-Api-Version": "2022-11-28",
179
+ };
180
+ if (token || process.env.GITHUB_TOKEN) {
181
+ headers.Authorization = `Bearer ${token || process.env.GITHUB_TOKEN}`;
182
+ }
183
+ try {
184
+ return await withRetry(() => ghsaRequestOnce({ path, headers, timeoutMs }), {
185
+ maxAttempts: 3,
186
+ baseDelayMs: 100,
187
+ maxDelayMs: 2000,
188
+ jitterFactor: 0.5,
189
+ });
190
+ } catch (e) {
191
+ const status = typeof e?.statusCode === "number" ? e.statusCode : null;
192
+ const error = status
193
+ ? `GHSA returned HTTP ${status}`
194
+ : `GHSA request failed: ${e.message || e}`;
195
+ return { ok: false, error, status, source: "offline" };
196
+ }
197
+ }
198
+
96
199
  /**
97
200
  * Fetch a single advisory by ID — accepts CVE-* or GHSA-* identifiers.
98
201
  *
@@ -103,12 +206,18 @@ async function fetchAdvisoryById(id, opts = {}) {
103
206
  if (!id || typeof id !== "string") {
104
207
  return { ok: false, error: "id is required (CVE-* or GHSA-*)", source: "offline" };
105
208
  }
209
+ // Finding 8: trim whitespace at the entry seam.
210
+ id = id.trim();
211
+ if (!id) {
212
+ return { ok: false, error: "id is required (CVE-* or GHSA-*)", source: "offline" };
213
+ }
106
214
  if (process.env.EXCEPTD_GHSA_FIXTURE) {
107
215
  const r = await fetchAdvisories(opts);
108
216
  if (!r.ok) return r;
217
+ const want = id.toUpperCase();
109
218
  const match = r.advisories.find(a =>
110
- (a.ghsa_id && a.ghsa_id.toUpperCase() === id.toUpperCase()) ||
111
- (a.cve_id && a.cve_id.toUpperCase() === id.toUpperCase())
219
+ (a.ghsa_id && String(a.ghsa_id).toUpperCase() === want) ||
220
+ (a.cve_id && String(a.cve_id).toUpperCase() === want)
112
221
  );
113
222
  if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
114
223
  return { ok: true, advisories: [match], source: "fixture" };
@@ -128,6 +237,23 @@ async function fetchAdvisoryById(id, opts = {}) {
128
237
  return { ok: false, error: `unrecognized id format: ${id}. Expected one of: CVE-YYYY-NNNN, GHSA-* (routed through source-ghsa); MAL-* / SNYK-* / RUSTSEC-* / USN-* / PYSEC-* / GO-* / MGASA-* / UVI- (routed through source-osv).`, source: "offline" };
129
238
  }
130
239
 
240
+ /**
241
+ * Validate + slice a published_at timestamp. Findings 2 + 17:
242
+ * - typeof guard so non-string inputs (number, object, undefined) become
243
+ * null instead of throwing on .slice().
244
+ * - ISO-prefix + year sanity bound so garbage timestamps don't pollute
245
+ * downstream surfaces.
246
+ */
247
+ function safeDateSlice(value) {
248
+ if (typeof value !== "string") return null;
249
+ const head = value.slice(0, 10);
250
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(head)) return null;
251
+ const year = parseInt(head.slice(0, 4), 10);
252
+ const now = new Date().getUTCFullYear();
253
+ if (!Number.isFinite(year) || year < 1990 || year > now + 1) return null;
254
+ return head;
255
+ }
256
+
131
257
  /**
132
258
  * Normalize a GHSA advisory object to the exceptd catalog draft shape.
133
259
  * Fields the GHSA carries authoritatively: cve_id, ghsa_id, summary,
@@ -146,7 +272,9 @@ function normalizeAdvisory(adv) {
146
272
  const ecosystems = new Set();
147
273
  const affected = [];
148
274
  const ecosystemPackages = [];
149
- for (const v of (adv.vulnerabilities || [])) {
275
+ // Finding 3: vulnerabilities may not be an array — guard before iterating.
276
+ const vulnList = Array.isArray(adv.vulnerabilities) ? adv.vulnerabilities : [];
277
+ for (const v of vulnList) {
150
278
  if (v?.package?.ecosystem) ecosystems.add(v.package.ecosystem);
151
279
  if (v?.package?.name) {
152
280
  ecosystemPackages.push(`${v.package.ecosystem || "?"}:${v.package.name}`);
@@ -156,7 +284,14 @@ function normalizeAdvisory(adv) {
156
284
  }
157
285
  }
158
286
 
159
- const cvssScore = adv.cvss?.score ?? null;
287
+ // Finding 4: cvss.score may arrive as a string ("9.8") rather than a
288
+ // number. Number-coerce + finite-check so the catalog field stays
289
+ // numeric across upstream shape drift.
290
+ let cvssScore = null;
291
+ if (adv.cvss != null && adv.cvss.score !== undefined && adv.cvss.score !== null) {
292
+ const n = Number(adv.cvss.score);
293
+ cvssScore = Number.isFinite(n) ? n : null;
294
+ }
160
295
  const cvssVector = adv.cvss?.vector_string || null;
161
296
  const severity = (adv.severity || "").toLowerCase();
162
297
  // Derive a coarse type from package ecosystem when nothing better available.
@@ -166,6 +301,13 @@ function normalizeAdvisory(adv) {
166
301
  : ecosystems.has("rubygems") ? "supply-chain-gem"
167
302
  : "supply-chain-other";
168
303
 
304
+ // Finding 2 + 17: type-safe + format-validated published_at slicing.
305
+ const publishedDate = safeDateSlice(adv.published_at);
306
+
307
+ // Finding 20: references may not be an array — guard the spread before
308
+ // it silently truncates to an empty list.
309
+ const refList = Array.isArray(adv.references) ? adv.references : [];
310
+
169
311
  return {
170
312
  [adv.cve_id]: {
171
313
  name: adv.summary || adv.cve_id,
@@ -205,7 +347,7 @@ function normalizeAdvisory(adv) {
205
347
  verification_sources: [
206
348
  ...(adv.html_url ? [adv.html_url] : []),
207
349
  ...(adv.cve_id ? [`https://nvd.nist.gov/vuln/detail/${adv.cve_id}`] : []),
208
- ...(adv.references || []).slice(0, 10),
350
+ ...refList.slice(0, 10),
209
351
  ],
210
352
  vendor_advisories: [
211
353
  {
@@ -213,7 +355,7 @@ function normalizeAdvisory(adv) {
213
355
  advisory_id: adv.ghsa_id || null,
214
356
  url: adv.html_url || `https://github.com/advisories?query=${encodeURIComponent(adv.cve_id)}`,
215
357
  severity: severity || null,
216
- published_date: (adv.published_at || "").slice(0, 10) || null,
358
+ published_date: publishedDate,
217
359
  },
218
360
  ],
219
361
  iocs: null,
@@ -231,19 +373,51 @@ function normalizeAdvisory(adv) {
231
373
  * Build a refresh diff for the existing refresh-external orchestrator.
232
374
  * Compares the latest 50 advisories' CVE IDs against the local catalog;
233
375
  * any CVE ID not in the catalog becomes an "add" diff.
376
+ *
377
+ * Finding 9: when the advisory is already in the catalog but a watched
378
+ * field has dropped from populated -> null, surface a `field_dropped`
379
+ * diff so curators don't silently lose signal.
380
+ *
381
+ * Finding 18: count + surface GHSA-only advisories (no CVE id) that were
382
+ * skipped, so the summary explains why N upstream advisories produced
383
+ * fewer than N diffs.
234
384
  */
235
385
  async function buildDiff(ctx) {
236
- const result = await fetchAdvisories({});
386
+ const result = await fetchAdvisories({ airGap: ctx?.airGap });
237
387
  if (!result.ok) {
238
388
  return { status: "unreachable", diffs: [], errors: 1, summary: `GHSA fetch failed: ${result.error}` };
239
389
  }
240
- const existing = new Set(Object.keys(ctx.cveCatalog || {}).filter(k => /^CVE-/.test(k)));
390
+ const cveCatalog = ctx.cveCatalog || {};
391
+ const existing = new Set(Object.keys(cveCatalog).filter(k => /^CVE-/.test(k)));
241
392
  const diffs = [];
393
+ let ghsaOnlySkipped = 0;
242
394
  for (const adv of result.advisories) {
243
- if (!adv.cve_id) continue;
244
- if (existing.has(adv.cve_id)) continue;
395
+ if (!adv.cve_id) { ghsaOnlySkipped++; continue; }
245
396
  const normalized = normalizeAdvisory(adv);
246
397
  if (!normalized) continue;
398
+ if (existing.has(adv.cve_id)) {
399
+ // Finding 9: field-dropped detection on the existing entry.
400
+ const before = cveCatalog[adv.cve_id] || {};
401
+ const after = normalized[adv.cve_id];
402
+ for (const field of FIELD_DROPPED_WATCH) {
403
+ const had = before[field];
404
+ const has = after[field];
405
+ const wasPopulated = had !== null && had !== undefined && had !== "" && had !== false;
406
+ const isNowEmpty = has === null || has === undefined;
407
+ if (wasPopulated && isNowEmpty) {
408
+ diffs.push({
409
+ id: adv.cve_id,
410
+ field,
411
+ before: had,
412
+ after: null,
413
+ severity: null,
414
+ source: "ghsa",
415
+ variant: "field_dropped",
416
+ });
417
+ }
418
+ }
419
+ continue;
420
+ }
247
421
  diffs.push({
248
422
  id: adv.cve_id,
249
423
  field: "_new_entry",
@@ -257,9 +431,17 @@ async function buildDiff(ctx) {
257
431
  status: "ok",
258
432
  diffs,
259
433
  errors: 0,
260
- summary: `GHSA returned ${result.advisories.length} reviewed advisories; ${diffs.length} new CVE ID(s) not yet in local catalog.`,
434
+ ghsa_only_skipped: ghsaOnlySkipped,
435
+ summary: `GHSA returned ${result.advisories.length} reviewed advisories; ${diffs.length} new CVE ID(s) not yet in local catalog, ${ghsaOnlySkipped} ghsa_only_skipped.`,
261
436
  rate_limit: result.rate_limit || null,
262
437
  };
263
438
  }
264
439
 
265
- module.exports = { fetchAdvisories, fetchAdvisoryById, normalizeAdvisory, buildDiff };
440
+ module.exports = {
441
+ fetchAdvisories,
442
+ fetchAdvisoryById,
443
+ normalizeAdvisory,
444
+ buildDiff,
445
+ FIELD_DROPPED_WATCH,
446
+ safeDateSlice,
447
+ };