@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.
- package/CHANGELOG.md +10 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +241 -241
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
package/lib/prefetch.js
CHANGED
|
@@ -1,39 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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
|
|
50
|
-
//
|
|
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
|
-
//
|
|
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
|
|
120
|
-
//
|
|
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
|
-
//
|
|
138
|
-
//
|
|
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
|
-
//
|
|
153
|
-
//
|
|
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
|
|
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
|
|
180
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
200
|
-
//
|
|
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
|
-
//
|
|
222
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
250
|
-
// errors
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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
|
|
269
|
-
//
|
|
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
|
|
282
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
354
|
-
//
|
|
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
|
-
//
|
|
367
|
-
//
|
|
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
|
-
//
|
|
395
|
-
//
|
|
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
|
-
//
|
|
411
|
-
//
|
|
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
|
|
433
|
-
// Windows
|
|
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
|
|
438
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
490
|
-
//
|
|
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
|
|
504
|
-
//
|
|
505
|
-
//
|
|
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
|
-
//
|
|
527
|
-
//
|
|
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
|
-
//
|
|
561
|
-
//
|
|
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
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
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)
|
|
596
|
-
//
|
|
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
|
|
628
|
-
//
|
|
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
|
-
//
|
|
640
|
-
//
|
|
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
|
-
//
|
|
655
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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)
|
|
710
|
-
//
|
|
711
|
-
//
|
|
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
|
|
747
|
-
//
|
|
748
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
819
|
-
//
|
|
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
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
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
|
|
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
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
952
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1006
|
-
// `--max-aeg 12h`
|
|
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
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
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
|
-
//
|
|
1059
|
-
//
|
|
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
|
-
//
|
|
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
|
};
|