fad-checker 2.4.3 → 2.4.5
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/AI_POLICY.md +74 -0
- package/CHANGELOG.md +171 -0
- package/CONTRIBUTING.md +61 -0
- package/LICENSE +21 -0
- package/README.md +62 -36
- package/SECURITY.md +60 -0
- package/fad-checker.js +38 -1
- package/lib/attribution.js +116 -0
- package/lib/core.js +58 -2
- package/lib/cpe.js +101 -9
- package/lib/cve-match.js +43 -2
- package/lib/dep-record.js +9 -0
- package/lib/maven-version.js +21 -0
- package/lib/retire.js +19 -4
- package/lib/transitive.js +22 -2
- package/lib/version-overlay.js +67 -22
- package/package.json +1 -1
package/fad-checker.js
CHANGED
|
@@ -250,6 +250,7 @@ program
|
|
|
250
250
|
.option("--no-all-libs", "skip Maven Central queries (outdated check + missing-on-central check)")
|
|
251
251
|
.option("--no-osv", "skip OSV.dev (Google/GitHub aggregated Maven CVE feed)")
|
|
252
252
|
.option("--no-nvd", "skip NIST NVD enrichment of matched CVEs")
|
|
253
|
+
.option("--nvd-cpe-match", "ALSO match deps against NVD CPE version ranges (opt-in, LOW PRECISION: ~12% of the findings it adds were corroborated by another scanner — triage aid, not a default)")
|
|
253
254
|
.option("--no-epss", "skip EPSS (FIRST.org exploit-prediction) enrichment")
|
|
254
255
|
.option("--no-kev", "skip CISA KEV (known-exploited) enrichment")
|
|
255
256
|
// Output family: each --report-<type> takes an OPTIONAL path (omit → default name
|
|
@@ -1090,6 +1091,30 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1090
1091
|
try {
|
|
1091
1092
|
const { enrichMatches } = require("./lib/nvd");
|
|
1092
1093
|
await enrichMatches(cveMatches, { verbose, offline, onProgress: (p, t) => st.tick(p, t) });
|
|
1094
|
+
|
|
1095
|
+
// 4c-bis. NVD CPE ranges as an ADDITIVE tier, curated coordinates only.
|
|
1096
|
+
// OSV/GHSA declare affected ranges per release BRANCH; NVD declares them for
|
|
1097
|
+
// every affected branch. For CVE-2020-9546, OSV covers 2.9.0–2.9.10.4 while
|
|
1098
|
+
// NVD also covers 2.0.0–2.7.9.7, so jackson-databind 2.5.2 is affected and
|
|
1099
|
+
// never got a fix. The data was already in the NVD cache and only ever used
|
|
1100
|
+
// to FILTER. Bounded on purpose: `data/cpe-coord-map.json` coordinates only
|
|
1101
|
+
// (no name heuristics — that is what makes CPE-driven scanners noisy), and
|
|
1102
|
+
// only CVEs already enriched, so this adds no network path of its own.
|
|
1103
|
+
let nvdAdded = 0;
|
|
1104
|
+
if (options.nvdCpeMatch) try {
|
|
1105
|
+
const { matchDepsAgainstNvdCpe } = require("./lib/cpe");
|
|
1106
|
+
const records = {};
|
|
1107
|
+
for (const m of cveMatches) {
|
|
1108
|
+
const id = m.cve?.id;
|
|
1109
|
+
if (id && m.cve.configurations?.length && !records[id]) records[id] = m.cve;
|
|
1110
|
+
}
|
|
1111
|
+
const extra = matchDepsAgainstNvdCpe(resolved, records);
|
|
1112
|
+
if (extra.length) {
|
|
1113
|
+
const before = cveMatches.length;
|
|
1114
|
+
cveMatches = mergeBySource(cveMatches, extra);
|
|
1115
|
+
nvdAdded = cveMatches.length - before;
|
|
1116
|
+
}
|
|
1117
|
+
} catch (err) { ui.warn(`NVD CPE matching skipped: ${err.message}`); }
|
|
1093
1118
|
// 4d. CPE refinement — use NVD's CPE configurations to upgrade match
|
|
1094
1119
|
// confidence and flag likely false positives (version outside CPE range).
|
|
1095
1120
|
let filtered = 0;
|
|
@@ -1099,7 +1124,7 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1099
1124
|
filtered = cveMatches.filter(m => m.cpeFiltered).length;
|
|
1100
1125
|
} catch (err) { ui.warn(`CPE refinement skipped: ${err.message}`); }
|
|
1101
1126
|
const uniqueCves = new Set(cveMatches.map(m => m.cve?.id)).size;
|
|
1102
|
-
st.done(`${uniqueCves} CVE${filtered ? ` · ${filtered} false-positive(s) filtered` : ""}${hasNvdKey ? "" : " · no key (slow)"}`);
|
|
1127
|
+
st.done(`${uniqueCves} CVE${nvdAdded ? ` · +${nvdAdded} via CPE ranges` : ""}${filtered ? ` · ${filtered} false-positive(s) filtered` : ""}${hasNvdKey ? "" : " · no key (slow)"}`);
|
|
1103
1128
|
} catch (err) { st.fail(err.message); }
|
|
1104
1129
|
}
|
|
1105
1130
|
}
|
|
@@ -1203,6 +1228,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1203
1228
|
}
|
|
1204
1229
|
}
|
|
1205
1230
|
|
|
1231
|
+
// 6a-bis. Attribute every match to the manifest/module that actually resolves the
|
|
1232
|
+
// version it matched on. A depRecord is coord-wide (versions[] and manifestPaths[]
|
|
1233
|
+
// with no link between them); a match carries ONE version. Must run after ALL match
|
|
1234
|
+
// sources are merged and before anything reads scope/paths (exec summary, charts,
|
|
1235
|
+
// chapters, exports, gate) — otherwise, on a root spanning several independent
|
|
1236
|
+
// projects, every version is reported against every manifest holding the coord.
|
|
1237
|
+
{
|
|
1238
|
+
const { attributeMatchOrigins } = require("./lib/attribution");
|
|
1239
|
+
const reattributed = attributeMatchOrigins(cveMatches);
|
|
1240
|
+
if (reattributed && verbose) console.log(` re-attributed ${reattributed} match(es) to their resolving manifest/module`);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1206
1243
|
// 6b. Supply-chain risk lane (pure + offline): flag KNOWN-MALICIOUS advisories
|
|
1207
1244
|
// (OSV MAL-… already in the match set) always, and detect suspected TYPOSQUATS
|
|
1208
1245
|
// (opt-in --typosquat, heuristic — names one edit from a popular package).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/attribution.js — attribute each MATCH to the manifest/module that actually
|
|
3
|
+
* resolves the version it was matched on.
|
|
4
|
+
*
|
|
5
|
+
* A depRecord is COORD-WIDE: the collectors merge every occurrence of a g:a across the
|
|
6
|
+
* whole scan root into ONE record, so `versions[]` and `manifestPaths[]` are both
|
|
7
|
+
* coord-wide sets with no link between them, and `scope` is a single merged value. A
|
|
8
|
+
* MATCH, by contrast, carries exactly ONE version (matchOne clones the record per
|
|
9
|
+
* version). Left alone that clone inherits the coord-wide manifest list and the merged
|
|
10
|
+
* scope — so every version is reported against every manifest holding the coord.
|
|
11
|
+
*
|
|
12
|
+
* Invisible on a single reactor; wrong on the real-world shape fad is pointed at: an
|
|
13
|
+
* audit root holding SEVERAL INDEPENDENT projects (each pom with its own external
|
|
14
|
+
* parent, no shared reactor). Project A pins jackson-databind 2.15.3, project B pins
|
|
15
|
+
* 2.17.0 → both projects get reported as holding BOTH versions, and each version's CVEs
|
|
16
|
+
* land on both. Same class of error for a version the per-module overlay recovered: it
|
|
17
|
+
* is a TRANSITIVE of one module, not the direct declaration the record describes —
|
|
18
|
+
* unattributed it tops the exec summary's "direct production dependencies" pointing at
|
|
19
|
+
* the very pom that pins the fixed version.
|
|
20
|
+
*
|
|
21
|
+
* Two provenance sources, checked per match:
|
|
22
|
+
* - `maskedVersions[]` (lib/version-overlay) — the version is a transitive of ONE
|
|
23
|
+
* module: re-stamp scope/via/depth and file it under that module.
|
|
24
|
+
* - `versionPaths{}` (lib/dep-record, maintained by the collectors) — the version is
|
|
25
|
+
* DECLARED in a known subset of manifests: narrow the match to that subset.
|
|
26
|
+
* A version with neither (e.g. a global-pass transitive) is left untouched.
|
|
27
|
+
*
|
|
28
|
+
* Runs ONCE, after every match source is merged (CVE index, OSV, snyk) and before
|
|
29
|
+
* anything reads scope/paths (exec summary, charts, chapters, exports, gate).
|
|
30
|
+
* Pure w.r.t. the scan set: replaces `m.dep` with a clone, never mutating the shared
|
|
31
|
+
* record — other matches still reference it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** manifestPaths and pomPaths must stay the SAME array (dep-record.js invariant). */
|
|
35
|
+
function withPaths(dep, paths) {
|
|
36
|
+
const p = paths.slice();
|
|
37
|
+
return { ...dep, manifestPaths: p, pomPaths: p };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sameList(a, b) {
|
|
41
|
+
const x = a || [], y = b || [];
|
|
42
|
+
return x.length === y.length && x.every((v, i) => v === y[i]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param matches the merged match set (mutated: each m.dep may be replaced by a clone)
|
|
47
|
+
* @returns number of matches re-attributed
|
|
48
|
+
*/
|
|
49
|
+
function attributeMatchOrigins(matches) {
|
|
50
|
+
let fixed = 0;
|
|
51
|
+
for (const m of matches || []) {
|
|
52
|
+
const dep = m && m.dep;
|
|
53
|
+
if (!dep || !dep.version) continue;
|
|
54
|
+
const ver = String(dep.version);
|
|
55
|
+
|
|
56
|
+
// A DECLARED version always wins over a transitive provenance for the same version.
|
|
57
|
+
// The overlay may legitimately also reach it as a transitive of some other module, but
|
|
58
|
+
// a manifest that writes `<version>` for a coord is the authority on that version.
|
|
59
|
+
// Without this precedence, xstream:1.4.10 — declared outright in
|
|
60
|
+
// dubbo-registry-eureka — was re-stamped from a test-scoped transitive provenance in
|
|
61
|
+
// dubbo-config-api and demoted out of production.
|
|
62
|
+
const declaredHere = dep.versionPaths && dep.versionPaths[ver];
|
|
63
|
+
const isDeclared = Array.isArray(declaredHere) && declaredHere.length > 0;
|
|
64
|
+
|
|
65
|
+
// 1. Recovered by the per-module overlay → a transitive of ONE module.
|
|
66
|
+
// ALL entries for this version, not the first: the same version is routinely masked in
|
|
67
|
+
// several modules, and they can disagree on scope. On Dubbo, jackson-databind:2.10.4 is
|
|
68
|
+
// test-scoped in dubbo-config-spring but COMPILE-scoped in dubbo-configcenter-nacos —
|
|
69
|
+
// taking whichever entry happened to be recorded first would call it dev and drop a
|
|
70
|
+
// genuine production finding out of the count and out of --fail-on.
|
|
71
|
+
const maskedAll = !isDeclared && Array.isArray(dep.maskedVersions)
|
|
72
|
+
? dep.maskedVersions.filter(x => String(x.version) === ver)
|
|
73
|
+
: [];
|
|
74
|
+
if (maskedAll.length) {
|
|
75
|
+
// Dev only when EVERY module resolving this version does so at test scope. Entries
|
|
76
|
+
// written before the overlay recorded scope have none — treat those as unknown and
|
|
77
|
+
// leave the coord-wide flag alone rather than guessing.
|
|
78
|
+
const scoped = maskedAll.filter(x => x.scope);
|
|
79
|
+
const isDev = scoped.length === maskedAll.length && scoped.every(x => x.scope === "test");
|
|
80
|
+
// Show the production path when there is one: it is the chain an auditor must act on.
|
|
81
|
+
const masked = maskedAll.find(x => x.scope && x.scope !== "test") || maskedAll[0];
|
|
82
|
+
const modules = [...new Set(maskedAll.map(x => x.module).filter(Boolean))];
|
|
83
|
+
m.dep = {
|
|
84
|
+
...withPaths(dep, modules),
|
|
85
|
+
scope: "transitive",
|
|
86
|
+
via: masked.via || [],
|
|
87
|
+
viaPaths: masked.viaPaths || (masked.via ? [masked.via] : []),
|
|
88
|
+
depth: masked.depth,
|
|
89
|
+
...(scoped.length ? { isDev } : {}),
|
|
90
|
+
};
|
|
91
|
+
fixed++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. Declared → narrow to the manifest(s) that declare THIS version, and take the
|
|
96
|
+
// scope from those declarations. `isDev` on the record is coord-wide, so a version
|
|
97
|
+
// declared only at test scope would otherwise inherit the coordinate's production
|
|
98
|
+
// flag. Dev only when EVERY declaration of this version is test/provided — the same
|
|
99
|
+
// widest-wins rule the masked branch uses.
|
|
100
|
+
const declared = dep.versionPaths && dep.versionPaths[ver];
|
|
101
|
+
if (Array.isArray(declared) && declared.length) {
|
|
102
|
+
const scopes = dep.versionScopes && dep.versionScopes[ver];
|
|
103
|
+
const isDev = Array.isArray(scopes) && scopes.length
|
|
104
|
+
? scopes.every(s => s === "test" || s === "provided")
|
|
105
|
+
: undefined;
|
|
106
|
+
const narrow = !sameList(declared, dep.manifestPaths);
|
|
107
|
+
if (narrow || (isDev !== undefined && isDev !== !!dep.isDev)) {
|
|
108
|
+
m.dep = { ...withPaths(dep, declared), ...(isDev !== undefined ? { isDev } : {}) };
|
|
109
|
+
fixed++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return fixed;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { attributeMatchOrigins };
|
package/lib/core.js
CHANGED
|
@@ -19,6 +19,47 @@ const SKIP_DIRS = new Set([
|
|
|
19
19
|
|
|
20
20
|
const coord = v => (v == null ? null : String(v).trim() || null);
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Interpolate `${…}` in a single string against a POM property map. Values arrive from
|
|
24
|
+
* xml2js as arrays, hence the unwrap. Resolution is recursive (`${a}` → `${b}` → value),
|
|
25
|
+
* bounded, and stops when it stops making progress; an unknown reference is left verbatim
|
|
26
|
+
* so it still surfaces downstream as an unresolved version rather than a wrong one.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately local to core.js: the equivalent in cve-match.js can't be reused without
|
|
29
|
+
* making core depend on it, and cve-match already depends on core.
|
|
30
|
+
*/
|
|
31
|
+
function interpolate(value, props) {
|
|
32
|
+
if (value == null) return value;
|
|
33
|
+
let out = String(value);
|
|
34
|
+
for (let i = 0; i < 10 && out.includes("${"); i++) {
|
|
35
|
+
const next = out.replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
|
|
36
|
+
const val = props?.[k];
|
|
37
|
+
if (Array.isArray(val)) return val[0];
|
|
38
|
+
return val != null ? val : m;
|
|
39
|
+
});
|
|
40
|
+
if (next === out) break;
|
|
41
|
+
out = next;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A dependencyManagement node with its coordinate resolved against `props`. Returns a NEW
|
|
48
|
+
* node — these xml2js structures are shared across POMs, so mutating one corrupts every
|
|
49
|
+
* other reader of the same parsed document.
|
|
50
|
+
*/
|
|
51
|
+
function resolveDepNodeProps(dep, props) {
|
|
52
|
+
if (!dep || !props) return dep;
|
|
53
|
+
const out = { ...dep };
|
|
54
|
+
for (const field of ["groupId", "artifactId", "version"]) {
|
|
55
|
+
const raw = dep[field]?.[0];
|
|
56
|
+
if (raw == null) continue;
|
|
57
|
+
const resolved = interpolate(raw, props);
|
|
58
|
+
if (resolved !== raw) out[field] = [resolved];
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
22
63
|
const defaultPomSkip = child => SKIP_DIRS.has(path.basename(child));
|
|
23
64
|
|
|
24
65
|
function findPomFiles(dir, skipDir = defaultPomSkip) {
|
|
@@ -224,9 +265,24 @@ async function getAllInheritedProps(pomPath, allPomMetadata, cache) {
|
|
|
224
265
|
|
|
225
266
|
for (const pom of toImport) {
|
|
226
267
|
const imported = await getAllInheritedProps(pom.pomPath, allPomMetadata, cache);
|
|
227
|
-
|
|
268
|
+
// `<scope>import</scope>` imports a BOM's <dependencyManagement> and NOTHING else.
|
|
269
|
+
// The BOM resolves its managed versions in ITS OWN property context, then those
|
|
270
|
+
// resolved versions are what the importer receives — its <properties> never become
|
|
271
|
+
// the importer's. Merging them (and merging them so the BOM WINS, as this line used
|
|
272
|
+
// to) lets a BOM silently redefine the importing project's own property values.
|
|
273
|
+
//
|
|
274
|
+
// Real case, Apache Dubbo 2.7.8: the reactor root sets
|
|
275
|
+
// <hibernate_validator_version>5.2.4.Final</…>, dubbo-dependencies-bom redefines it
|
|
276
|
+
// to 5.4.1.Final, and dubbo-filter-validation's <version>${hibernate_validator_version}</version>
|
|
277
|
+
// resolved to the wrong one — a different version means a different CVE set.
|
|
278
|
+
// `mvn dependency:tree` reports 5.2.4.Final for that module.
|
|
279
|
+
//
|
|
280
|
+
// So: interpolate the BOM's managed entries against the BOM's properties here, at
|
|
281
|
+
// the import boundary, and drop the properties. (lib/transitive.js#effectivePom
|
|
282
|
+
// already does the equivalent for EXTERNAL import BOMs.)
|
|
228
283
|
merged.dependencies.push(...imported.dependencies);
|
|
229
|
-
merged.dependencyManagement.push(
|
|
284
|
+
merged.dependencyManagement.push(
|
|
285
|
+
...imported.dependencyManagement.map(d => resolveDepNodeProps(d, imported.properties)));
|
|
230
286
|
}
|
|
231
287
|
|
|
232
288
|
// Cache the (still-mutating) object BEFORE recursing into the parent so a
|
package/lib/cpe.js
CHANGED
|
@@ -127,21 +127,21 @@ function matchVersionRange(depVersion, cpeMatch) {
|
|
|
127
127
|
*
|
|
128
128
|
* Returns true if any vulnerable cpeMatch in the node matches the dep.
|
|
129
129
|
*/
|
|
130
|
-
function nodeAffectsDep(node, dep, cpeCoordMap) {
|
|
130
|
+
function nodeAffectsDep(node, dep, cpeCoordMap, opts = {}) {
|
|
131
131
|
if (!node) return false;
|
|
132
132
|
const matches = node.cpeMatch || node.cpe_match || [];
|
|
133
133
|
for (const m of matches) {
|
|
134
134
|
if (m.vulnerable === false) continue;
|
|
135
135
|
const parsed = parseCpe23Cached(m);
|
|
136
136
|
if (!parsed) continue;
|
|
137
|
-
if (!cpeMatchesDep(parsed, dep, cpeCoordMap)) continue;
|
|
137
|
+
if (!cpeMatchesDep(parsed, dep, cpeCoordMap, opts)) continue;
|
|
138
138
|
if (!matchVersionRange(dep.version, m)) continue;
|
|
139
139
|
return true;
|
|
140
140
|
}
|
|
141
141
|
// Recurse into children (NVD nests AND/OR nodes)
|
|
142
142
|
if (Array.isArray(node.children)) {
|
|
143
143
|
for (const child of node.children) {
|
|
144
|
-
if (nodeAffectsDep(child, dep, cpeCoordMap)) return true;
|
|
144
|
+
if (nodeAffectsDep(child, dep, cpeCoordMap, opts)) return true;
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
return false;
|
|
@@ -198,7 +198,7 @@ function nodeIsContextOnly(node) {
|
|
|
198
198
|
* equals/contains product. Same logic as cve-match.vendorMatchesGroup
|
|
199
199
|
* but kept local here so cpe.js is standalone.
|
|
200
200
|
*/
|
|
201
|
-
function cpeMatchesDep(cpe, dep, cpeCoordMap) {
|
|
201
|
+
function cpeMatchesDep(cpe, dep, cpeCoordMap, opts = {}) {
|
|
202
202
|
if (!cpe || !dep) return false;
|
|
203
203
|
if (cpe.part !== "a" && cpe.part !== "*") return false; // we only care about apps
|
|
204
204
|
const map = cpeCoordMap || loadCpeCoordMap();
|
|
@@ -214,6 +214,12 @@ function cpeMatchesDep(cpe, dep, cpeCoordMap) {
|
|
|
214
214
|
if (inList(map.byVendorProduct?.[vp])) return true;
|
|
215
215
|
if (inList(map.byProduct?.[cpe.product?.toLowerCase()])) return true;
|
|
216
216
|
|
|
217
|
+
// `curatedOnly` stops here. The heuristic below is safe when REFINING a match another
|
|
218
|
+
// source already made (worst case it declines to upgrade confidence), but it is not safe
|
|
219
|
+
// as a way to CREATE matches: name-similarity between a Maven coordinate and a CPE
|
|
220
|
+
// product is exactly what makes CPE-driven scanners noisy. matchDepsAgainstNvdCpe sets it.
|
|
221
|
+
if (opts.curatedOnly) return false;
|
|
222
|
+
|
|
217
223
|
// Heuristic fallback
|
|
218
224
|
if (dep.ecosystem === "npm") {
|
|
219
225
|
const name = (dep.artifactId || "").toLowerCase();
|
|
@@ -252,7 +258,7 @@ function altDepKey(dep) {
|
|
|
252
258
|
* - "exact" when a curated mapping confirms vendor:product → dep
|
|
253
259
|
* - "probable" when only heuristic matched
|
|
254
260
|
*/
|
|
255
|
-
function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
|
|
261
|
+
function evaluateCveForDep(cveRecord, dep, cpeCoordMap, opts = {}) {
|
|
256
262
|
const map = cpeCoordMap || loadCpeCoordMap();
|
|
257
263
|
const configs = cveRecord?.configurations || [];
|
|
258
264
|
let bestConfidence = null;
|
|
@@ -272,9 +278,9 @@ function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
|
|
|
272
278
|
// this, a "vulnerable:false" platform node makes .every() false and the real
|
|
273
279
|
// finding is wrongly dropped as a CPE false-positive.
|
|
274
280
|
const vulnNodes = nodes.filter(n => !nodeIsContextOnly(n));
|
|
275
|
-
passes = vulnNodes.length > 0 && vulnNodes.every(n => nodeAffectsDep(n, dep, map));
|
|
281
|
+
passes = vulnNodes.length > 0 && vulnNodes.every(n => nodeAffectsDep(n, dep, map, opts));
|
|
276
282
|
} else {
|
|
277
|
-
passes = nodes.some(n => nodeAffectsDep(n, dep, map));
|
|
283
|
+
passes = nodes.some(n => nodeAffectsDep(n, dep, map, opts));
|
|
278
284
|
}
|
|
279
285
|
if (passes) {
|
|
280
286
|
// Determine confidence: scan vulnerable cpeMatches that hit
|
|
@@ -283,7 +289,7 @@ function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
|
|
|
283
289
|
if (m.vulnerable === false) continue;
|
|
284
290
|
const parsed = parseCpe23Cached(m);
|
|
285
291
|
if (!parsed) continue;
|
|
286
|
-
if (!cpeMatchesDep(parsed, dep, map)) continue;
|
|
292
|
+
if (!cpeMatchesDep(parsed, dep, map, opts)) continue;
|
|
287
293
|
if (!matchVersionRange(dep.version, m)) continue;
|
|
288
294
|
const vp = `${parsed.vendor}:${parsed.product}`.toLowerCase();
|
|
289
295
|
const depKey = depToKey(dep).toLowerCase();
|
|
@@ -302,7 +308,7 @@ function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
|
|
|
302
308
|
for (const uri of cveRecord.cpes) {
|
|
303
309
|
const parsed = parseCpe23(uri);
|
|
304
310
|
if (!parsed) continue;
|
|
305
|
-
if (cpeMatchesDep(parsed, dep, map)) {
|
|
311
|
+
if (cpeMatchesDep(parsed, dep, map, opts)) {
|
|
306
312
|
note("probable");
|
|
307
313
|
break;
|
|
308
314
|
}
|
|
@@ -355,10 +361,96 @@ function refineMatchesWithCpe(matches, opts = {}) {
|
|
|
355
361
|
return matches;
|
|
356
362
|
}
|
|
357
363
|
|
|
364
|
+
/**
|
|
365
|
+
* ADDITIVE matching tier: NVD's CPE version ranges, for CURATED coordinates only.
|
|
366
|
+
*
|
|
367
|
+
* OSV/GHSA declare affected ranges per release *branch* — for CVE-2020-9546 only
|
|
368
|
+
* 2.9.0–2.9.10.4, the branch that got a fix. NVD declares three ranges for the same CVE
|
|
369
|
+
* (`2.0.0 ≤ v < 2.7.9.7`, `2.8.0 ≤ v < 2.8.11.6`, `2.9.0 ≤ v < 2.9.10.4`), so
|
|
370
|
+
* jackson-databind 2.5.2 is affected and simply never received a fix. fad already had that
|
|
371
|
+
* data — every enriched CVE's `configurations` sits in the NVD cache — but only ever used it
|
|
372
|
+
* SUBTRACTIVELY, to filter false positives. This uses it additively.
|
|
373
|
+
*
|
|
374
|
+
* MEASURED PRECISION IS POOR, which is why the orchestrator keeps this behind an opt-in flag
|
|
375
|
+
* (`--nvd-cpe-match`). On Dubbo 2.7.8 it adds 76 findings of which only 9 (12%) are
|
|
376
|
+
* corroborated by Snyk. The cause is structural and not fixable by better curation: CPE
|
|
377
|
+
* products are FRAMEWORK-level (`spring_framework`, `netty`, `log4j`) while Maven coordinates
|
|
378
|
+
* are ARTIFACT-level, so a framework CVE lands on every artifact of that framework —
|
|
379
|
+
* CVE-2016-1000027 is a spring-web flaw and CPE puts it on spring-core. This is the same
|
|
380
|
+
* limitation that makes CPE-driven scanners noisy. Useful as a triage aid ("what might I be
|
|
381
|
+
* missing?"), not as a default.
|
|
382
|
+
*
|
|
383
|
+
* Strictly bounded, because the cost of getting this wrong is a false-positive engine:
|
|
384
|
+
* - `curatedOnly`: a coordinate must appear in `data/cpe-coord-map.json`. No name heuristics.
|
|
385
|
+
* - Only CVEs already in the NVD cache are considered — no new fetch, no new network path.
|
|
386
|
+
* - Every distinct concrete version is evaluated; unresolved or `${…}` versions never match,
|
|
387
|
+
* mirroring the rule in cve-match.js that an unversioned dep is not assumed vulnerable.
|
|
388
|
+
*
|
|
389
|
+
* @param resolvedDeps Map<coordKey, depRecord>
|
|
390
|
+
* @param nvdRecordsById {[cveId]: nvdRecord} records with `configurations`
|
|
391
|
+
* @returns match[] in the shape mergeBySource expects, tagged `source: "nvd"`
|
|
392
|
+
*/
|
|
393
|
+
function matchDepsAgainstNvdCpe(resolvedDeps, nvdRecordsById, opts = {}) {
|
|
394
|
+
const map = opts.cpeCoordMap || loadCpeCoordMap();
|
|
395
|
+
const out = [];
|
|
396
|
+
if (!resolvedDeps || !nvdRecordsById) return out;
|
|
397
|
+
|
|
398
|
+
// UNAMBIGUOUS entries only: a CPE product that maps to a SINGLE Maven coordinate.
|
|
399
|
+
// The curated map deliberately maps one CPE to several artifacts — its own comment says
|
|
400
|
+
// so ("one CPE may legitimately map to several Maven artifacts"). That is right for
|
|
401
|
+
// FILTERING, where any member matching means "don't call this a false positive", and
|
|
402
|
+
// catastrophic for MATCHING, where it would assert the CVE affects all of them.
|
|
403
|
+
// Measured on Dubbo: allowing 1:N entries produced 262 new findings of which only 8%
|
|
404
|
+
// were corroborated by any other scanner — CVE-2016-1000027 (a spring-web flaw) landed
|
|
405
|
+
// on spring-core/-beans/-context, CVE-2019-20444 (netty-codec-http) on
|
|
406
|
+
// netty-common/-codec/-handler. That is precisely the CPE noise this tier must not add.
|
|
407
|
+
const curated = new Set();
|
|
408
|
+
for (const list of [...Object.values(map.byVendorProduct || {}), ...Object.values(map.byProduct || {})]) {
|
|
409
|
+
if (!Array.isArray(list) || list.length !== 1) continue;
|
|
410
|
+
curated.add(String(list[0]).toLowerCase());
|
|
411
|
+
}
|
|
412
|
+
const candidates = [];
|
|
413
|
+
for (const dep of resolvedDeps.values()) {
|
|
414
|
+
if (!dep || dep.ecosystem !== "maven") continue;
|
|
415
|
+
if (dep.provenance === "binary") continue; // no coordinate, identified by hash
|
|
416
|
+
const key = `${dep.groupId}:${dep.artifactId}`.toLowerCase();
|
|
417
|
+
if (!curated.has(key)) continue;
|
|
418
|
+
const versions = (Array.isArray(dep.versions) && dep.versions.length ? dep.versions : [dep.version])
|
|
419
|
+
.filter(v => v && !/\$\{/.test(String(v)));
|
|
420
|
+
if (versions.length) candidates.push([dep, versions]);
|
|
421
|
+
}
|
|
422
|
+
if (!candidates.length) return out;
|
|
423
|
+
|
|
424
|
+
for (const [cveId, record] of Object.entries(nvdRecordsById)) {
|
|
425
|
+
if (!record?.configurations?.length) continue;
|
|
426
|
+
for (const [dep, versions] of candidates) {
|
|
427
|
+
for (const version of versions) {
|
|
428
|
+
const probe = { ...dep, version };
|
|
429
|
+
if (!evaluateCveForDep(record, probe, map, { curatedOnly: true }).affected) continue;
|
|
430
|
+
out.push({
|
|
431
|
+
dep: probe,
|
|
432
|
+
cve: {
|
|
433
|
+
id: cveId,
|
|
434
|
+
severity: record.severity || "UNKNOWN",
|
|
435
|
+
score: record.score ?? null,
|
|
436
|
+
description: record.description || "",
|
|
437
|
+
cvssVector: record.cvssVector || null,
|
|
438
|
+
published: record.published || null,
|
|
439
|
+
},
|
|
440
|
+
source: "nvd",
|
|
441
|
+
confidence: "exact", // curated coordinate + explicit NVD version range
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
|
|
358
449
|
module.exports = {
|
|
359
450
|
parseCpe23,
|
|
360
451
|
matchVersionRange,
|
|
361
452
|
cpeMatchesDep,
|
|
453
|
+
matchDepsAgainstNvdCpe,
|
|
362
454
|
nodeAffectsDep,
|
|
363
455
|
evaluateCveForDep,
|
|
364
456
|
cveCpeNamesDep,
|
package/lib/cve-match.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @email: pp9ping@gmail.com
|
|
7
7
|
*/
|
|
8
8
|
const { coord } = require("./core");
|
|
9
|
-
const { compareMavenVersions, isVersionAffected } = require("./maven-version");
|
|
9
|
+
const { compareMavenVersions, isVersionAffected, normalizeHardPin } = require("./maven-version");
|
|
10
10
|
const { resolveTransitiveDeps } = require("./transitive");
|
|
11
11
|
const { makeDepRecord } = require("./dep-record");
|
|
12
12
|
|
|
@@ -29,7 +29,13 @@ function resolveDepVersion(rawVersion, allProps) {
|
|
|
29
29
|
if (next === v) break; // no progress → remaining refs are unresolvable
|
|
30
30
|
v = next;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
// Maven hard pin: "[1.2.3]" means EXACTLY 1.2.3. It is a concrete version wearing range
|
|
33
|
+
// brackets, so unwrap it — keeping the brackets corrupts the coordinate for every consumer
|
|
34
|
+
// downstream (report, purl, SBOM/CSAF/SARIF/JSON) and makes the finding un-joinable with
|
|
35
|
+
// any other tool's output. A comma means a genuine range ("[1.0,2.0)", "(,1.5]") and is
|
|
36
|
+
// left verbatim: picking a version out of it is resolution, not interpolation, and it
|
|
37
|
+
// still surfaces as unresolved. Runs AFTER interpolation so "[${netty.version}]" works.
|
|
38
|
+
return normalizeHardPin(v);
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
/**
|
|
@@ -83,6 +89,24 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
|
83
89
|
// A dep is "dev" overall only if every occurrence is test/provided.
|
|
84
90
|
if (!isDev) existing.isDev = false;
|
|
85
91
|
}
|
|
92
|
+
// Remember WHICH pom declares THIS version. The record is coord-wide, so
|
|
93
|
+
// this is the only link back from a scanned version to its manifest —
|
|
94
|
+
// without it a match on one version is reported against every pom that
|
|
95
|
+
// declares the coord at any version (lib/attribution.js consumes this).
|
|
96
|
+
if (concrete) {
|
|
97
|
+
const rec = out.get(key);
|
|
98
|
+
if (!rec.versionPaths) rec.versionPaths = {};
|
|
99
|
+
const paths = rec.versionPaths[concrete] || (rec.versionPaths[concrete] = []);
|
|
100
|
+
if (!paths.includes(pomPath)) paths.push(pomPath);
|
|
101
|
+
// …and WHICH SCOPE that declaration uses. `isDev` on the record is coord-wide
|
|
102
|
+
// (false as soon as any occurrence is production), so without this a version
|
|
103
|
+
// declared only at test scope inherits the coordinate's production flag and
|
|
104
|
+
// inflates both the production count and the CI gate. Mirror of
|
|
105
|
+
// maskedVersions[].scope for overlay-recovered versions.
|
|
106
|
+
if (!rec.versionScopes) rec.versionScopes = {};
|
|
107
|
+
const scopes = rec.versionScopes[concrete] || (rec.versionScopes[concrete] = []);
|
|
108
|
+
if (!scopes.includes(scope)) scopes.push(scope);
|
|
109
|
+
}
|
|
86
110
|
}
|
|
87
111
|
};
|
|
88
112
|
|
|
@@ -164,8 +188,20 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
164
188
|
const rootEcoType = new Map();
|
|
165
189
|
for (const d of directs) rootEcoType.set(`${d.groupId}:${d.artifactId}`, d.ecosystemType || d.ecosystem || "maven");
|
|
166
190
|
|
|
191
|
+
// Maven's scope matrix: `test → compile = test`. The compile dependencies of a
|
|
192
|
+
// test-scoped dep ARE on the test classpath, recursively — only `test → test` is
|
|
193
|
+
// omitted (SCOPE_MATRIX handles that). So when test roots are in the scan set, "test"
|
|
194
|
+
// has to be an accepted propagated scope too, otherwise every child of a test root is
|
|
195
|
+
// discarded at the first hop and the dev chapter only ever shows DIRECTLY declared test
|
|
196
|
+
// deps. Measured on Apache Dubbo 2.7.8: this is what hid spring-boot:1.5.17.RELEASE,
|
|
197
|
+
// which `mvn dependency:tree` reports at scope=test via
|
|
198
|
+
// registry-test → registry-server-integration → spring-boot-starter.
|
|
199
|
+
const includedScopes = opts.includedScopes
|
|
200
|
+
|| (opts.includeTestDeps ? ["compile", "runtime", "provided", "test"] : undefined);
|
|
201
|
+
|
|
167
202
|
const transitives = await resolveTransitiveDeps(directs, {
|
|
168
203
|
...opts,
|
|
204
|
+
...(includedScopes ? { includedScopes } : {}),
|
|
169
205
|
rootDepMgmt,
|
|
170
206
|
});
|
|
171
207
|
|
|
@@ -178,6 +214,11 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
178
214
|
version: t.version,
|
|
179
215
|
versions: t.version && !/\$\{/.test(t.version) ? [t.version] : [],
|
|
180
216
|
scope: "transitive",
|
|
217
|
+
// A coord reachable ONLY through a test-scoped root lives on the test classpath.
|
|
218
|
+
// It must land in the dev chapter and stay out of the production count and the
|
|
219
|
+
// CI gate — reporting a test-only dependency as a production finding is a false
|
|
220
|
+
// positive that costs an auditor real time.
|
|
221
|
+
isDev: t.scope === "test",
|
|
181
222
|
pomPaths: [],
|
|
182
223
|
via: t.via,
|
|
183
224
|
viaPaths: t.viaPaths || [t.via],
|
package/lib/dep-record.js
CHANGED
|
@@ -55,6 +55,15 @@ function makeDepRecord(input) {
|
|
|
55
55
|
name,
|
|
56
56
|
version: version || null,
|
|
57
57
|
versions: concrete ? [concrete] : [],
|
|
58
|
+
// Per-version → the manifest(s) DECLARING that version. `versions[]` and
|
|
59
|
+
// `manifestPaths[]` are both coord-wide sets with no link between them, so
|
|
60
|
+
// neither says which manifest declared which version. A match carries ONE
|
|
61
|
+
// version, so without this link it renders against EVERY manifest holding the
|
|
62
|
+
// coord: on a scan root spanning several independent projects (A pins
|
|
63
|
+
// jackson-databind 2.15.3, B pins 2.17.0) both projects are reported as holding
|
|
64
|
+
// BOTH versions and each version's CVEs land on both. Maintained on merge by the
|
|
65
|
+
// collectors; consumed by lib/attribution.js.
|
|
66
|
+
versionPaths: concrete && manifestPath ? { [concrete]: [manifestPath] } : {},
|
|
58
67
|
coordKey,
|
|
59
68
|
provenance,
|
|
60
69
|
hashes,
|
package/lib/maven-version.js
CHANGED
|
@@ -164,10 +164,31 @@ function parseRange(rangeStr) {
|
|
|
164
164
|
};
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Maven's hard-pin syntax: "[1.2.3]" means EXACTLY 1.2.3. It is a concrete version wearing
|
|
169
|
+
* range brackets, and upstream POMs on Maven Central do use it (e.g. netty declares
|
|
170
|
+
* "[4.1.35.Final]"). Unwrap it to the bare version.
|
|
171
|
+
*
|
|
172
|
+
* Keeping the brackets corrupts the coordinate for every consumer downstream — the report,
|
|
173
|
+
* the purl, and the SBOM/CSAF/SARIF/JSON exports — and makes the finding un-joinable with any
|
|
174
|
+
* other tool's output for the same dependency.
|
|
175
|
+
*
|
|
176
|
+
* A comma means a genuine range ("[1.0,2.0)", "(,1.5]") and is returned unchanged: choosing a
|
|
177
|
+
* version out of a range is resolution, not normalisation, and an unresolved range should keep
|
|
178
|
+
* surfacing as unresolved rather than silently becoming a concrete version.
|
|
179
|
+
*/
|
|
180
|
+
function normalizeHardPin(versionStr) {
|
|
181
|
+
if (versionStr == null) return versionStr;
|
|
182
|
+
const s = String(versionStr).trim();
|
|
183
|
+
const pin = /^\[\s*([^,\[\]]+?)\s*\]$/.exec(s);
|
|
184
|
+
return pin ? pin[1] : versionStr;
|
|
185
|
+
}
|
|
186
|
+
|
|
167
187
|
module.exports = {
|
|
168
188
|
parseMavenVersion,
|
|
169
189
|
compareMavenVersions,
|
|
170
190
|
isVersionAffected,
|
|
171
191
|
versionLikeBound,
|
|
172
192
|
parseRange,
|
|
193
|
+
normalizeHardPin,
|
|
173
194
|
};
|
package/lib/retire.js
CHANGED
|
@@ -173,18 +173,33 @@ const DEFAULT_RETIRE_SKIP_DIRS = [
|
|
|
173
173
|
*
|
|
174
174
|
* retire walks with POSIX-style paths (its own matchers use `/`), so we emit `/`.
|
|
175
175
|
*/
|
|
176
|
-
function buildRetireIgnorePatterns({ srcDir, excludePath = [], defaultExcludes = true } = {}) {
|
|
176
|
+
function buildRetireIgnorePatterns({ srcDir, excludePath = [], defaultExcludes = true, sep = path.sep } = {}) {
|
|
177
177
|
const lines = [];
|
|
178
|
+
// retire compiles each line into a REGEX and tests it against the file path AND
|
|
179
|
+
// path.resolve(file) — both of which use BACKSLASHES on Windows. A forward-slash
|
|
180
|
+
// pattern therefore never matches there, and every exclusion (including the defaults
|
|
181
|
+
// that keep retire out of node_modules) is silently inert. A character class like
|
|
182
|
+
// [\\/] is not an option: retire escapes `[` and `]` before compiling. So emit the
|
|
183
|
+
// POSIX form plus, on Windows, the native-separator twin — retire's `.some()` means
|
|
184
|
+
// either one matching is enough, and on POSIX the twin is identical and deduped away.
|
|
185
|
+
const push = line => { if (!lines.includes(line)) lines.push(line); };
|
|
186
|
+
const emit = body => {
|
|
187
|
+
push(`@${body}`);
|
|
188
|
+
if (sep === "\\") push(`@${body.split("/").join("\\")}`);
|
|
189
|
+
};
|
|
190
|
+
|
|
178
191
|
if (defaultExcludes !== false) {
|
|
179
|
-
for (const d of DEFAULT_RETIRE_SKIP_DIRS)
|
|
192
|
+
for (const d of DEFAULT_RETIRE_SKIP_DIRS) emit(`/${d}/`);
|
|
180
193
|
}
|
|
181
|
-
|
|
194
|
+
// Separator-agnostic on INPUT (path.resolve yields backslashes on Windows); `sep`
|
|
195
|
+
// only decides what we emit.
|
|
196
|
+
const root = srcDir ? path.resolve(srcDir).replace(/\\/g, "/").replace(/\/+$/, "") : "";
|
|
182
197
|
for (const g of excludePath || []) {
|
|
183
198
|
// Normalise like compileGlobs: strip a leading ./ or /, a trailing /, and a
|
|
184
199
|
// trailing /** (the dir's whole subtree is already covered by the segment).
|
|
185
200
|
const base = String(g || "").trim().replace(/^\.?\/+/, "").replace(/\/+$/, "").replace(/\/\*\*$/, "");
|
|
186
201
|
if (!base) continue;
|
|
187
|
-
|
|
202
|
+
emit(root ? `${root}/${base}/` : `/${base}/`);
|
|
188
203
|
}
|
|
189
204
|
return lines;
|
|
190
205
|
}
|
package/lib/transitive.js
CHANGED
|
@@ -26,6 +26,7 @@ const fs = require("fs");
|
|
|
26
26
|
const path = require("path");
|
|
27
27
|
const os = require("os");
|
|
28
28
|
const { parseStringPromise } = require("xml2js");
|
|
29
|
+
const { normalizeHardPin } = require("./maven-version");
|
|
29
30
|
|
|
30
31
|
const POM_CACHE_DIR = path.join(os.homedir(), ".fad-checker", "poms-cache");
|
|
31
32
|
const MAVEN_CENTRAL = "https://repo1.maven.org/maven2";
|
|
@@ -140,7 +141,7 @@ async function parsePomXml(xml) {
|
|
|
140
141
|
return depsBlock.dependency.map(d => ({
|
|
141
142
|
groupId: coord(d.groupId?.[0]),
|
|
142
143
|
artifactId: coord(d.artifactId?.[0]),
|
|
143
|
-
version: coord(d.version?.[0]),
|
|
144
|
+
version: normalizeHardPin(coord(d.version?.[0])),
|
|
144
145
|
scope: coord(d.scope?.[0]) || "compile",
|
|
145
146
|
optional: d.optional?.[0] === "true",
|
|
146
147
|
type: coord(d.type?.[0]) || "jar",
|
|
@@ -242,7 +243,7 @@ async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
|
|
|
242
243
|
...d,
|
|
243
244
|
groupId: resolveProps(d.groupId, effProps, builtins),
|
|
244
245
|
artifactId: resolveProps(d.artifactId, effProps, builtins),
|
|
245
|
-
version: resolveProps(d.version, effProps, builtins),
|
|
246
|
+
version: normalizeHardPin(resolveProps(d.version, effProps, builtins)),
|
|
246
247
|
});
|
|
247
248
|
merged.depMgmt = merged.depMgmt.map(resolveDep);
|
|
248
249
|
merged.deps = merged.deps.map(resolveDep);
|
|
@@ -392,6 +393,25 @@ async function resolveTransitiveDeps(directDeps, opts = {}) {
|
|
|
392
393
|
if (!existing.viaPaths.some(p => p.join("→") === sig)) {
|
|
393
394
|
existing.viaPaths.push(via);
|
|
394
395
|
}
|
|
396
|
+
// WIDEST scope wins across paths, and the version follows the scope.
|
|
397
|
+
// We dedupe by `g:a` and keep the first chain walked, but scope is a
|
|
398
|
+
// property of the coord, not of the chain: a coord reachable from both a
|
|
399
|
+
// test root and a compile root really is on the compile classpath.
|
|
400
|
+
// Without this, BFS order decides two things it has no business
|
|
401
|
+
// deciding. (1) The scope: a production dependency reached through a
|
|
402
|
+
// test path first gets stamped "test" and drops out of the production
|
|
403
|
+
// count and the --fail-on gate. (2) The VERSION: the test path's version
|
|
404
|
+
// would be reported as the production one, so a test-only version gets
|
|
405
|
+
// scanned as production while the version actually on the compile
|
|
406
|
+
// classpath is never scanned at all. So on widening we adopt the wider
|
|
407
|
+
// path's version and chain too. Never narrow, only widen; the version
|
|
408
|
+
// the test path holds is recovered per-module by lib/version-overlay.js.
|
|
409
|
+
if (existing.scope === "test" && propagated !== "test") {
|
|
410
|
+
existing.scope = propagated;
|
|
411
|
+
existing.version = resolvedVersion;
|
|
412
|
+
existing.via = via;
|
|
413
|
+
existing.depth = node.depth + 1;
|
|
414
|
+
}
|
|
395
415
|
}
|
|
396
416
|
continue;
|
|
397
417
|
}
|