@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/source-osv.js CHANGED
@@ -38,6 +38,7 @@
38
38
 
39
39
  const https = require("https");
40
40
  const fs = require("fs");
41
+ const { withRetry } = require("../vendor/blamejs/retry.js");
41
42
 
42
43
  // OSV_HOST_OVERRIDE lets tests redirect the network call to a local HTTP
43
44
  // server bound on 127.0.0.1:<port>. The override accepts either a bare
@@ -77,6 +78,21 @@ const OSV_ID_PREFIXES = [
77
78
  "OPENSUSE-", // openSUSE
78
79
  ];
79
80
 
81
+ /**
82
+ * Set of OSV draft fields the field-dropped detector watches. When a
83
+ * previously-populated value goes to null on a refresh, surface it as a
84
+ * `field_dropped` diff so curators can investigate the upstream regression
85
+ * rather than silently losing signal. Keep this set small + intentional —
86
+ * fields here MUST be ones the editorial review process can re-source.
87
+ */
88
+ const FIELD_DROPPED_WATCH = Object.freeze([
89
+ "cvss_score",
90
+ "cisa_kev_pending",
91
+ "active_exploitation",
92
+ "ai_discovered",
93
+ "poc_available",
94
+ ]);
95
+
80
96
  /**
81
97
  * Return true when `id` looks like an OSV-native primary key (i.e. NOT a
82
98
  * CVE-* identifier and NOT a GHSA-* identifier). Both CVE-* and GHSA-*
@@ -84,98 +100,119 @@ const OSV_ID_PREFIXES = [
84
100
  */
85
101
  function isOsvId(id) {
86
102
  if (!id || typeof id !== "string") return false;
87
- const up = id.toUpperCase();
103
+ // F8 (finding 8): trim trailing/leading whitespace so operators pasting
104
+ // ids from clipboards / multi-line files don't see a surprising routing
105
+ // miss. Empty after trim → not an OSV id.
106
+ const trimmed = id.trim();
107
+ if (!trimmed) return false;
108
+ const up = trimmed.toUpperCase();
88
109
  if (/^CVE-\d{4}-\d+$/.test(up)) return false;
89
110
  if (up.startsWith("GHSA-")) return false;
90
111
  return OSV_ID_PREFIXES.some((p) => up.startsWith(p));
91
112
  }
92
113
 
114
+ /**
115
+ * Return true when the runtime context requests air-gap mode. Sources MUST
116
+ * refuse network calls when this is set — fall through to fixture or return
117
+ * a structured `air-gap: no fixture available` error so the operator sees
118
+ * an explicit refusal, not a silent network attempt.
119
+ */
120
+ function isAirGap(opts) {
121
+ if (opts && opts.airGap) return true;
122
+ if (process.env.EXCEPTD_AIR_GAP === "1") return true;
123
+ return false;
124
+ }
125
+
93
126
  /**
94
127
  * Resolve the OSV transport target. When OSV_HOST_OVERRIDE is set the
95
128
  * request switches to plain HTTP on the override host:port so test
96
129
  * harnesses can stand up a local server without TLS. Production omits the
97
130
  * override entirely and lands on api.osv.dev over HTTPS.
131
+ *
132
+ * Finding 13: validate the override aggressively. Garbage env values
133
+ * (random binary, embedded NUL, port > 65535) previously slipped through
134
+ * into the http.request options and produced opaque ENOTFOUND / EADDRINUSE
135
+ * errors far from the source. Reject with a structured error here instead.
98
136
  */
99
137
  function osvTransport() {
100
138
  const override = process.env.OSV_HOST_OVERRIDE;
101
139
  if (!override) return { mod: https, host: OSV_HOST, port: 443 };
102
- // Accept either "host:port" or a full URL.
103
- let raw = override.trim();
140
+ const raw = String(override).trim();
141
+ const HOST_RE = /^[a-z0-9.-]+$/i;
142
+ let host;
143
+ let port;
104
144
  if (/^https?:\/\//i.test(raw)) {
105
- const u = new URL(raw);
106
- return { mod: require("http"), host: u.hostname, port: parseInt(u.port, 10) || 80 };
145
+ let u;
146
+ try { u = new URL(raw); }
147
+ catch (e) {
148
+ return { error: `OSV_HOST_OVERRIDE: invalid URL: ${e.message}` };
149
+ }
150
+ host = u.hostname;
151
+ port = parseInt(u.port, 10);
152
+ if (!port) port = u.protocol === "https:" ? 443 : 80;
153
+ } else {
154
+ const [h, p] = raw.split(":");
155
+ host = h;
156
+ port = parseInt(p, 10) || 80;
107
157
  }
108
- const [h, p] = raw.split(":");
109
- return { mod: require("http"), host: h || "127.0.0.1", port: parseInt(p, 10) || 80 };
158
+ if (!host || !HOST_RE.test(host)) {
159
+ return { error: `OSV_HOST_OVERRIDE: invalid host '${host || ""}'; must match /^[a-z0-9.-]+$/i` };
160
+ }
161
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
162
+ return { error: `OSV_HOST_OVERRIDE: invalid port '${port}'; must be 1..65535` };
163
+ }
164
+ return { mod: require("http"), host, port };
110
165
  }
