@blamejs/exceptd-skills 0.19.32 → 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 (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,27 +1,11 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/source-ghsa.js
5
- *
6
- * GitHub Advisory Database fetcher. The GHSA covers npm, PyPI, RubyGems,
7
- * Maven, NuGet, Go, Composer, Swift, Erlang, Pub, and Rust ecosystems in
8
- * one feed and is updated within hours of disclosure — much faster than
9
- * NVD (~10 days) or KEV (variable, often days).
10
- *
11
- * Endpoint: GET https://api.github.com/advisories
12
- * - Unauthenticated: 60 req/hr (sufficient for nightly refresh)
13
- * - Authenticated: 5000 req/hr (set GITHUB_TOKEN env var)
14
- *
15
- * Returns drafts — every imported entry carries `_auto_imported: true`
16
- * + `_draft: true` so the strict catalog validator treats them as
17
- * warnings, not errors. Editorial fields (framework_control_gaps,
18
- * iocs, atlas_refs) are left null until a human or AI assistant
19
- * fills them in via the seven-phase playbook flow.
20
- *
21
- * Honors EXCEPTD_GHSA_FIXTURE env var for offline testing — value is a
22
- * path to a JSON array matching the api.github.com/advisories shape.
23
- *
24
- * Zero npm deps. Node 24 stdlib only.
4
+ * GitHub Advisory Database fetcher — GET https://api.github.com/advisories,
5
+ * rate-limited to 60 req/hr unauthenticated and 5000 with GITHUB_TOKEN set.
6
+ * EXCEPTD_GHSA_FIXTURE points at a JSON file of the same shape for offline runs.
7
+ * Everything imported is a draft: entries carry `_auto_imported` + `_draft`, so
8
+ * the strict validator warns rather than errors and editorial fields stay null.
25
9
  */
26
10
 
27
11
  const https = require("https");
@@ -33,37 +17,24 @@ const GHSA_PATH = "/advisories?per_page=50&type=reviewed&sort=published&directio
33
17
  const REQUEST_TIMEOUT_MS = 10000;
34
18
  const USER_AGENT = "exceptd-security/source-ghsa (+https://exceptd.com)";
35
19
 
36
- // Cap the streamed response so a hijacked DNS / compromised CDN / MITM serving
37
- // api.github.com can't stream unbounded bytes into RAM. A 50-advisory page is
38
- // well under a MB; 16 MB is generous headroom. Symmetric with the JSON cap
39
- // getJsonOnce enforces in lib/refresh-network.js and the OSV poller's cap.
40
- // Tunable via EXCEPTD_GHSA_RESPONSE_CAP_BYTES for future API growth.
20
+ // Caps the streamed response so anything impersonating api.github.com cannot push
21
+ // unbounded bytes into RAM. Symmetric with getJsonOnce in lib/refresh-network.js.
41
22
  function ghsaResponseCapBytes() {
42
23
  const env = parseInt(process.env.EXCEPTD_GHSA_RESPONSE_CAP_BYTES, 10);
43
24
  return Number.isFinite(env) && env > 0 ? env : 16 * 1024 * 1024;
44
25
  }
45
26
 
46
- /**
47
- * Field-dropped watch set — fields the buildDiff regression-detector
48
- * watches when the upstream still has an entry but a previously-populated
49
- * local value has gone null. Mirrors lib/source-osv.js. Finding 9.
50
- */
27
+ /** Fields buildDiff watches going null while upstream still has the entry; mirrors lib/source-osv.js. */
51
28
  const FIELD_DROPPED_WATCH = Object.freeze([
52
- // Only fields the upstream feed actually populates can legitimately "drop"
53
- // from populated -> null on a re-import. active_exploitation / ai_discovered /
54
- // poc_available are EDITORIAL (curated by hand, never upstream-sourced), so
55
- // the normalize step always nulls them — watching them flagged a field_dropped
56
- // "regression" on every single curated re-import. Restrict the watch to the
57
- // upstream-sourced fields.
29
+ // Only upstream-populated fields: normalize nulls the editorial ones on every
30
+ // import, so watching them reports a regression on each re-import.
58
31
  "cvss_score",
59
32
  "cisa_kev_pending",
60
33
  ]);
61
34
 
62
35
  /**
63
- * Return true when the runtime context requests air-gap mode. Sources MUST
64
- * refuse network calls when this is set — fall through to fixture or return
65
- * a structured `air-gap: no fixture available` error so the operator sees
66
- * an explicit refusal, not a silent network attempt. Mirrors source-osv.
36
+ * True when air-gap mode is requested; a source must then fall through to a
37
+ * fixture or return the structured `air-gap: no fixture available` error.
67
38
  */
68
39
  function isAirGap(opts) {
69
40
  if (opts && opts.airGap) return true;
@@ -72,16 +43,9 @@ function isAirGap(opts) {
72
43
  }
73
44
 
74
45
  /**
75
- * Read EXCEPTD_GHSA_FIXTURE and return a structured envelope. Finding 5:
76
- * mirror the OSV-source convention so a fixture file containing `null`,
77
- * a number, or a string at its root doesn't slip through as an empty
78
- * advisories array — the strict catalog validator would later swallow the
79
- * resulting drift as "no advisories returned" instead of surfacing it as
80
- * a fixture configuration error. Returns:
81
- *
82
- * null when env var is unset
83
- * { ok: true, advisories: [...], source } on success
84
- * { ok: false, error, source: "offline" } on any failure
46
+ * Read EXCEPTD_GHSA_FIXTURE. Null when unset, `{ ok: true, advisories, source }`
47
+ * on success, `{ ok: false, error, source }` on any failure — including a
48
+ * non-object root, which would otherwise pass as an empty advisory list.
85
49
  */
86
50
  function readFixture() {
87
51
  const fp = process.env.EXCEPTD_GHSA_FIXTURE;
@@ -105,9 +69,8 @@ function readFixture() {
105
69
  }
106
70
 
107
71
  /**
108
- * One HTTPS GET against api.github.com. Throws on retryable conditions so
109
- * withRetry's default classifier (HTTP 408/425/429/5xx + ECONNRESET et al)
110
- * picks them up; resolves to a structured envelope on permanent conditions.
72
+ * One HTTPS GET against api.github.com. Rejects on retryable conditions so
73
+ * withRetry's classifier picks them up, resolves an envelope on permanent ones.
111
74
  */
112
75
  function ghsaRequestOnce({ path, headers, timeoutMs }) {
113
76
  return new Promise((resolve, reject) => {
@@ -149,8 +112,7 @@ function ghsaRequestOnce({ path, headers, timeoutMs }) {
149
112
  chunks.push(c);
150
113
  });
151
114
  res.on("end", () => {
152
- // If the cap fired, req.destroy already routed through req.on("error");
153
- // don't also resolve here (avoids a double-settle on the promise).
115
+ // The cap already routed through req.on("error"); resolving here settles twice.
154
116
  if (capped) return;
155
117
  try {
156
118
  const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
@@ -184,18 +146,13 @@ function ghsaRequestOnce({ path, headers, timeoutMs }) {
184
146
  }
185
147
 
186
148
  /**
187
- * Fetch a page of advisories (default: latest 50). Wraps the underlying
188
- * HTTPS request in withRetry so transient 429/503/5xx + net errors back off
189
- * automatically.
190
- *
191
- * Returns:
192
- * { ok: true, advisories: [...], source: "github-api" | "fixture", rate_limit?: { remaining, reset } }
193
- * { ok: false, error, source: "offline" }
149
+ * Fetch a page of advisories, backing off through withRetry on transient
150
+ * failures. `{ ok: true, advisories, source: "github-api" | "fixture",
151
+ * rate_limit? }` or `{ ok: false, error, source: "offline" }`.
194
152
  */
195
153
  async function fetchAdvisories({ timeoutMs = REQUEST_TIMEOUT_MS, path = GHSA_PATH, token = null, airGap = false } = {}) {
196
154
  const fixture = readFixture();
197
155
  if (fixture) return fixture;
198
- // Finding 7: air-gap hard-refuses network when no fixture is configured.
199
156
  if (isAirGap({ airGap })) {
200
157
  return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_GHSA_FIXTURE)", source: "offline" };
201
158
  }
@@ -224,16 +181,13 @@ async function fetchAdvisories({ timeoutMs = REQUEST_TIMEOUT_MS, path = GHSA_PAT
224
181
  }
225
182
 
226
183
  /**
227
- * Fetch a single advisory by ID — accepts CVE-* or GHSA-* identifiers.
228
- *
229
- * GHSA-IDs hit /advisories/<ghsa-id> directly. CVE-IDs require a search
230
- * since the API is keyed by GHSA. We fall back to a query-string search.
184
+ * Fetch a single advisory by CVE-* or GHSA-* id. A GHSA id addresses
185
+ * /advisories/<ghsa-id>; the API is keyed by GHSA, so a CVE id goes via search.
231
186
  */
232
187
  async function fetchAdvisoryById(id, opts = {}) {
233
188
  if (!id || typeof id !== "string") {
234
189
  return { ok: false, error: "id is required (CVE-* or GHSA-*)", source: "offline" };
235
190
  }
236
- // Finding 8: trim whitespace at the entry seam.
237
191
  id = id.trim();
238
192
  if (!id) {
239
193
  return { ok: false, error: "id is required (CVE-* or GHSA-*)", source: "offline" };
@@ -249,33 +203,21 @@ async function fetchAdvisoryById(id, opts = {}) {
249
203
  if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
250
204
  return { ok: true, advisories: [match], source: "fixture" };
251
205
  }
252
- // Validate the FULL GHSA token shape (GHSA-xxxx-xxxx-xxxx) before building the
253
- // request path, and encodeURIComponent the segment — symmetric with the CVE
254
- // branch below. A prefix-only `/^GHSA-/i` left the remainder unconstrained, so
255
- // an operator-supplied id like `GHSA-aaaa/../../meta` survived verbatim into
256
- // `/advisories/...` as path-control segments; the strict shape now routes a
257
- // malformed id to the unrecognized-id error envelope instead.
206
+ // The full GHSA token shape, not a `/^GHSA-/` prefix: `GHSA-aaaa/../../meta`
207
+ // would otherwise reach `/advisories/` as path-control segments.
258
208
  if (/^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$/i.test(id)) {
259
209
  return fetchAdvisories({ ...opts, path: `/advisories/${encodeURIComponent(id.toLowerCase())}` });
260
210
  }
261
211
  if (/^CVE-\d{4}-\d+$/i.test(id)) {
262
212
  return fetchAdvisories({ ...opts, path: `/advisories?cve_id=${encodeURIComponent(id.toUpperCase())}` });
263
213
  }
264
- // v0.12.11: widen the error to enumerate OSV-native prefixes — operators
265
- // running `exceptd refresh --advisory FOO-BAR` previously got an error
266
- // mentioning only CVE / GHSA, even though MAL-*, SNYK-*, RUSTSEC-*,
267
- // USN-*, PYSEC-*, GO-*, MGASA-*, UVI- are also valid id shapes routed
268
- // through source-osv. The hint here mirrors lib/refresh-external.js
269
- // seedSingleAdvisory's documented acceptance set.
214
+ // The accepted set mirrors seedSingleAdvisory in lib/refresh-external.js.
270
215
  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" };
271
216
  }
272
217
 
273
218
  /**
274
- * Validate + slice a published_at timestamp. Findings 2 + 17:
275
- * - typeof guard so non-string inputs (number, object, undefined) become
276
- * null instead of throwing on .slice().
277
- * - ISO-prefix + year sanity bound so garbage timestamps don't pollute
278
- * downstream surfaces.
219
+ * The YYYY-MM-DD head of an upstream timestamp, or null — a non-string, a
220
+ * non-ISO prefix or a year outside [1990, next year] all yield null.
279
221
  */
280
222
  function safeDateSlice(value) {
281
223
  if (typeof value !== "string") return null;
@@ -288,16 +230,9 @@ function safeDateSlice(value) {
288
230
  }
289
231
 
290
232
  /**
291
- * Normalize a GHSA advisory object to the exceptd catalog draft shape.
292
- * Fields the GHSA carries authoritatively: cve_id, ghsa_id, summary,
293
- * severity, cvss, vulnerabilities (package + version range), published_at,
294
- * references. Editorial fields (framework_control_gaps, iocs, atlas_refs,
295
- * attack_refs, rwep_factors) are LEFT NULL — drafts. The seven-phase
296
- * playbook flow OR a human reviewer fills these in.
297
- *
298
- * Returns null if the advisory lacks a CVE ID (we don't import GHSA-only
299
- * advisories into the CVE catalog — they belong in a separate GHSA index
300
- * which is a v0.13 design).
233
+ * Normalize a GHSA advisory into the catalog draft shape. Only what GHSA carries
234
+ * authoritatively is populated; editorial fields stay null for a curator. Null
235
+ * for an advisory with no CVE id — a GHSA-only advisory is not a CVE entry.
301
236
  */
302
237
  function normalizeAdvisory(adv) {
303
238
  if (!adv || !adv.cve_id) return null;
@@ -305,7 +240,6 @@ function normalizeAdvisory(adv) {
305
240
  const ecosystems = new Set();
306
241
  const affected = [];
307
242
  const ecosystemPackages = [];
308
- // Finding 3: vulnerabilities may not be an array — guard before iterating.
309
243
  const vulnList = Array.isArray(adv.vulnerabilities) ? adv.vulnerabilities : [];
310
244
  for (const v of vulnList) {
311
245
  if (v?.package?.ecosystem) ecosystems.add(v.package.ecosystem);
@@ -317,9 +251,7 @@ function normalizeAdvisory(adv) {
317
251
  }
318
252
  }
319
253
 
320
- // Finding 4: cvss.score may arrive as a string ("9.8") rather than a
321
- // number. Number-coerce + finite-check so the catalog field stays
322
- // numeric across upstream shape drift.
254
+ // cvss.score arrives as a string often enough to need coercion + a finite check.
323
255
  let cvssScore = null;
324
256
  if (adv.cvss != null && adv.cvss.score !== undefined && adv.cvss.score !== null) {
325
257
  const n = Number(adv.cvss.score);
@@ -334,11 +266,8 @@ function normalizeAdvisory(adv) {
334
266
  : ecosystems.has("rubygems") ? "supply-chain-gem"
335
267
  : "supply-chain-other";
336
268
 
337
- // Finding 2 + 17: type-safe + format-validated published_at slicing.
338
269
  const publishedDate = safeDateSlice(adv.published_at);
339
270
 
340
- // Finding 20: references may not be an array — guard the spread before
341
- // it silently truncates to an empty list.
342
271
  const refList = Array.isArray(adv.references) ? adv.references : [];
343
272
 
344
273
  return {
@@ -397,8 +326,7 @@ function normalizeAdvisory(adv) {
397
326
  _draft_reason: "Imported from GHSA on " + new Date().toISOString().slice(0, 10) + ". 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.",
398
327
  _source_ghsa_id: adv.ghsa_id || null,
399
328
  _source_published_at: adv.published_at || null,
400
- // GitHub sets `withdrawn_at` when an advisory is retracted. Surface it as
401
- // structured status so a withdrawn advisory is flagged, not imported as live.
329
+ // `withdrawn_at` marks a retracted advisory; carried as status, not imported live.
402
330
  ...(adv.withdrawn_at ? { status: "withdrawn", status_source: "ghsa:withdrawn_at", status_verified: new Date().toISOString().slice(0, 10) } : {}),
403
331
  last_updated: new Date().toISOString().slice(0, 10),
404
332
  },
@@ -406,17 +334,9 @@ function normalizeAdvisory(adv) {
406
334
  }
407
335
 
408
336
  /**
409
- * Build a refresh diff for the existing refresh-external orchestrator.
410
- * Compares the latest 50 advisories' CVE IDs against the local catalog;
411
- * any CVE ID not in the catalog becomes an "add" diff.
412
- *
413
- * Finding 9: when the advisory is already in the catalog but a watched
414
- * field has dropped from populated -> null, surface a `field_dropped`
415
- * diff so curators don't silently lose signal.
416
- *
417
- * Finding 18: count + surface GHSA-only advisories (no CVE id) that were
418
- * skipped, so the summary explains why N upstream advisories produced
419
- * fewer than N diffs.
337
+ * Build a refresh diff for the refresh-external orchestrator. A CVE id absent
338
+ * from the local catalog becomes a `_new_entry` diff; one already present whose
339
+ * FIELD_DROPPED_WATCH value has gone null becomes a `field_dropped` diff.
420
340
  */
421
341
  async function buildDiff(ctx) {
422
342
  const result = await fetchAdvisories({ airGap: ctx?.airGap });
@@ -432,7 +352,6 @@ async function buildDiff(ctx) {
432
352
  const normalized = normalizeAdvisory(adv);
433
353
  if (!normalized) continue;
434
354
  if (existing.has(adv.cve_id)) {
435
- // Finding 9: field-dropped detection on the existing entry.
436
355
  const before = cveCatalog[adv.cve_id] || {};
437
356
  const after = normalized[adv.cve_id];
438
357
  for (const field of FIELD_DROPPED_WATCH) {
@@ -463,9 +382,7 @@ async function buildDiff(ctx) {
463
382
  source: "ghsa",
464
383
  });
465
384
  }
466
- // diffs holds BOTH _new_entry and field_dropped records — count them
467
- // separately so the summary doesn't mislabel field-dropped regressions as
468
- // brand-new CVE IDs.
385
+ // diffs holds both kinds; count separately so a field_dropped is not a new CVE.
469
386
  const newCount = diffs.filter((d) => d.field === "_new_entry").length;
470
387
  const droppedCount = diffs.filter((d) => d.variant === "field_dropped").length;
471
388
  return {