@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
package/lib/prefetch.js CHANGED
@@ -1,39 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
- * lib/prefetch.js
4
- *
5
- * Pre-downloads every upstream artifact the project queries (CISA KEV,
6
- * NIST NVD per-CVE, FIRST EPSS per-CVE, IETF Datatracker per-RFC, MITRE
7
- * GitHub releases) into a local cache directory. Operators behind an air
8
- * gap can run this once on a connected host and ship `.cache/upstream/`
9
- * across the boundary. CI runs use it as a warm cache so each refresh job
10
- * doesn't re-pay full network latency.
11
- *
12
- * Cache layout (`.cache/upstream/` by default — gitignored):
13
- *
14
- * _index.json — per-entry fetch metadata
15
- * kev/known_exploited_vulnerabilities.json — full KEV feed
16
- * nvd/<cve-id>.json — NVD 2.0 per-CVE response
17
- * epss/<cve-id>.json — EPSS per-CVE response
18
- * rfc/<doc-name>.json — IETF Datatracker doc record
19
- * pins/<owner>__<repo>__releases.json — MITRE GitHub releases listing
20
- *
21
- * The registered source names in SOURCES below are `rfc` and `pins`.
22
- * `--source ietf` or `--source github` would hit "unknown source"
23
- * because no such key exists. The names below are the canonical ones
24
- * consumed by --source filtering.
25
- *
26
- * Usage:
27
- * node lib/prefetch.js # fetch everything not fresh
28
- * node lib/prefetch.js --max-age 12h # re-fetch entries older than 12h
29
- * node lib/prefetch.js --source kev,nvd # scope by source
30
- * node lib/prefetch.js --force # ignore freshness, refetch all
31
- * node lib/prefetch.js --no-network # report-only: list what would be fetched
32
- *
33
- * Every fetch routes through lib/job-queue.js so per-source rate budgets
34
- * (NVD 5 req/30s anon, GitHub 60/h anon, etc.) are respected.
35
- *
36
- * Zero npm deps. Node 24 stdlib only.
3
+ * Warms a local cache (`.cache/upstream/`, gitignored) of every upstream
4
+ * artifact this project queries.
37
5
  */
38
6
 
39
7
  const fs = require("fs");
@@ -46,15 +14,8 @@ const DEFAULT_CACHE = path.join(ROOT, ".cache", "upstream");
46
14
  const REQUEST_TIMEOUT_MS = 10_000;
47
15
  const USER_AGENT = "exceptd-security/prefetch (+https://exceptd.com)";
48
16
 
