@blamejs/exceptd-skills 0.9.1

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 (136) hide show
  1. package/AGENTS.md +232 -0
  2. package/ARCHITECTURE.md +267 -0
  3. package/CHANGELOG.md +616 -0
  4. package/CONTEXT.md +203 -0
  5. package/LICENSE +200 -0
  6. package/NOTICE +82 -0
  7. package/README.md +307 -0
  8. package/SECURITY.md +73 -0
  9. package/agents/README.md +81 -0
  10. package/agents/report-generator.md +156 -0
  11. package/agents/skill-updater.md +102 -0
  12. package/agents/source-validator.md +119 -0
  13. package/agents/threat-researcher.md +149 -0
  14. package/bin/exceptd.js +183 -0
  15. package/data/_indexes/_meta.json +88 -0
  16. package/data/_indexes/activity-feed.json +362 -0
  17. package/data/_indexes/catalog-summaries.json +229 -0
  18. package/data/_indexes/chains.json +7135 -0
  19. package/data/_indexes/currency.json +359 -0
  20. package/data/_indexes/did-ladders.json +451 -0
  21. package/data/_indexes/frequency.json +2072 -0
  22. package/data/_indexes/handoff-dag.json +476 -0
  23. package/data/_indexes/jurisdiction-clocks.json +967 -0
  24. package/data/_indexes/jurisdiction-map.json +536 -0
  25. package/data/_indexes/recipes.json +319 -0
  26. package/data/_indexes/section-offsets.json +3656 -0
  27. package/data/_indexes/stale-content.json +14 -0
  28. package/data/_indexes/summary-cards.json +1736 -0
  29. package/data/_indexes/theater-fingerprints.json +381 -0
  30. package/data/_indexes/token-budget.json +2137 -0
  31. package/data/_indexes/trigger-table.json +1374 -0
  32. package/data/_indexes/xref.json +818 -0
  33. package/data/atlas-ttps.json +282 -0
  34. package/data/cve-catalog.json +496 -0
  35. package/data/cwe-catalog.json +1017 -0
  36. package/data/d3fend-catalog.json +738 -0
  37. package/data/dlp-controls.json +1039 -0
  38. package/data/exploit-availability.json +67 -0
  39. package/data/framework-control-gaps.json +1255 -0
  40. package/data/global-frameworks.json +2913 -0
  41. package/data/rfc-references.json +324 -0
  42. package/data/zeroday-lessons.json +377 -0
  43. package/keys/public.pem +3 -0
  44. package/lib/framework-gap.js +328 -0
  45. package/lib/job-queue.js +195 -0
  46. package/lib/lint-skills.js +536 -0
  47. package/lib/prefetch.js +372 -0
  48. package/lib/refresh-external.js +713 -0
  49. package/lib/schemas/cve-catalog.schema.json +151 -0
  50. package/lib/schemas/manifest.schema.json +106 -0
  51. package/lib/schemas/skill-frontmatter.schema.json +113 -0
  52. package/lib/scoring.js +149 -0
  53. package/lib/sign.js +197 -0
  54. package/lib/ttp-mapper.js +80 -0
  55. package/lib/validate-catalog-meta.js +198 -0
  56. package/lib/validate-cve-catalog.js +213 -0
  57. package/lib/validate-indexes.js +83 -0
  58. package/lib/validate-package.js +162 -0
  59. package/lib/validate-vendor.js +85 -0
  60. package/lib/verify.js +216 -0
  61. package/lib/worker-pool.js +84 -0
  62. package/manifest-snapshot.json +1833 -0
  63. package/manifest.json +2108 -0
  64. package/orchestrator/README.md +124 -0
  65. package/orchestrator/dispatcher.js +140 -0
  66. package/orchestrator/event-bus.js +146 -0
  67. package/orchestrator/index.js +874 -0
  68. package/orchestrator/pipeline.js +201 -0
  69. package/orchestrator/scanner.js +327 -0
  70. package/orchestrator/scheduler.js +137 -0
  71. package/package.json +113 -0
  72. package/sbom.cdx.json +158 -0
  73. package/scripts/audit-cross-skill.js +261 -0
  74. package/scripts/audit-perf.js +160 -0
  75. package/scripts/bootstrap.js +205 -0
  76. package/scripts/build-indexes.js +721 -0
  77. package/scripts/builders/activity-feed.js +79 -0
  78. package/scripts/builders/catalog-summaries.js +67 -0
  79. package/scripts/builders/currency.js +109 -0
  80. package/scripts/builders/cwe-chains.js +105 -0
  81. package/scripts/builders/did-ladders.js +149 -0
  82. package/scripts/builders/frequency.js +89 -0
  83. package/scripts/builders/jurisdiction-clocks.js +126 -0
  84. package/scripts/builders/recipes.js +159 -0
  85. package/scripts/builders/section-offsets.js +162 -0
  86. package/scripts/builders/stale-content.js +171 -0
  87. package/scripts/builders/summary-cards.js +166 -0
  88. package/scripts/builders/theater-fingerprints.js +198 -0
  89. package/scripts/builders/token-budget.js +96 -0
  90. package/scripts/check-manifest-snapshot.js +217 -0
  91. package/scripts/predeploy.js +267 -0
  92. package/scripts/refresh-manifest-snapshot.js +57 -0
  93. package/scripts/refresh-sbom.js +222 -0
  94. package/skills/age-gates-child-safety/skill.md +456 -0
  95. package/skills/ai-attack-surface/skill.md +282 -0
  96. package/skills/ai-c2-detection/skill.md +440 -0
  97. package/skills/ai-risk-management/skill.md +311 -0
  98. package/skills/api-security/skill.md +287 -0
  99. package/skills/attack-surface-pentest/skill.md +381 -0
  100. package/skills/cloud-security/skill.md +384 -0
  101. package/skills/compliance-theater/skill.md +365 -0
  102. package/skills/container-runtime-security/skill.md +379 -0
  103. package/skills/coordinated-vuln-disclosure/skill.md +473 -0
  104. package/skills/defensive-countermeasure-mapping/skill.md +300 -0
  105. package/skills/dlp-gap-analysis/skill.md +337 -0
  106. package/skills/email-security-anti-phishing/skill.md +206 -0
  107. package/skills/exploit-scoring/skill.md +331 -0
  108. package/skills/framework-gap-analysis/skill.md +374 -0
  109. package/skills/fuzz-testing-strategy/skill.md +313 -0
  110. package/skills/global-grc/skill.md +564 -0
  111. package/skills/identity-assurance/skill.md +272 -0
  112. package/skills/incident-response-playbook/skill.md +546 -0
  113. package/skills/kernel-lpe-triage/skill.md +303 -0
  114. package/skills/mcp-agent-trust/skill.md +326 -0
  115. package/skills/mlops-security/skill.md +325 -0
  116. package/skills/ot-ics-security/skill.md +340 -0
  117. package/skills/policy-exception-gen/skill.md +437 -0
  118. package/skills/pqc-first/skill.md +546 -0
  119. package/skills/rag-pipeline-security/skill.md +294 -0
  120. package/skills/researcher/skill.md +310 -0
  121. package/skills/sector-energy/skill.md +409 -0
  122. package/skills/sector-federal-government/skill.md +302 -0
  123. package/skills/sector-financial/skill.md +398 -0
  124. package/skills/sector-healthcare/skill.md +373 -0
  125. package/skills/security-maturity-tiers/skill.md +464 -0
  126. package/skills/skill-update-loop/skill.md +463 -0
  127. package/skills/supply-chain-integrity/skill.md +318 -0
  128. package/skills/threat-model-currency/skill.md +404 -0
  129. package/skills/threat-modeling-methodology/skill.md +312 -0
  130. package/skills/webapp-security/skill.md +281 -0
  131. package/skills/zeroday-gap-learn/skill.md +350 -0
  132. package/vendor/blamejs/LICENSE +201 -0
  133. package/vendor/blamejs/README.md +54 -0
  134. package/vendor/blamejs/_PROVENANCE.json +54 -0
  135. package/vendor/blamejs/retry.js +335 -0
  136. package/vendor/blamejs/worker-pool.js +418 -0
