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.
- package/CLAUDE.md +126 -0
- package/README.md +279 -0
- package/completions/fad-check.bash +23 -0
- package/completions/fad-check.zsh +27 -0
- package/data/cpe-coord-map.json +112 -0
- package/data/eol-mapping.json +34 -0
- package/data/known-obsolete.json +142 -0
- package/data/known-public-namespaces.json +79 -0
- package/docs/ARCHITECTURE.md +152 -0
- package/docs/USAGE.md +179 -0
- package/fad-check.js +665 -0
- package/lib/cache-archive.js +107 -0
- package/lib/config.js +87 -0
- package/lib/core.js +317 -0
- package/lib/cpe.js +287 -0
- package/lib/cve-download.js +369 -0
- package/lib/cve-match.js +228 -0
- package/lib/cve-report.js +1455 -0
- package/lib/maven-repo.js +134 -0
- package/lib/maven-version.js +153 -0
- package/lib/npm/collect.js +224 -0
- package/lib/npm/parse.js +291 -0
- package/lib/nvd.js +239 -0
- package/lib/osv.js +298 -0
- package/lib/outdated.js +265 -0
- package/lib/retire.js +211 -0
- package/lib/scan-completeness.js +67 -0
- package/lib/snyk.js +127 -0
- package/lib/transitive.js +410 -0
- package/package.json +35 -0
- package/test/core.test.js +153 -0
- package/test/cpe.test.js +148 -0
- package/test/cve-download.test.js +39 -0
- package/test/cve-match.test.js +108 -0
- package/test/cve-report.test.js +79 -0
- package/test/fixtures/complex-enterprise/api/pom.xml +32 -0
- package/test/fixtures/complex-enterprise/build/pom.xml +46 -0
- package/test/fixtures/complex-enterprise/dao/pom.xml +37 -0
- package/test/fixtures/complex-enterprise/pom.xml +66 -0
- package/test/fixtures/complex-enterprise/web/pom.xml +77 -0
- package/test/fixtures/cve-samples/cve-non-java.json +19 -0
- package/test/fixtures/cve-samples/cve-product-only.json +31 -0
- package/test/fixtures/cve-samples/cve-with-packagename.json +37 -0
- package/test/fixtures/cve-samples/nvd-log4shell.json +40 -0
- package/test/fixtures/cve-samples/nvd-npm-lodash.json +22 -0
- package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +26 -0
- package/test/fixtures/monorepo-mixed/packages/cli/package.json +14 -0
- package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +41 -0
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +9 -0
- package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +71 -0
- package/test/fixtures/monorepo-mixed/packages/web-app/package.json +17 -0
- package/test/fixtures/monorepo-mixed/pom.xml +29 -0
- package/test/fixtures/monorepo-mixed/services/api/pom.xml +27 -0
- package/test/fixtures/monorepo-mixed/services/worker/pom.xml +28 -0
- package/test/fixtures/private-lib-detection/core/pom.xml +26 -0
- package/test/fixtures/private-lib-detection/plugin/pom.xml +23 -0
- package/test/fixtures/private-lib-detection/pom.xml +35 -0
- package/test/fixtures/simple/app/pom.xml +28 -0
- package/test/fixtures/simple/lib/pom.xml +18 -0
- package/test/fixtures/simple/pom.xml +24 -0
- package/test/maven-repo.test.js +111 -0
- package/test/maven-version.test.js +48 -0
- package/test/monorepo.test.js +132 -0
- package/test/npm.test.js +146 -0
- package/test/outdated.test.js +56 -0
- package/test/snyk.test.js +64 -0
- package/test/transitive.test.js +305 -0
package/lib/npm/parse.js
ADDED
|
@@ -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
|
+
};
|