111
166
 
112
167
  /**
113
- * Low-level GET against OSV. Resolves to { ok, record|error, source }.
114
- * Honors OSV_HOST_OVERRIDE for offline tests.
168
+ * Make one OSV request (HEAD/GET/POST). Throws on retryable conditions
169
+ * (HTTP 429/503/5xx + ECONNRESET/ETIMEDOUT family) and resolves to a
170
+ * structured `{ok:false}` envelope on permanent conditions (4xx other than
171
+ * 408/425/429). The thrown errors carry `statusCode` so withRetry's default
172
+ * classifier recognizes them as retryable.
115
173
  */
116
- function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
117
- return new Promise((resolve) => {
118
- const { mod, host, port } = osvTransport();
119
- const req = mod.get({
120
- host,
121
- port,
122
- path: reqPath,
123
- headers: {
124
- "Accept": "application/json",
125
- "User-Agent": USER_AGENT,
126
- },
127
- timeout: timeoutMs,
128
- }, (res) => {
129
- if (res.statusCode !== 200) {
174
+ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
175
+ return new Promise((resolve, reject) => {
176
+ const t = osvTransport();
177
+ if (t.error) {
178
+ // F13: surface the validation error structurally; no retry.
179
+ return resolve({ ok: false, error: t.error, source: "offline" });
180
+ }
181
+ const { mod, host, port } = t;
182
+ const headers = {
183
+ "Accept": "application/json",
184
+ "User-Agent": USER_AGENT,
185
+ };
186
+ let payload = null;
187
+ if (method === "POST" && body) {
188
+ payload = Buffer.from(JSON.stringify(body), "utf8");
189
+ headers["Content-Type"] = "application/json";
190
+ headers["Content-Length"] = payload.length;
191
+ }
192
+ const opts = { host, port, path: reqPath, method, headers, timeout: timeoutMs };
193
+ const req = mod.request(opts, (res) => {
194
+ const status = res.statusCode;
195
+ // 401/404 (and other 4xx aside from 408/425/429) are permanent.
196
+ // 429/503 + 5xx are retryable. Honor Retry-After when present.
197
+ const retryAfterRaw = res.headers["retry-after"];
198
+ if (status === 429 || status === 503 || (status >= 500 && status <= 599) ||
199
+ status === 408 || status === 425) {
130
200
  res.resume();
131
- const status = res.statusCode;
132
- const error = status === 429
133
- ? `OSV rate-limited (HTTP 429)`
134
- : `OSV returned HTTP ${status}`;
135
- return resolve({ ok: false, error, status, source: "offline" });
136
- }
137
- const chunks = [];
138
- res.on("data", (c) => chunks.push(c));
139
- res.on("end", () => {
140
- try {
141
- const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
142
- resolve({ ok: true, record: body, source: "osv-api" });
143
- } catch (e) {
144
- resolve({ ok: false, error: `parse: ${e.message}`, source: "offline" });
201
+ const err = new Error(`OSV returned HTTP ${status}`);
202
+ err.statusCode = status;
203
+ // Surface Retry-After (seconds or HTTP-date). The retry caller
204
+ // doesn't currently consume this directly — withRetry's backoff is
205
+ // its own schedule but exposing it lets future schedulers honor
206
+ // server-advertised delay.
207
+ if (retryAfterRaw) {
208
+ const secs = parseInt(retryAfterRaw, 10);
209
+ if (Number.isFinite(secs)) err.retryAfterMs = secs * 1000;
145
210
  }
146
- });
147
- });
148
- req.on("timeout", () => req.destroy(new Error("OSV request timed out")));
149
- req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
150
- });
151
- }
152
-
153
- /**
154
- * Low-level POST against OSV. Body is JSON-stringified.
155
- */
156
- function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
157
- return new Promise((resolve) => {
158
- const payload = Buffer.from(JSON.stringify(body), "utf8");
159
- const { mod, host, port } = osvTransport();
160
- const req = mod.request({
161
- host,
162
- port,
163
- path: reqPath,
164
- method: "POST",
165
- headers: {
166
- "Content-Type": "application/json",
167
- "Content-Length": payload.length,
168
- "Accept": "application/json",
169
- "User-Agent": USER_AGENT,
170
- },
171
- timeout: timeoutMs,
172
- }, (res) => {
173
- if (res.statusCode !== 200) {
211
+ return reject(err);
212
+ }
213
+ if (status !== 200) {
174
214
  res.resume();
175
- const status = res.statusCode;
176
- const error = status === 429
177
- ? `OSV rate-limited (HTTP 429)`
178
- : `OSV returned HTTP ${status}`;
215
+ const error = `OSV returned HTTP ${status}`;
179
216
  return resolve({ ok: false, error, status, source: "offline" });
180
217
  }
181
218
  const chunks = [];
@@ -189,13 +226,73 @@ function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
189
226
  }
190
227
  });
191
228
  });
192
- req.on("timeout", () => req.destroy(new Error("OSV request timed out")));
193
- req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
194
- req.write(payload);
229
+ req.on("timeout", () => {
230
+ const err = new Error("OSV request timed out");
231
+ err.code = "ETIMEDOUT";
232
+ req.destroy(err);
233
+ });
234
+ req.on("error", (e) => {
235
+ // Retryable network errors propagate up to withRetry; non-retryable
236
+ // resolve as structured offline.
237
+ if (e && e.code && /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|EAGAIN|ENOTFOUND|ENETUNREACH)$/.test(e.code)) {
238
+ return reject(e);
239
+ }
240
+ resolve({ ok: false, error: e.message, source: "offline" });
241
+ });
242
+ if (payload) req.write(payload);
195
243
  req.end();
196
244
  });
197
245
  }
