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/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
- merged.properties = { ...merged.properties, ...imported.properties };
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(...imported.dependencyManagement);
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
- return v;
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,
@@ -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) lines.push(`@/${d}/`);
192
+ for (const d of DEFAULT_RETIRE_SKIP_DIRS) emit(`/${d}/`);
180
193
  }
181
- const root = srcDir ? path.resolve(srcDir).split(path.sep).join("/").replace(/\/+$/, "") : "";
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
- lines.push(root ? `@${root}/${base}/` : `@/${base}/`);
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
  }