@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
@@ -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
+ };