198
246
 
247
+ /**
248
+ * Low-level GET against OSV. Resolves to { ok, record|error, source }.
249
+ * Honors OSV_HOST_OVERRIDE for offline tests. Wraps the request in
250
+ * withRetry so 429/503/5xx + transient net errors back off automatically.
251
+ */
252
+ async function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
253
+ try {
254
+ return await withRetry(() => osvRequestOnce({ method: "GET", reqPath, timeoutMs }), {
255
+ maxAttempts: 3,
256
+ baseDelayMs: 100,
257
+ maxDelayMs: 2000,
258
+ jitterFactor: 0.5,
259
+ });
260
+ } catch (e) {
261
+ // After exhaustion of retries, return a structured envelope rather
262
+ // than letting the throw escape into the caller's promise chain.
263
+ const status = typeof e?.statusCode === "number" ? e.statusCode : null;
264
+ const error = status === 429
265
+ ? `OSV rate-limited (HTTP 429)`
266
+ : status
267
+ ? `OSV returned HTTP ${status}`
268
+ : `OSV request failed: ${e.message || e}`;
269
+ return { ok: false, error, status, source: "offline" };
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Low-level POST against OSV. Body is JSON-stringified. Same retry policy
275
+ * as osvGet — 429/503/5xx + transient net errors back off automatically.
276
+ */
277
+ async function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
278
+ try {
279
+ return await withRetry(() => osvRequestOnce({ method: "POST", reqPath, body, timeoutMs }), {
280
+ maxAttempts: 3,
281
+ baseDelayMs: 100,
282
+ maxDelayMs: 2000,
283
+ jitterFactor: 0.5,
284
+ });
285
+ } catch (e) {
286
+ const status = typeof e?.statusCode === "number" ? e.statusCode : null;
287
+ const error = status === 429
288
+ ? `OSV rate-limited (HTTP 429)`
289
+ : status
290
+ ? `OSV returned HTTP ${status}`
291
+ : `OSV request failed: ${e.message || e}`;
292
+ return { ok: false, error, status, source: "offline" };
293
+ }
294
+ }
295
+
199
296
  /**
200
297
  * Read EXCEPTD_OSV_FIXTURE and return a structured envelope. Matches the
201
298
  * GHSA-source convention: on any failure (missing file, malformed JSON,
@@ -241,11 +338,14 @@ async function fetchAdvisoryById(id, opts = {}) {
241
338
  return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
242
339
  }
243
340
  // OSV.dev's /v1/vulns/{id} is case-sensitive — `mal-2026-3083` 404s while
244
- // `MAL-2026-3083` resolves. Uppercase at entry so operators piping
341
+ // `MAL-2026-3083` resolves. Uppercase + trim at entry so operators piping
245
342
  // lowercase ids from grep/jq don't get a surprising 404 from the network
246
343
  // path. Fixture lookup already case-folds, so this normalization is a
247
344
  // no-op there but harmless.
248
- id = id.toUpperCase();
345
+ id = id.trim().toUpperCase();
346
+ if (!id) {
347
+ return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
348
+ }
249
349
  const fixture = readFixture();
250
350
  if (fixture) {
251
351
  if (!fixture.ok) return fixture; // F1: structured error envelope
@@ -259,6 +359,12 @@ async function fetchAdvisoryById(id, opts = {}) {
259
359
  if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
260
360
  return { ok: true, advisories: [match], source: "fixture" };
261
361
  }
362
+ // Finding 7: air-gap mode hard-refuses network calls. Operators running
363
+ // `exceptd refresh --air-gap` without a fixture get a structured refusal,
364
+ // not an outbound DNS query.
365
+ if (isAirGap(opts)) {
366
+ return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_OSV_FIXTURE)", source: "offline" };
367
+ }
262
368
  const result = await osvGet(`/v1/vulns/${encodeURIComponent(id)}`, opts.timeoutMs);
263
369
  if (!result.ok) return result;
264
370
  return { ok: true, advisories: [result.record], source: "osv-api" };
@@ -290,6 +396,10 @@ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
290
396
  });
291
397
  return { ok: true, advisories: matches, source: "fixture" };
292
398
  }
399
+ // Finding 7: air-gap refusal applies to the package query path too.
400
+ if (isAirGap(opts)) {
401
+ return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_OSV_FIXTURE)", source: "offline" };
402
+ }
293
403
  const body = { package: { name, ecosystem } };
294
404
  if (version) body.version = version;
295
405
  const r = await osvPost("/v1/query", body, opts.timeoutMs);
@@ -302,12 +412,19 @@ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
302
412
  * Pick the catalog key for an OSV record. If `aliases` contains a CVE-*
303
413
  * value, prefer that (preserving the existing CVE-keyed convention).
304
414
  * Otherwise return the OSV id verbatim — MAL-*, SNYK-*, RUSTSEC-*, etc.
415
+ *
416
+ * Finding 14: the non-CVE branch must String-coerce + uppercase so a
417
+ * record with `id: 12345` (numeric) or `id: "mal-2026-3083"` (lowercase)
418
+ * doesn't produce a catalog key that diverges from the canonical
419
+ * uppercase-prefix convention. The CVE branch already upper-cases via
420
+ * `String(cve).toUpperCase()`.
305
421
  */