@@ -0,0 +1,372 @@
1
+ "use strict";
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
+ * ietf/<doc-name>.json — IETF Datatracker doc record
19
+ * github/<owner>__<repo>__releases.json — releases listing
20
+ *
21
+ * Usage:
22
+ * node lib/prefetch.js # fetch everything not fresh
23
+ * node lib/prefetch.js --max-age 12h # re-fetch entries older than 12h
24
+ * node lib/prefetch.js --source kev,nvd # scope by source
25
+ * node lib/prefetch.js --force # ignore freshness, refetch all
26
+ * node lib/prefetch.js --no-network # report-only: list what would be fetched
27
+ *
28
+ * Every fetch routes through lib/job-queue.js so per-source rate budgets
29
+ * (NVD 5 req/30s anon, GitHub 60/h anon, etc.) are respected.
30
+ *
31
+ * Zero npm deps. Node 24 stdlib only.
32
+ */
33
+
34
+ const fs = require("fs");
35
+ const path = require("path");
36
+ const crypto = require("crypto");
37
+ const { JobQueue } = require("./job-queue");
38
+
39
+ const ROOT = path.join(__dirname, "..");
40
+ const DEFAULT_CACHE = path.join(ROOT, ".cache", "upstream");
41
+ const REQUEST_TIMEOUT_MS = 10_000;
42
+ const USER_AGENT = "exceptd-security/prefetch (+https://exceptd.com)";
43
+
44
+ const SOURCES = {
45
+ kev: {
46
+ description: "CISA Known Exploited Vulnerabilities (single feed)",
47
+ rate: { tokens: 6, windowMs: 60_000 }, // very gentle
48
+ concurrency: 1,
49
+ expand: () => [{ id: "known_exploited_vulnerabilities", url: "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" }],
50
+ },
51
+ nvd: {
52
+ description: "NIST NVD 2.0 per-CVE responses",
53
+ rate: { tokens: 5, windowMs: 30_000 }, // anon budget; NVD_API_KEY lifts to 50
54
+ rate_with_key: { tokens: 50, windowMs: 30_000 },
55
+ concurrency: 4,
56
+ expand: (ctx) => Object.keys(ctx.cveCatalog)
57
+ .filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k))
58
+ .map((id) => ({ id, url: `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(id)}` })),
59
+ },
60
+ epss: {
61
+ description: "FIRST.org EPSS per-CVE responses",
62
+ rate: { tokens: 30, windowMs: 60_000 },
63
+ concurrency: 4,
64
+ expand: (ctx) => Object.keys(ctx.cveCatalog)
65
+ .filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k))
66
+ .map((id) => ({ id, url: `https://api.first.org/data/v1/epss?cve=${encodeURIComponent(id)}` })),
67
+ },
68
+ rfc: {
69
+ description: "IETF Datatracker per-RFC/doc records",
70
+ rate: { tokens: 30, windowMs: 60_000 },
71
+ concurrency: 4,
72
+ expand: (ctx) => Object.keys(ctx.rfcCatalog)
73
+ .filter((k) => !k.startsWith("_"))
74
+ .map((id) => {
75
+ let docName;
76
+ if (id.startsWith("RFC-")) docName = `rfc${id.slice(4)}`;
77
+ else if (id.startsWith("DRAFT-")) docName = `draft-${id.slice(6).toLowerCase()}`;
78
+ return docName ? { id: docName, url: `https://datatracker.ietf.org/api/v1/doc/document/?name=${encodeURIComponent(docName)}&format=json` } : null;
79
+ })
80
+ .filter(Boolean),
81
+ },
82
+ pins: {
83
+ description: "MITRE GitHub releases for ATLAS / ATT&CK / D3FEND / CWE pin checks",
84
+ rate: { tokens: 30, windowMs: 60 * 60_000 }, // anon: 60/h, leave headroom
85
+ rate_with_key: { tokens: 500, windowMs: 60 * 60_000 },
86
+ concurrency: 2,
87
+ expand: () => [
88
+ { id: "mitre-atlas__atlas-data__releases", url: "https://api.github.com/repos/mitre-atlas/atlas-data/releases?per_page=5" },
89
+ { id: "mitre-attack__attack-stix-data__releases", url: "https://api.github.com/repos/mitre-attack/attack-stix-data/releases?per_page=5" },
90
+ { id: "d3fend__d3fend-data__releases", url: "https://api.github.com/repos/d3fend/d3fend-data/releases?per_page=5" },
91
+ { id: "mitre__cwe__releases", url: "https://api.github.com/repos/mitre/cwe/releases?per_page=5" },
92
+ ],
93
+ },
94
+ };
95
+
96
+ function parseArgs(argv) {
97
+ const out = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, help: false };
98
+ for (let i = 2; i < argv.length; i++) {
99
+ const a = argv[i];
100
+ if (a === "--force") out.force = true;
101
+ else if (a === "--no-network" || a === "--dry-run") out.noNetwork = true;
102
+ else if (a === "--quiet") out.quiet = true;
103
+ else if (a === "--help" || a === "-h") out.help = true;
104
+ else if (a === "--source") out.source = argv[++i];
105
+ else if (a.startsWith("--source=")) out.source = a.slice("--source=".length);
106
+ else if (a === "--max-age") out.maxAgeMs = parseDuration(argv[++i]);
107
+ else if (a.startsWith("--max-age=")) out.maxAgeMs = parseDuration(a.slice("--max-age=".length));
108
+ else if (a === "--cache-dir") out.cacheDir = path.resolve(argv[++i]);
109
+ else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
110
+ }
111
+ return out;
112
+ }
113
+
114
+ function parseDuration(s) {
115
+ if (!s) return 0;
116
+ const m = String(s).match(/^(\d+)\s*([smhd])?$/);
117
+ if (!m) throw new Error(`prefetch: invalid duration "${s}"`);
118
+ const n = Number(m[1]);
119
+ const unit = (m[2] || "h").toLowerCase();
120
+ const mult = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
121
+ return n * mult;
122
+ }
123
+
124
+ function printHelp() {
125
+ console.log(`prefetch — warm a local cache of every upstream artifact this project consumes.
126
+
127
+ Sources:
128
+ kev CISA Known Exploited Vulnerabilities
129
+ nvd NIST NVD 2.0 per-CVE
130
+ epss FIRST EPSS per-CVE
131
+ ietf IETF Datatracker per-RFC
132
+ github MITRE GitHub releases (ATLAS / ATT&CK / D3FEND / CWE)
133
+
134
+ Options:
135
+ --max-age <dur> skip entries fresher than this (e.g. 12h, 1d). Default: 24h.
136
+ --source kev,nvd scope by comma-separated source list.
137
+ --force ignore freshness; re-fetch every entry.
138
+ --no-network report-only; list what would be fetched.
139
+ --cache-dir <path> override cache root (default .cache/upstream).
140
+ --quiet suppress per-entry log lines.
141
+
142
+ Use NVD_API_KEY / GITHUB_TOKEN env vars to lift rate limits.
143
+
144
+ Outputs:
145
+ <cache-dir>/_index.json — per-entry metadata
146
+ <cache-dir>/<source>/<id>.json — raw upstream payloads
147
+ `);
148
+ }
149
+
150
+ async function timedFetch(url, headers = {}) {
151
+ const ac = new AbortController();
152
+ const t = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS);
153
+ try {
154
+ const res = await fetch(url, {
155
+ signal: ac.signal,
156
+ headers: { "User-Agent": USER_AGENT, Accept: "application/json", ...headers },
157
+ });
158
+ if (!res.ok) {
159
+ const err = new Error(`HTTP ${res.status}`);
160
+ err.status = res.status;
161
+ throw err;
162
+ }
163
+ const etag = res.headers.get("etag") || null;
164
+ const lastModified = res.headers.get("last-modified") || null;
165
+ const json = await res.json();
166
+ return { json, etag, lastModified };
167
+ } finally {
168
+ clearTimeout(t);
169
+ }
170
+ }
171
+
172
+ function loadIndex(cacheDir) {
173
+ const p = path.join(cacheDir, "_index.json");
174
+ if (!fs.existsSync(p)) return { entries: {}, generated_at: null };
175
+ try {
176
+ return JSON.parse(fs.readFileSync(p, "utf8"));
177
+ } catch {
178
+ return { entries: {}, generated_at: null };
179
+ }
180
+ }
181
+
182
+ function saveIndex(cacheDir, idx) {
183
+ if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
184
+ fs.writeFileSync(path.join(cacheDir, "_index.json"), JSON.stringify(idx, null, 2) + "\n", "utf8");
185
+ }
186
+
187
+ function entryKey(source, id) {
188
+ return `${source}/${id}`;
189
+ }
190
+
191
+ function entryPath(cacheDir, source, id) {
192
+ // Sanitize id for filesystem.
193
+ const safe = id.replace(/[^A-Za-z0-9._-]/g, "_");
194
+ return path.join(cacheDir, source, `${safe}.json`);
195
+ }
196
+
197
+ function isFresh(idx, source, id, maxAgeMs) {
198
+ const e = idx.entries[entryKey(source, id)];
199
+ if (!e) return false;
200
+ if (!e.fetched_at) return false;
201
+ return Date.now() - new Date(e.fetched_at).getTime() < maxAgeMs;
202
+ }
203
+
204
+ function authHeadersForSource(source) {
205
+ if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
206
+ if (source === "github" && process.env.GITHUB_TOKEN) return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
207
+ return {};
208
+ }
209
+
210
+ async function prefetch(options = {}) {
211
+ const opts = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, ...options };
212
+ const ctx = loadCtx();
213
+ const chosen = opts.source
214
+ ? opts.source.split(",").map((s) => s.trim()).filter(Boolean)
215
+ : Object.keys(SOURCES);
216
+ for (const n of chosen) {
217
+ if (!SOURCES[n]) throw new Error(`prefetch: unknown source "${n}"`);
218
+ }
219
+
220
+ // Build the queue with per-source budgets. NVD / GitHub upgrade if env-key
221
+ // is present.
222
+ const sources = {};
223
+ for (const n of chosen) {
224
+ const cfg = SOURCES[n];
225
+ const rate = (n === "nvd" && process.env.NVD_API_KEY && cfg.rate_with_key)
226
+ || (n === "pins" && process.env.GITHUB_TOKEN && cfg.rate_with_key)
227
+ || cfg.rate
228
+ || null;
229
+ sources[n] = { concurrency: cfg.concurrency, ...(rate ? { rate } : {}) };
230
+ }
231
+ const queue = new JobQueue({ sources });
232
+
233
+ const idx = loadIndex(opts.cacheDir);
234
+ if (!fs.existsSync(opts.cacheDir)) fs.mkdirSync(opts.cacheDir, { recursive: true });
235
+
236
+ const plan = [];
237
+ for (const sourceName of chosen) {
238
+ const cfg = SOURCES[sourceName];
239
+ const entries = cfg.expand(ctx);
240
+ for (const e of entries) {
241
+ const fresh = !opts.force && isFresh(idx, sourceName, e.id, opts.maxAgeMs);
242
+ plan.push({ source: sourceName, id: e.id, url: e.url, fresh });
243
+ }
244
+ }
245
+
246
+ const log = (s) => opts.quiet || console.log(s);
247
+ log(`\nprefetch — ${opts.noNetwork ? "DRY-RUN" : "fetching"} ${plan.length} item(s) across ${chosen.length} source(s)`);
248
+ log(`Cache dir: ${path.relative(ROOT, opts.cacheDir)}`);
249
+ log(`Max age: ${(opts.maxAgeMs / 3_600_000).toFixed(1)}h${opts.force ? " (forced)" : ""}`);
250
+
251
+ const result = { fetched: 0, skipped_fresh: 0, errors: 0, by_source: {} };
252
+ for (const s of chosen) result.by_source[s] = { fetched: 0, skipped_fresh: 0, errors: 0 };
253
+
254
+ if (opts.noNetwork) {
255
+ for (const item of plan) {
256
+ const tag = item.fresh ? "FRESH (skip)" : "STALE (would fetch)";
257
+ log(` [${item.source}] ${item.id} — ${tag}`);
258
+ if (item.fresh) {
259
+ result.skipped_fresh++;
260
+ result.by_source[item.source].skipped_fresh++;
261
+ }
262
+ }
263
+ return result;
264
+ }
265
+
266
+ const jobPromises = plan.map((item) => {
267
+ if (item.fresh) {
268
+ result.skipped_fresh++;
269
+ result.by_source[item.source].skipped_fresh++;
270
+ return Promise.resolve();
271
+ }
272
+ const headers = authHeadersForSource(item.source);
273
+ // NVD takes its key in a custom header.
274
+ const reqHeaders = item.source === "nvd" && headers.apiKey ? { apiKey: headers.apiKey } : (item.source === "pins" ? headers : {});
275
+ return queue
276
+ .add({
277
+ source: item.source,
278
+ priority: priorityFor(item.source),
279
+ run: () => timedFetch(item.url, reqHeaders),
280
+ meta: { id: item.id },
281
+ })
282
+ .then((res) => {
283
+ const dir = path.dirname(entryPath(opts.cacheDir, item.source, item.id));
284
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
285
+ fs.writeFileSync(entryPath(opts.cacheDir, item.source, item.id), JSON.stringify(res.json, null, 2) + "\n", "utf8");
286
+ idx.entries[entryKey(item.source, item.id)] = {
287
+ fetched_at: new Date().toISOString(),
288
+ etag: res.etag,
289
+ last_modified: res.lastModified,
290
+ url: item.url,
291
+ sha256: crypto.createHash("sha256").update(JSON.stringify(res.json)).digest("hex"),
292
+ };
293
+ result.fetched++;
294
+ result.by_source[item.source].fetched++;
295
+ log(` [${item.source}] ${item.id} — ok`);
296
+ })
297
+ .catch((err) => {
298
+ result.errors++;
299
+ result.by_source[item.source].errors++;
300
+ log(` [${item.source}] ${item.id} — error: ${err.message}`);
301
+ });
302
+ });
303
+
304
+ await Promise.all(jobPromises);
305
+ await queue.drain();
306
+ idx.generated_at = new Date().toISOString();
307
+ saveIndex(opts.cacheDir, idx);
308
+
309
+ log(`\nprefetch summary: ${result.fetched} fetched, ${result.skipped_fresh} fresh, ${result.errors} error(s)`);
310
+ return result;
311
+ }
312
+
313
+ function priorityFor(source) {
314
+ // KEV is operationally most urgent; pins are least.
315
+ return { kev: 10, nvd: 8, epss: 6, rfc: 4, pins: 2 }[source] || 0;
316
+ }
317
+
318
+ function loadCtx() {
319
+ return {
320
+ manifest: JSON.parse(fs.readFileSync(path.join(ROOT, "manifest.json"), "utf8")),
321
+ cveCatalog: JSON.parse(fs.readFileSync(path.join(ROOT, "data/cve-catalog.json"), "utf8")),
322
+ rfcCatalog: JSON.parse(fs.readFileSync(path.join(ROOT, "data/rfc-references.json"), "utf8")),
323
+ };
324
+ }
325
+
326
+ // --- Cache-read helpers (consumed by validate-cves / validate-rfcs / refresh)
327
+
328
+ /**
329
+ * Read a cached entry, returning `null` if absent or stale.
330
+ *
331
+ * @param {string} cacheDir cache root
332
+ * @param {string} source "kev" | "nvd" | "epss" | "ietf" | "github"
333
+ * @param {string} id entry id (CVE-id, doc-name, etc.)
334
+ * @param {object} opts { maxAgeMs?: number; allowStale?: boolean }
335
+ * defaults: 24h fresh, allowStale=false
336
+ * @returns {{ data: object, age_ms: number, meta: object } | null}
337
+ */
338
+ function readCached(cacheDir, source, id, opts = {}) {
339
+ const maxAgeMs = opts.maxAgeMs ?? 24 * 3600 * 1000;
340
+ const idx = loadIndex(cacheDir);
341
+ const meta = idx.entries[entryKey(source, id)];
342
+ if (!meta) return null;
343
+ const ageMs = Date.now() - new Date(meta.fetched_at).getTime();
344
+ if (!opts.allowStale && ageMs > maxAgeMs) return null;
345
+ const p = entryPath(cacheDir, source, id);
346
+ if (!fs.existsSync(p)) return null;
347
+ try {
348
+ const data = JSON.parse(fs.readFileSync(p, "utf8"));
349
+ return { data, age_ms: ageMs, meta };
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ async function main() {
356
+ const opts = parseArgs(process.argv);
357
+ if (opts.help) {
358
+ printHelp();
359
+ process.exit(0);
360
+ }
361
+ try {
362
+ const result = await prefetch(opts);
363
+ process.exit(result.errors > 0 ? 1 : 0);
364
+ } catch (err) {
365
+ console.error(`prefetch: fatal: ${err.message}`);
366
+ process.exit(2);
367
+ }
368
+ }
369
+
370
+ if (require.main === module) main();
371
+
372
+ module.exports = { prefetch, readCached, parseArgs, SOURCES, DEFAULT_CACHE };