@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
@@ -1,30 +1,9 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/collectors/citation-hygiene.js
5
- *
6
- * Companion collector for the `citation-hygiene` playbook. Walks the cwd
7
- * tree (source, comments, docstrings, and security documentation) and
8
- * extracts every CVE and RFC citation, then cross-references each against
9
- * the shipped CVE catalog (data/cve-catalog.json) and RFC index
10
- * (data/rfc-references.json).
11
- *
12
- * It flips signal_overrides only for verdicts determinable offline from
13
- * the catalogs:
14
- * - fabricated-cve-id: a citation whose tail is not the canonical
15
- * all-numeric CVE form (CVE-2024-XXXX, CVE-2024-zlib). Deterministic.
16
- * - rejected-or-disputed-cve: a well-formed citation that resolves to a
17
- * catalog entry whose analyst notes mark it rejected / disputed.
18
- * - rfc-number-title-mismatch: a citation pairing a number with a title
19
- * that conflicts with the index title for that number.
20
- *
21
- * Indicators that need an out-of-band lookup or human judgement
22
- * (cve-citation-needs-external-verification, draft-mislabeled-as-rfc) are
23
- * surfaced in the artifacts text and left UNFLIPPED so the runner returns
24
- * inconclusive rather than a forced miss — the catalog is curated, not
25
- * exhaustive, so absence is never a clean clear or a false fabrication.
26
- *
27
- * Interface: see lib/collectors/README.md
4
+ * Companion collector for the `citation-hygiene` playbook: cross-references CVE
5
+ * and RFC citations in the cwd tree against data/cve-catalog.json and
6
+ * data/rfc-references.json. Interface: see lib/collectors/README.md
28
7
  */
29
8
 
30
9
  const fs = require("node:fs");
@@ -34,10 +13,7 @@ const { codeExcludeSet, walkTree, buildEvidenceLocations, lineFromOffset } = req
34
13
 
35
14
  const COLLECTOR_ID = "citation-hygiene";
36
15
 
37
- // Opt-in resolver (`exceptd collect citation-hygiene --resolve`). Lets the
38
- // collector resolve the citations the offline catalog can't confirm — once,
39
- // through the shared cache — instead of parking them as inconclusive for an
40
- // agent to research. Required lazily so a plain collect() never loads it.
16
+ // Required lazily so a plain collect() never loads the resolver.
41
17
  function loadResolver() {
42
18
  return require("../citation-resolve.js");
43
19
  }