306
422
  function pickCatalogKey(rec) {
307
- if (!rec || !rec.id) return null;
423
+ if (!rec || rec.id == null) return null;
308
424
  const aliases = Array.isArray(rec.aliases) ? rec.aliases : [];
309
425
  const cve = aliases.find((a) => /^CVE-\d{4}-\d+$/i.test(String(a)));
310
- return cve ? String(cve).toUpperCase() : String(rec.id);
426
+ if (cve) return String(cve).toUpperCase();
427
+ return String(rec.id).toUpperCase();
311
428
  }
312
429
 
313
430
  /**
@@ -365,57 +482,101 @@ function cvss3BaseScore(vector) {
365
482
  } else {
366
483
  base = Math.min(1.08 * (impact + exploitability), 10);
367
484
  }
368
- // roundUp1: round up to one decimal (CVSS 3.1 §7.1).
369
- const rounded = Math.ceil(base * 10) / 10;
370
- if (!Number.isFinite(rounded) || rounded < 0 || rounded > 10) return null;
371
- return rounded;
485
+ // roundUp1 per CVSS 3.1 §7.1: round up to one decimal place. The spec
486
+ // uses an integer-arithmetic formulation (Math.ceil(input * 100000) /
487
+ // 10000 / 10) to avoid floating-point off-by-ones (e.g. 5.55 -> 5.6,
488
+ // not 5.5 if naive `Math.ceil(base * 10) / 10` is applied to a value
489
+ // that lands at 5.5499999... after IEEE 754 rounding). Finding 11.
490
+ const rounded = Math.ceil(base * 100000) / 1000000 < 0
491
+ ? null
492
+ : (Math.ceil(base * 100000) / 100000); // intermediate at 5 decimals
493
+ if (rounded == null) return null;
494
+ // Now round-up to 1 decimal from the high-precision intermediate.
495
+ const out = Math.ceil(rounded * 10 - 1e-9) / 10;
496
+ if (!Number.isFinite(out) || out < 0 || out > 10) return null;
497
+ return Math.round(out * 10) / 10; // strip trailing fp noise
372
498
  }
373
499
 
374
500
  /**
375
501
  * Pull a numeric CVSS score + vector out of an OSV severity[] entry. CVSS
376
502
  * vectors start with "CVSS:3.x/" or "CVSS:4.0/". When multiple vectors are
377
- * present (e.g. both V3 and V4), the highest version wins regardless of
378
- * array order. When the OSV record has no embedded numeric tail, the score
379
- * is computed from the vector itself via cvss3BaseScore(). Returns null
380
- * components when nothing parseable is present.
503
+ * present (e.g. both V3 and V4), the highest version wins UNLESS the v4
504
+ * vector cannot be scored (this module does not implement CVSS 4.0
505
+ * derivation yet), in which case fall back to the highest computable
506
+ * version below v4 so we don't silently lose a v3 9.8 (Finding 10).
507
+ * Returns null components when nothing parseable is present.
508
+ *
509
+ * Finding 19: when `s.score` is an object (some Snyk records embed
510
+ * `{ value: "CVSS:3.1/..." }`), accept `s.score.value` as the string
511
+ * source rather than silently producing null.
381
512
  */
