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/cpe.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/cpe.js — CPE 2.3 parsing + matching of NVD configuration trees.
|
|
3
|
+
*
|
|
4
|
+
* CPE 2.3 URI binding:
|
|
5
|
+
* cpe:2.3:part:vendor:product:version:update:edition:language:
|
|
6
|
+
* sw_edition:target_sw:target_hw:other
|
|
7
|
+
* Each field can be a literal, "*" (ANY) or "-" (NA). Some characters
|
|
8
|
+
* are percent-encoded, we only need to undo "\:" and "\\" for the
|
|
9
|
+
* fields we read.
|
|
10
|
+
*
|
|
11
|
+
* Used in two directions:
|
|
12
|
+
* 1. Given a NVD-enriched match (cve.cpes[] + cve.configurations[]),
|
|
13
|
+
* decide whether a dep is _truly_ affected (version range check)
|
|
14
|
+
* and whether we can upgrade the match confidence.
|
|
15
|
+
* 2. Given a dep coord, find CVEs in the index whose CPE configurations
|
|
16
|
+
* it satisfies. (Future use — kept compatible with the data shape.)
|
|
17
|
+
*/
|
|
18
|
+
const { compareMavenVersions } = require("./maven-version");
|
|
19
|
+
|
|
20
|
+
let CPE_COORD_MAP_CACHE = null;
|
|
21
|
+
function loadCpeCoordMap() {
|
|
22
|
+
if (CPE_COORD_MAP_CACHE) return CPE_COORD_MAP_CACHE;
|
|
23
|
+
try {
|
|
24
|
+
CPE_COORD_MAP_CACHE = require("../data/cpe-coord-map.json");
|
|
25
|
+
} catch {
|
|
26
|
+
CPE_COORD_MAP_CACHE = { byVendorProduct: {}, byProduct: {} };
|
|
27
|
+
}
|
|
28
|
+
return CPE_COORD_MAP_CACHE;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a CPE 2.3 URI. Returns null on malformed input.
|
|
33
|
+
* Field order: cpe:2.3:part:vendor:product:version:update:edition:language:
|
|
34
|
+
* sw_edition:target_sw:target_hw:other
|
|
35
|
+
*/
|
|
36
|
+
function parseCpe23(uri) {
|
|
37
|
+
if (typeof uri !== "string") return null;
|
|
38
|
+
if (!uri.startsWith("cpe:2.3:")) return null;
|
|
39
|
+
// Split on `:` but respect backslash escaping.
|
|
40
|
+
const fields = [];
|
|
41
|
+
let buf = "";
|
|
42
|
+
for (let i = 0; i < uri.length; i++) {
|
|
43
|
+
const c = uri[i];
|
|
44
|
+
if (c === "\\" && i + 1 < uri.length) {
|
|
45
|
+
buf += uri[i + 1]; i++;
|
|
46
|
+
} else if (c === ":") {
|
|
47
|
+
fields.push(buf); buf = "";
|
|
48
|
+
} else {
|
|
49
|
+
buf += c;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
fields.push(buf);
|
|
53
|
+
// fields = ["cpe","2.3","a","vendor","product","version","update","edition","language","sw_edition","target_sw","target_hw","other"]
|
|
54
|
+
if (fields.length < 7) return null;
|
|
55
|
+
return {
|
|
56
|
+
part: fields[2] || "*",
|
|
57
|
+
vendor: fields[3] || "*",
|
|
58
|
+
product: fields[4] || "*",
|
|
59
|
+
version: fields[5] || "*",
|
|
60
|
+
update: fields[6] || "*",
|
|
61
|
+
edition: fields[7] || "*",
|
|
62
|
+
language: fields[8] || "*",
|
|
63
|
+
sw_edition: fields[9] || "*",
|
|
64
|
+
target_sw: fields[10] || "*",
|
|
65
|
+
target_hw: fields[11] || "*",
|
|
66
|
+
other: fields[12] || "*",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Match a dep version against a single NVD cpeMatch entry.
|
|
72
|
+
* The entry has the shape:
|
|
73
|
+
* { criteria: "cpe:2.3:a:vendor:product:version:...",
|
|
74
|
+
* vulnerable: true,
|
|
75
|
+
* versionStartIncluding, versionStartExcluding,
|
|
76
|
+
* versionEndIncluding, versionEndExcluding }
|
|
77
|
+
*
|
|
78
|
+
* Returns true if `depVersion` is in range. If the CPE itself pins a
|
|
79
|
+
* concrete version (criteria's version field != "*"/"-"), that pin wins.
|
|
80
|
+
*/
|
|
81
|
+
function matchVersionRange(depVersion, cpeMatch) {
|
|
82
|
+
if (!cpeMatch) return false;
|
|
83
|
+
if (!depVersion) return true; // unknown version → assume affected
|
|
84
|
+
const parsed = parseCpe23(cpeMatch.criteria || "");
|
|
85
|
+
if (!parsed) return false;
|
|
86
|
+
|
|
87
|
+
// Hard pin in the criteria URI itself
|
|
88
|
+
if (parsed.version && parsed.version !== "*" && parsed.version !== "-") {
|
|
89
|
+
try { return compareMavenVersions(depVersion, parsed.version) === 0; }
|
|
90
|
+
catch { return false; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (cpeMatch.versionStartIncluding && compareMavenVersions(depVersion, cpeMatch.versionStartIncluding) < 0) return false;
|
|
95
|
+
if (cpeMatch.versionStartExcluding && compareMavenVersions(depVersion, cpeMatch.versionStartExcluding) <= 0) return false;
|
|
96
|
+
if (cpeMatch.versionEndIncluding && compareMavenVersions(depVersion, cpeMatch.versionEndIncluding) > 0) return false;
|
|
97
|
+
if (cpeMatch.versionEndExcluding && compareMavenVersions(depVersion, cpeMatch.versionEndExcluding) >= 0) return false;
|
|
98
|
+
} catch { return false; }
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Evaluate a node (OR/AND of cpeMatch entries). For our use we only care
|
|
104
|
+
* about the `vulnerable` matches — "AND runs on Windows" runtime context
|
|
105
|
+
* is ignored on purpose: we assume the library is being _used_, so any
|
|
106
|
+
* vulnerable-marked CPE that matches the dep version is a real hit.
|
|
107
|
+
*
|
|
108
|
+
* Returns true if any vulnerable cpeMatch in the node matches the dep.
|
|
109
|
+
*/
|
|
110
|
+
function nodeAffectsDep(node, dep, cpeCoordMap) {
|
|
111
|
+
if (!node) return false;
|
|
112
|
+
const matches = node.cpeMatch || node.cpe_match || [];
|
|
113
|
+
for (const m of matches) {
|
|
114
|
+
if (m.vulnerable === false) continue;
|
|
115
|
+
const parsed = parseCpe23(m.criteria || "");
|
|
116
|
+
if (!parsed) continue;
|
|
117
|
+
if (!cpeMatchesDep(parsed, dep, cpeCoordMap)) continue;
|
|
118
|
+
if (!matchVersionRange(dep.version, m)) continue;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
// Recurse into children (NVD nests AND/OR nodes)
|
|
122
|
+
if (Array.isArray(node.children)) {
|
|
123
|
+
for (const child of node.children) {
|
|
124
|
+
if (nodeAffectsDep(child, dep, cpeCoordMap)) return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Decide whether a CPE (parsed) refers to the same artifact as a dep.
|
|
132
|
+
* Lookup order:
|
|
133
|
+
* 1. Curated map `byVendorProduct["vendor:product"]` — array of dep keys
|
|
134
|
+
* (maven "g:a" or "npm:name"). Exact, high confidence.
|
|
135
|
+
* 2. Curated map `byProduct[product]` — same shape, used when vendor is
|
|
136
|
+
* ambiguous (Apache wraps several groupIds).
|
|
137
|
+
* 3. Heuristic: vendor token appears in groupId, or groupId/artifactId
|
|
138
|
+
* equals/contains product. Same logic as cve-match.vendorMatchesGroup
|
|
139
|
+
* but kept local here so cpe.js is standalone.
|
|
140
|
+
*/
|
|
141
|
+
function cpeMatchesDep(cpe, dep, cpeCoordMap) {
|
|
142
|
+
if (!cpe || !dep) return false;
|
|
143
|
+
if (cpe.part !== "a" && cpe.part !== "*") return false; // we only care about apps
|
|
144
|
+
const map = cpeCoordMap || loadCpeCoordMap();
|
|
145
|
+
const vp = `${cpe.vendor}:${cpe.product}`.toLowerCase();
|
|
146
|
+
const depKey = depToKey(dep).toLowerCase();
|
|
147
|
+
const altKey = altDepKey(dep).toLowerCase();
|
|
148
|
+
|
|
149
|
+
const inList = arr => Array.isArray(arr) && arr.some(k => {
|
|
150
|
+
const lk = String(k).toLowerCase();
|
|
151
|
+
return lk === depKey || lk === altKey;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (inList(map.byVendorProduct?.[vp])) return true;
|
|
155
|
+
if (inList(map.byProduct?.[cpe.product?.toLowerCase()])) return true;
|
|
156
|
+
|
|
157
|
+
// Heuristic fallback
|
|
158
|
+
if (dep.ecosystem === "npm") {
|
|
159
|
+
const name = (dep.artifactId || "").toLowerCase();
|
|
160
|
+
if (cpe.product?.toLowerCase() === name) return true;
|
|
161
|
+
// scoped packages: @scope/name → CPE vendor="scope", product="name"
|
|
162
|
+
const m = name.match(/^@([^/]+)\/(.+)$/);
|
|
163
|
+
if (m && cpe.vendor?.toLowerCase() === m[1] && cpe.product?.toLowerCase() === m[2]) return true;
|
|
164
|
+
} else {
|
|
165
|
+
const g = (dep.groupId || "").toLowerCase();
|
|
166
|
+
const a = (dep.artifactId || "").toLowerCase();
|
|
167
|
+
const v = (cpe.vendor || "").toLowerCase();
|
|
168
|
+
const p = (cpe.product || "").toLowerCase();
|
|
169
|
+
if (p !== a) return false;
|
|
170
|
+
if (g === v) return true;
|
|
171
|
+
if (g.split(".").includes(v)) return true;
|
|
172
|
+
if (g.includes(v) || v.includes(g)) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function depToKey(dep) {
|
|
178
|
+
if (dep.ecosystem === "npm") return `npm:${dep.artifactId}`;
|
|
179
|
+
return `${dep.groupId}:${dep.artifactId}`;
|
|
180
|
+
}
|
|
181
|
+
function altDepKey(dep) {
|
|
182
|
+
// For artifactId-only matching against curated lists keyed by artifact-only.
|
|
183
|
+
return dep.ecosystem === "npm" ? `npm:${dep.artifactId}` : dep.artifactId || "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Run the CPE configurations of `cveRecord` against `dep`.
|
|
188
|
+
* `cveRecord.configurations` follows NVD's shape. Returns:
|
|
189
|
+
* { affected: boolean, confidence: "exact" | "probable" | null }
|
|
190
|
+
*
|
|
191
|
+
* - "exact" when a curated mapping confirms vendor:product → dep
|
|
192
|
+
* - "probable" when only heuristic matched
|
|
193
|
+
*/
|
|
194
|
+
function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
|
|
195
|
+
const map = cpeCoordMap || loadCpeCoordMap();
|
|
196
|
+
const configs = cveRecord?.configurations || [];
|
|
197
|
+
let bestConfidence = null;
|
|
198
|
+
const note = c => {
|
|
199
|
+
if (c === "exact" || bestConfidence === "exact") bestConfidence = "exact";
|
|
200
|
+
else if (c === "probable") bestConfidence = bestConfidence || "probable";
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
for (const cfg of configs) {
|
|
204
|
+
const nodes = cfg.nodes || [];
|
|
205
|
+
const op = (cfg.operator || "OR").toUpperCase();
|
|
206
|
+
const evals = nodes.map(n => {
|
|
207
|
+
const hit = nodeAffectsDep(n, dep, map);
|
|
208
|
+
return hit;
|
|
209
|
+
});
|
|
210
|
+
const passes = op === "AND" ? evals.every(Boolean) : evals.some(Boolean);
|
|
211
|
+
if (passes) {
|
|
212
|
+
// Determine confidence: scan vulnerable cpeMatches that hit
|
|
213
|
+
for (const n of nodes) {
|
|
214
|
+
for (const m of n.cpeMatch || n.cpe_match || []) {
|
|
215
|
+
if (m.vulnerable === false) continue;
|
|
216
|
+
const parsed = parseCpe23(m.criteria || "");
|
|
217
|
+
if (!parsed) continue;
|
|
218
|
+
if (!cpeMatchesDep(parsed, dep, map)) continue;
|
|
219
|
+
if (!matchVersionRange(dep.version, m)) continue;
|
|
220
|
+
const vp = `${parsed.vendor}:${parsed.product}`.toLowerCase();
|
|
221
|
+
const depKey = depToKey(dep).toLowerCase();
|
|
222
|
+
const curated = (map.byVendorProduct?.[vp] || []).some(k => String(k).toLowerCase() === depKey);
|
|
223
|
+
note(curated ? "exact" : "probable");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fallback: no configurations[] (some NVD records only have cpes[]).
|
|
230
|
+
// We accept a hit if any CPE string matches the dep coord — but with no
|
|
231
|
+
// version range info, we can only say "possible" (caller's existing
|
|
232
|
+
// confidence stays unless we can prove version match).
|
|
233
|
+
if (!configs.length && Array.isArray(cveRecord?.cpes)) {
|
|
234
|
+
for (const uri of cveRecord.cpes) {
|
|
235
|
+
const parsed = parseCpe23(uri);
|
|
236
|
+
if (!parsed) continue;
|
|
237
|
+
if (cpeMatchesDep(parsed, dep, map)) {
|
|
238
|
+
note("probable");
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { affected: bestConfidence !== null, confidence: bestConfidence };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Walk an enriched matches list and refine each entry using NVD CPE data.
|
|
249
|
+
* Mutates each match in place:
|
|
250
|
+
* - sets m.cpeConfidence = "exact" | "probable" | null
|
|
251
|
+
* - if NVD configurations include version ranges that the dep version does NOT
|
|
252
|
+
* satisfy AND no other configuration places it in scope, sets
|
|
253
|
+
* m.cpeFiltered = true so the report can mark it as "likely false positive".
|
|
254
|
+
* - upgrades m.confidence from "possible" → "probable", "probable" → "exact"
|
|
255
|
+
* when the CPE side confirms with curated mapping.
|
|
256
|
+
*
|
|
257
|
+
* Note: m.cve.configurations is only present on NVD-enriched records. If
|
|
258
|
+
* absent, we still try m.cve.cpes[] (URI list) for vendor:product matching.
|
|
259
|
+
*/
|
|
260
|
+
function refineMatchesWithCpe(matches, opts = {}) {
|
|
261
|
+
const map = opts.cpeCoordMap || loadCpeCoordMap();
|
|
262
|
+
for (const m of matches) {
|
|
263
|
+
const cve = m.cve || {};
|
|
264
|
+
// Build a tiny record shape that evaluateCveForDep accepts
|
|
265
|
+
const rec = { configurations: cve.configurations || [], cpes: cve.cpes || [] };
|
|
266
|
+
if (!rec.configurations.length && !rec.cpes.length) continue;
|
|
267
|
+
const { affected, confidence } = evaluateCveForDep(rec, m.dep, map);
|
|
268
|
+
m.cpeConfidence = confidence;
|
|
269
|
+
if (!affected && rec.configurations.length) {
|
|
270
|
+
// Configurations exist but version doesn't satisfy any → likely FP
|
|
271
|
+
m.cpeFiltered = true;
|
|
272
|
+
}
|
|
273
|
+
if (affected && confidence === "exact" && m.confidence !== "exact") m.confidence = "exact";
|
|
274
|
+
else if (affected && confidence === "probable" && m.confidence === "possible") m.confidence = "probable";
|
|
275
|
+
}
|
|
276
|
+
return matches;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = {
|
|
280
|
+
parseCpe23,
|
|
281
|
+
matchVersionRange,
|
|
282
|
+
cpeMatchesDep,
|
|
283
|
+
nodeAffectsDep,
|
|
284
|
+
evaluateCveForDep,
|
|
285
|
+
refineMatchesWithCpe,
|
|
286
|
+
loadCpeCoordMap,
|
|
287
|
+
};
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/cve-download.js — fetch the CVEProject/cvelistV5 daily bundle and
|
|
3
|
+
* build a compact Maven-relevant index (~5-20 MB).
|
|
4
|
+
*
|
|
5
|
+
* The bundle is ~500 MB+. We shell out to curl + unzip rather than
|
|
6
|
+
* bundling a zip library. The extracted JSON is deleted after the index
|
|
7
|
+
* is built.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const os = require("os");
|
|
12
|
+
const { execFile } = require("child_process");
|
|
13
|
+
const { promisify } = require("util");
|
|
14
|
+
const execFileP = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
const CVE_DATA_DIR = path.join(os.homedir(), ".fad-check", "cve-data");
|
|
17
|
+
const CVE_INDEX_PATH = path.join(CVE_DATA_DIR, "maven-cve-index.json");
|
|
18
|
+
const CVE_META_PATH = path.join(CVE_DATA_DIR, "meta.json");
|
|
19
|
+
const CVE_EXTRACT_DIR = path.join(CVE_DATA_DIR, "extracted");
|
|
20
|
+
const CVE_ZIP_PATH = path.join(CVE_DATA_DIR, "cves.zip");
|
|
21
|
+
|
|
22
|
+
const KNOWN_JAVA_VENDORS = new Set([
|
|
23
|
+
"apache", "org.apache", "com.apache",
|
|
24
|
+
"spring", "springframework", "org.springframework", "pivotal", "pivotal_software",
|
|
25
|
+
"fasterxml", "com.fasterxml",
|
|
26
|
+
"jboss", "redhat", "wildfly",
|
|
27
|
+
"oracle", "sun", "openjdk",
|
|
28
|
+
"google", "com.google", "guava",
|
|
29
|
+
"eclipse", "org.eclipse",
|
|
30
|
+
"hibernate", "org.hibernate",
|
|
31
|
+
"netty", "io.netty",
|
|
32
|
+
"jetty", "eclipse_jetty",
|
|
33
|
+
"tomcat", "apache_tomcat",
|
|
34
|
+
"log4j", "logback",
|
|
35
|
+
"struts", "apache_struts",
|
|
36
|
+
"shiro", "apache_shiro",
|
|
37
|
+
"xstream", "thoughtworks",
|
|
38
|
+
"jackson", "snakeyaml",
|
|
39
|
+
"squareup", "okhttp",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
function isMavenRelevant(affected) {
|
|
43
|
+
if (!affected || !Array.isArray(affected)) return false;
|
|
44
|
+
for (const a of affected) {
|
|
45
|
+
if (a.packageName && String(a.packageName).includes(":")) return true;
|
|
46
|
+
if (a.collectionURL && /maven|sonatype|jboss\.org|jcenter/i.test(a.collectionURL)) return true;
|
|
47
|
+
if (a.versions?.some(v => v.versionType === "maven")) return true;
|
|
48
|
+
const v = String(a.vendor || "").toLowerCase();
|
|
49
|
+
const p = String(a.product || "").toLowerCase();
|
|
50
|
+
if (KNOWN_JAVA_VENDORS.has(v)) return true;
|
|
51
|
+
// Heuristic: product names that look like Maven artifactIds
|
|
52
|
+
if (/^(spring-|jackson-|commons-|hibernate-|netty-|guava|log4j|slf4j|okhttp|httpclient|jetty-|tomcat-|struts-)/.test(p)) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extractDescription(cveJson) {
|
|
58
|
+
const descs = cveJson?.containers?.cna?.descriptions || cveJson?.containers?.adp?.[0]?.descriptions || [];
|
|
59
|
+
const en = descs.find(d => (d.lang || "").startsWith("en"));
|
|
60
|
+
const txt = (en?.value || descs[0]?.value || "").trim();
|
|
61
|
+
return txt.length > 400 ? txt.slice(0, 400) + "…" : txt;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractSeverity(cveJson) {
|
|
65
|
+
const containers = [cveJson?.containers?.cna, ...(cveJson?.containers?.adp || [])];
|
|
66
|
+
for (const c of containers) {
|
|
67
|
+
const metrics = c?.metrics;
|
|
68
|
+
if (!Array.isArray(metrics)) continue;
|
|
69
|
+
for (const m of metrics) {
|
|
70
|
+
const cvss = m.cvssV3_1 || m.cvssV3_0 || m.cvssV4_0 || m.cvssV2_0;
|
|
71
|
+
if (cvss) {
|
|
72
|
+
return {
|
|
73
|
+
severity: (cvss.baseSeverity || severityFromScore(cvss.baseScore) || "UNKNOWN").toUpperCase(),
|
|
74
|
+
score: cvss.baseScore != null ? Number(cvss.baseScore) : null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { severity: "UNKNOWN", score: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function severityFromScore(s) {
|
|
83
|
+
if (s == null) return null;
|
|
84
|
+
if (s >= 9) return "CRITICAL";
|
|
85
|
+
if (s >= 7) return "HIGH";
|
|
86
|
+
if (s >= 4) return "MEDIUM";
|
|
87
|
+
if (s > 0) return "LOW";
|
|
88
|
+
return "NONE";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractAffectedRanges(affected) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const a of affected || []) {
|
|
94
|
+
const packageName = a.packageName && String(a.packageName).includes(":") ? a.packageName : null;
|
|
95
|
+
const vendor = a.vendor ? String(a.vendor).toLowerCase() : null;
|
|
96
|
+
const product = a.product ? String(a.product).toLowerCase() : null;
|
|
97
|
+
const versions = Array.isArray(a.versions) ? a.versions : [];
|
|
98
|
+
const ranges = versions.map(v => ({
|
|
99
|
+
version: v.version,
|
|
100
|
+
status: v.status,
|
|
101
|
+
versionType: v.versionType,
|
|
102
|
+
lessThan: v.lessThan,
|
|
103
|
+
lessThanOrEqual: v.lessThanOrEqual,
|
|
104
|
+
}));
|
|
105
|
+
out.push({ packageName, vendor, product, ranges });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fixVersionFromRanges(ranges) {
|
|
111
|
+
for (const r of ranges) {
|
|
112
|
+
if (r.lessThan) return r.lessThan;
|
|
113
|
+
if (r.lessThanOrEqual) return r.lessThanOrEqual;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractMavenRelevantCve(json) {
|
|
119
|
+
const cveId = json?.cveMetadata?.cveId || json?.cve?.id;
|
|
120
|
+
if (!cveId) return null;
|
|
121
|
+
const affected = json?.containers?.cna?.affected || [];
|
|
122
|
+
if (!isMavenRelevant(affected)) return null;
|
|
123
|
+
|
|
124
|
+
const { severity, score } = extractSeverity(json);
|
|
125
|
+
const description = extractDescription(json);
|
|
126
|
+
const ranges = extractAffectedRanges(affected);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
id: cveId,
|
|
130
|
+
severity,
|
|
131
|
+
score,
|
|
132
|
+
description,
|
|
133
|
+
published: json?.cveMetadata?.datePublished || null,
|
|
134
|
+
modified: json?.cveMetadata?.dateUpdated || null,
|
|
135
|
+
affected: ranges,
|
|
136
|
+
fixVersion: fixVersionFromRanges(ranges.flatMap(a => a.ranges)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function newIndex() {
|
|
141
|
+
return {
|
|
142
|
+
meta: { builtAt: null, releaseTag: null, cveCount: 0 },
|
|
143
|
+
byPackageName: {},
|
|
144
|
+
byProduct: {},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function addToIndex(index, cve) {
|
|
149
|
+
for (const a of cve.affected) {
|
|
150
|
+
const entry = {
|
|
151
|
+
id: cve.id,
|
|
152
|
+
severity: cve.severity,
|
|
153
|
+
score: cve.score,
|
|
154
|
+
description: cve.description,
|
|
155
|
+
fixVersion: cve.fixVersion,
|
|
156
|
+
ranges: a.ranges,
|
|
157
|
+
vendor: a.vendor,
|
|
158
|
+
product: a.product,
|
|
159
|
+
};
|
|
160
|
+
if (a.packageName) {
|
|
161
|
+
(index.byPackageName[a.packageName.toLowerCase()] ||= []).push(entry);
|
|
162
|
+
}
|
|
163
|
+
if (a.product) {
|
|
164
|
+
(index.byProduct[a.product] ||= []).push(entry);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function commandExists(name) {
|
|
170
|
+
try {
|
|
171
|
+
await execFileP(process.platform === "win32" ? "where" : "which", [name]);
|
|
172
|
+
return true;
|
|
173
|
+
} catch { return false; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function fetchLatestRelease() {
|
|
177
|
+
const headers = { "User-Agent": "fad-check-cve-downloader" };
|
|
178
|
+
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
179
|
+
const res = await fetch("https://api.github.com/repos/CVEProject/cvelistV5/releases/latest", { headers });
|
|
180
|
+
if (!res.ok) throw new Error(`GitHub API HTTP ${res.status}`);
|
|
181
|
+
const data = await res.json();
|
|
182
|
+
const asset = data.assets?.find(a => /\.zip$/i.test(a.name) && /(cves|main|all)/i.test(a.name)) || data.assets?.[0];
|
|
183
|
+
if (!asset) throw new Error("no zip asset on latest cvelistV5 release");
|
|
184
|
+
return { url: asset.browser_download_url, tag: data.tag_name, name: asset.name };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function download(url, dest, opts = {}) {
|
|
188
|
+
const { verbose } = opts;
|
|
189
|
+
if (await commandExists("curl")) {
|
|
190
|
+
const args = ["-L", "--fail", "--retry", "3", "-o", dest, url];
|
|
191
|
+
if (verbose) args.unshift("-#");
|
|
192
|
+
else args.unshift("-s");
|
|
193
|
+
await execFileP("curl", args, { maxBuffer: 1024 * 1024 * 16 });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Fallback to node fetch (slower for 500MB but works)
|
|
197
|
+
const res = await fetch(url);
|
|
198
|
+
if (!res.ok || !res.body) throw new Error(`download HTTP ${res.status}`);
|
|
199
|
+
const w = fs.createWriteStream(dest);
|
|
200
|
+
const reader = res.body.getReader();
|
|
201
|
+
while (true) {
|
|
202
|
+
const { done, value } = await reader.read();
|
|
203
|
+
if (done) break;
|
|
204
|
+
w.write(value);
|
|
205
|
+
}
|
|
206
|
+
w.end();
|
|
207
|
+
await new Promise(r => w.on("close", r));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function extractZipOnce(zip, dest) {
|
|
211
|
+
await fs.promises.mkdir(dest, { recursive: true });
|
|
212
|
+
if (await commandExists("unzip")) {
|
|
213
|
+
await execFileP("unzip", ["-o", "-q", zip, "-d", dest], { maxBuffer: 1024 * 1024 * 16 });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (process.platform === "win32" && await commandExists("powershell")) {
|
|
217
|
+
await execFileP("powershell", ["-NoProfile", "-Command",
|
|
218
|
+
`Expand-Archive -Path '${zip}' -DestinationPath '${dest}' -Force`]);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
throw new Error("no unzip/powershell available; please install unzip");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// CVEProject ships ".zip.zip" bundles — a zip whose sole content is another zip.
|
|
225
|
+
// Extract once and, if the result is a single nested zip, extract that one too.
|
|
226
|
+
async function extractZip(zip, dest, opts = {}) {
|
|
227
|
+
const { verbose, depth = 0 } = opts;
|
|
228
|
+
if (depth > 3) throw new Error("zip nesting too deep");
|
|
229
|
+
await extractZipOnce(zip, dest);
|
|
230
|
+
const entries = await fs.promises.readdir(dest, { withFileTypes: true });
|
|
231
|
+
const zips = entries.filter(e => e.isFile() && /\.zip$/i.test(e.name));
|
|
232
|
+
if (zips.length === 1 && entries.length === 1) {
|
|
233
|
+
const nested = path.join(dest, zips[0].name);
|
|
234
|
+
if (verbose) console.log(` nested zip found: ${zips[0].name} (${Math.round(fs.statSync(nested).size / 1024 / 1024)} MB) — extracting…`);
|
|
235
|
+
const nestedDest = path.join(dest, "_inner");
|
|
236
|
+
await extractZip(nested, nestedDest, { verbose, depth: depth + 1 });
|
|
237
|
+
await fs.promises.rm(nested, { force: true });
|
|
238
|
+
// Move the inner contents up
|
|
239
|
+
const innerEntries = await fs.promises.readdir(nestedDest, { withFileTypes: true });
|
|
240
|
+
for (const e of innerEntries) {
|
|
241
|
+
const from = path.join(nestedDest, e.name);
|
|
242
|
+
const to = path.join(dest, e.name);
|
|
243
|
+
await fs.promises.rename(from, to);
|
|
244
|
+
}
|
|
245
|
+
await fs.promises.rm(nestedDest, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function walkJsonFiles(dir, onFile) {
|
|
250
|
+
const stack = [dir];
|
|
251
|
+
let count = 0;
|
|
252
|
+
while (stack.length) {
|
|
253
|
+
const cur = stack.pop();
|
|
254
|
+
let entries;
|
|
255
|
+
try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
|
|
256
|
+
catch { continue; }
|
|
257
|
+
for (const e of entries) {
|
|
258
|
+
const p = path.join(cur, e.name);
|
|
259
|
+
if (e.isDirectory()) stack.push(p);
|
|
260
|
+
else if (e.name.endsWith(".json")) {
|
|
261
|
+
try { onFile(p); count++; } catch { /* skip malformed */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return count;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function buildCveIndex(extractDir, opts = {}) {
|
|
269
|
+
const { verbose, releaseTag } = opts;
|
|
270
|
+
const index = newIndex();
|
|
271
|
+
let processed = 0;
|
|
272
|
+
let added = 0;
|
|
273
|
+
const tick = 5000;
|
|
274
|
+
walkJsonFiles(extractDir, file => {
|
|
275
|
+
processed++;
|
|
276
|
+
let json;
|
|
277
|
+
try { json = JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
278
|
+
catch { return; }
|
|
279
|
+
const cve = extractMavenRelevantCve(json);
|
|
280
|
+
if (cve) {
|
|
281
|
+
addToIndex(index, cve);
|
|
282
|
+
added++;
|
|
283
|
+
}
|
|
284
|
+
if (verbose && processed % tick === 0) {
|
|
285
|
+
process.stdout.write(`\r scanned ${processed} CVE files, ${added} Maven-relevant`);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
if (verbose) process.stdout.write(`\r scanned ${processed} CVE files, ${added} Maven-relevant\n`);
|
|
289
|
+
index.meta.builtAt = new Date().toISOString();
|
|
290
|
+
index.meta.releaseTag = releaseTag || null;
|
|
291
|
+
index.meta.cveCount = added;
|
|
292
|
+
await fs.promises.mkdir(CVE_DATA_DIR, { recursive: true });
|
|
293
|
+
await fs.promises.writeFile(CVE_INDEX_PATH, JSON.stringify(index));
|
|
294
|
+
await fs.promises.writeFile(CVE_META_PATH, JSON.stringify(index.meta, null, 2));
|
|
295
|
+
return index;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function loadCveIndex() {
|
|
299
|
+
if (!fs.existsSync(CVE_INDEX_PATH)) return null;
|
|
300
|
+
try { return JSON.parse(fs.readFileSync(CVE_INDEX_PATH, "utf8")); }
|
|
301
|
+
catch { return null; }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isCveIndexFresh(maxAgeHours = 24) {
|
|
305
|
+
if (!fs.existsSync(CVE_META_PATH)) return false;
|
|
306
|
+
try {
|
|
307
|
+
const meta = JSON.parse(fs.readFileSync(CVE_META_PATH, "utf8"));
|
|
308
|
+
if (!meta.builtAt) return false;
|
|
309
|
+
const age = Date.now() - new Date(meta.builtAt).getTime();
|
|
310
|
+
return age < maxAgeHours * 3600 * 1000;
|
|
311
|
+
} catch { return false; }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function downloadCveData(opts = {}) {
|
|
315
|
+
const { verbose, force } = opts;
|
|
316
|
+
await fs.promises.mkdir(CVE_DATA_DIR, { recursive: true });
|
|
317
|
+
if (!force && isCveIndexFresh()) {
|
|
318
|
+
if (verbose) console.log("CVE index is fresh (<24h); skipping download");
|
|
319
|
+
return loadCveIndex();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (verbose) console.log("📥 fetching latest CVE release metadata…");
|
|
323
|
+
const { url, tag, name } = await fetchLatestRelease();
|
|
324
|
+
if (verbose) console.log(` ${tag} → ${name}`);
|
|
325
|
+
|
|
326
|
+
if (verbose) console.log("📥 downloading CVE zip (this may take a while)…");
|
|
327
|
+
await download(url, CVE_ZIP_PATH, { verbose });
|
|
328
|
+
|
|
329
|
+
if (verbose) console.log("📦 extracting…");
|
|
330
|
+
try { await fs.promises.rm(CVE_EXTRACT_DIR, { recursive: true, force: true }); } catch {}
|
|
331
|
+
await extractZip(CVE_ZIP_PATH, CVE_EXTRACT_DIR, { verbose });
|
|
332
|
+
|
|
333
|
+
if (verbose) console.log("🔎 building Maven CVE index…");
|
|
334
|
+
const index = await buildCveIndex(CVE_EXTRACT_DIR, { verbose, releaseTag: tag });
|
|
335
|
+
|
|
336
|
+
// cleanup heavy files
|
|
337
|
+
try { await fs.promises.rm(CVE_EXTRACT_DIR, { recursive: true, force: true }); } catch {}
|
|
338
|
+
try { await fs.promises.rm(CVE_ZIP_PATH, { force: true }); } catch {}
|
|
339
|
+
|
|
340
|
+
return index;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Single entry point used by fad-check.js: returns a usable CVE index,
|
|
345
|
+
* downloading/refreshing as needed.
|
|
346
|
+
*/
|
|
347
|
+
async function ensureCveIndex(opts = {}) {
|
|
348
|
+
const { force, offline, verbose } = opts;
|
|
349
|
+
const cached = loadCveIndex();
|
|
350
|
+
if (offline) {
|
|
351
|
+
if (!cached) throw new Error("--cve-offline set but no cached CVE index found");
|
|
352
|
+
return cached;
|
|
353
|
+
}
|
|
354
|
+
if (!force && cached && isCveIndexFresh()) return cached;
|
|
355
|
+
return downloadCveData({ verbose, force });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = {
|
|
359
|
+
CVE_DATA_DIR,
|
|
360
|
+
CVE_INDEX_PATH,
|
|
361
|
+
CVE_META_PATH,
|
|
362
|
+
downloadCveData,
|
|
363
|
+
buildCveIndex,
|
|
364
|
+
loadCveIndex,
|
|
365
|
+
isCveIndexFresh,
|
|
366
|
+
ensureCveIndex,
|
|
367
|
+
extractMavenRelevantCve,
|
|
368
|
+
isMavenRelevant,
|
|
369
|
+
};
|