@@ -45,9 +21,7 @@ function loadResolver() {
45
21
  const DEFAULT_MAX_DEPTH = 8;
46
22
  const EXCLUDES = codeExcludeSet();
47
23
 
48
- // File extensions whose contents are worth scanning for citations: source,
49
- // markup/docs, config that carries security prose. Citations live in
50
- // comments and docstrings (source) and in docs (md / rst / txt / adoc).
24
+ // Citations live in source comments, and in docs and config prose.
51
25
  const SCAN_EXTS = new Set([
52
26
  ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts",
53
27
  ".py", ".pyi",
@@ -66,16 +40,13 @@ const SCAN_EXTS = new Set([
66
40
 
67
41
  const MAX_FILE_BYTES = 2 * 1024 * 1024;
68
42
 
69
- // Paths whose citations are illustrative (templates / fixtures / the
70
- // scanner's own pattern catalogue), not real self-citations.
43
+ // Paths whose citations are illustrative, not real self-citations.
71
44
  const ILLUSTRATIVE_PATH_SEGMENTS = [
72
45
  "/test/", "/tests/", "/spec/", "/specs/", "/__tests__/",
73
46
  "/fixtures/", "/fixture/",
74
47
  "/.github/issue_template/", "/.github/pull_request_template/",
75
48
  "/issue_template/", "/pull_request_template/",
76
- // The collectors and the playbooks directory literally contain CVE /
77
- // RFC patterns and example citations; scanning them would flag the
78
- // scanner itself. The playbook's intent is the consumer's source.
49
+ // These directories hold the patterns themselves; scanning them flags the scanner.
79
50
  "/lib/collectors/", "/data/playbooks/", "/lib/schemas/",
80
51
  ];
81
52
 
@@ -91,60 +62,37 @@ function isIllustrativePath(rel) {
91
62
 
92
63
  function readSafe(full) {
93
64
  try {
94
- // Read raw bytes, enforce the 2 MB cap on the buffer length, then decode.
95
- // Replaces a statSync-before-read with a single read; the cap is
96
- // byte-based, so Buffer.length is the correct measure and an oversized
97
- // file is rejected before any UTF-8 decode.
65
+ // The cap is byte-based: enforced on the buffer, before any UTF-8 decode.
98
66
  const raw = fs.readFileSync(full);
99
67
  if (raw.length > MAX_FILE_BYTES) return null;
100
68
  return raw.toString("utf8");
101
69
  } catch { return null; }
102
70
  }
103
71
 
104
- // Permissive CVE matcher: 4-digit year, then a tail of digits OR letters
105
- // (so malformed citations like CVE-2024-XXXX / CVE-2024-zlib are captured,
106
- // not silently skipped). The canonical-form test is applied afterwards.
72
+ // Permissive: a letter tail is captured, not skipped, so the canonical-form test
73
+ // below can flag it.
107
74
  const CVE_CITATION_RE = /CVE-(\d{4})-([0-9A-Za-z]+)/g;
108
75
  const CVE_CANONICAL_RE = /^CVE-\d{4}-\d{4,}$/;
109
76
 
110
- // RFC citation: `RFC 9404`, `RFC9404`, `RFC-9404`. Capture the number.
111
77
  const RFC_CITATION_RE = /RFC[\s-]?(\d{1,5})\b/gi;
112
78
 
113
- // A catalog note records a rejected/disputed RECORD for THIS CVE only when a
114
- // reject/dispute/withdraw word refers to the citation's own record — not to a
115
- // CVSS *scoring* disagreement, a disclosure-coordination dispute, or a
116
- // DIFFERENT CVE the note merely mentions. A bare word-anywhere scan matched all
117
- // three, producing a false "citation to a rejected record" hit AND a false
118
- // __fp_checks[1] attestation (telling the runner not to downgrade), so a valid,
119
- // often actively-exploited CVE surfaced as a confirmed rejected/disputed
120
- // citation. `selfId` is the citation's own CVE id.
79
+ // True only when a reject/dispute/withdraw word refers to THIS citation's own
80
+ // record — not a CVSS disagreement, a coordination dispute, or another CVE the
81
+ // note mentions. `selfId` is the citation's own CVE id.
121
82
  function recordRejectedOrDisputed(note, selfId) {
122
83
  if (!note) return false;
123
84
  const self = String(selfId || "").toUpperCase();
124
85
  const re = /\b(reject(?:ed|s|ion)?|disputed?|withdrawn)\b/gi;
125
- // Qualifier nouns that make a "dispute" a disagreement about something OTHER
126
- // than the record's validity (the score, the severity, the disclosure
127
- // process, …). 'rejected'/'withdrawn' are record-level words and bypass this.
86
+ // Qualifier nouns that make a "dispute" a disagreement about something other
87
+ // than the record's validity; 'rejected' / 'withdrawn' bypass this.
128
88
  const QUALIFIER = /\b(cvss|scoring|score|severity|coordination|disclosure|methodolog\w*|attribution|naming|assignment|priorit\w*)\b/i;
129
- // A "duplicate of / superseded by / replaced by / merged into / in favour of"
130
- // construction names the REPLACEMENT cve — THIS record is still the rejected
131
- // one, so a different cve appearing as that replacement must NOT suppress the
132
- // flag (e.g. "this record was rejected as a duplicate of CVE-Y").
89
+ // A "duplicate of / superseded by / in favour of" construction names the
90
+ // REPLACEMENT cve, so that other id must not suppress the flag.
133
91
  const REPLACEMENT_OF = /\b(?:duplicate|dup)\b[\s\w-]*\bof\b|\b(?:supersed\w+|replaced|merged)\b[\s\w-]*\b(?:by|into)\b|\bin\s+favou?r\s+of\b/i;
134
- // "Reject" is also an ordinary verb for what code does to input, and a vector
135
- // description is full of it: a parser rejects an invalid DTD, a validator
136
- // rejects a traversal sequence. Nothing about the CVE record is being claimed
137
- // there. The record sense always names what was rejected — a record, an
138
- // entry, an identifier — or who rejected it (MITRE, NVD, the CNA), so require
139
- // one of those nearby before treating a reject-word as record-level. The
140
- // alternative is rewording accurate mechanism prose to dodge a regex, which
141
- // costs the catalog more than the false positive does.
142
- // Deliberately excludes bare "entry" and "advisory". Both are ordinary words
143
- // in mechanism prose — "rejection log entry", "cache entry", "registry entry" —
144
- // and including them let an Exim entry describing SMTP message rejection read
145
- // as a claim that the CVE record itself was rejected. The surviving tokens all
146
- // name the record or the body that maintains it, so they cannot be satisfied
147
- // by a sentence about software rejecting input.
92
+ // "Reject" is also the ordinary verb for what code does to input, so the record
93
+ // sense must name what was rejected or who rejected it. Bare "entry" and
94
+ // "advisory" stay out: they are ordinary words in mechanism prose, and admitting
95
+ // them reads SMTP message rejection as a rejected CVE record.
148
96
  const RECORD_CONTEXT = /\b(CVE record|catalog(?:ue)? entry|record status|identifier|assignment|CVE|MITRE|NVD|CNA)\b/i;
149
97
  const otherCve = (s) => (s.match(/CVE-\d{4}-\d{4,}/gi) || []).some((c) => c.toUpperCase() !== self);
150
98
  let m;
@@ -152,21 +100,16 @@ function recordRejectedOrDisputed(note, selfId) {
152
100
  const word = m[1].toLowerCase();
153
101
  const before = note.slice(Math.max(0, m.index - 60), m.index);
154
102
  const after = note.slice(re.lastIndex, re.lastIndex + 60);
155
- // (a) A different cve BEFORE the word is the subject ("CVE-Y was rejected")
156
- // — the status is about that record, not this citation.
103
+ // A different cve BEFORE the word is the subject: that record's status.
157
104
  if (otherCve(before)) continue;
158
- // (b) A different cve AFTER the word suppresses too, UNLESS it is the
159
- // replacement target of a duplicate-of/superseded-by construction, in
160
- // which case THIS record is the rejected one — keep it flagged.
105
+ // A cve AFTER suppresses too, unless it is a duplicate-of replacement target.
161
106
  if (otherCve(after) && !REPLACEMENT_OF.test(after)) continue;
162
- // (c) A 'dispute(d)' qualified by a non-record noun is a disagreement about
163
- // that noun, not a record rejection.
107
+ // A 'dispute' qualified by a non-record noun is about that noun.
164
108
  if (word.startsWith("disput")) {
165
109
  const lastTokens = before.trim().split(/[\s-]+/).slice(-3).join(" ");
166
110
  if (QUALIFIER.test(lastTokens)) continue;
167
111
  }
168
- // (d) A 'reject' with nothing record-shaped around it is the ordinary verb
169
- // — code rejecting input — not a statement about this CVE's record.
112
+ // A 'reject' with nothing record-shaped around it is the ordinary verb.
170
113
  if (word.startsWith("reject") && !RECORD_CONTEXT.test(before + " " + after)) continue;
171
114
  return true;
172
115
  }
@@ -177,10 +120,9 @@ function recordRejectedOrDisputed(note, selfId) {
177
120
  const DRAFT_LANGUAGE_RE = /\b(draft-[a-z0-9-]+|internet[- ]draft|work[- ]in[- ]progress|i-d\b)\b/i;
178
121
 
179
122
  /**
180
- * Load the shipped CVE catalog and RFC index. The catalogs ship in the
181
- * package tarball under data/; resolve relative to this module so the
182
- * collector works whether run from the source tree or a node_modules
183
- * install. Returns { cveKeys:Set, cveNotes:Map<id,string>, rfcTitles:Map<number,string>, errors:[] }.
123
+ * Load the shipped CVE catalog and RFC index, resolved relative to this module so
124
+ * the collector works from a source tree or a node_modules install. Returns
125
+ * { cveKeys:Set, cveNotes:Map<id,string>, rfcTitles:Map<number,string>, errors:[] }.
184
126
  */
185
127
  function loadCatalogs() {
186
128
  const errors = [];
@@ -195,9 +137,8 @@ function loadCatalogs() {
195
137
  if (k.startsWith("_")) continue;
196
138
  cveKeys.add(k);
197
139
  if (v && typeof v === "object") {
198
- // Concatenate the analyst-note fields that carry rejected /
199
- // disputed status. Matching the cited key's OWN notes (not a
200
- // neighbour's) is enforced by per-entry concatenation.
140
+ // Analyst-note fields carrying rejected / disputed status, concatenated
141
+ // per entry so a citation matches its own notes and never a neighbour's.
201
142
  const noteParts = [
202
143
  v.cvss_note, v.active_exploitation_notes, v.vector,
203
144
  v.discovery_attribution_note, v.ai_discovery_notes,
@@ -225,8 +166,6 @@ function loadCatalogs() {
225
166
  return { cveKeys, cveNotes, rfcTitles, errors };
226
167
  }
227
168
 
228
- // Normalise a title for comparison: lowercase, drop punctuation, collapse
229
- // whitespace, and strip a leading "the".
230
169
  function normalizeTitle(s) {
231
170
  return s
232
171
  .toLowerCase()
@@ -247,42 +186,24 @@ function titleTokens(s) {
247
186
  );
248
187
  }
249
188
 
250
- // Ordered list of meaningful (post-stopword, non-numeric) tokens in a
251
- // title — used both for overlap and for acronym construction.
252
189
  function orderedTitleTokens(s) {
253
190
  return normalizeTitle(s)
254
191
  .split(" ")
255
192
  .filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !TITLE_STOPWORDS.has(t));
256
193
  }
257
194
 
258
- // Build the lowercase acronym from the title's meaningful words
259
- // (Transport Layer Security Protocol -> "tls", since protocol/version are
260
- // stopwords). Lets a nickname / abbreviation in the adjacent text be
261
- // recognised as the same document, not a wrong title.
195
+ // Lowercase acronym from the title's meaningful words: "Transport Layer Security
196
+ // Protocol" gives "tls". Lets a nickname be recognised as the same document.
262
197
  function titleAcronym(realTitle) {
263
198
  return orderedTitleTokens(realTitle).map((w) => w[0]).join("");
264
199
  }
265
200
 
266
201
  /**
267
- * Decide whether an EXPLICITLY STATED title conflicts with the real index
268
- * title. The stated title is extracted by the caller (see statedTitleAfter):
269
- * only a title introduced immediately after the RFC number by a delimiter —
270
- * `RFC N: The Title`, `RFC N "The Title"`, `RFC N (The Title)` — counts.
271
- *
272
- * This is deliberately strict because the dominant real-world pattern is a
273
- * mechanism citation — "CRLF line endings per RFC 5322", "renders values per
274
- * RFC 8785", "ETag repeated per RFC 7232 §4.1" — where the prose describes
275
- * what the code does *per* the RFC using vocabulary that never overlaps the
276
- * RFC's formal title. Comparing that prose against the title produced a
277
- * false "mismatch" on correct citations. Such references state no title and
278
- * are filtered out before this function is reached.
279
- *
280
- * - at least TWO meaningful tokens in the stated title (a bare acronym /
281
- * nickname is not a title),
282
- * - the title's acronym appearing in the stated title is the same document
283
- * (TLS for Transport Layer Security); not a mismatch,
284
- * - only ZERO overlap between stated-title tokens and real-title tokens
285
- * flags a mismatch; any shared content word is a paraphrase — demote.
202
+ * Decide whether an EXPLICITLY STATED title conflicts with the real index title.
203
+ * Only a title introduced immediately after the RFC number counts (see
204
+ * statedTitleAfter): a mechanism citation states no title, and comparing that
205
+ * prose against a formal title reports a mismatch on a correct citation. Only
206
+ * ZERO token overlap is a mismatch — any shared content word is a paraphrase.
286
207
  * Returns "mismatch" | "match" | "no-title-claim".
287
208
  */
288
209
  function classifyRfcTitle(statedTitle, realTitle) {
@@ -290,30 +211,21 @@ function classifyRfcTitle(statedTitle, realTitle) {
290
211
  if (adjTokens.size < 2) return "no-title-claim";
291
212
  const realTokens = titleTokens(realTitle);
292
213
  if (realTokens.size === 0) return "no-title-claim";
293
- // Acronym recognition: "tls" in the stated title matches "Transport
294
- // Layer Security". Same document, not a wrong title.
295
214
  const acronym = titleAcronym(realTitle);
296
215
  if (acronym.length >= 2 && adjTokens.has(acronym)) return "match";
297
216
  let overlap = 0;
298
217
  for (const t of adjTokens) {
299
218
  if (realTokens.has(t)) { overlap++; continue; }
300
- // Nickname / short-name recognition: a stated token that contains (or is
301
- // contained by) a real-title token of length >= 4 is the same document
302
- // under a common name — "IMAP4rev2" carries the real token "imap"
303
- // ("...Access Protocol (IMAP)..."). Avoids false mismatches on the way
304
- // developers actually cite RFCs by their well-known short names.
219
+ // Nickname recognition: a stated token containing (or contained by) a real
220
+ // token of length >= 4 is the same document — "IMAP4rev2" carries "imap".
305
221
  for (const rt of realTokens) {
306
222
  if (rt.length >= 4 && (t.includes(rt) || rt.includes(t))) { overlap++; break; }
307
223
  }
308
224
  }
309
- // Any shared content word -> the author is describing the right
310
- // document. Only a stated title with ZERO overlap is a conflicting
311
- // claim. This trades recall for precision intentionally.
312
225
  return overlap === 0 ? "mismatch" : "match";
313
226
  }
314
227
 
315
- // Pull the text on the same line as the match, used as the "adjacent text"
316
- // for the RFC title comparison.
228
+ // The whole line containing `index`, as adjacent text for the title comparison.
317
229
  function lineAround(content, index) {
318
230
  const start = content.lastIndexOf("\n", index) + 1;
319
231
  let end = content.indexOf("\n", index);
@@ -321,24 +233,12 @@ function lineAround(content, index) {
321
233
  return content.slice(start, end);
322
234
  }
323
235
 
324
- // Extract a title EXPLICITLY QUOTED immediately after the RFC number on the
325
- // same line:
326
- // RFC N "The Title" RFC N: "The Title" RFC N ("The Title")
327
- // A quoted string is the only unambiguous title claim. Everything else states
328
- // no title and returns null:
329
- // - free prose ("RFC 6455 wire layer"), a section pointer ("RFC 5322 §2.3"),
330
- // and "X per RFC N" mechanism attributions describe usage, not the title;
331
- // - bare nicknames ("RFC 9051 (IMAP4rev2)") are common short names;
332
- // - and crucially, an RFC-number-shaped token inside CODE (`envelope.rfc822`
333
- // matches "RFC 822"; `RFC 3339:` ahead of an object literal) is followed
334
- // by code punctuation, never a quoted title — so comparing a code fragment
335
- // against a formal title can no longer produce a phantom mismatch.
336
- // The opening quote must be SEPARATED from the number by whitespace or a
337
- // `:` / `(` introducer. A quote touching the last digit (`"…RFC 3339"`) is the
338
- // CLOSING quote of a string that happens to end with the citation, not the
339
- // opening quote of a title — without this guard the following code was
340
- // captured as a phantom "title". The closing quote bounds the title; straight
341
- // and typographic quotes are accepted.
236
+ // Extract a title EXPLICITLY QUOTED immediately after the RFC number on the same
237
+ // line — `RFC N "The Title"`, `RFC N: "The Title"`, `RFC N ("The Title")`. Prose,
238
+ // section pointers, bare nicknames and `envelope.rfc822` state no title. The
239
+ // opening quote must be SEPARATED from the number by whitespace or a `:` / `(`
240
+ // introducer: one touching the last digit is the CLOSING quote of a string ending
241
+ // with the citation, and the code after it is then captured as a phantom title.
342
242
  function statedTitleAfter(after) {
343
243
  const m = after.match(/^(?:\s*[:(]\s*|\s+)["“]([^"”\n]{3,100})["”]/);
344
244
  return m ? m[1].trim() : null;
@@ -370,16 +270,13 @@ function collect({ cwd = process.cwd() } = {}) {
370
270
 
371
271
  const scanFiles = files.filter((f) => SCAN_EXTS.has(path.extname(f.name).toLowerCase()));
372
272
 
373
- // Hit collectors. Each entry keeps the file + the citation text so the
374
- // artifact summary is auditable. CVE / RFC literals are references, not
375
- // secrets, so they are safe to retain in the value text.
273
+ // Each entry keeps file and citation text so the artifact summary is auditable.
376
274
  const hits = {
377
275
  "fabricated-cve-id": [],
378
276
  "rejected-or-disputed-cve": [],
379
277
  "rfc-number-title-mismatch": [],
380
278
  };
381
- // Inconclusive / needs-verification buckets — surfaced in artifacts,
382
- // never flipped to a deterministic verdict.
279
+ // Needs-verification buckets: surfaced in artifacts, never flipped to a verdict.
383
280
  const needsVerify = {
384
281
  cve_not_in_catalog: [],
385
282
  rfc_not_in_index: [],
@@ -397,36 +294,29 @@ function collect({ cwd = process.cwd() } = {}) {
397
294
  }
398
295
  const illustrative = isIllustrativePath(f.rel);
399
296
 
400
- // ---- CVE citations ----
401
297
  for (const m of content.matchAll(CVE_CITATION_RE)) {
402
298
  const full = m[0];
403
299
  totalCveCitations++;
404
- // 1-based line of the citation so the evidence location carries a SARIF
405
- // startLine region. Does not change any hit/miss verdict.
300
+ // 1-based, so the evidence location carries a SARIF startLine region.
406
301
  const cveLine = lineFromOffset(content, m.index);
407
302
  const canonical = CVE_CANONICAL_RE.test(full);
408
303
  if (!canonical) {
409
- // Fabricated / malformed. Illustrative surfaces (templates,
410
- // fixtures, the format-explaining docs) are demoted.
411
304
  if (!illustrative) {
412
305
  hits["fabricated-cve-id"].push({ file: f.rel, citation: full, line: cveLine });
413
306
  }
414
307
  continue;
415
308
  }
416
- // Well-formed. Cross-reference the catalog.
417
309
  if (cveKeys.has(full)) {
418
310
  const note = cveNotes.get(full) || "";
419
311
  if (recordRejectedOrDisputed(note, full) && !illustrative) {
420
312
  hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full, line: cveLine });
421
313
  }
422
314
  } else if (catalogsLoaded && !illustrative) {
423
- // Absent from the curated catalog: needs an external lookup.
424
- // NOT a fabrication — inconclusive by design.
315
+ // Absent from the curated catalog is inconclusive, not a fabrication.
425
316
  needsVerify.cve_not_in_catalog.push({ file: f.rel, citation: full });
426
317
  }
427
318
  }
428
319
 
429
- // ---- RFC citations ----
430
320
  for (const m of content.matchAll(RFC_CITATION_RE)) {
431
321
  totalRfcCitations++;
432
322
  const num = Number(m[1]);
@@ -447,9 +337,7 @@ function collect({ cwd = process.cwd() } = {}) {
447
337
  });
448
338
  }
449
339
  } else if (catalogsLoaded && !illustrative) {
450
- // Number not in the published index. Needs verification; if draft
451
- // language is adjacent, record it as a draft-as-RFC candidate
452
- // (still inconclusive — left unflipped).
340
+ // Adjacent draft language makes it a draft-as-RFC candidate, still unflipped.
453
341
  needsVerify.rfc_not_in_index.push({ file: f.rel, citation: `RFC ${num}` });
454
342
  if (DRAFT_LANGUAGE_RE.test(line)) {
455
343
  needsVerify.draft_as_rfc_candidates.push({ file: f.rel, citation: `RFC ${num}` });
@@ -458,53 +346,39 @@ function collect({ cwd = process.cwd() } = {}) {
458
346
  }
459
347
  }
460
348
 
461
- // signal_overrides: only the deterministically-decidable indicators are
462
- // flipped. The needs-verification indicators stay absent so the runner
463
- // returns inconclusive for them.
349
+ // Only deterministically-decidable indicators flip; the rest stay absent so the
350
+ // runner returns inconclusive for them.
464
351
  const signal_overrides = {
465
352
  "fabricated-cve-id": hits["fabricated-cve-id"].length > 0 ? "hit" : "miss",
466
353
  "rfc-number-title-mismatch": hits["rfc-number-title-mismatch"].length > 0 ? "hit" : "miss",
467
354
  };
468
- // rejected-or-disputed-cve is high-confidence (not deterministic) — flip
469
- // on a catalog-backed match, otherwise miss. Only assert a verdict when
470
- // the catalog actually loaded; without it the check could not run.
355
+ // Only assert a verdict when the catalog loaded; without it the check cannot run.
471
356
  if (cveKeys.size > 0) {
472
357
  signal_overrides["rejected-or-disputed-cve"] =
473
358
  hits["rejected-or-disputed-cve"].length > 0 ? "hit" : "miss";
474
359
  } else {
475
360
  signal_overrides["rejected-or-disputed-cve"] = "inconclusive";
476
361
  }
477
- // The needs-verification CVE indicator: hit means "found citations the
478
- // offline catalog cannot confirm" — itself an inconclusive state, so it
479
- // maps to inconclusive (not a clean miss) when such citations exist.
362
+ // Citations the offline catalog cannot confirm are inconclusive, not a miss.
480
363
  if (needsVerify.cve_not_in_catalog.length > 0) {
481
364
  signal_overrides["cve-citation-needs-external-verification"] = "inconclusive";
482
365
  }
483
366
 
484
- // __fp_checks attestation for the FP-gated indicators the collector decides
485
- // deterministically. Each hit already excludes illustrative (template /
486
- // fixture / doc-snippet) paths and is keyed off the shipped catalogs, so the
487
- // path / catalog-cross-reference / same-citation checks the collector ran
488
- // are attested; surrounding-text-acknowledgement remains operator judgement.
489
- // Without this the runner downgrades a real bad citation to inconclusive.
367
+ // __fp_checks attest the FP checks this collector ran; without the attestation
368
+ // the runner downgrades a real bad citation to inconclusive.
490
369
  if (signal_overrides["fabricated-cve-id"] === "hit") {
491
- // [0] not under a fixture / regex-example / doc-snippet path (illustrative
492
- // paths are excluded before the hit). [1] placeholder forms (CVE-TBD /
493
- // pending) never match the numeric citation regex, so a fired hit is
494
- // not a placeholder.
370
+ // [0] illustrative paths are excluded before the hit; [1] placeholder forms
371
+ // never match the numeric citation regex.
495
372
  signal_overrides["fabricated-cve-id__fp_checks"] = { "0": true, "1": true };
496
373
  }
497
374
  if (signal_overrides["rejected-or-disputed-cve"] === "hit") {
498
- // [1] the catalog note marks THIS exact identifier rejected/disputed.
499
- // [2] the identifier is present in the catalog (absence does not fire).
500
- // [0] inline dispute-acknowledgement in surrounding prose is operator
501
- // judgement — left unattested.
375
+ // [1] the catalog note marks THIS identifier; [2] it is in the catalog.
376
+ // [0] inline dispute acknowledgement in prose is left unattested.
502
377
  signal_overrides["rejected-or-disputed-cve__fp_checks"] = { "1": true, "2": true };
503
378
  }
504
379
  if (signal_overrides["rfc-number-title-mismatch"] === "hit") {
505
- // [0] a paraphrase / nickname (no title claim) does not fire. [1] numbers
506
- // absent from the shipped RFC index do not fire. [2] the stated title is
507
- // extracted from the SAME citation line.
380
+ // [0] a paraphrase / nickname does not fire; [1] numbers absent from the
381
+ // index do not fire; [2] the stated title comes from the same line.
508
382
  signal_overrides["rfc-number-title-mismatch__fp_checks"] = { "0": true, "1": true, "2": true };
509
383
  }
510
384
 
@@ -549,10 +423,7 @@ function collect({ cwd = process.cwd() } = {}) {
549
423
  },
550
424
  };
551
425
 
552
- // Per-indicator file locations for the indicators flipped to "hit",
553
- // so SARIF results point at the source file that carries the bad
554
- // citation. The hits record a 1-based `line` (from the match offset),
555
- // so locations include a startLine region.
426
+ // Locations for flipped indicators, so SARIF results point at the source line.
556
427
  const evidence_locations = {};
557
428
  for (const id of Object.keys(hits)) {
558
429
  if (signal_overrides[id] === "hit") {
@@ -568,9 +439,7 @@ function collect({ cwd = process.cwd() } = {}) {
568
439
  artifacts,
569
440
  signal_overrides,
570
441
  ...(Object.keys(evidence_locations).length ? { evidence_locations } : {}),
571
- // The citations the offline catalog could not confirm. `applyResolution`
572
- // (opt-in --resolve) consumes this to resolve + flip them; on a plain
573
- // collect it documents what still needs verification.
442
+ // Consumed by applyResolution (--resolve) to resolve and flip these.
574
443
  needs_verification: needsVerify,
575
444
  collector_meta: {
576
445
  collector_id: COLLECTOR_ID,
@@ -590,14 +459,11 @@ function collect({ cwd = process.cwd() } = {}) {
590
459
  }
591
460
 
592
461
  /**
593
- * Resolve the citations a plain collect() left as needs-verification, flipping
594
- * the parked signals from inconclusive to a real verdict. Opt-in: only invoked
595
- * for `exceptd collect citation-hygiene --resolve`. Each uncatalogued CVE goes
596
- * through the shared resolver (catalog -> cache -> one NVD lookup, cached), so a
597
- * fan-out resolves each id once. Honors air-gap (resolver returns unknown).
598
- *
599
- * Mutates a shallow copy of the submission's signal_overrides and records a
600
- * resolution summary artifact. Returns the updated submission.
462
+ * Resolve the citations collect() left as needs-verification, flipping the parked
463
+ * signals from inconclusive to a real verdict. Each uncatalogued CVE goes through
464
+ * the shared resolver (catalog -> cache -> one NVD lookup, cached), so a fan-out
465
+ * resolves each id once. Returns a shallow copy carrying a resolution summary
466
+ * artifact; `submission` itself is not mutated.
601
467
  *
602
468
  * @param {object} submission the object returned by collect()
603
469
  * @param {object} [opts] { airGap?: boolean, _resolveCve?, _resolveRfc? }
@@ -630,9 +496,7 @@ async function applyResolution(submission, opts = {}) {
630
496
  }
631
497
  if (rejectedHit) signals["rejected-or-disputed-cve"] = "hit";
632
498
  if (fabricatedHit) signals["fabricated-cve-id"] = "hit";
633
- // The needs-verification signal: a clean miss once every parked CVE was
634
- // classified, inconclusive while any remain unresolvable (NVD unreachable /
635
- // air-gap), and absent when there was nothing to verify.
499
+ // A miss once every parked CVE is classified; inconclusive while any stay unknown.
636
500
  if (cveList.length > 0) {
637
501
  signals["cve-citation-needs-external-verification"] = cveUnknown > 0 ? "inconclusive" : "miss";
638
502
  }
@@ -643,9 +507,7 @@ async function applyResolution(submission, opts = {}) {
643
507
  const num = (cite.match(/(\d+)/) || [])[1];
644
508
  const r = await resolver.resolveRfc(num || cite, { airGap });
645
509
  resolved.rfc.push({ citation: cite, file: item.file, status: r.status, found: r.found, from: r.from, title: r.title || null });
646
- // A cited RFC number that resolves to nothing is a bad citation, same class
647
- // as a fabricated CVE — surface it instead of discarding the verdict. (An
648
- // obsoleted/historic RFC that resolves IS a real RFC, so it isn't flagged.)
510
+ // A number resolving to nothing is a bad citation; an obsoleted RFC still resolves.
649
511
  if (r.status === "nonexistent") rfcNonexistentHit = true;
650
512
  }
651
513
  if (rfcNonexistentHit) signals["rfc-number-title-mismatch"] = "hit";