382
513
  function extractCvss(rec) {
383
514
  const sev = Array.isArray(rec?.severity) ? rec.severity : [];
384
515
  let score = null;
385
- let bestVector = null;
386
- let bestVersion = 0;
516
+ // Collect all parseable vectors keyed by major version so we can fall
517
+ // back from v4 -> v3 if v4 fails to compute.
518
+ const vectorsByVersion = new Map(); // version (number) -> vector string
519
+ let bareScore = null;
387
520
  for (const s of sev) {
388
- if (typeof s?.score !== "string") continue;
389
- const v = s.score.trim();
521
+ if (s == null) continue;
522
+ let raw = null;
523
+ if (typeof s.score === "string") raw = s.score;
524
+ else if (typeof s.score === "object" && s.score && typeof s.score.value === "string") {
525
+ raw = s.score.value; // Finding 19
526
+ }
527
+ if (raw == null) continue;
528
+ const v = raw.trim();
390
529
  // Bare numeric score (no vector prefix).
391
530
  const num = parseFloat(v);
392
531
  if (!Number.isNaN(num) && num >= 0 && num <= 10 && !v.includes("/")) {
393
- if (score == null) score = num;
532
+ if (bareScore == null) bareScore = num;
394
533
  continue;
395
534
  }
396
535
  const m = v.match(/^CVSS:(\d+\.\d+)/);
397
536
  if (!m) continue;
398
537
  const ver = parseFloat(m[1]);
399
- if (ver > bestVersion) {
400
- bestVersion = ver;
401
- bestVector = v;
402
- }
538
+ // Keep the highest score within each major version.
539
+ const prev = vectorsByVersion.get(ver);
540
+ if (!prev) vectorsByVersion.set(ver, v);
403
541
  }
404
- // If we picked a vector, try to read an embedded score from the trailing
405
- // fragment (some Snyk records carry it as ".../9.3"). Otherwise compute
406
- // it from the vector for CVSS 3.x. CVSS 4.0 base-score derivation is
407
- // intentionally not implemented here that's a v0.13 follow-up.
408
- if (bestVector && score == null) {
409
- const tail = bestVector.match(/\/(\d+(?:\.\d+)?)$/);
542
+ // F10: try versions in descending order. CVSS 4.0 derivation is not yet
543
+ // implemented here if v4 was the highest but can't be computed, walk
544
+ // down to v3.x. Only return null when ALL versions fail.
545
+ const versions = Array.from(vectorsByVersion.keys()).sort((a, b) => b - a);
546
+ let bestVector = null;
547
+ for (const ver of versions) {
548
+ const candidate = vectorsByVersion.get(ver);
549
+ if (!candidate) continue;
550
+ bestVector = candidate;
551
+ const tail = candidate.match(/\/(\d+(?:\.\d+)?)$/);
410
552
  if (tail) {
411
- const candidate = parseFloat(tail[1]);
412
- if (candidate >= 0 && candidate <= 10) score = candidate;
553
+ const t = parseFloat(tail[1]);
554
+ if (t >= 0 && t <= 10) { score = t; break; }
555
+ }
556
+ if (/^CVSS:3\./.test(candidate)) {
557
+ const computed = cvss3BaseScore(candidate);
558
+ if (computed != null) { score = computed; break; }
413
559
  }
414
- if (score == null && /^CVSS:3\./.test(bestVector)) {
415
- const computed = cvss3BaseScore(bestVector);
416
- if (computed != null) score = computed;
560
+ // v4 has no in-module computer — keep walking down to the next
561
+ // version. The bestVector tracker holds whatever was tried last;
562
+ // overwrite it with the next computable on the loop iteration.
563
+ }
564
+ // If we landed on an uncomputable v4 vector but a lower-version vector
565
+ // was usable, prefer the lower-version one's vector string so callers
566
+ // don't get a v4 vector + null score combo when v3 was available.
567
+ if (score == null && versions.length > 0) {
568
+ for (const ver of versions) {
569
+ if (ver >= 4) continue;
570
+ const candidate = vectorsByVersion.get(ver);
571
+ if (candidate) { bestVector = candidate; break; }
417
572
  }
418
573
  }
574
+ // F10 fix: if score is still null but we have a v4 vector + a bare
575
+ // score that came from a v3-only severity entry, prefer the v3 vector
576
+ // string when one exists. (Handles the case described in the audit:
577
+ // v4 is the highest version but uncomputable; a v3 vector with 9.8
578
+ // sits alongside. Without this fallback we lose the 9.8.)
579
+ if (score == null && bareScore != null) score = bareScore;
419
580
  return { score, vector: bestVector };
420
581
  }
421
582
 
@@ -425,7 +586,8 @@ function extractCvss(rec) {
425
586
  */
426
587
  function inferType(rec) {
427
588
  const ecos = new Set();
428
- for (const a of (rec?.affected || [])) {
589
+ const affected = Array.isArray(rec?.affected) ? rec.affected : [];
590
+ for (const a of affected) {
429
591
  if (a?.package?.ecosystem) ecos.add(String(a.package.ecosystem).toLowerCase());
430
592
  }
431
593
  if (ecos.has("pypi") || ecos.has("pip")) return "supply-chain-pypi";
@@ -439,6 +601,23 @@ function inferType(rec) {
439
601
  return "supply-chain-other";
440
602
  }
441
603
 
604
+ /**
605
+ * Validate + slice a published/modified timestamp string. Findings 2 + 17:
606
+ * - typeof guard so non-string inputs (number, object, undefined) become
607
+ * null instead of throwing on .slice().
608
+ * - ISO-prefix regex + year sanity bound so garbage like "yesterday" or
609
+ * "0001-01-01" doesn't pollute downstream surfaces.
610
+ */
611
+ function safeDateSlice(value) {
612
+ if (typeof value !== "string") return null;
613
+ const head = value.slice(0, 10);
614
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(head)) return null;
615
+ const year = parseInt(head.slice(0, 4), 10);
616
+ const now = new Date().getUTCFullYear();
617
+ if (!Number.isFinite(year) || year < 1990 || year > now + 1) return null;
618
+ return head;
619
+ }
620
+
442
621
  /**
443
622
  * Normalize an OSV record into the exceptd catalog draft shape. Returns
444
623
  * `{ [catalogKey]: <draft-entry> }` so callers can spread it into the
@@ -450,7 +629,10 @@ function inferType(rec) {
450
629
  * mark the entry for the strict catalog validator (warn, not error).
451
630
  */
452
631
  function normalizeAdvisory(rec) {
453
- if (!rec || !rec.id) return null;
632
+ if (!rec || rec.id == null) return null;
633
+ // Trim id so trailing whitespace doesn't bleed into pickCatalogKey + key.
634
+ if (typeof rec.id === "string") rec = { ...rec, id: rec.id.trim() };
635
+ if (!rec.id) return null;
454
636
  const catalogKey = pickCatalogKey(rec);
455
637
  if (!catalogKey) return null;
456
638
 
@@ -463,7 +645,9 @@ function normalizeAdvisory(rec) {
463
645
 
464
646
  const affectedPackages = [];
465
647
  const affectedVersions = [];
466
- for (const a of (rec.affected || [])) {
648
+ // Finding 3: rec.affected might not be an array — guard before iterating.
649
+ const affectedList = Array.isArray(rec.affected) ? rec.affected : [];
650
+ for (const a of affectedList) {
467
651
  const pkg = a?.package || {};
468
652
  if (pkg.name && pkg.ecosystem) {
469
653
  affectedPackages.push(`${pkg.ecosystem}:${pkg.name}`);
@@ -472,14 +656,46 @@ function normalizeAdvisory(rec) {
472
656
  for (const v of versions) {
473
657
  affectedVersions.push(`${pkg.name || "?"} == ${v}`);
474
658
  }
475
- // Range bounds: surface "introduced/fixed" pairs as a textual range.
659
+ // Finding 16: walk events sequentially. OSV emits a stream of
660
+ // introduced/fixed/last_affected events; the historical implementation
661
+ // collected the FIRST `introduced` + FIRST `fixed` per range and
662
+ // emitted one range, losing re-introduction cycles (an introduced ->
663
+ // fixed -> introduced -> fixed sequence collapsed to one range).
664
+ // Sequential pairing produces ONE entry per (introduced, fixed |
665
+ // last-known-vulnerable) pair instead.
476
666
  const ranges = Array.isArray(a.ranges) ? a.ranges : [];
477
667
  for (const r of ranges) {
478
668
  const events = Array.isArray(r.events) ? r.events : [];
479
- const intro = events.find((e) => e.introduced)?.introduced;
480
- const fixed = events.find((e) => e.fixed)?.fixed;
481
- if (intro || fixed) {
482
- affectedVersions.push(`${pkg.name || "?"} >= ${intro || "0"}` + (fixed ? `, < ${fixed}` : ""));
669
+ let openIntro = null;
670
+ let lastKnownVulnerable = null;
671
+ for (const e of events) {
672
+ if (!e || typeof e !== "object") continue;
673
+ if (typeof e.introduced === "string") {
674
+ // Flush any prior open pair with whatever upper bound we have.
675
+ if (openIntro != null) {
676
+ const upper = lastKnownVulnerable ? `, <= ${lastKnownVulnerable}` : "";
677
+ affectedVersions.push(`${pkg.name || "?"} >= ${openIntro}${upper}`);
678
+ lastKnownVulnerable = null;
679
+ }
680
+ openIntro = e.introduced;
681
+ } else if (typeof e.fixed === "string") {
682
+ if (openIntro != null) {
683
+ affectedVersions.push(`${pkg.name || "?"} >= ${openIntro}, < ${e.fixed}`);
684
+ openIntro = null;
685
+ lastKnownVulnerable = null;
686
+ } else {
687
+ // Defensive: fixed-without-introduced — emit a fixed-only marker.
688
+ affectedVersions.push(`${pkg.name || "?"} < ${e.fixed}`);
689
+ }
690
+ } else if (typeof e.last_affected === "string") {
691
+ lastKnownVulnerable = e.last_affected;
692
+ }
693
+ }
694
+ // Trailing open range — no `fixed` ever observed. Emit as
695
+ // `>= introduced` (optionally with last_known_vulnerable upper).
696
+ if (openIntro != null) {
697
+ const upper = lastKnownVulnerable ? `, <= ${lastKnownVulnerable}` : "";
698
+ affectedVersions.push(`${pkg.name || "?"} >= ${openIntro}${upper}`);
483
699
  }
484
700
  }
485
701
  }
@@ -497,8 +713,10 @@ function normalizeAdvisory(rec) {
497
713
  }
498
714
 
499
715
  // Reference URLs — OSV `references` is `[{ type, url }, ...]`.
716
+ // Finding 20: guard non-array references silently truncating to [].
500
717
  const refUrls = [];
501
- for (const r of (rec.references || [])) {
718
+ const refList = Array.isArray(rec.references) ? rec.references : [];
719
+ for (const r of refList) {
502
720
  if (r && typeof r.url === "string") refUrls.push(r.url);
503
721
  }
504
722
 
@@ -512,8 +730,9 @@ function normalizeAdvisory(rec) {
512
730
  const pending = severityWord === "critical" || (score != null && score >= 9.0);
513
731
 
514
732
  const today = new Date().toISOString().slice(0, 10);
515
- const published = (rec.published || "").slice(0, 10) || null;
516
- const modified = (rec.modified || "").slice(0, 10) || null;
733
+ // Finding 2 + 17: type-safe + format-validated date slicing.
734
+ const published = safeDateSlice(rec.published);
735
+ const modified = safeDateSlice(rec.modified);
517
736
 
518
737
  // OSV.dev canonical advisory URL — used as the primary vendor advisory.
519
738
  const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
@@ -600,11 +819,14 @@ function normalizeAdvisory(rec) {
600
819
  * Build a refresh diff for the refresh-external orchestrator. v0.12.10
601
820
  * supports targeted seeding: when `ctx.osv_ids` is populated, fetch each
602
821
  * id and emit one `_new_entry` diff per record that isn't already in the
603
- * local catalog. The broader package-watchlist path (bulk import from
604
- * a watched-packages list) is deferred to v0.13.
822
+ * local catalog. Finding 9: when the record already exists and a watched
823
+ * field has dropped from populated -> null, emit a `field_dropped` diff
824
+ * so curators see the upstream regression instead of silently absorbing it.
605
825
  */
606
826
  async function buildDiff(ctx) {
607
- const ids = Array.isArray(ctx?.osv_ids) ? ctx.osv_ids : [];
827
+ // Finding 8: trim ids defensively at the entry seam.
828
+ const rawIds = Array.isArray(ctx?.osv_ids) ? ctx.osv_ids : [];
829
+ const ids = rawIds.map((x) => (typeof x === "string" ? x.trim() : "")).filter(Boolean);
608
830
  if (ids.length === 0) {
609
831
  return {
610
832
  status: "ok",
@@ -615,7 +837,8 @@ async function buildDiff(ctx) {
615
837
  summary: "OSV: no ids requested (set ctx.osv_ids to seed a draft, or pass --advisory <MAL-...> for one-shot import).",
616
838
  };
617
839
  }
618
- const existingKeys = new Set(Object.keys(ctx.cveCatalog || {}));
840
+ const cveCatalog = ctx.cveCatalog || {};
841
+ const existingKeys = new Set(Object.keys(cveCatalog));
619
842
  const diffs = [];
620
843
  // F7: distinguish unreachable (fetch failed, network or 5xx) from
621
844
  // normalize-rejected (record fetched but normalization produced null).
@@ -623,15 +846,47 @@ async function buildDiff(ctx) {
623
846
  // network outage or a malformed upstream record.
624
847
  let unreachable = 0;
625
848
  let normalizeErrors = 0;
849
+ // Finding 18: ids that ARE in the catalog but skipped because of overlap
850
+ // are not "errors"; surface them so the summary doesn't read as silently
851
+ // dropping work. Particularly useful when a curator dispatches the same
852
+ // batch twice and wonders why nothing happened.
853
+ let ghsaOnlySkipped = 0;
626
854
  for (const id of ids) {
627
- const r = await fetchAdvisoryById(id);
855
+ const r = await fetchAdvisoryById(id, { airGap: ctx.airGap });
628
856
  if (!r.ok) { unreachable++; continue; }
629
857
  const rec = r.advisories[0];
630
858
  if (!rec) { unreachable++; continue; }
631
859
  const normalized = normalizeAdvisory(rec);
632
860
  if (!normalized) { normalizeErrors++; continue; }
633
861
  const key = Object.keys(normalized)[0];
634
- if (existingKeys.has(key)) continue;
862
+ if (existingKeys.has(key)) {
863
+ // Finding 9: field-dropped detection. Compare watched fields between
864
+ // the existing local entry and the freshly-normalized one. Emit a
865
+ // `field_dropped` diff per regression rather than a `_new_entry`.
866
+ const before = cveCatalog[key] || {};
867
+ const after = normalized[key];
868
+ let dropped = false;
869
+ for (const field of FIELD_DROPPED_WATCH) {
870
+ const had = before[field];
871
+ const has = after[field];
872
+ const wasPopulated = had !== null && had !== undefined && had !== "" && had !== false;
873
+ const isNowEmpty = has === null || has === undefined;
874
+ if (wasPopulated && isNowEmpty) {
875
+ diffs.push({
876
+ id: key,
877
+ field,
878
+ before: had,
879
+ after: null,
880
+ severity: null,
881
+ source: "osv",
882
+ variant: "field_dropped",
883
+ });
884
+ dropped = true;
885
+ }
886
+ }
887
+ if (!dropped) ghsaOnlySkipped++;
888
+ continue;
889
+ }
635
890
  diffs.push({
636
891
  id: key,
637
892
  field: "_new_entry",
@@ -642,13 +897,15 @@ async function buildDiff(ctx) {
642
897
  });
643
898
  }
644
899
  const errors = unreachable + normalizeErrors;
900
+ const summary = `OSV fetched ${ids.length} id(s); ${diffs.length} new entry diff(s), ${unreachable} unreachable, ${normalizeErrors} normalize-rejected, ${ghsaOnlySkipped} ghsa_only_skipped.`;
645
901
  return {
646
902
  status: errors === 0 ? "ok" : errors === ids.length ? "unreachable" : "partial",
647
903
  diffs,
648
904
  errors,
649
905
  unreachable_count: unreachable,
650
906
  normalize_error_count: normalizeErrors,
651
- summary: `OSV fetched ${ids.length} id(s); ${diffs.length} new entry diff(s), ${unreachable} unreachable, ${normalizeErrors} normalize-rejected.`,
907
+ ghsa_only_skipped: ghsaOnlySkipped,
908
+ summary,
652
909
  };
653
910
  }
654
911
 
@@ -661,4 +918,6 @@ module.exports = {
661
918
  extractCvss,
662
919
  cvss3BaseScore,
663
920
  OSV_ID_PREFIXES,
921
+ FIELD_DROPPED_WATCH,
922
+ safeDateSlice,
664
923
  };