@blamejs/exceptd-skills 0.19.33 → 0.19.34

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 (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -152
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +35 -114
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -412
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +50 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +13 -73
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +66 -197
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +3 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -140
package/lib/source-osv.js CHANGED
@@ -1,70 +1,33 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/source-osv.js
5
- *
6
- * OSV.dev fetcher. OSV aggregates OSSF Malicious Packages (MAL-*), Snyk
7
- * (SNYK-*), GitHub Advisory Database (GHSA-*), RustSec (RUSTSEC-*),
8
- * Mageia (MGASA-*), Go Vuln DB (GO-*), Ubuntu USN (USN-*), and several
9
- * other ecosystems into a single unauthenticated API.
10
- *
11
- * Endpoints:
12
- * GET https://api.osv.dev/v1/vulns/{id}
13
- * Fetch by OSV id. CVE-* is NOT a primary key — CVE numbers live
14
- * under `aliases` on records whose primary id is GHSA-*, MAL-*, etc.
15
- * POST https://api.osv.dev/v1/query
16
- * Body { "package": { "name": "...", "ecosystem": "..." }
17
- * [,"version": "..."] }
18
- * Lists vulns for a package (optionally filtered to a version).
19
- *
20
- * Why this matters: MAL-* (OSSF Malicious Packages) is the canonical
21
- * namespace for package-compromise events that don't have a CVE yet.
22
- * The elementary-data PyPI worm (MAL-2026-3083) is the catalog's
23
- * reference example of that class.
24
- *
25
- * Returns drafts — every imported entry carries `_auto_imported: true`
26
- * + `_draft: true` so the strict catalog validator treats them as
27
- * warnings, not errors. Editorial fields (framework_control_gaps,
28
- * atlas_refs, attack_refs, rwep_factors) remain null until a human or
29
- * AI assistant fills them in via the cve-curation skill / seven-phase
30
- * playbook flow.
31
- *
32
- * Honors EXCEPTD_OSV_FIXTURE env var for offline testing — value is a
33
- * path to a JSON file containing either a single OSV record or an
34
- * array of OSV records. Matches the GHSA fixture pattern.
35
- *
36
- * Zero npm deps. Node 24 stdlib only.
4
+ * OSV.dev fetcher. CVE-* is NOT a primary key there — CVE numbers live under
5
+ * `aliases` on records whose id is GHSA-*, MAL-*, etc.; MAL-* is the canonical
6
+ * namespace for a package compromise with no CVE. Every imported entry is a
7
+ * draft (`_auto_imported` + `_draft`), so the strict validator warns rather than
8
+ * errors and editorial fields stay null. EXCEPTD_OSV_FIXTURE points at a JSON
9
+ * file of one record or an array of them, for offline runs.
37
10
  */
38
11
 
39
12
  const https = require("https");
40
13
  const fs = require("fs");
41
14
  const { withRetry } = require("../vendor/blamejs/retry.js");
42
15
 
43
- // Production OSV endpoint. Requests land here over HTTPS unless a test
44
- // harness sets OSV_HOST_OVERRIDE, whose parsing and host/port/transport
45
- // substitution are documented at osvTransport() below.
16
+ // Production endpoint; OSV_HOST_OVERRIDE substitutes host/port/transport — see osvTransport().
46
17
  const OSV_HOST = "api.osv.dev";
47
18
  const REQUEST_TIMEOUT_MS = 10000;
48
19
  const USER_AGENT = "exceptd-security/source-osv (+https://exceptd.com)";
49
20
 
50
- // Cap the streamed response so a hostile or misconfigured endpoint (a hijacked
51
- // DNS / compromised CDN / OSV_HOST_OVERRIDE pointed at a malicious server)
52
- // can't stream unbounded bytes into RAM. A real /v1/vulns/{id} or /v1/query
53
- // response is a few KB; 16 MB is generous headroom. Symmetric with the JSON
54
- // cap getJsonOnce enforces in lib/refresh-network.js. Tunable via
55
- // EXCEPTD_OSV_RESPONSE_CAP_BYTES for future API growth.
21
+ // Caps the streamed response so a hostile endpoint cannot stream unbounded bytes
22
+ // into RAM. Symmetric with the JSON cap getJsonOnce enforces in lib/refresh-network.js.
56
23
  function osvResponseCapBytes() {
57
24
  const env = parseInt(process.env.EXCEPTD_OSV_RESPONSE_CAP_BYTES, 10);
58
25
  return Number.isFinite(env) && env > 0 ? env : 16 * 1024 * 1024;
59
26
  }
60
27
 
61
- // Identifier namespaces OSV uses as PRIMARY keys. GHSA-* is intentionally
62
- // NOT in this list — `seedSingleAdvisory` in lib/refresh-external.js routes
63
- // CVE-* and GHSA-* through `source-ghsa` because GHSA carries richer field
64
- // coverage (cvss object, vulnerable_version_range string, ghsa_id linkage)
65
- // than OSV's import of the same advisories. Keep this list in sync with the
66
- // dispatcher in lib/refresh-external.js — adding a new prefix here is not
67
- // enough; the dispatcher's --advisory regex must also accept it.
28
+ // Identifier namespaces OSV uses as PRIMARY keys. GHSA-* is deliberately absent:
29
+ // seedSingleAdvisory in lib/refresh-external.js routes CVE-* and GHSA-* through
30
+ // `source-ghsa`. Adding a prefix here also needs that dispatcher's --advisory regex.
68
31
  const OSV_ID_PREFIXES = [
69
32
  "MAL-", // OSSF Malicious Packages
70
33
  "SNYK-", // Snyk
@@ -88,32 +51,20 @@ const OSV_ID_PREFIXES = [
88
51
  ];
89
52
 
90
53
  /**
91
- * Set of OSV draft fields the field-dropped detector watches. When a
92
- * previously-populated value goes to null on a refresh, surface it as a
93
- * `field_dropped` diff so curators can investigate the upstream regression
94
- * rather than silently losing signal. Keep this set small + intentional —
95
- * fields here MUST be ones the editorial review process can re-source.
54
+ * Draft fields the field-dropped detector watches. Keep the set small — a field
55
+ * here MUST be one the editorial review process can re-source.
96
56
  */
97
57
  const FIELD_DROPPED_WATCH = Object.freeze([
98
- // Only fields the upstream feed actually populates can legitimately "drop"
99
- // from populated -> null on a re-import. active_exploitation / ai_discovered /
100
- // poc_available are EDITORIAL (curated by hand, never upstream-sourced), so
101
- // the normalize step always nulls them — watching them flagged a field_dropped
102
- // "regression" on every curated re-import. Restrict to upstream-sourced fields.
58
+ // Only upstream-populated fields: normalize nulls the editorial ones on every
59
+ // import, so watching them flags a false regression on each re-import.
103
60
  "cvss_score",
104
61
  "cisa_kev_pending",
105
62
  ]);
106
63
 
107
- /**
108
- * Return true when `id` looks like an OSV-native primary key (i.e. NOT a
109
- * CVE-* identifier and NOT a GHSA-* identifier). Both CVE-* and GHSA-*
110
- * route through `source-ghsa` for richer field coverage.
111
- */
64
+ /** True when `id` is an OSV-native primary key; CVE-* and GHSA-* route to `source-ghsa`. */
112
65
  function isOsvId(id) {
113
66
  if (!id || typeof id !== "string") return false;
114
- // F8 (finding 8): trim trailing/leading whitespace so operators pasting
115
- // ids from clipboards / multi-line files don't see a surprising routing
116
- // miss. Empty after trim → not an OSV id.
67
+ // Trimmed first, so a pasted id doesn't miss the routing.
117
68
  const trimmed = id.trim();
118
69
  if (!trimmed) return false;
119
70
  const up = trimmed.toUpperCase();
@@ -123,10 +74,8 @@ function isOsvId(id) {
123
74
  }
124
75
 
125
76
  /**
126
- * Return true when the runtime context requests air-gap mode. Sources MUST
127
- * refuse network calls when this is set — fall through to fixture or return
128
- * a structured `air-gap: no fixture available` error so the operator sees
129
- * an explicit refusal, not a silent network attempt.
77
+ * True when the runtime context requests air-gap mode. Sources MUST refuse the
78
+ * network then — a fixture, or a structured `air-gap` error; never a silent attempt.
130
79
  */
131
80
  function isAirGap(opts) {
132
81
  if (opts && opts.airGap) return true;
@@ -135,15 +84,9 @@ function isAirGap(opts) {
135
84
  }
136
85
 
137
86
  /**
138
- * Resolve the OSV transport target. When OSV_HOST_OVERRIDE is set the
139
- * request switches to plain HTTP on the override host:port so test
140
- * harnesses can stand up a local server without TLS. Production omits the
141
- * override entirely and lands on api.osv.dev over HTTPS.
142
- *
143
- * Finding 13: validate the override aggressively. Garbage env values
144
- * (random binary, embedded NUL, port > 65535) previously slipped through
145
- * into the http.request options and produced opaque ENOTFOUND / EADDRINUSE
146
- * errors far from the source. Reject with a structured error here instead.
87
+ * Resolve the OSV transport target. OSV_HOST_OVERRIDE switches the request to
88
+ * plain HTTP on the override host:port; production lands on api.osv.dev over
89
+ * HTTPS. The override is validated here, not left to surface as an ENOTFOUND.
147
90
  */
148
91
  function osvTransport() {
149
92
  const override = process.env.OSV_HOST_OVERRIDE;
@@ -176,11 +119,9 @@ function osvTransport() {
176
119
  }
177
120
 
178
121
  /**
179
- * Make one OSV request (HEAD/GET/POST). Throws on retryable conditions
180
- * (HTTP 429/503/5xx + ECONNRESET/ETIMEDOUT family) and resolves to a
181
- * structured `{ok:false}` envelope on permanent conditions (4xx other than
182
- * 408/425/429). The thrown errors carry `statusCode` so withRetry's default
183
- * classifier recognizes them as retryable.
122
+ * One OSV request. THROWS on retryable conditions (429/503/5xx, 408/425, the
123
+ * ECONNRESET/ETIMEDOUT family) and RESOLVES a structured `{ok:false}` envelope on
124
+ * permanent ones. A thrown error carries `statusCode` for withRetry's classifier.
184
125
  */
185
126
  function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
186
127
  return new Promise((resolve, reject) => {
@@ -203,18 +144,13 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
203
144
  const opts = { host, port, path: reqPath, method, headers, timeout: timeoutMs };
204
145
  const req = mod.request(opts, (res) => {
205
146
  const status = res.statusCode;
206
- // 401/404 (and other 4xx aside from 408/425/429) are permanent.
207
- // 429/503 + 5xx are retryable. Honor Retry-After when present.
208
147
  const retryAfterRaw = res.headers["retry-after"];
209
148
  if (status === 429 || status === 503 || (status >= 500 && status <= 599) ||
210
149
  status === 408 || status === 425) {
211
150
  res.resume();
212
151
  const err = new Error(`OSV returned HTTP ${status}`);
213
152
  err.statusCode = status;
214
- // Surface Retry-After (seconds or HTTP-date). The retry caller
215
- // doesn't currently consume this directly — withRetry's backoff is
216
- // its own schedule — but exposing it lets future schedulers honor
217
- // server-advertised delay.
153
+ // Retry-After is exposed for a scheduler; withRetry runs its own backoff.
218
154
  if (retryAfterRaw) {
219
155
  const secs = parseInt(retryAfterRaw, 10);
220
156
  if (Number.isFinite(secs)) err.retryAfterMs = secs * 1000;
@@ -240,8 +176,7 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
240
176
  chunks.push(c);
241
177
  });
242
178
  res.on("end", () => {
243
- // If the cap fired, req.destroy already routed through req.on("error");
244
- // don't also resolve here (avoids a double-settle on the promise).
179
+ // The cap already routed through req.on("error"); resolving here settles twice.
245
180
  if (capped) return;
246
181
  try {
247
182
  const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
@@ -257,8 +192,7 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
257
192
  req.destroy(err);
258
193
  });
259
194
  req.on("error", (e) => {
260
- // Retryable network errors propagate up to withRetry; non-retryable
261
- // resolve as structured offline.
195
+ // Retryable network errors propagate to withRetry; the rest resolve offline.
262
196
  if (e && e.code && /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|EAGAIN|ENOTFOUND|ENETUNREACH)$/.test(e.code)) {
263
197
  return reject(e);
264
198
  }
@@ -269,11 +203,7 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
269
203
  });
270
204
  }
271
205
 
272
- /**
273
- * Low-level GET against OSV. Resolves to { ok, record|error, source }.
274
- * Honors OSV_HOST_OVERRIDE for offline tests. Wraps the request in
275
- * withRetry so 429/503/5xx + transient net errors back off automatically.
276
- */
206
+ /** GET against OSV → { ok, record|error, source }; withRetry backs off 429/503/5xx. */
277
207
  async function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
278
208
  try {
279
209
  return await withRetry(() => osvRequestOnce({ method: "GET", reqPath, timeoutMs }), {
@@ -283,8 +213,7 @@ async function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
283
213
  jitterFactor: 0.5,
284
214
  });
285
215
  } catch (e) {
286
- // After exhaustion of retries, return a structured envelope rather
287
- // than letting the throw escape into the caller's promise chain.
216
+ // Once retries are exhausted, return an envelope; never let the throw escape.
288
217
  const status = typeof e?.statusCode === "number" ? e.statusCode : null;
289
218
  const error = status === 429
290
219
  ? `OSV rate-limited (HTTP 429)`
@@ -295,10 +224,7 @@ async function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
295
224
  }
296
225
  }
297
226
 
298
- /**
299
- * Low-level POST against OSV. Body is JSON-stringified. Same retry policy
300
- * as osvGet — 429/503/5xx + transient net errors back off automatically.
301
- */
227
+ /** POST against OSV; the body is JSON-stringified. Same retry policy as osvGet. */
302
228
  async function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
303
229
  try {
304
230
  return await withRetry(() => osvRequestOnce({ method: "POST", reqPath, body, timeoutMs }), {
@@ -319,16 +245,9 @@ async function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
319
245
  }
320
246
 
321
247
  /**
322
- * Read EXCEPTD_OSV_FIXTURE and return a structured envelope. Matches the
323
- * GHSA-source convention: on any failure (missing file, malformed JSON,
324
- * root not object/array) return `{ ok: false, error, source: "offline" }`
325
- * rather than throw — operators on the CLI surface get a structured error
326
- * instead of a Node stack trace.
327
- *
328
- * Returns:
329
- * null when env var is unset
330
- * { ok: true, advisories: [...], source } on success
331
- * { ok: false, error, source: "offline" } on any failure
248
+ * Read EXCEPTD_OSV_FIXTURE. Null when unset, `{ ok: true, advisories, source }`
249
+ * on success, `{ ok: false, error, source: "offline" }` on any failure — missing
250
+ * file, malformed JSON, root neither object nor array. Never throws.
332
251
  */
333
252
  function readFixture() {
334
253
  const fp = process.env.EXCEPTD_OSV_FIXTURE;
@@ -352,9 +271,7 @@ function readFixture() {
352
271
  }
353
272
 
354
273
  /**
355
- * Fetch a single OSV record by id (MAL-*, GHSA-*, SNYK-*, RUSTSEC-*, etc.).
356
- *
357
- * Returns shape matches source-ghsa.fetchAdvisoryById:
274
+ * Fetch one OSV record by id. Return shape matches source-ghsa.fetchAdvisoryById:
358
275
  * { ok: true, advisories: [<osv_record>], source: "osv-api" | "fixture" }
359
276
  * { ok: false, error, source: "offline" | "fixture" }
360
277
  */
@@ -362,18 +279,15 @@ async function fetchAdvisoryById(id, opts = {}) {
362
279
  if (!id || typeof id !== "string") {
363
280
  return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
364
281
  }
365
- // OSV.dev's /v1/vulns/{id} is case-sensitive — `mal-2026-3083` 404s while
366
- // `MAL-2026-3083` resolves. Uppercase + trim at entry so operators piping
367
- // lowercase ids from grep/jq don't get a surprising 404 from the network
368
- // path. Fixture lookup already case-folds, so this normalization is a
369
- // no-op there but harmless.
282
+ // /v1/vulns/{id} is case-sensitive — `mal-2026-3083` 404s where `MAL-2026-3083`
283
+ // resolves — so uppercase and trim at entry.
370
284
  id = id.trim().toUpperCase();
371
285
  if (!id) {
372
286
  return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
373
287
  }
374
288
  const fixture = readFixture();
375
289
  if (fixture) {
376
- if (!fixture.ok) return fixture; // F1: structured error envelope
290
+ if (!fixture.ok) return fixture;
377
291
  const want = id;
378
292
  const match = fixture.advisories.find((rec) => {
379
293
  const recId = (rec && rec.id) ? String(rec.id).toUpperCase() : null;
@@ -384,9 +298,7 @@ async function fetchAdvisoryById(id, opts = {}) {
384
298
  if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
385
299
  return { ok: true, advisories: [match], source: "fixture" };
386
300
  }
387
- // Finding 7: air-gap mode hard-refuses network calls. Operators running
388
- // `exceptd refresh --air-gap` without a fixture get a structured refusal,
389
- // not an outbound DNS query.
301
+ // Air-gap hard-refuses: no fixture means a structured refusal, not a DNS query.
390
302
  if (isAirGap(opts)) {
391
303
  return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_OSV_FIXTURE)", source: "offline" };
392
304
  }
@@ -395,21 +307,15 @@ async function fetchAdvisoryById(id, opts = {}) {
395
307
  return { ok: true, advisories: [result.record], source: "osv-api" };
396
308
  }
397
309
 
398
- /**
399
- * List advisories for a package, optionally filtered to a specific version.
400
- * The single-package network path is implemented; there is no bulk /
401
- * package-watchlist import caller — advisories are seeded one at a time via
402
- * `refresh --advisory <id>`.
403
- */
310
+ /** List advisories for a package, optionally filtered to one version. */
404
311
  async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
405
312
  if (!name || !ecosystem) {
406
313
  return { ok: false, error: "name and ecosystem are required", source: "offline" };
407
314
  }
408
315
  const fixture = readFixture();
409
316
  if (fixture) {
410
- if (!fixture.ok) return fixture; // F1: structured error envelope
411
- // Best-effort fixture filtering: match any record whose `affected[]`
412
- // contains the requested package + ecosystem (+ version when set).
317
+ if (!fixture.ok) return fixture;
318
+ // Best-effort: match on `affected[]` package + ecosystem, and version when given.
413
319
  const matches = fixture.advisories.filter((rec) => {
414
320
  const affected = Array.isArray(rec?.affected) ? rec.affected : [];
415
321
  return affected.some((a) => {
@@ -423,7 +329,7 @@ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
423
329
  });
424
330
  return { ok: true, advisories: matches, source: "fixture" };
425
331
  }
426
- // Finding 7: air-gap refusal applies to the package query path too.
332
+ // The air-gap refusal applies to the package-query path too.
427
333
  if (isAirGap(opts)) {
428
334
  return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_OSV_FIXTURE)", source: "offline" };
429
335
  }
@@ -436,15 +342,9 @@ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
436
342
  }
437
343
 
438
344
  /**
439
- * Pick the catalog key for an OSV record. If `aliases` contains a CVE-*
440
- * value, prefer that (preserving the existing CVE-keyed convention).
441
- * Otherwise return the OSV id verbatim — MAL-*, SNYK-*, RUSTSEC-*, etc.
442
- *
443
- * Finding 14: the non-CVE branch must String-coerce + uppercase so a
444
- * record with `id: 12345` (numeric) or `id: "mal-2026-3083"` (lowercase)
445
- * doesn't produce a catalog key that diverges from the canonical
446
- * uppercase-prefix convention. The CVE branch already upper-cases via
447
- * `String(cve).toUpperCase()`.
345
+ * Catalog key for an OSV record: a CVE-* alias when present, preserving the
346
+ * CVE-keyed convention, else the OSV id. Both branches String-coerce and
347
+ * uppercase, so a numeric or lowercase id cannot diverge from the convention.
448
348
  */
449
349
  function pickCatalogKey(rec) {
450
350
  if (!rec || rec.id == null) return null;
@@ -455,12 +355,9 @@ function pickCatalogKey(rec) {
455
355
  }
456
356
 
457
357
  /**
458
- * CVSS 3.1 base-score computation from a vector string. Implements Table 6
459
- * of the FIRST CVSS 3.1 specification. Used when an OSV record carries a
460
- * vector but no embedded numeric score (the common case for MAL-* records).
461
- * Returns null on malformed input.
462
- *
463
- * Reference: https://www.first.org/cvss/v3.1/specification-document
358
+ * CVSS 3.1 base score from a vector string, per Table 6 of the FIRST CVSS 3.1
359
+ * specification (https://www.first.org/cvss/v3.1/specification-document). Used
360
+ * when a record carries a vector but no numeric score. Null on malformed input.
464
361
  */
465
362
  function cvss3BaseScore(vector) {
466
363
  if (typeof vector !== "string") return null;
@@ -473,7 +370,6 @@ function cvss3BaseScore(vector) {
473
370
  if (!k || !v) return null;
474
371
  metrics[k] = v;
475
372
  }
476
- // Required metrics — bail if any are missing.
477
373
  for (const k of ["AV", "AC", "PR", "UI", "S", "C", "I", "A"]) {
478
374
  if (!metrics[k]) return null;
479
375
  }
@@ -509,45 +405,30 @@ function cvss3BaseScore(vector) {
509
405
  } else {
510
406
  base = Math.min(1.08 * (impact + exploitability), 10);
511
407
  }
512
- // roundUp1 per CVSS 3.1 §7.1: round up to one decimal place. The spec
513
- // uses an integer-arithmetic formulation (Math.ceil(input * 100000) /
514
- // 10000 / 10) to avoid floating-point off-by-ones (e.g. 5.55 -> 5.6,
515
- // not 5.5 if naive `Math.ceil(base * 10) / 10` is applied to a value
516
- // that lands at 5.5499999... after IEEE 754 rounding). Finding 11.
408
+ // roundUp1 per CVSS 3.1 §7.1. The spec's integer formulation avoids the IEEE-754
409
+ // off-by-one where Math.ceil(base * 10) / 10 turns 5.5499999… into 5.5, not 5.6.
517
410
  const rounded = Math.ceil(base * 100000) / 1000000 < 0
518
411
  ? null
519
412
  : (Math.ceil(base * 100000) / 100000); // intermediate at 5 decimals
520
413
  if (rounded == null) return null;
521
- // Now round-up to 1 decimal from the high-precision intermediate.
522
414
  const out = Math.ceil(rounded * 10 - 1e-9) / 10;
523
415
  if (!Number.isFinite(out) || out < 0 || out > 10) return null;
524
416
  return Math.round(out * 10) / 10; // strip trailing fp noise
525
417
  }
526
418
 
527
419
  /**
528
- * Pull a numeric CVSS score + vector out of an OSV severity[] entry. CVSS
529
- * vectors start with "CVSS:3.x/" or "CVSS:4.0/". When multiple vectors are
530
- * present (e.g. both V3 and V4), the highest version wins — UNLESS the v4
531
- * vector cannot be scored (this module does not implement CVSS 4.0
532
- * derivation yet), in which case fall back to the highest computable
533
- * version below v4 so we don't silently lose a v3 9.8 (Finding 10).
534
- * Returns null components when nothing parseable is present.
535
- *
536
- * Finding 19: when `s.score` is an object (some Snyk records embed
537
- * `{ value: "CVSS:3.1/..." }`), accept `s.score.value` as the string
538
- * source rather than silently producing null.
420
+ * Pull a numeric CVSS score + vector out of an OSV severity[] entry. The highest
421
+ * version wins UNLESS it cannot be scored — CVSS 4.0 derivation is not implemented
422
+ * — in which case the highest computable version below it is used, so a v3 9.8 is
423
+ * not silently lost. `s.score` may be a string or an object carrying `.value`.
539
424
  */
540
425
  function extractCvss(rec) {
541
426
  const sev = Array.isArray(rec?.severity) ? rec.severity : [];
542
427
  let score = null;
543
- // Collect all parseable vectors keyed by major version so we can fall
544
- // back from v4 -> v3 if v4 fails to compute.
545
428
  const vectorsByVersion = new Map(); // version (number) -> vector string
546
429
  let bareScore = null;
547
- // Computed score for a single CVSS vector string: its trailing /N.N score if
548
- // present, else the derived CVSS 3.x base score, else null (e.g. an
549
- // uncomputable v4 vector). Used to keep the HIGHEST-scoring vector per major
550
- // version regardless of severity[] array order.
430
+ // Score for one vector: its trailing /N.N when present, else the derived CVSS 3.x
431
+ // base score, else null. Keeps the HIGHEST-scoring vector per major version.
551
432
  const vectorScore = (vec) => {
552
433
  const tail = vec.match(/\/(\d+(?:\.\d+)?)$/);
553
434
  if (tail) { const t = parseFloat(tail[1]); if (t >= 0 && t <= 10) return t; }
@@ -559,32 +440,26 @@ function extractCvss(rec) {
559
440
  let raw = null;
560
441
  if (typeof s.score === "string") raw = s.score;
561
442
  else if (typeof s.score === "object" && s.score && typeof s.score.value === "string") {
562
- raw = s.score.value; // Finding 19
443
+ raw = s.score.value;
563
444
  }
564
445
  if (raw == null) continue;
565
446
  const v = raw.trim();
566
447
  // Bare numeric score (no vector prefix).
567
448
  const num = parseFloat(v);
568
449
  if (!Number.isNaN(num) && num >= 0 && num <= 10 && !v.includes("/")) {
569
- // HIGHEST-wins, not first-wins: an OSV record legitimately carries
570
- // multiple same-version severity entries (e.g. an NVD base score plus a
571
- // CNA/vendor score) and the upstream array order is not guaranteed. A
572
- // first-wins guard silently downgraded a 9.8 critical to a 5.3 medium
573
- // purely because the lower score appeared earlier in severity[].
450
+ // HIGHEST-wins, not first-wins: several same-version entries arrive in no
451
+ // guaranteed order, and first-wins downgrades a 9.8 critical to a 5.3.
574
452
  if (bareScore == null || num > bareScore) bareScore = num;
575
453
  continue;
576
454
  }
577
455
  const m = v.match(/^CVSS:(\d+\.\d+)/);
578
456
  if (!m) continue;
579
457
  const ver = parseFloat(m[1]);
580
- // Keep the HIGHEST-scoring vector within each major version (same rationale
581
- // as the bare-score path above). Compare computed scores, not array order.
458
+ // Same within each major version: compare computed scores, not array order.
582
459
  const prev = vectorsByVersion.get(ver);
583
460
  if (!prev || (vectorScore(v) ?? -1) > (vectorScore(prev) ?? -1)) vectorsByVersion.set(ver, v);
584
461
  }
585
- // Try versions in descending order. CVSS 4.0 derivation is not yet
586
- // implemented here — if v4 was the highest but can't be computed, walk
587
- // down to v3.x. Only return null when ALL versions fail.
462
+ // Descending; score stays null only when every version fails.
588
463
  const versions = Array.from(vectorsByVersion.keys()).sort((a, b) => b - a);
589
464
  let bestVector = null;
590
465
  for (const ver of versions) {
@@ -600,13 +475,9 @@ function extractCvss(rec) {
600
475
  const computed = cvss3BaseScore(candidate);
601
476
  if (computed != null) { score = computed; break; }
602
477
  }
603
- // v4 has no in-module computer — keep walking down to the next
604
- // version. The bestVector tracker holds whatever was tried last;
605
- // overwrite it with the next computable on the loop iteration.
478
+ // v4 has no in-module computer — keep walking down.
606
479
  }
607
- // If we landed on an uncomputable v4 vector but a lower-version vector
608
- // was usable, prefer the lower-version one's vector string so callers
609
- // don't get a v4 vector + null score combo when v3 was available.
480
+ // Prefer a lower-version vector when the highest was uncomputable, so no v4-with-null.
610
481
  if (score == null && versions.length > 0) {
611
482
  for (const ver of versions) {
612
483
  if (ver >= 4) continue;
@@ -614,19 +485,12 @@ function extractCvss(rec) {
614
485
  if (candidate) { bestVector = candidate; break; }
615
486
  }
616
487
  }
617
- // F10 fix: if score is still null but we have a v4 vector + a bare
618
- // score that came from a v3-only severity entry, prefer the v3 vector
619
- // string when one exists. (Handles the case described in the audit:
620
- // v4 is the highest version but uncomputable; a v3 vector with 9.8
621
- // sits alongside. Without this fallback we lose the 9.8.)
488
+ // Last resort: a bare numeric score, when no vector was computable.
622
489
  if (score == null && bareScore != null) score = bareScore;
623
490
  return { score, vector: bestVector };
624
491
  }
625
492
 
626
- /**
627
- * Coarse package-ecosystem inference for the catalog `type` field. Mirrors
628
- * the same heuristic used by source-ghsa.
629
- */
493
+ /** Coarse package-ecosystem inference for the catalog `type`. Mirrors source-ghsa. */
630
494
  function inferType(rec) {
631
495
  const ecos = new Set();
632
496
  const affected = Array.isArray(rec?.affected) ? rec.affected : [];
@@ -645,11 +509,8 @@ function inferType(rec) {
645
509
  }
646
510
 
647
511
  /**
648
- * Validate + slice a published/modified timestamp string. Findings 2 + 17:
649
- * - typeof guard so non-string inputs (number, object, undefined) become
650
- * null instead of throwing on .slice().
651
- * - ISO-prefix regex + year sanity bound so garbage like "yesterday" or
652
- * "0001-01-01" doesn't pollute downstream surfaces.
512
+ * Validate and slice a published/modified timestamp; a non-string, a non-ISO
513
+ * prefix or a year outside [1990, next year] all yield null rather than throwing.
653
514
  */
654
515
  function safeDateSlice(value) {
655
516
  if (typeof value !== "string") return null;
@@ -662,14 +523,10 @@ function safeDateSlice(value) {
662
523
  }
663
524
 
664
525
  /**
665
- * Normalize an OSV record into the exceptd catalog draft shape. Returns
666
- * `{ [catalogKey]: <draft-entry> }` so callers can spread it into the
667
- * catalog object directly. Returns null if the record is unusable.
668
- *
669
- * Editorial fields (framework_control_gaps, atlas_refs, attack_refs,
670
- * rwep_factors) are left null — the seven-phase playbook flow or a human
671
- * reviewer fills these in. `_auto_imported: true` + `_draft: true` flags
672
- * mark the entry for the strict catalog validator (warn, not error).
526
+ * Normalize an OSV record into the catalog draft shape, as
527
+ * `{ [catalogKey]: <draft-entry> }` so a caller can spread it into the catalog.
528
+ * Null when the record is unusable. Editorial fields are left null, and
529
+ * `_auto_imported` + `_draft` mark the entry warn-not-error for the validator.
673
530
  */
674
531
  function normalizeAdvisory(rec) {
675
532
  if (!rec || rec.id == null) return null;
@@ -680,15 +537,13 @@ function normalizeAdvisory(rec) {
680
537
  if (!catalogKey) return null;
681
538
 
682
539
  const aliases = Array.isArray(rec.aliases) ? rec.aliases.slice() : [];
683
- // If the catalog key came from aliases (CVE-*), put the OSV id back into
684
- // the aliases array so it stays discoverable.
540
+ // When the key came from aliases (CVE-*), the OSV id goes back into aliases.
685
541
  if (catalogKey !== rec.id && !aliases.includes(rec.id)) aliases.push(rec.id);
686
542
 
687
543
  const { score, vector } = extractCvss(rec);
688
544
 
689
545
  const affectedPackages = [];
690
546
  const affectedVersions = [];
691
- // Finding 3: rec.affected might not be an array — guard before iterating.
692
547
  const affectedList = Array.isArray(rec.affected) ? rec.affected : [];
693
548
  for (const a of affectedList) {
694
549
  const pkg = a?.package || {};
@@ -699,13 +554,9 @@ function normalizeAdvisory(rec) {
699
554
  for (const v of versions) {
700
555
  affectedVersions.push(`${pkg.name || "?"} == ${v}`);
701
556
  }
702
- // Finding 16: walk events sequentially. OSV emits a stream of
703
- // introduced/fixed/last_affected events; the historical implementation
704
- // collected the FIRST `introduced` + FIRST `fixed` per range and
705
- // emitted one range, losing re-introduction cycles (an introduced ->
706
- // fixed -> introduced -> fixed sequence collapsed to one range).
707
- // Sequential pairing produces ONE entry per (introduced, fixed |
708
- // last-known-vulnerable) pair instead.
557
+ // Events are walked sequentially, one entry per (introduced, fixed |
558
+ // last-known-vulnerable) pair. Pairing the FIRST introduced with the FIRST fixed
559
+ // collapses an introduced → fixed → introduced cycle and loses the re-introduction.
709
560
  const ranges = Array.isArray(a.ranges) ? a.ranges : [];
710
561
  for (const r of ranges) {
711
562
  const events = Array.isArray(r.events) ? r.events : [];
@@ -734,8 +585,7 @@ function normalizeAdvisory(rec) {
734
585
  lastKnownVulnerable = e.last_affected;
735
586
  }
736
587
  }
737
- // Trailing open range — no `fixed` ever observed. Emit as
738
- // `>= introduced` (optionally with last_known_vulnerable upper).
588
+ // Trailing open range — no `fixed` was ever observed.
739
589
  if (openIntro != null) {
740
590
  const upper = lastKnownVulnerable ? `, <= ${lastKnownVulnerable}` : "";
741
591
  affectedVersions.push(`${pkg.name || "?"} >= ${openIntro}${upper}`);
@@ -743,9 +593,8 @@ function normalizeAdvisory(rec) {
743
593
  }
744
594
  }
745
595
 
746
- // IoC seeding from database_specific.iocs if present (some Snyk + StepSec
747
- // imported records carry this). Domains + URLs land in c2_indicators so
748
- // an operator scanning a repo has something to grep for immediately.
596
+ // IoC seeding from database_specific.iocs; domains and URLs land in c2_indicators
597
+ // so an operator has something to grep for immediately.
749
598
  const dsIocs = rec?.database_specific?.iocs || null;
750
599
  let iocs = null;
751
600
  if (dsIocs && (Array.isArray(dsIocs.domains) || Array.isArray(dsIocs.urls))) {
@@ -755,15 +604,13 @@ function normalizeAdvisory(rec) {
755
604
  iocs = { c2_indicators: c2 };
756
605
  }
757
606
 
758
- // Reference URLs — OSV `references` is `[{ type, url }, ...]`.
759
- // Finding 20: guard non-array references silently truncating to [].
607
+ // OSV `references` is `[{ type, url }, ...]`.
760
608
  const refUrls = [];
761
609
  const refList = Array.isArray(rec.references) ? rec.references : [];
762
610
  for (const r of refList) {
763
611
  if (r && typeof r.url === "string") refUrls.push(r.url);
764
612
  }
765
613
 
766
- // Severity wording from CVSS / qualitative hint.
767
614
  const severityWord = score != null && score >= 9.0 ? "critical"
768
615
  : score != null && score >= 7.0 ? "high"
769
616
  : score != null && score >= 4.0 ? "medium"
@@ -773,25 +620,19 @@ function normalizeAdvisory(rec) {
773
620
  const pending = severityWord === "critical" || (score != null && score >= 9.0);
774
621
 
775
622
  const today = new Date().toISOString().slice(0, 10);
776
- // Finding 2 + 17: type-safe + format-validated date slicing.
777
623
  const published = safeDateSlice(rec.published);
778
624
  const modified = safeDateSlice(rec.modified);
779
625
 
780
- // OSV.dev canonical advisory URL — used as the primary vendor advisory.
626
+ // The canonical osv.dev URL, used as the primary vendor advisory.
781
627
  const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
782
628
 
783
- // Dedupe verification_sources. OSV records frequently carry the
784
- // canonical osv.dev URL in references[] as well, which would otherwise
785
- // produce a duplicate alongside the prepended `osvUrl`.
629
+ // Deduped: references[] often carries the canonical osv.dev URL as well.
786
630
  const verification_sources = Array.from(new Set([
787
631
  osvUrl,
788
632
  ...(/^CVE-/i.test(catalogKey) ? [`https://nvd.nist.gov/vuln/detail/${catalogKey}`] : []),
789
633
  ...refUrls.slice(0, 10),
790
634
  ]));
791
635
 
792
- // EPSS coverage does not extend to non-CVE identifiers. Surface this
793
- // explicitly so curators know to re-query if MITRE later assigns a CVE
794
- // id to the entry.
795
636
  const isCveKey = /^CVE-/i.test(catalogKey);
796
637
  const epss_note = isCveKey
797
638
  ? null
@@ -853,9 +694,8 @@ function normalizeAdvisory(rec) {
853
694
  _draft_reason: "Imported from OSV.dev on " + today + ". Editorial fields (framework_control_gaps, atlas_refs, attack_refs, iocs, vector, complexity, rwep_factors) require human review. Run `exceptd run sbom --evidence -` against an affected repo to gather IoCs; consult MITRE ATLAS + ATT&CK catalogs for refs.",
854
695
  _source_osv_id: rec.id,
855
696
  _source_published_at: rec.published || null,
856
- // OSV sets a top-level `withdrawn` timestamp when a record is retracted.
857
- // Surface it as structured status so a citation check (and the resolver)
858
- // can flag a withdrawn advisory instead of importing it as if live.
697
+ // A retracted record carries a top-level `withdrawn` timestamp; surfaced as
698
+ // structured status so a citation check flags it instead of importing it live.
859
699
  ...(rec.withdrawn ? { status: "withdrawn", status_source: "osv:withdrawn", status_verified: today } : {}),
860
700
  last_updated: modified || today,
861
701
  },
@@ -863,15 +703,12 @@ function normalizeAdvisory(rec) {
863
703
  }
864
704
 
865
705
  /**
866
- * Build a refresh diff for the refresh-external orchestrator. v0.12.10
867
- * supports targeted seeding: when `ctx.osv_ids` is populated, fetch each
868
- * id and emit one `_new_entry` diff per record that isn't already in the
869
- * local catalog. Finding 9: when the record already exists and a watched
870
- * field has dropped from populated -> null, emit a `field_dropped` diff
871
- * so curators see the upstream regression instead of silently absorbing it.
706
+ * Build a refresh diff for the refresh-external orchestrator. With `ctx.osv_ids`
707
+ * populated, each id is fetched and emitted as a `_new_entry` diff unless its key
708
+ * is already in the local catalog — in which case a watched field that dropped to
709
+ * null emits a `field_dropped` diff instead.
872
710
  */
873
711
  async function buildDiff(ctx) {
874
- // Finding 8: trim ids defensively at the entry seam.
875
712
  const rawIds = Array.isArray(ctx?.osv_ids) ? ctx.osv_ids : [];
876
713
  const ids = rawIds.map((x) => (typeof x === "string" ? x.trim() : "")).filter(Boolean);
877
714
  if (ids.length === 0) {
@@ -887,16 +724,12 @@ async function buildDiff(ctx) {
887
724
  const cveCatalog = ctx.cveCatalog || {};
888
725
  const existingKeys = new Set(Object.keys(cveCatalog));
889
726
  const diffs = [];
890
- // Distinguish unreachable (fetch failed, network or 5xx) from
891
- // normalize-rejected (record fetched but normalization produced null).
892
- // Operators triaging a refresh-report want to know whether to chase a
893
- // network outage or a malformed upstream record.
727
+ // Unreachable (fetch failed) and normalize-rejected (fetched, normalized to null)
728
+ // are counted apart, so triage knows a network outage from a malformed record.
894
729
  let unreachable = 0;
895
730
  let normalizeErrors = 0;
896
- // Ids that ARE in the catalog but skipped because of overlap
897
- // are not "errors"; surface them so the summary doesn't read as silently
898
- // dropping work. Particularly useful when a curator dispatches the same
899
- // batch twice and wonders why nothing happened.
731
+ // An id already in the catalog is skipped, not an error; counting it keeps a
732
+ // re-dispatched batch from reading as silently dropped work.
900
733
  let ghsaOnlySkipped = 0;
901
734
  for (const id of ids) {
902
735
  const r = await fetchAdvisoryById(id, { airGap: ctx.airGap });
@@ -907,9 +740,6 @@ async function buildDiff(ctx) {
907
740
  if (!normalized) { normalizeErrors++; continue; }
908
741
  const key = Object.keys(normalized)[0];
909
742
  if (existingKeys.has(key)) {
910
- // Finding 9: field-dropped detection. Compare watched fields between
911
- // the existing local entry and the freshly-normalized one. Emit a
912
- // `field_dropped` diff per regression rather than a `_new_entry`.
913
743
  const before = cveCatalog[key] || {};
914
744
  const after = normalized[key];
915
745
  let dropped = false;
@@ -944,9 +774,7 @@ async function buildDiff(ctx) {
944
774
  });
945
775
  }
946
776
  const errors = unreachable + normalizeErrors;
947
- // diffs holds BOTH _new_entry and field_dropped records — count them
948
- // separately so the summary doesn't mislabel field-dropped regressions as
949
- // brand-new entries.
777
+ // diffs holds both kinds; count separately so a field_dropped is not a new entry.
950
778
  const newCount = diffs.filter((d) => d.field === "_new_entry").length;
951
779
  const droppedCount = diffs.filter((d) => d.variant === "field_dropped").length;
952
780
  const summary = `OSV fetched ${ids.length} id(s); ${newCount} new entry diff(s), ${droppedCount} field-dropped regression(s), ${unreachable} unreachable, ${normalizeErrors} normalize-rejected, ${ghsaOnlySkipped} ghsa_only_skipped.`;