fad-checker 1.0.0

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 (67) hide show
  1. package/CLAUDE.md +126 -0
  2. package/README.md +279 -0
  3. package/completions/fad-check.bash +23 -0
  4. package/completions/fad-check.zsh +27 -0
  5. package/data/cpe-coord-map.json +112 -0
  6. package/data/eol-mapping.json +34 -0
  7. package/data/known-obsolete.json +142 -0
  8. package/data/known-public-namespaces.json +79 -0
  9. package/docs/ARCHITECTURE.md +152 -0
  10. package/docs/USAGE.md +179 -0
  11. package/fad-check.js +665 -0
  12. package/lib/cache-archive.js +107 -0
  13. package/lib/config.js +87 -0
  14. package/lib/core.js +317 -0
  15. package/lib/cpe.js +287 -0
  16. package/lib/cve-download.js +369 -0
  17. package/lib/cve-match.js +228 -0
  18. package/lib/cve-report.js +1455 -0
  19. package/lib/maven-repo.js +134 -0
  20. package/lib/maven-version.js +153 -0
  21. package/lib/npm/collect.js +224 -0
  22. package/lib/npm/parse.js +291 -0
  23. package/lib/nvd.js +239 -0
  24. package/lib/osv.js +298 -0
  25. package/lib/outdated.js +265 -0
  26. package/lib/retire.js +211 -0
  27. package/lib/scan-completeness.js +67 -0
  28. package/lib/snyk.js +127 -0
  29. package/lib/transitive.js +410 -0
  30. package/package.json +35 -0
  31. package/test/core.test.js +153 -0
  32. package/test/cpe.test.js +148 -0
  33. package/test/cve-download.test.js +39 -0
  34. package/test/cve-match.test.js +108 -0
  35. package/test/cve-report.test.js +79 -0
  36. package/test/fixtures/complex-enterprise/api/pom.xml +32 -0
  37. package/test/fixtures/complex-enterprise/build/pom.xml +46 -0
  38. package/test/fixtures/complex-enterprise/dao/pom.xml +37 -0
  39. package/test/fixtures/complex-enterprise/pom.xml +66 -0
  40. package/test/fixtures/complex-enterprise/web/pom.xml +77 -0
  41. package/test/fixtures/cve-samples/cve-non-java.json +19 -0
  42. package/test/fixtures/cve-samples/cve-product-only.json +31 -0
  43. package/test/fixtures/cve-samples/cve-with-packagename.json +37 -0
  44. package/test/fixtures/cve-samples/nvd-log4shell.json +40 -0
  45. package/test/fixtures/cve-samples/nvd-npm-lodash.json +22 -0
  46. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +26 -0
  47. package/test/fixtures/monorepo-mixed/packages/cli/package.json +14 -0
  48. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +41 -0
  49. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +9 -0
  50. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +71 -0
  51. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +17 -0
  52. package/test/fixtures/monorepo-mixed/pom.xml +29 -0
  53. package/test/fixtures/monorepo-mixed/services/api/pom.xml +27 -0
  54. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +28 -0
  55. package/test/fixtures/private-lib-detection/core/pom.xml +26 -0
  56. package/test/fixtures/private-lib-detection/plugin/pom.xml +23 -0
  57. package/test/fixtures/private-lib-detection/pom.xml +35 -0
  58. package/test/fixtures/simple/app/pom.xml +28 -0
  59. package/test/fixtures/simple/lib/pom.xml +18 -0
  60. package/test/fixtures/simple/pom.xml +24 -0
  61. package/test/maven-repo.test.js +111 -0
  62. package/test/maven-version.test.js +48 -0
  63. package/test/monorepo.test.js +132 -0
  64. package/test/npm.test.js +146 -0
  65. package/test/outdated.test.js +56 -0
  66. package/test/snyk.test.js +64 -0
  67. package/test/transitive.test.js +305 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * lib/npm/parse.js — parse package.json, package-lock.json (v1/v2/v3),