49
- // CVE ids the CISA KEV feed lists that are NOT yet in the local catalog.
50
- // Pure — no fs/network. `kevFeed` is the parsed known_exploited_vulnerabilities
51
- // JSON (or null/undefined if unavailable); `cveCatalog` is data/cve-catalog.json
52
- // (or a synthetic subset in tests). Consumed by nvd/epss expand() below so a
53
- // CVE CISA newly added to KEV gets its NVD/EPSS sidecar prefetched in the SAME
54
- // run auto-discovery will later read via lib/auto-discovery.js:readCachedJson
55
- // — without this, buildKevDraftEntry's nvd/epss payloads stayed null forever
56
- // for anything auto-discovery itself found, because nothing had ever asked
57
- // prefetch to warm a sidecar for an id absent from the local catalog.
17
+ // CVE ids the KEV feed lists that are NOT yet in the local catalog; a null
18
+ // `kevFeed` yields [].
58
19
  function newKevIds(kevFeed, cveCatalog) {
59
20
  const have = new Set(Object.keys(cveCatalog || {}));
60
21
  const vulns = Array.isArray(kevFeed && kevFeed.vulnerabilities) ? kevFeed.vulnerabilities : [];
@@ -75,11 +36,7 @@ const SOURCES = {
75
36
  rate: { tokens: 5, windowMs: 30_000 }, // anon budget; NVD_API_KEY lifts to 50
76
37
  rate_with_key: { tokens: 50, windowMs: 30_000 },
77
38
  concurrency: 4,
78
- // Union the local catalog with any newly-KEV-listed id (ctx.kevFeed, when
79
- // the run loop resolved it — see prefetch()'s KEV pre-fetch step below).
80
- // ctx.kevFeed absent/null is the documented fallback (KEV out of scope
81
- // this run, or --no-network): newKevIds returns [], so this degrades to
82
- // the pre-Task-11 catalog-only expansion, not a crash.
39
+ // The catalog unioned with any newly-KEV-listed id.
83
40
  expand: (ctx) => {
84
41
  const ids = new Set(Object.keys(ctx.cveCatalog).filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k)));
85
42
  for (const id of newKevIds(ctx.kevFeed, ctx.cveCatalog)) ids.add(id);
@@ -116,17 +73,8 @@ const SOURCES = {
116
73
  rate: { tokens: 30, windowMs: 60 * 60_000 }, // anon: 60/h, leave headroom
117
74
  rate_with_key: { tokens: 500, windowMs: 60 * 60_000 },
118
75
  concurrency: 2,
119
- // D3FEND and CWE were previously listed here but neither project
120
- // publishes via GitHub Releases — D3FEND distributes the ontology
121
- // from d3fend/d3fend-ontology without tagged releases, and CWE
122
- // ships its catalog as XML/JSON downloads from cwe.mitre.org rather
123
- // than a GitHub repo. The old api.github.com URLs (mitre/cwe and
124
- // d3fend/d3fend-data) returned HTTP 404 on every refresh, surfacing
125
- // as "2 error(s)" in the prefetch summary. Pin currency for those
126
- // two frameworks is tracked via lib/upstream-check.js against
127
- // cwe.mitre.org and d3fend.mitre.org respectively; the prefetch
128
- // registry only contains sources that actually have a GitHub
129
- // Releases feed to poll.
76
+ // D3FEND and CWE publish no GitHub Releases, so an entry for either 404s on
77
+ // every refresh; lib/upstream-check.js tracks their pin currency instead.
130
78
  expand: () => [
131
79
  { id: "mitre-atlas__atlas-data__releases", url: "https://api.github.com/repos/mitre-atlas/atlas-data/releases?per_page=5" },
132
80
  { id: "mitre-attack__attack-stix-data__releases", url: "https://api.github.com/repos/mitre-attack/attack-stix-data/releases?per_page=5" },
@@ -134,11 +82,8 @@ const SOURCES = {
134
82
  },
135
83
  };
136
84
 
137
- // Sources the refresh orchestrator knows but that have no prefetch cache
138
- // layer: they resolve advisories by live id lookup, so there is nothing to
139
- // warm. Named here so an operator who scopes a cache-warm to one of them gets
140
- // "no prefetch cache layer (live id lookup only)" rather than a misleading
141
- // "unknown source" — the source is real, just not cacheable.
85
+ // Refresh-orchestrator sources with nothing to warm (live id lookup only), so
86
+ // scoping a warm to one reports that rather than "unknown source".
142
87
  const LIVE_ONLY_REFRESH_SOURCES = new Set(["ghsa", "osv", "advisories", "cve-regression-watcher"]);
143
88
 
144
89
  function parseArgs(argv) {
@@ -149,58 +94,37 @@ function parseArgs(argv) {
149
94
  else if (a === "--no-network" || a === "--dry-run" || a === "--air-gap") out.noNetwork = true;
150
95
  else if (a === "--quiet") out.quiet = true;
151
96
  else if (a === "--help" || a === "-h") out.help = true;
152
- // The space-separated forms of --source / --max-age / --cache-dir consume
153
- // the next token. A trailing flag (e.g. `prefetch --cache-dir` with no
154
- // following value) would otherwise pass `undefined` into path.resolve /
155
- // parseDuration — path.resolve(undefined) throws an uncaught TypeError,
156
- // and parseDuration(undefined) silently returns 0 (which flips --max-age
157
- // into "everything is stale, refetch all"). A bare --source likewise flips
158
- // the scope to all sources. Treat a missing value (next token absent or
159
- // itself a --flag) as a usage error so main() refuses with exit 2 instead.
97
+ // A trailing value-flag is a usage error, never a default: an undefined
98
+ // --max-age means "everything is stale" and a bare --source means all.
160
99
  else if (a === "--source") { const v = takesValue(argv, ++i); if (v === undefined) out._argError = "prefetch: --source requires a value"; else out.source = v; }
161
100
  else if (a.startsWith("--source=")) out.source = a.slice("--source=".length);
162
101
  else if (a === "--max-age") { const v = takesValue(argv, ++i); if (v === undefined) out._argError = "prefetch: --max-age requires a value"; else out.maxAgeMs = parseDuration(v); }
163
102
  else if (a.startsWith("--max-age=")) out.maxAgeMs = parseDuration(a.slice("--max-age=".length));
164
103
  else if (a === "--cache-dir") { const v = takesValue(argv, ++i); if (v === undefined) out._argError = "prefetch: --cache-dir requires a value"; else out.cacheDir = path.resolve(v); }
165
104
  else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
166
- // Per-entry fetch-error tolerance. An integer is an absolute budget; an
167
- // "<N>%" string is a fraction of the planned fetch count. A malformed
168
- // value is recorded as an arg error so main() refuses with exit 2 rather
169
- // than silently falling back to an unbounded tolerance.
170
105
  else if (a === "--max-errors") { try { out.maxErrors = parseErrorThreshold(argv[++i]); } catch (e) { out._argError = e.message; } }
171
106
  else if (a.startsWith("--max-errors=")) { try { out.maxErrors = parseErrorThreshold(a.slice("--max-errors=".length)); } catch (e) { out._argError = e.message; } }
172
- // Any remaining --flag is an unrecognized typo. Record it; main() refuses
173
- // before any network work rather than silently dropping it.
107
+ // Any remaining --flag is a typo; main() refuses before any network work.
174
108
  else if (typeof a === "string" && a.startsWith("--")) {
175
109
  const base = a.indexOf("=") === -1 ? a : a.slice(0, a.indexOf("="));
176
110
  (out._unknownFlags || (out._unknownFlags = [])).push(base);
177
111
  }
178
112
  }
179
- // A supplied-but-empty --source (`--source ""`, `--source=`, or a comma-only
180
- // value like `--source ,`) resolves to no source names. Left unguarded, the
181
- // empty string is falsy and silently warms ALL sources, while a comma-only
182
- // value silently warms none — both reporting success. Treat either as a
183
- // usage error so main() refuses with exit 2, matching the unknown-source
184
- // contract. Only fire when --source was actually supplied (out.source != null)
185
- // so the omitted-flag default (warm all) is preserved.
113
+ // A supplied-but-empty --source would warm ALL sources and a comma-only value
114
+ // none, both reporting success. Omitting the flag still warms everything.
186
115
  if (!out._argError && out.source != null) {
187
116
  const names = String(out.source).split(",").map((s) => s.trim()).filter(Boolean);
188
117
  if (names.length === 0) {
189
118
  out._argError = "prefetch: --source given but resolved to no source names (empty or comma-only value)";
190
119
  }
191
120
  }
192
- // The global air-gap switch implies a report-only / no-egress run: treat
193
- // EXCEPTD_AIR_GAP=1 the same as --no-network so prefetch never plans live
194
- // fetches under air-gap.
121
+ // EXCEPTD_AIR_GAP=1 is --no-network: no live fetch is planned under air-gap.
195
122
  if (process.env.EXCEPTD_AIR_GAP === "1") out.noNetwork = true;
196
123
  return out;
197
124
  }
198
125
 
199
- // Read the value token a space-separated value-flag expects. Returns the
200
- // token, or `undefined` when the operator left the flag trailing (no token
201
- // follows) or the next token is itself a --flag (a swallowed missing value,
202
- // e.g. `--max-age --no-network`). Callers convert undefined into a usage
203
- // error rather than consuming a bad value.
126
+ // The value token a space-separated flag expects, or `undefined` when the flag
127
+ // trails or the next token is itself a --flag. Callers turn that into an error.
204
128
  function takesValue(argv, i) {
205
129
  const v = argv[i];
206
130
  if (v === undefined) return undefined;
@@ -218,9 +142,8 @@ function parseDuration(s) {
218
142
  return n * mult;
219
143
  }
220
144
 
221
- // Parse a --max-errors value into either an absolute integer budget or a
222
- // percentage marker ("<N>%"). Throws on anything else so a typo can't degrade
223
- // into an unbounded tolerance.
145
+ // Returns an absolute integer budget or a "<N>%" marker; throws on anything
146
+ // else, so a typo cannot degrade into an unbounded tolerance.
224
147
  function parseErrorThreshold(s) {
225
148
  const str = String(s == null ? "" : s).trim();
226
149
  const m = str.match(/^(\d+)(%?)$/);
@@ -229,14 +152,12 @@ function parseErrorThreshold(s) {
229
152
  }
230
153
 
231
154
  // Total entries a run planned to fetch (fetched + skipped-fresh + errored).
232
- // The denominator for a percentage error budget.
233
155
  function plannedCount(result) {
234
156
  if (!result) return 0;
235
157
  return (result.fetched || 0) + (result.skipped_fresh || 0) + (result.errors || 0);
236
158
  }
237
159
 
238
- // Resolve a --max-errors value (absolute number, "<N>%" string, or null) into
239
- // an absolute count against the planned total.
160
+ // Resolves a --max-errors value into an absolute count against the planned total.
240
161
  function errorBudget(maxErrors, planned) {
241
162
  if (maxErrors == null) return 0;
242
163
  if (typeof maxErrors === "number") return Number.isFinite(maxErrors) ? maxErrors : 0;
@@ -246,49 +167,29 @@ function errorBudget(maxErrors, planned) {
246
167
  return Number.isFinite(n) ? n : 0;
247
168
  }
248
169
 
249
- // Decide prefetch's 0-vs-1 exit code from a completed run. Per-entry fetch
250
- // errors are counted only after the job queue exhausts its retries. They split
251
- // into two classes that mean very different things for a best-effort cache
252
- // warm:
253
- // - HARD errors (404/410/parse failure/4xx-not-429) are real data faults.
254
- // These count toward `opts.maxErrors` (default 0, so any hard error exits
255
- // 1 — the strict contract a manual operator expects).
256
- // - TRANSIENT errors (HTTP 408/425/429/5xx + ETIMEDOUT/ECONNRESET et al that
257
- // exhausted their retry budget) are the upstream throttling us, not a data
258
- // fault. They are surfaced in the summary and deferred to the next run
259
- // (where the entries that DID land this run are fresh-skipped, freeing rate
260
- // budget for the throttled ones) — they never fail the run on their own.
261
- // Without the split, a daily NVD rate-limit on a subset of CVEs hard-failed the
262
- // whole scheduled refresh and skipped the auto-PR every single run, since the
263
- // ephemeral runner cache restarts cold each time and re-hits the same throttle.
264
- // Fatal errors (bad flags, an unhandled throw) are handled in main() and exit 2.
170
+ // prefetch's 0-vs-1 exit code, counted only after the queue exhausts retries.
171
+ // Hard errors (data faults) count against `opts.maxErrors`; transient ones
172
+ // (the upstream throttling us) never fail a run on their own. Fatal errors
173
+ // exit 2 from main().
265
174
  function exitCodeForResult(result, opts = {}) {
266
175
  const errors = (result && result.errors) || 0;
267
176
  if (errors === 0) return 0;
268
- // A source that landed no usable entries this run — nothing freshly fetched
269
- // and nothing already fresh in the cache, yet errors recorded — is entirely
270
- // unreachable, and the refresh would silently skip it. A dead KEV feed is
271
- // only one error (well under any global budget) but means the run missed
272
- // every new KEV flag. Fail regardless of the budget OR error class so a
273
- // single fully-dead feed (incl. an NVD that 429s/503s every single request)
274
- // can't pass quietly.
177
+ // A source with errors but nothing fetched and nothing fresh is unreachable:
178
+ // that fails regardless of budget or error class.
275
179
  const bySource = (result && result.by_source) || {};
276
180
  for (const s of Object.values(bySource)) {
277
181
  if (s && (s.errors || 0) > 0 && (s.fetched || 0) === 0 && (s.skipped_fresh || 0) === 0) {
278
182
  return 1;
279
183
  }
280
184
  }
281
- // Only HARD errors gate the exit code against the budget. Back-compat: a
282
- // result built without the split (errors_hard undefined) treats every error
283
- // as hard, preserving the prior strict behavior for callers/tests that
284
- // construct a bare { errors } result.
185
+ // Only hard errors gate against the budget. A result built without the split
186
+ // (errors_hard undefined) treats every error as hard.
285
187
  const hardErrors = (result && result.errors_hard != null) ? result.errors_hard : errors;
286
188
  const budget = errorBudget(opts.maxErrors, plannedCount(result));
287
189
  return hardErrors > budget ? 1 : 0;
288
190
  }
289
191
 
290
- // One-line run summary. When a run has errors, names the per-source counts so
291
- // "1 error(s)" in a --quiet log is actionable instead of a blind count.
192
+ // One-line run summary; names per-source counts when a run has errors.
292
193
  function formatSummary(result, opts = {}) {
293
194
  let line = `prefetch summary: ${result.fetched} fetched, ${result.skipped_fresh} fresh, ${result.errors} error(s)`;
294
195
  if (result.errors > 0 && result.by_source) {
@@ -297,9 +198,7 @@ function formatSummary(result, opts = {}) {
297
198
  .map(([name, s]) => `${name}=${s.errors}`);
298
199
  if (parts.length) line += ` [${parts.join(", ")}]`;
299
200
  }
300
- // Name the transient vs hard split when present so a large error count that
301
- // is purely upstream throttling reads as "throttled — retried, deferred to
302
- // next run" rather than a silent data failure. Only hard errors gate exit 1.
201
+ // The transient/hard split; only hard errors gate exit 1.
303
202
  if (result.errors > 0 && result.errors_transient != null && result.errors_hard != null) {
304
203
  line += ` (${result.errors_transient} transient/throttled, ${result.errors_hard} hard)`;
305
204
  }
@@ -350,10 +249,8 @@ async function timedFetch(url, headers = {}) {
350
249
  });
351
250
  if (!res.ok) {
352
251
  const err = new Error(`HTTP ${res.status}`);
353
- // The vendored retry classifier (vendor/blamejs/retry.js isRetryable)
354
- // keys off err.statusCode — set it so a 429/5xx from KEV/NVD/EPSS/OSV
355
- // routes through the job-queue backoff instead of being dropped on the
356
- // first hiccup. err.status kept for callers that read it for messaging.
252
+ // vendor/blamejs/retry.js isRetryable keys off err.statusCode, so a
253
+ // 429/5xx reaches the backoff; err.status is for callers that message.
357
254
  err.statusCode = res.status;
358
255
  err.status = res.status;
359
256
  throw err;
@@ -363,13 +260,8 @@ async function timedFetch(url, headers = {}) {
363
260
  const json = await res.json();
364
261
  return { json, etag, lastModified };
365
262
  } catch (e) {
366
- // A timeout surfaces as an AbortError with NO statusCode, which the retry
367
- // classifier would not retry — so under heavy upstream load (NVD rate
368
- // limiting + slow responses) timed-out fetches piled up as final errors and
369
- // pushed the total past --max-errors, failing the whole scheduled refresh.
370
- // Re-mark a timeout as a retryable network error (ETIMEDOUT) so the job
371
- // queue backs off and retries instead of dropping it on the first slow
372
- // response.
263
+ // An AbortError carries no statusCode and so would not be retried;
264
+ // re-marking a timeout as ETIMEDOUT keeps the queue backing off.
373
265
  if (timedOut || (e && (e.name === "AbortError" || e.code === "ABORT_ERR"))) {
374
266
  const te = new Error(`request timed out after ${REQUEST_TIMEOUT_MS}ms`);
375
267
  te.code = "ETIMEDOUT";
@@ -391,11 +283,8 @@ function loadIndex(cacheDir) {
391
283
  }
392
284
  }
393
285
 
394
- // v0.12.12 C4: atomic write helper — tmp + rename. Concurrent readers either
395
- // see the prior file in full or the new file in full, never a half-written
396
- // buffer. fs.renameSync is atomic on POSIX and on Windows for same-volume
397
- // renames; a `.tmp.<pid>.<rand>` sibling to the destination is always
398
- // same-volume.
286
+ // tmp + rename, so a reader sees either file whole. The `.tmp.<pid>.<rand>`
287
+ // sibling keeps the rename same-volume, which is what makes it atomic.
399
288
  function writeFileAtomic(p, body) {
400
289
  const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
401
290
  fs.writeFileSync(tmpPath, body);
@@ -407,15 +296,8 @@ function writeFileAtomic(p, body) {
407
296
  }
408
297
  }
409
298
 
410
- // v0.12.12 C2: lockfile-gated read-modify-write for _index.json. Two
411
- // concurrent prefetch runs against the same cache dir previously raced —
412
- // each loaded the index at start, mutated its in-memory copy as entries
413
- // fetched, then wrote at the end. The second writer overwrote the first,
414
- // silently dropping any entries the first run wrote.
415
- //
416
- // Stale-lock recovery: if a holder crashes without unlinking, the lockfile
417
- // persists. After backoff, if the lockfile's mtime is older than 30s we
418
- // treat it as orphaned and unlink it before retrying.
299
+ // Lockfile-gated read-modify-write for _index.json; unlocked, two concurrent
300
+ // runs lose one's entries. A lockfile older than 30s is reclaimed as orphaned.
419
301
  async function withIndexLock(cacheDir, mutator) {
420
302
  if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
421
303
  const lockPath = path.join(cacheDir, "_index.json.lock");
@@ -429,16 +311,11 @@ async function withIndexLock(cacheDir, mutator) {
429
311
  acquired = true;
430
312
  break;
431
313
  } catch (e) {
432
- // EEXIST is the POSIX signal another process holds the lock. On
433
- // Windows the same race surfaces as EPERM (a sharing-violation
434
- // raised when the other process is mid-unlink). Treat both as
435
- // "lock held, back off" rather than a fatal error.
314
+ // EEXIST is "lock held" on POSIX; the same race surfaces as EPERM on
315
+ // Windows, a sharing violation while the holder is mid-unlink.
436
316
  if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
437
- // PID-liveness check. Same pattern as withCatalogLock in
438
- // lib/refresh-external.js — read the lockfile's PID, probe with
439
- // process.kill(pid, 0); ESRCH → holder dead, reclaim immediately;
440
- // EPERM → holder alive (different user), keep waiting. The mtime
441
- // fallback below covers malformed / unreadable lockfiles.
317
+ // PID liveness, mirroring withCatalogLock in lib/refresh-external.js:
318
+ // ESRCH means the holder is dead, EPERM means alive under another user.
442
319
  let reclaimedByPid = false;
443
320
  try {
444
321
  const raw = fs.readFileSync(lockPath, "utf8").trim();
@@ -468,8 +345,7 @@ async function withIndexLock(cacheDir, mutator) {
468
345
  throw new Error(`withIndexLock: could not acquire ${lockPath} after ${MAX_RETRIES} attempts`);
469
346
  }
470
347
  try {
471
- // Always re-read the current on-disk index inside the lock. Stale
472
- // in-memory copies from before acquisition are the entire bug class.
348
+ // Re-read inside the lock: a copy loaded before acquisition is stale.
473
349
  let current;
474
350
  if (fs.existsSync(indexPath)) {
475
351
  try { current = JSON.parse(fs.readFileSync(indexPath, "utf8")); }
@@ -486,10 +362,8 @@ async function withIndexLock(cacheDir, mutator) {
486
362
  }
487
363
  }
488
364
 
489
- // Back-compat: existing callers used saveIndex(cacheDir, idx). The thin
490
- // wrapper merges entries under the lock so a concurrent run's writes are
491
- // preserved (rather than blindly overwriting them with the caller's
492
- // possibly-stale in-memory `idx`).
365
+ // Merges `idx.entries` into the on-disk index under the lock, so a concurrent
366
+ // run's writes survive the caller's possibly-stale in-memory snapshot.
493
367
  async function saveIndex(cacheDir, idx) {
494
368
  await withIndexLock(cacheDir, (current) => {
495
369
  const mergedEntries = { ...current.entries, ...idx.entries };
@@ -500,12 +374,9 @@ async function saveIndex(cacheDir, idx) {
500
374
  });
501
375
  }
502
376
 
503
- // Canonical bytes for _index.json signing. Mirrors the manifest-signing
504
- // contract (lib/sign.js + lib/verify.js canonicalManifestBytes): deep-sort
505
- // keys, JSON.stringify with no formatting overhead the verifier can drift
506
- // against. Any change here must be mirrored in verifyIndexSignature() below.
507
- // The signature covers the index AS PERSISTED — `index_signature` is
508
- // excluded from the canonical bytes (the signature cannot sign itself).
377
+ // Canonical bytes for _index.json signing, mirroring canonicalManifestBytes in
378
+ // lib/sign.js: deep-sorted keys, no formatting to drift against, and
379
+ // `index_signature` excluded. verifyIndexSignature below must match any change.
509
380
  function canonicalizeIndex(value) {
510
381
  if (Array.isArray(value)) return value.map(canonicalizeIndex);
511
382
  if (value && typeof value === "object") {
@@ -523,15 +394,8 @@ function canonicalIndexBytes(idx) {
523
394
  return Buffer.from(JSON.stringify(canonicalizeIndex(clone)), "utf8");
524
395
  }
525
396
 
526
- // Sign _index.json with the Ed25519 private key (.keys/private.pem). The
527
- // signature is written as a sidecar `_index.json.sig` containing
528
- // { algorithm: "Ed25519", signature_base64, signed_at }. readCachedJson /
529
- // loadCtx --from-cache verify this against keys/public.pem.
530
- //
531
- // Behavior on missing private key: emit a warning and return; the cache is
532
- // left unsigned. Operators on connected hosts where prefetch runs without
533
- // the maintainer keypair will see this warning. The verify side treats a
534
- // missing sidecar as "unsigned cache" and refuses unless --force-stale.
397
+ // Signs _index.json with .keys/private.pem into the sidecar `_index.json.sig`.
398
+ // With no private key present it warns and returns { signed: false }.
535
399
  function signIndex(cacheDir) {
536
400
  const privPath = path.join(ROOT, ".keys", "private.pem");
537
401
  if (!fs.existsSync(privPath)) {
@@ -557,10 +421,8 @@ function signIndex(cacheDir) {
557
421
  return { signed: true };
558
422
  }
559
423
 
560
- // Verify _index.json against its sidecar signature using keys/public.pem.
561
- // Returns { status: "valid" | "missing" | "invalid", reason? }. Callers
562
- // decide policy: typically refuse unless --force-stale on "missing" /
563
- // "invalid".
424
+ // Returns { status: "valid" | "missing" | "invalid", reason? } for _index.json
425
+ // against its sidecar and keys/public.pem. Callers set policy.
564
426
  function verifyIndexSignature(cacheDir) {
565
427
  const indexPath = path.join(cacheDir, "_index.json");
566
428
  const sigPath = path.join(cacheDir, "_index.json.sig");
@@ -575,13 +437,9 @@ function verifyIndexSignature(cacheDir) {
575
437
  const pubPath = path.join(ROOT, "keys", "public.pem");
576
438
  if (!fs.existsSync(pubPath)) return { status: "invalid", reason: "keys/public.pem absent — cannot verify cache signature" };
577
439
  const pubPem = fs.readFileSync(pubPath, "utf8");
578
- // Consult keys/EXPECTED_FINGERPRINT BEFORE crypto.verify, the same external
579
- // trust anchor every other signature-verifying ingest site enforces. A
580
- // host-local keys/public.pem swap paired with an attacker-signed
581
- // _index.json.sig would otherwise authenticate against the attacker's own
582
- // key (the signature verifies against whatever public.pem is present). The
583
- // pin is the off-host anchor that closes that gap; honor KEYS_ROTATED=1 for
584
- // legitimate rotations and warn-and-continue when no pin file is present.
440
+ // The pin is checked BEFORE crypto.verify: a swapped host-local public.pem
441
+ // paired with an attacker-signed sidecar verifies against the attacker's own
442
+ // key. KEYS_ROTATED=1 permits a legitimate rotation.
585
443
  try {
586
444
  const { publicKeyFingerprint, checkExpectedFingerprint } = require("./verify.js");
587
445
  const pinResult = checkExpectedFingerprint(publicKeyFingerprint(pubPem));
@@ -592,10 +450,8 @@ function verifyIndexSignature(cacheDir) {
592
450
  };
593
451
  }
594
452
  } catch {
595
- // verify.js unavailable (partial install). The caller (loadCtx) already
596
- // treats a verifier-unavailable signature path as a hard refusal unless
597
- // --force-stale, so falling through to the signature check below keeps
598
- // behavior no weaker than before the pin was added.
453
+ // verify.js unavailable (partial install): the signature check below runs
454
+ // unpinned rather than refusing here.
599
455
  }
600
456
  const idx = JSON.parse(fs.readFileSync(indexPath, "utf8"));
601
457
  const bytes = canonicalIndexBytes(idx);
@@ -614,7 +470,6 @@ function entryKey(source, id) {
614
470
  }
615
471
 
616
472
  function entryPath(cacheDir, source, id) {
617
- // Sanitize id for filesystem.
618
473
  const safe = id.replace(/[^A-Za-z0-9._-]/g, "_");
619
474
  return path.join(cacheDir, source, `${safe}.json`);
620
475
  }
@@ -624,25 +479,16 @@ function isFresh(idx, source, id, maxAgeMs) {
624
479
  if (!e) return false;
625
480
  if (!e.fetched_at) return false;
626
481
  const ageMs = Date.now() - new Date(e.fetched_at).getTime();
627
- // A non-finite or negative age means the entry's provenance is untrustworthy:
628
- // an unparseable fetched_at, or a future-dated one (clock skew or a poisoned
629
- // index inflating apparent freshness past the maxAge gate). Either way, treat
630
- // it as stale and force a re-fetch — re-fetching restores trustworthy
631
- // provenance. This mirrors readCached()'s lower-bound guard so the planning
632
- // side and read side cannot diverge on the same poisoned entry.
482
+ // A non-finite or negative age is untrustworthy provenance — an unparseable
483
+ // or future-dated fetched_at — so re-fetch. readCached's guard must match.
633
484
  if (!Number.isFinite(ageMs) || ageMs < 0) return false;
634
485
  return ageMs < maxAgeMs;
635
486
  }
636
487
 
637
488
  function authHeadersForSource(source) {
638
489
  if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
639
- // The registered source name for MITRE GitHub releases is `pins`
640
- // (see SOURCES above). Accept both `pins` and `github` so GITHUB_TOKEN
641
- // reaches the per-request Authorization header regardless of which
642
- // spelling the operator's automation uses; without this, anonymous
643
- // rate-limited fetches happen even when a token is configured. Be
644
- // forgiving of
645
- // the historical naming and the registered name.
490
+ // `pins` is the registered source name; `github` is accepted too, so
491
+ // GITHUB_TOKEN reaches the header whichever spelling an operator uses.
646
492
  if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
647
493
  return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
648
494
  }
@@ -651,20 +497,11 @@ function authHeadersForSource(source) {
651
497
 
652
498
  async function prefetch(options = {}) {
653
499
  const opts = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, ...options };
654
- // Honor the global air-gap switch for programmatic callers too. parseArgs
655
- // applies EXCEPTD_AIR_GAP for the CLI path, but a direct prefetch({...}) call
656
- // bypasses parseArgs — so without this guard an air-gapped host that imports
657
- // and calls prefetch() would egress live. Bind it here, at the function that
658
- // actually issues the fetches, covering both the CLI and exported-API callers.
500
+ // Bound here, at the function that issues the fetches, so a direct
501
+ // prefetch({...}) call bypassing parseArgs still cannot egress under air-gap.
659
502
  if (process.env.EXCEPTD_AIR_GAP === "1" || opts.airGap) opts.noNetwork = true;
660
503
  const ctx = loadCtx();
661
- // Distinguish "operator omitted --source" (resolve to all sources, the
662
- // documented default) from "operator passed --source but it resolved to
663
- // nothing" (empty string or a comma-only value). The latter is a usage
664
- // error, not a silent run-everything / run-nothing: an empty value would
665
- // otherwise warm ALL sources and a comma-only value would warm NONE, both
666
- // reporting success. Refuse so the typo surfaces. (main() maps the throw to
667
- // exit 2, matching the existing unknown-source contract.)
504
+ // An omitted --source warms every source; a supplied-but-empty one throws.
668
505
  const sourceSupplied = opts.source != null;
669
506
  const chosen = sourceSupplied
670
507
  ? opts.source.split(",").map((s) => s.trim()).filter(Boolean)
@@ -674,11 +511,6 @@ async function prefetch(options = {}) {
674
511
  }
675
512
  for (const n of chosen) {
676
513
  if (!SOURCES[n]) {
677
- // The refresh orchestrator exposes additional sources (ghsa, osv,
678
- // advisories, cve-regression-watcher) that resolve advisories by live
679
- // id lookup and have no prefetch cache layer. When the operator scopes
680
- // a cache-warm to one of those, name the prefetchable subset rather than
681
- // a bare "unknown source" — the source is real, it just isn't cacheable.
682
514
  if (LIVE_ONLY_REFRESH_SOURCES.has(n)) {
683
515
  throw new Error(`prefetch: source "${n}" has no prefetch cache layer (live id lookup only); prefetchable sources: ${Object.keys(SOURCES).join(",")}`);
684
516
  }
@@ -686,8 +518,7 @@ async function prefetch(options = {}) {
686
518
  }
687
519
  }
688
520
 
689
- // Build the queue with per-source budgets. NVD / GitHub upgrade if env-key
690
- // is present.
521
+ // Per-source budgets; NVD and GitHub lift theirs when an env key is present.
691
522
  const sources = {};
692
523
  for (const n of chosen) {
693
524
  const cfg = SOURCES[n];
@@ -706,21 +537,9 @@ async function prefetch(options = {}) {
706
537
  const result = { fetched: 0, skipped_fresh: 0, errors: 0, errors_transient: 0, errors_hard: 0, by_source: {} };
707
538
  for (const s of chosen) result.by_source[s] = { fetched: 0, skipped_fresh: 0, errors: 0, errors_transient: 0, errors_hard: 0 };
708
539
 
709
- // Fetch (or fresh-skip) a single plan item: on a live fetch, writes the
710
- // payload + updates _index.json under lock and mirrors it into the
711
- // in-memory `idx` snapshot; on a fresh-skip, just counts it. Updates
712
- // `result` bookkeeping identically either way. Factored out of the main
713
- // per-item loop below so the KEV pre-fetch step (Task 11, next) can reuse
714
- // the exact same fetch/write/index/error-classification contract instead
715
- // of duplicating it.
716
- //
717
- // Returns the entry's parsed JSON body when `needData` is true — a
718
- // freshly-fetched item returns it for free (already in memory from the
719
- // fetch); a fresh-skipped item costs one extra cache read via
720
- // `readCached`, so `needData` defaults to false and the main plan loop
721
- // (hundreds to ~9.7k items on the Monday RFC sweep) never pays it. Only
722
- // the one-off KEV pre-fetch below opts in. Returns null on error, or when
723
- // `needData` is false.
540
+ // Fetch (or fresh-skip) one plan item, writing the payload and its index
541
+ // entry under lock. Returns the parsed body when `needData` is true, null
542
+ // otherwise and on error — a fresh-skip pays an extra cache read to honour it.
724
543
  async function fetchEntry(item, { needData = false } = {}) {
725
544
  if (item.fresh) {
726
545
  result.skipped_fresh++;
@@ -743,17 +562,9 @@ async function prefetch(options = {}) {
743
562
  const dir = path.dirname(targetPath);
744
563
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
745
564
  const body = JSON.stringify(res.json, null, 2) + "\n";
746
- // Stage the payload to a same-volume tmp file BEFORE attempting
747
- // to acquire the index lock. If withIndexLock fails (timeout
748
- // after MAX_RETRIES), the partially-completed download must be
749
- // discarded — not left on disk as an orphan payload with no
750
- // index entry. Air-gap operators feed off `readCached`, which
751
- // consults the index; an unindexed payload silently becomes junk
752
- // taking cache space. Pattern: stage → lock → rename+index →
753
- // release. The rename is atomic same-volume; if it fails inside
754
- // the lock we clean up the tmp file. If we never reach the rename
755
- // (lock acquisition throws), the tmp file is unlinked in the
756
- // catch block below.
565
+ // Stage → lock → rename+index → release. Staging before the lock means a
566
+ // lock timeout discards the payload rather than orphaning it outside the
567
+ // index, where `readCached` could never see it.
757
568
  const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
758
569
  fs.writeFileSync(tmpPath, body);
759
570
  const meta = {
@@ -764,32 +575,21 @@ async function prefetch(options = {}) {
764
575
  sha256: crypto.createHash("sha256").update(JSON.stringify(res.json)).digest("hex"),
765
576
  };
766
577
  try {
767
- // v0.12.12 C2: persist this entry's metadata to _index.json under
768
- // lock immediately, merging with whatever the on-disk index has
769
- // (another concurrent prefetch may have written sibling entries).
770
- // Inside the lock we also rename the staged tmp → final path so
771
- // a concurrent reader sees the new payload + new index entry as
772
- // an atomic pair.
578
+ // The rename is inside the lock: payload and index entry land as a pair.
773
579
  await withIndexLock(opts.cacheDir, (current) => {
774
580
  try {
775
581
  fs.renameSync(tmpPath, targetPath);
776
582
  } catch (renameErr) {
777
- // Surface as a failure to mutator: throwing here aborts the
778
- // lock's write step. We re-throw to the outer catch which
779
- // will increment errors.
583
+ // Throwing aborts the lock's write step; the outer catch counts it.
780
584
  throw renameErr;
781
585
  }
782
586
  current.entries[entryKey(item.source, item.id)] = meta;
783
587
  return current;
784
588
  });
785
- // Mirror the entry into the in-memory idx snapshot so any
786
- // later in-run freshness check sees this entry as fresh. The
787
- // authoritative on-disk write already happened under the lock
788
- // above; this is the in-memory copy only.
589
+ // In-memory mirror so a later in-run freshness check sees this entry.
789
590
  idx.entries[entryKey(item.source, item.id)] = meta;
790
591
  } catch (lockErr) {
791
- // Lock failure OR rename-inside-lock failure — unlink the staged
792
- // tmp so the cache directory does not accumulate orphans.
592
+ // Lock or rename failure: unlink the staged tmp so no orphan is left.
793
593
  try { fs.unlinkSync(tmpPath); } catch {}
794
594
  throw lockErr;
795
595
  }
@@ -800,13 +600,7 @@ async function prefetch(options = {}) {
800
600
  } catch (err) {
801
601
  result.errors++;
802
602
  result.by_source[item.source].errors++;
803
- // Classify the post-retry error. Transient iff the job queue would
804
- // have retried it (the same isRetryable classifier the queue used):
805
- // HTTP 408/425/429/5xx + ETIMEDOUT/ECONNRESET et al — the upstream
806
- // throttling/timing-out, not a data fault. Anything else (404/410/
807
- // parse failure) is hard. Only hard errors gate the exit code; a
808
- // best-effort warm tolerates transient throttling and retries it on
809
- // the next run. The split is surfaced in the summary so nothing hides.
603
+ // Transient iff the queue's own isRetryable classifier says so.
810
604
  const transient = isRetryable(err);
811
605
  if (transient) {
812
606
  result.errors_transient++;
@@ -815,29 +609,16 @@ async function prefetch(options = {}) {
815
609
  result.errors_hard++;
816
610
  result.by_source[item.source].errors_hard++;
817
611
  }
818
- // Errors go to stderr unconditionally — they are diagnostics, not the
819
- // per-entry success chatter --quiet suppresses. A CI run with --quiet
820
- // still surfaces which source/id failed and whether it was transient.
612
+ // stderr unconditionally: --quiet suppresses success chatter, not
613
+ // diagnostics.
821
614
  console.error(` [${item.source}] ${item.id} — ${transient ? "transient" : "hard"} error: ${err.message}`);
822
615
  return null;
823
616
  }
824
617
  }
825
618
 
826
- // Task 11: resolve the KEV feed BEFORE nvd/epss build their expansion
827
- // list, so a CVE CISA added to KEV today gets an NVD/EPSS sidecar
828
- // prefetched in THIS run. Reading a previously-persisted cache file would
829
- // not do this reliably — the scheduled workflow's cache directory is not
830
- // persisted across runs (each job warms an empty `.cache/upstream` from
831
- // scratch), so by the time a later run's plan is built, "today's" KEV
832
- // addition would already be a day old. Fetching (or fresh-skipping) KEV
833
- // synchronously here, before the main plan is built, guarantees ctx.kevFeed
834
- // reflects this run's own KEV data.
835
- //
836
- // Only fires when "kev" is actually in scope and network fetches are
837
- // enabled. Under --no-network (dry-run/--air-gap) or a --source scope that
838
- // excludes kev, ctx.kevFeed stays unset and nvd/epss's expand() falls back
839
- // to catalog-only expansion via newKevIds' null-safe default — no crash,
840
- // and no behavior change from before this task.
619
+ // KEV resolves BEFORE nvd/epss build their expansion lists, so a CVE added
620
+ // today gets its sidecar warmed in THIS run. Skipped when kev is out of scope
621
+ // or the network is off; ctx.kevFeed then stays unset.
841
622
  let kevPrefetched = false;
842
623
  if (chosen.includes("kev") && !opts.noNetwork) {
843
624
  const [kevEntry] = SOURCES.kev.expand();
@@ -874,8 +655,7 @@ async function prefetch(options = {}) {
874
655
  result.by_source[item.source].skipped_fresh++;
875
656
  }
876
657
  }
877
- // Unconditional one-line summary (--quiet preserved on per-entry chatter
878
- // but operator still needs confirmation the dry-run completed).
658
+ // Unconditional: --quiet drops per-entry chatter, not the run's outcome.
879
659
  const stale = plan.length - result.skipped_fresh;
880
660
  console.log(`prefetch summary: 0 fetched, ${result.skipped_fresh} fresh, ${stale} would-fetch (dry-run)`);
881
661
  return result;
@@ -885,35 +665,20 @@ async function prefetch(options = {}) {
885
665
 
886
666
  await Promise.all(jobPromises);
887
667
  await queue.drain();
888
- // Each fetched entry was already persisted to the on-disk index under
889
- // lock during the run (the per-entry withIndexLock above), so the final
890
- // write only needs to stamp generated_at. Re-merging the whole
891
- // start-of-run `idx` snapshot here would RESURRECT entries a concurrent
892
- // run pruned between our snapshot and now — partially defeating the
893
- // concurrency fix the per-entry lock provides. Bump generated_at on the
894
- // CURRENT on-disk index under lock instead, touching nothing else.
668
+ // Stamps generated_at on the CURRENT on-disk index and nothing else:
669
+ // re-merging the start-of-run `idx` snapshot would resurrect entries a
670
+ // concurrent run pruned.
895
671
  await withIndexLock(opts.cacheDir, (current) => {
896
672
  current.generated_at = new Date().toISOString();
897
673
  return current;
898
674
  });
899
675
 
900
- // Sign the freshly-written _index.json with the Ed25519 private key
901
- // (.keys/private.pem). The signature is a sidecar `_index.json.sig`;
902
- // consumers reading via --from-cache verify it against keys/public.pem
903
- // before trusting any entry. If the private key is absent (typical on
904
- // operator-side prefetch runs where the maintainer keypair isn't
905
- // present), signIndex() warns-and-returns and the cache is left
906
- // unsigned — downstream verify will then refuse it without --force-stale.
907
676
  try {
908
677
  signIndex(opts.cacheDir);
909
678
  } catch (err) {
910
679
  console.warn(`[prefetch] WARN: _index.json signing failed: ${err && err.message}; cache left unsigned.`);
911
680
  }
912
681
 
913
- // Final summary is unconditional — --quiet suppresses per-entry chatter
914
- // (the noisy part) but the operator still needs one line confirming success.
915
- // Without this, --quiet + --no-network was zero output even on dry-run
916
- // success, leaving operators unsure if the command had run at all.
917
682
  console.log(formatSummary(result, { noNetwork: opts.noNetwork }));
918
683
  return result;
919
684
  }
@@ -931,35 +696,17 @@ function loadCtx() {
931
696
  };
932
697
  }
933
698
 
934
- // --- Cache-read helpers (consumed by validate-cves / validate-rfcs / refresh)
935
-
936
- /**
937
- * Read a cached entry, returning `null` if absent or stale.
938
- *
939
- * @param {string} cacheDir cache root
940
- * @param {string} source "kev" | "nvd" | "epss" | "ietf" | "github"
941
- * @param {string} id entry id (CVE-id, doc-name, etc.)
942
- * @param {object} opts { maxAgeMs?: number; allowStale?: boolean }
943
- * defaults: 24h fresh, allowStale=false
944
- * @returns {{ data: object, age_ms: number, meta: object } | null}
945
- */
699
+ // Returns { data, age_ms, meta } for a cached entry, or null when it is absent
700
+ // or stale. Defaults: 24h freshness, allowStale=false.
946
701
  function readCached(cacheDir, source, id, opts = {}) {
947
702
  const maxAgeMs = opts.maxAgeMs ?? 24 * 3600 * 1000;
948
703
  const idx = loadIndex(cacheDir);
949
704
  const meta = idx.entries[entryKey(source, id)];
950
705
  if (!meta) return null;
951
- // When `fetched_at` is missing / non-string / unparseable,
952
- // `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false,
953
- // so the cached entry would have been returned as if fresh. Treat any
954
- // non-finite age as "no provenance, refuse" unless the caller explicitly
955
- // opted into allowStale.
706
+ // `NaN > maxAgeMs` is false, so an unparseable fetched_at would read as
707
+ // fresh. A non-finite age is no provenance: refuse unless allowStale.
956
708
  const ageMs = meta.fetched_at ? Date.now() - new Date(meta.fetched_at).getTime() : NaN;
957
- // Future-dated `fetched_at` (ageMs < 0) is a poisoning signal: either the
958
- // host clock jumped backwards mid-fetch, or an attacker rewrote the index
959
- // to inflate apparent freshness past the maxAge gate. Either way the
960
- // entry's provenance is no longer trustworthy. Treat as missing — refuse
961
- // even when allowStale is set, because allowStale loosens the upper bound,
962
- // not the lower one.
709
+ // A future-dated fetched_at is refused even under allowStale.
963
710
  if (Number.isFinite(ageMs) && ageMs < 0) return null;
964
711
  if (!opts.allowStale) {
965
712
  if (!meta.fetched_at || !Number.isFinite(ageMs)) return null;
@@ -975,8 +722,7 @@ function readCached(cacheDir, source, id, opts = {}) {
975
722
  }
976
723
  }
977
724
 
978
- // Known --flag base names prefetch accepts. Drives the unknown-flag error
979
- // message's known list.
725
+ // --flag base names prefetch accepts; drives the unknown-flag error message.
980
726
  const PREFETCH_KNOWN_FLAGS = Object.freeze([
981
727
  "--force", "--no-network", "--dry-run", "--air-gap", "--quiet", "--help", "-h",
982
728
  "--source", "--max-age", "--cache-dir", "--max-errors",
@@ -989,9 +735,7 @@ async function main() {
989
735
  return;
990
736
  }
991
737
 
992
- // A malformed --max-errors value is a usage error — refuse with exit 2
993
- // (prefetch's usage-error convention) rather than running with an
994
- // unintended tolerance.
738
+ // Usage errors exit 2 rather than run with an unintended scope or tolerance.
995
739
  if (opts._argError) {
996
740
  process.stderr.write(JSON.stringify({
997
741
  ok: false,
@@ -1002,10 +746,8 @@ async function main() {
1002
746
  return;
1003
747
  }
1004
748
 
1005
- // Reject unknown flags BEFORE any network work. A swallowed typo (e.g.
1006
- // `--max-aeg 12h`) previously fell through to a default full-cache fetch.
1007
- // Exit 2 matches prefetch's existing usage-error convention (invalid
1008
- // --source / --max-age also surface as exit 2 via main()'s catch).
749
+ // Unknown flags are rejected before any network work: a swallowed typo like
750
+ // `--max-aeg 12h` would otherwise fall through to a full-cache fetch.
1009
751
  if (Array.isArray(opts._unknownFlags) && opts._unknownFlags.length > 0) {
1010
752
  const uniq = [...new Set(opts._unknownFlags)];
1011
753
  process.stderr.write(JSON.stringify({
@@ -1018,18 +760,9 @@ async function main() {
1018
760
  process.exitCode = 2;
1019
761
  return;
1020
762
  }
1021
- // Why process.exitCode and not process.exit():
1022
- // On Windows + Node 25 (libuv), calling process.exit() synchronously
1023
- // while in-flight fetch / AbortController teardown is still mid-close
1024
- // produced `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING),
1025
- // file src\win\async.c, line 76` followed by exit 3221226505
1026
- // (STATUS_STACK_BUFFER_OVERRUN). The summary line had already
1027
- // flushed, so operators saw the crash *after* their summary —
1028
- // contractually correct but visibly noisy. Letting the event loop
1029
- // drain naturally — via exitCode + return — lets undici's connection
1030
- // pool and the AbortController signal listeners finish teardown
1031
- // before the process exits, eliminating the assertion. Same pattern as
1032
- // the `ci` #100 stdout-flush regression.
763
+ // `process.exitCode` + return, never process.exit(): exiting while undici's
764
+ // pool and the AbortController listeners tear down trips a libuv assertion
765
+ // on Windows.
1033
766
  try {
1034
767
  const result = await prefetch(opts);
1035
768
  process.exitCode = exitCodeForResult(result, opts);
@@ -1050,21 +783,12 @@ module.exports = {
1050
783
  formatSummary,
1051
784
  SOURCES,
1052
785
  DEFAULT_CACHE,
1053
- // Task 11: pure helper (KEV feed minus local catalog) consumed by
1054
- // nvd/epss expand() above. Exported for direct unit testing and so other
1055
- // callers (e.g. a future report-only "what's new in KEV" surface) don't
1056
- // have to re-derive the same set.
1057
786
  newKevIds,
1058
- // Ed25519 _index.json signing + verification. Exported so
1059
- // lib/refresh-external.js (which consumes --from-cache) can verify the
1060
- // sidecar before trusting any cached entry, and so test harnesses can
1061
- // exercise the signing path without running the full prefetch pipeline.
787
+ // Exported for lib/refresh-external.js, which verifies the sidecar before
788
+ // trusting any --from-cache entry.
1062
789
  signIndex,
1063
790
  verifyIndexSignature,
1064
791
  canonicalIndexBytes,
1065
- // v0.12.12 C2: exported for the concurrent-writer regression test.
1066
- // Not part of the operator-facing API — internal contract for tests
1067
- // that need to exercise the lockfile path without spawning the full
1068
- // prefetch network pipeline.
792
+ // Test-only access; not operator-facing API.
1069
793
  _internal: { withIndexLock, writeFileAtomic, loadIndex, saveIndex, timedFetch },
1070
794
  };