3
+ * and yarn.lock v1. Pure functions: no I/O scheduling, no console output.
4
+ *
5
+ * The shape returned by each parser is normalised to:
6
+ * {
7
+ * manifestPath, // absolute path to the parsed file
8
+ * manifestType, // "package.json" | "package-lock" | "yarn.lock"
9
+ * packageName, // top-level project name (if known)
10
+ * packageVersion, // top-level project version (if known)
11
+ * deps: [ // every package present (direct + transitive)
12
+ * {
13
+ * name, // "lodash" | "@scope/pkg"
14
+ * version, // resolved (lockfile) or range (package.json)
15
+ * scope, // "prod" | "dev" | "peer" | "optional"
16
+ * depth, // 0 for top-level; >0 for transitive in lockfile tree
17
+ * from, // for transitives in npm v1 lockfile: parent chain
18
+ * },
19
+ * ],
20
+ * }
21
+ *
22
+ * The collector (lib/npm/collect.js) is responsible for merging across
23
+ * files and applying exclusion rules.
24
+ */
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+
28
+ function isWorkspaceVersion(v) {
29
+ // npm v9+ uses "*" / "" for workspace-local refs; yarn uses "workspace:*"
30
+ if (!v) return true;
31
+ return v === "*" || v === "" || String(v).startsWith("workspace:") || String(v).startsWith("file:") || String(v).startsWith("link:");
32
+ }
33
+
34
+ function pickName(pkgKey) {
35
+ // npm v2/v3 lockfile keys look like "node_modules/foo" or "node_modules/foo/node_modules/bar"
36
+ const parts = pkgKey.split("node_modules/");
37
+ const last = parts[parts.length - 1];
38
+ return last || null;
39
+ }
40
+
41
+ function depthFromKey(pkgKey) {
42
+ // "node_modules/a/node_modules/b" → depth 2
43
+ const n = (pkgKey.match(/node_modules\//g) || []).length;
44
+ return Math.max(0, n - 1);
45
+ }
46
+
47
+ /* -------- package.json ---------- */
48
+ function parsePackageJson(filePath) {
49
+ const raw = fs.readFileSync(filePath, "utf8");
50
+ let json;
51
+ try { json = JSON.parse(raw); }
52
+ catch (e) { throw new Error(`package.json parse failed (${filePath}): ${e.message}`); }
53
+ const deps = [];
54
+ const push = (obj, scope) => {
55
+ for (const [name, version] of Object.entries(obj || {})) {
56
+ if (isWorkspaceVersion(version)) continue;
57
+ const isDev = scope === "dev" || scope === "optional";
58
+ deps.push({ name, version: String(version), scope, isDev, depth: 0 });
59
+ }
60
+ };
61
+ push(json.dependencies, "prod");
62
+ push(json.devDependencies, "dev");
63
+ push(json.peerDependencies, "peer");
64
+ push(json.optionalDependencies, "optional");
65
+ return {
66
+ manifestPath: filePath,
67
+ manifestType: "package.json",
68
+ packageName: json.name || null,
69
+ packageVersion: json.version || null,
70
+ workspaces: Array.isArray(json.workspaces) ? json.workspaces : (json.workspaces?.packages || []),
71
+ deps,
72
+ };
73
+ }
74
+
75
+ /* -------- package-lock.json (v1, v2, v3) ---------- */
76
+ function parsePackageLock(filePath) {
77
+ const raw = fs.readFileSync(filePath, "utf8");
78
+ let json;
79
+ try { json = JSON.parse(raw); }
80
+ catch (e) { throw new Error(`package-lock parse failed (${filePath}): ${e.message}`); }
81
+
82
+ const lockfileVersion = json.lockfileVersion || 1;
83
+ const out = {
84
+ manifestPath: filePath,
85
+ manifestType: "package-lock",
86
+ packageName: json.name || null,
87
+ packageVersion: json.version || null,
88
+ lockfileVersion,
89
+ deps: [],
90
+ };
91
+
92
+ if (lockfileVersion >= 2 && json.packages) {
93
+ // v2/v3: flat `packages` map keyed by relative path.
94
+ // The empty-string key is the root project; node_modules/foo → installed dep.
95
+ const root = json.packages[""] || {};
96
+ const directProd = root.dependencies || {};
97
+ const directDev = root.devDependencies || {};
98
+ const directOpt = root.optionalDependencies || {};
99
+ const directPeer = root.peerDependencies || {};
100
+ const isDirect = (name, scope) => {
101
+ if (scope === "prod" && Object.prototype.hasOwnProperty.call(directProd, name)) return true;
102
+ if (scope === "dev" && Object.prototype.hasOwnProperty.call(directDev, name)) return true;
103
+ if (scope === "optional" && Object.prototype.hasOwnProperty.call(directOpt, name)) return true;
104
+ if (scope === "peer" && Object.prototype.hasOwnProperty.call(directPeer, name)) return true;
105
+ return false;
106
+ };
107
+ const isAnyDirect = name =>
108
+ Object.prototype.hasOwnProperty.call(directProd, name) ||
109
+ Object.prototype.hasOwnProperty.call(directDev, name) ||
110
+ Object.prototype.hasOwnProperty.call(directOpt, name) ||
111
+ Object.prototype.hasOwnProperty.call(directPeer, name);
112
+
113
+ for (const [pkgKey, entry] of Object.entries(json.packages)) {
114
+ if (pkgKey === "") continue; // root
115
+ if (!pkgKey.includes("node_modules/")) continue; // workspace member, not a dep
116
+ if (entry.link) continue; // symlink to workspace
117
+ const name = pickName(pkgKey);
118
+ if (!name) continue;
119
+ const depth = depthFromKey(pkgKey);
120
+ // scope inference. npm v3+ flattens transitives into the top-level
121
+ // node_modules/, so depth===0 alone doesn't mean "direct". An entry
122
+ // is direct iff it appears in the root project's dependency lists.
123
+ let scope = "prod";
124
+ if (entry.dev || entry.devOptional) scope = "dev";
125
+ else if (entry.optional) scope = "optional";
126
+ else if (entry.peer) scope = "peer";
127
+ const isDirectDep = depth === 0 && isAnyDirect(name);
128
+ if (isDirectDep) {
129
+ if (isDirect(name, "dev")) scope = "dev";
130
+ else if (isDirect(name, "optional")) scope = "optional";
131
+ else if (isDirect(name, "peer")) scope = "peer";
132
+ else scope = "prod";
133
+ } else if (depth > 0 || !isAnyDirect(name)) {
134
+ // Flattened-but-not-direct = transitive. Keep dev/optional flags
135
+ // for filtering, but record it as a transitive.
136
+ scope = "transitive";
137
+ }
138
+ // isDev flag survives the scope reclassification: a flattened
139
+ // transitive of a dev-only dep is still dev-only.
140
+ const isDev = !!(entry.dev || entry.devOptional || (isDirectDep && isDirect(name, "dev")));
141
+ out.deps.push({
142
+ name,
143
+ version: entry.version || null,
144
+ scope,
145
+ isDev,
146
+ depth,
147
+ resolved: entry.resolved || null,
148
+ integrity: entry.integrity || null,
149
+ });
150
+ }
151
+ } else if (json.dependencies) {
152
+ // v1: nested `dependencies` tree
153
+ const walk = (node, depth, parentChain, parentIsDev) => {
154
+ for (const [name, entry] of Object.entries(node)) {
155
+ let scope = "prod";
156
+ if (entry.dev) scope = "dev";
157
+ else if (entry.optional) scope = "optional";
158
+ const isDev = !!entry.dev || parentIsDev;
159
+ out.deps.push({
160
+ name,
161
+ version: entry.version || null,
162
+ scope,
163
+ isDev,
164
+ depth,
165
+ from: parentChain.length ? parentChain.join(" > ") : null,
166
+ resolved: entry.resolved || null,
167
+ integrity: entry.integrity || null,
168
+ });
169
+ if (entry.dependencies) walk(entry.dependencies, depth + 1, [...parentChain, name], isDev);
170
+ }
171
+ };
172
+ walk(json.dependencies, 0, [], false);
173
+ }
174
+
175
+ return out;
176
+ }
177
+
178
+ /* -------- yarn.lock v1 ----------
179
+ Format example:
180
+ "lodash@^4.17.0":
181
+ version "4.17.21"
182
+ resolved "https://registry.yarnpkg.com/..."
183
+ integrity sha512-...
184
+ dependencies:
185
+ "another-pkg" "^1.0.0"
186
+ Each block starts at column 0 with one or more comma-separated specifiers,
187
+ ending with ":". Indented lines hold key-value pairs and dependency blocks.
188
+ */
189
+ function parseYarnLockV1(filePath) {
190
+ const raw = fs.readFileSync(filePath, "utf8");
191
+ if (raw.includes("__metadata:")) {
192
+ // Berry / yarn 2+ uses YAML. Punt for now — return empty deps + a flag.
193
+ return {
194
+ manifestPath: filePath,
195
+ manifestType: "yarn.lock",
196
+ lockfileVersion: "berry",
197
+ deps: [],
198
+ unsupported: "yarn-berry",
199
+ };
200
+ }
201
+ const out = {
202
+ manifestPath: filePath,
203
+ manifestType: "yarn.lock",
204
+ lockfileVersion: 1,
205
+ deps: [],
206
+ };
207
+ const lines = raw.split(/\r?\n/);
208
+ let i = 0;
209
+ // Track unique (name, version) so we don't emit duplicate entries for
210
+ // every range spec that resolves to the same version.
211
+ const seen = new Set();
212
+ while (i < lines.length) {
213
+ const line = lines[i];
214
+ if (!line || /^#/.test(line)) { i++; continue; }
215
+ if (line[0] === " " || line[0] === "\t") { i++; continue; }
216
+ // Header line: one or more comma-separated specifiers ending with ":"
217
+ const header = line.replace(/:\s*$/, "");
218
+ const specifiers = header.split(",").map(s => s.trim().replace(/^"|"$/g, ""));
219
+ // Each specifier is "name@range". Names may contain "@" for scoped pkgs.
220
+ const names = new Set();
221
+ for (const spec of specifiers) {
222
+ const at = spec.lastIndexOf("@");
223
+ if (at <= 0) continue; // malformed
224
+ names.add(spec.slice(0, at));
225
+ }
226
+ i++;
227
+ // Read the indented body until the next non-indented line
228
+ let version = null;
229
+ let resolved = null;
230
+ while (i < lines.length && (lines[i].startsWith(" ") || lines[i].startsWith("\t") || lines[i] === "")) {
231
+ const body = lines[i].trim();
232
+ if (body.startsWith("version ")) {
233
+ version = body.slice("version ".length).replace(/^"|"$/g, "");
234
+ } else if (body.startsWith("resolved ")) {
235
+ resolved = body.slice("resolved ".length).replace(/^"|"$/g, "");
236
+ }
237
+ i++;
238
+ }
239
+ for (const name of names) {
240
+ if (!version) continue;
241
+ const k = `${name}@${version}`;
242
+ if (seen.has(k)) continue;
243
+ seen.add(k);
244
+ out.deps.push({ name, version, scope: "prod", depth: 0, resolved });
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+
250
+ /* -------- discovery ---------- */
251
+ // Dirs that hold packaged / generated content — never our own source.
252
+ // Conservative list: only well-known build-output / package-cache dirs.
253
+ const DEFAULT_JS_SKIP_DIRS = new Set([
254
+ "node_modules", "bower_components", "jspm_packages",
255
+ ".git", ".idea", ".vscode", ".gradle", ".mvn",
256
+ "dist", "build", "out", "target", "coverage", ".next", ".nuxt",
257
+ ]);
258
+
259
+ function findJsManifests(rootDir, opts = {}) {
260
+ const { skipDirs = DEFAULT_JS_SKIP_DIRS } = opts;
261
+ const found = [];
262
+ const stack = [rootDir];
263
+ while (stack.length) {
264
+ const cur = stack.pop();
265
+ let entries;
266
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
267
+ catch { continue; }
268
+ // Group lockfile per directory so we can prefer lock > package.json
269
+ const here = { dir: cur, packageJson: null, packageLock: null, yarnLock: null };
270
+ for (const e of entries) {
271
+ const p = path.join(cur, e.name);
272
+ if (e.isDirectory()) {
273
+ if (skipDirs.has(e.name)) continue;
274
+ stack.push(p);
275
+ } else if (e.isFile()) {
276
+ if (e.name === "package.json") here.packageJson = p;
277
+ else if (e.name === "package-lock.json") here.packageLock = p;
278
+ else if (e.name === "yarn.lock") here.yarnLock = p;
279
+ }
280
+ }
281
+ if (here.packageJson || here.packageLock || here.yarnLock) found.push(here);
282
+ }
283
+ return found;
284
+ }
285
+
286
+ module.exports = {
287
+ parsePackageJson,
288
+ parsePackageLock,
289
+ parseYarnLockV1,
290
+ findJsManifests,
291
+ };
package/lib/nvd.js ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * lib/nvd.js — enrich already-found CVEs with the NIST NVD record.
3
+ *
4
+ * NVD's API doesn't expose a Maven-coord index, so we don't use it for
5
+ * recall. Instead we look up each known CVE ID to get the canonical
6
+ * description, full CVSS metrics (v2 / v3.1 / v4.0), CPE configurations,
7
+ * and reference URLs.
8
+ *
9
+ * API: https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-YYYY-NNNN
10
+ * Rate limit: 5 req / 30s unauthenticated, 50 req / 30s with NVD_API_KEY env var.
11
+ *
12
+ * Cache: ~/.fad-check/nvd-cache/<cve-id>.json, 7-day TTL.
13
+ */
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const os = require("os");
17
+ const { getNvdApiKey } = require("./config");
18
+
19
+ const NVD_CACHE_DIR = path.join(os.homedir(), ".fad-check", "nvd-cache");
20
+ const NVD_CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
21
+ const NVD_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0";
22
+
23
+ function getRateDelay() {
24
+ // 50 req / 30s with API key, 5 req / 30s without
25
+ return getNvdApiKey() ? 600 : 6000;
26
+ }
27
+
28
+ function cachePath(cveId) {
29
+ return path.join(NVD_CACHE_DIR, `${cveId}.json`);
30
+ }
31
+
32
+ function readCache(cveId) {
33
+ const p = cachePath(cveId);
34
+ if (!fs.existsSync(p)) return null;
35
+ try {
36
+ const data = JSON.parse(fs.readFileSync(p, "utf8"));
37
+ if (Date.now() - data._fetchedAt < NVD_CACHE_TTL_MS) return data.body;
38
+ } catch { /* ignore */ }
39
+ return null;
40
+ }
41
+
42
+ function writeCache(cveId, body) {
43
+ fs.mkdirSync(NVD_CACHE_DIR, { recursive: true });
44
+ fs.writeFileSync(cachePath(cveId), JSON.stringify({ _fetchedAt: Date.now(), body }));
45
+ }
46
+
47
+ function bestMetric(metrics) {
48
+ // NVD 2.0 returns metrics organised by version: cvssMetricV40, V31, V30, V2
49
+ const order = ["cvssMetricV40", "cvssMetricV31", "cvssMetricV30", "cvssMetricV2"];
50
+ for (const k of order) {
51
+ if (Array.isArray(metrics?.[k]) && metrics[k].length) {
52
+ const cv = metrics[k][0].cvssData;
53
+ return {
54
+ version: k.replace("cvssMetric", "CVSS:"),
55
+ score: cv.baseScore,
56
+ severity: (cv.baseSeverity || severityFromScore(cv.baseScore) || "UNKNOWN").toUpperCase(),
57
+ vector: cv.vectorString,
58
+ };
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function severityFromScore(s) {
65
+ if (s == null) return null;
66
+ if (s >= 9) return "CRITICAL";
67
+ if (s >= 7) return "HIGH";
68
+ if (s >= 4) return "MEDIUM";
69
+ if (s > 0) return "LOW";
70
+ return "NONE";
71
+ }
72
+
73
+ function extractFromNvdRecord(record) {
74
+ if (!record) return null;
75
+ const desc = (record.descriptions || []).find(d => d.lang === "en")?.value || "";
76
+ const metric = bestMetric(record.metrics);
77
+ // Keep each reference together with its NVD tags (Patch, Exploit, Vendor Advisory, Third Party Advisory, Mailing List, ...)
78
+ const refs = (record.references || []).map(r => ({
79
+ url: r.url,
80
+ source: r.source || null,
81
+ tags: r.tags || [],
82
+ }));
83
+ const cpes = [];
84
+ for (const c of record.configurations || []) {
85
+ for (const n of c.nodes || []) {
86
+ for (const m of n.cpeMatch || []) cpes.push(m.criteria);
87
+ }
88
+ }
89
+ // Preserve the full configurations tree so cve-match / cpe.js can evaluate
90
+ // AND/OR nodes and version ranges (versionStartIncluding, etc.). We strip
91
+ // `matchCriteriaId` and other UUID-only fields to keep the cache compact.
92
+ const configurations = (record.configurations || []).map(c => ({
93
+ operator: c.operator || "OR",
94
+ negate: c.negate || false,
95
+ nodes: (c.nodes || []).map(slimNode),
96
+ }));
97
+ // CWE identifiers (Common Weakness Enumeration). NVD shape:
98
+ // weaknesses: [{ source, type, description: [{ lang, value: "CWE-79" }] }]
99
+ const cwes = [];
100
+ const seenCwe = new Set();
101
+ for (const w of record.weaknesses || []) {
102
+ for (const d of w.description || []) {
103
+ const v = (d.value || "").trim();
104
+ if (/^CWE-\d+$/i.test(v) && !seenCwe.has(v.toUpperCase())) {
105
+ seenCwe.add(v.toUpperCase());
106
+ cwes.push(v.toUpperCase());
107
+ }
108
+ }
109
+ }
110
+ return {
111
+ id: record.id,
112
+ description: desc,
113
+ severity: metric?.severity || "UNKNOWN",
114
+ score: metric?.score ?? null,
115
+ cvssVector: metric?.vector || null,
116
+ cvssVersion: metric?.version || null,
117
+ published: record.published || null,
118
+ modified: record.lastModified || null,
119
+ references: refs,
120
+ cpes,
121
+ cwes,
122
+ configurations,
123
+ };
124
+ }
125
+
126
+ function slimNode(n) {
127
+ return {
128
+ operator: n.operator || "OR",
129
+ negate: n.negate || false,
130
+ cpeMatch: (n.cpeMatch || []).map(m => ({
131
+ vulnerable: m.vulnerable !== false,
132
+ criteria: m.criteria,
133
+ versionStartIncluding: m.versionStartIncluding,
134
+ versionStartExcluding: m.versionStartExcluding,
135
+ versionEndIncluding: m.versionEndIncluding,
136
+ versionEndExcluding: m.versionEndExcluding,
137
+ })),
138
+ children: Array.isArray(n.children) ? n.children.map(slimNode) : undefined,
139
+ };
140
+ }
141
+
142
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
143
+
144
+ async function fetchOne(cveId, opts = {}) {
145
+ const { fetcher = globalThis.fetch, verbose, offline } = opts;
146
+ const cached = readCache(cveId);
147
+ if (cached !== null && cached !== undefined) return cached;
148
+ if (offline) return null;
149
+ const headers = { "User-Agent": "fad-check-nvd-enrich" };
150
+ const key = getNvdApiKey();
151
+ if (key) headers["apiKey"] = key;
152
+ const url = `${NVD_BASE}?cveId=${encodeURIComponent(cveId)}`;
153
+ try {
154
+ const r = await fetcher(url, { headers });
155
+ if (r.status === 404) { writeCache(cveId, null); return null; }
156
+ if (!r.ok) {
157
+ if (verbose) console.warn(` NVD HTTP ${r.status} for ${cveId}`);
158
+ return null;
159
+ }
160
+ const data = await r.json();
161
+ const record = data.vulnerabilities?.[0]?.cve;
162
+ const extracted = extractFromNvdRecord(record);
163
+ writeCache(cveId, extracted);
164
+ return extracted;
165
+ } catch (err) {
166
+ if (verbose) console.warn(` NVD fetch failed for ${cveId}: ${err.message}`);
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Enrich an array of fad-check matches in place by fetching their NVD records.
173
+ * Adds: cve.description (replaced by NVD's), cve.cvssVector, cve.cvssVersion,
174
+ * cve.references, cve.cpes. Severity/score are only overwritten if currently UNKNOWN/null.
175
+ *
176
+ * Rate limited per the NIST policy (use NVD_API_KEY for faster access).
177
+ */
178
+ async function enrichMatches(matches, opts = {}) {
179
+ const { verbose, offline } = opts;
180
+ const uniqueCves = new Set();
181
+ for (const m of matches) if (m.cve?.id?.startsWith("CVE-")) uniqueCves.add(m.cve.id);
182
+ const hasKey = !!getNvdApiKey();
183
+ const delay = getRateDelay();
184
+ if (verbose) console.log(`🔍 NVD: enriching ${uniqueCves.size} unique CVEs${offline ? " (offline — cache only)" : hasKey ? " (with API key, 50/30s)" : " (no API key — throttled to 5/30s; pass --set-nvd-key for 10× faster)"}…`);
185
+
186
+ const byId = new Map();
187
+ let i = 0;
188
+ for (const cveId of uniqueCves) {
189
+ // Only sleep between live (non-cached) requests.
190
+ const cached = readCache(cveId);
191
+ if (cached !== null && cached !== undefined) {
192
+ byId.set(cveId, cached);
193
+ continue;
194
+ }
195
+ if (offline) { byId.set(cveId, null); continue; }
196
+ const data = await fetchOne(cveId, opts);
197
+ byId.set(cveId, data);
198
+ i++;
199
+ if (verbose && i % 5 === 0) process.stdout.write(`\r NVD: ${i} fetched`);
200
+ // Rate limit between requests
201
+ await sleep(delay);
202
+ }
203
+ if (verbose && i) process.stdout.write(`\r NVD: ${i} fetched \n`);
204
+
205
+ for (const m of matches) {
206
+ const data = byId.get(m.cve?.id);
207
+ if (!data) continue;
208
+ // Merge: prefer NVD's official text + CVSS but keep what fad-check/OSV already has
209
+ // NVD's description is the official long form — prefer it when available.
210
+ if (data.description) {
211
+ m.cve.description = data.description.length > 2000
212
+ ? data.description.slice(0, 2000) + "…"
213
+ : data.description;
214
+ }
215
+ if (m.cve.severity === "UNKNOWN" || !m.cve.severity) m.cve.severity = data.severity;
216
+ if (m.cve.score == null) m.cve.score = data.score;
217
+ m.cve.cvssVector = data.cvssVector || null;
218
+ m.cve.cvssVersion = data.cvssVersion || null;
219
+ m.cve.nvdRefs = data.references || []; // [{url, tags, source}]
220
+ m.cve.cpes = data.cpes || [];
221
+ m.cve.cwes = data.cwes || [];
222
+ m.cve.configurations = data.configurations || [];
223
+ if (!m.cve.published && data.published) m.cve.published = data.published;
224
+ if (!m.cve.modified && data.modified) m.cve.modified = data.modified;
225
+ // Tag NVD as a contributing source so the report shows fad+nvd / osv+nvd.
226
+ const sources = new Set((m.source || "").split("+").filter(Boolean));
227
+ sources.add("nvd");
228
+ m.source = [...sources].sort().join("+");
229
+ }
230
+ return matches;
231
+ }
232
+
233
+ module.exports = {
234
+ enrichMatches,
235
+ fetchOne,
236
+ extractFromNvdRecord,
237
+ NVD_CACHE_DIR,
238
+ getNvdApiKey,
239
+ };