fad-checker 2.2.3 → 2.3.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.
@@ -22,7 +22,13 @@ const LOCKS = [
22
22
  ["pdm.lock", P.parsePdmLock],
23
23
  ];
24
24
 
25
- const FALLBACK_MANIFESTS = ["pyproject.toml", "requirements.txt"];
25
+ // The whole requirements*.txt family, not just the literal "requirements.txt": projects
26
+ // using pip-compile/Dependabot keep their actual pins in requirements-pinned.txt (or
27
+ // similar) while requirements.txt holds only ranges. requirements-(dev|test|lint|docs|…)
28
+ // are dev/optional tooling.
29
+ const REQ_RE = /^requirements.*\.txt$/i;
30
+ const DEV_REQ_RE = /requirements[-_.](dev|test|tests|lint|docs?|typing|type|check|ci|mypy|style|format)\b/i;
31
+ const reqFilesIn = names => [...names].filter(n => REQ_RE.test(n)).sort();
26
32
 
27
33
  function findPyDirs(dir, skipDir = (child, name) => SKIP.has(name)) {
28
34
  const groups = [];
@@ -31,7 +37,8 @@ function findPyDirs(dir, skipDir = (child, name) => SKIP.has(name)) {
31
37
  const cur = stack.pop();
32
38
  let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
33
39
  const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
34
- if ([...LOCKS.map(l => l[0]), ...FALLBACK_MANIFESTS].some(n => names.has(n))) groups.push({ dir: cur, names });
40
+ const qualifies = LOCKS.some(l => names.has(l[0])) || names.has("pyproject.toml") || reqFilesIn(names).length > 0;
41
+ if (qualifies) groups.push({ dir: cur, names });
35
42
  for (const e of entries) if (e.isDirectory() && !skipDir(path.join(cur, e.name), e.name)) stack.push(path.join(cur, e.name));
36
43
  }
37
44
  return groups;
@@ -53,46 +60,62 @@ module.exports = {
53
60
  const { ignoreTest, deps2Exclude } = opts;
54
61
  const out = new Map();
55
62
  const warnings = [];
63
+ const parsedManifests = [];
56
64
  const add = (d, manifestPath) => {
57
65
  if (ignoreTest && d.isDev) return;
58
66
  if (deps2Exclude && deps2Exclude.test(d.name)) return;
59
- out.set(coordKeyFor("pypi", "", d.name), makeDepRecord({ ecosystem: "pypi", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev }));
67
+ const key = coordKeyFor("pypi", "", d.name);
68
+ const rec = makeDepRecord({ ecosystem: "pypi", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev });
69
+ const existing = out.get(key);
70
+ if (!existing) { out.set(key, rec); return; }
71
+ // Same package across several requirements files: merge manifests, keep a concrete
72
+ // version, and let prod win over dev (declared prod anywhere ⇒ prod).
73
+ for (const p of rec.manifestPaths) if (!existing.manifestPaths.includes(p)) existing.manifestPaths.push(p);
74
+ if (!existing.version && rec.version) { existing.version = rec.version; if (!existing.versions.includes(rec.version)) existing.versions.push(rec.version); }
75
+ if (existing.isDev && !rec.isDev) { existing.isDev = false; existing.scope = rec.scope || "prod"; }
60
76
  };
61
77
  for (const g of findPyDirs(dir, pyDirFilter(dir, opts))) {
62
78
  const lock = LOCKS.find(([n]) => g.names.has(n));
63
79
  if (lock) {
64
80
  const fp = path.join(g.dir, lock[0]);
81
+ parsedManifests.push(fp);
65
82
  try { const { deps } = lock[1](fp); for (const d of deps) add(d, fp); }
66
83
  catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${lock[0]} parse failed: ${e.message}` }); }
67
84
  continue; // lockfile is authoritative for this directory
68
85
  }
69
- // No lockfile → best-effort from pyproject.toml and/or requirements.txt.
70
- let pinned = 0, skipped = 0;
86
+ // No lockfile → best-effort from pyproject.toml and EVERY requirements*.txt file.
87
+ let skipped = 0;
71
88
  const sources = [];
89
+ const before = out.size;
72
90
  if (g.names.has("pyproject.toml")) {
73
91
  const fp = path.join(g.dir, "pyproject.toml");
92
+ parsedManifests.push(fp);
74
93
  try {
75
94
  const r = P.parsePyprojectToml(fp);
76
95
  for (const d of r.deps) add(d, fp);
77
- pinned += r.deps.length; skipped += r.skipped;
96
+ skipped += r.skipped;
78
97
  if (r.deps.length || r.skipped) sources.push("pyproject.toml");
79
98
  } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `pyproject.toml parse failed: ${e.message}` }); }
80
99
  }
81
- if (g.names.has("requirements.txt")) {
82
- const fp = path.join(g.dir, "requirements.txt");
100
+ for (const rf of reqFilesIn(g.names)) {
101
+ const fp = path.join(g.dir, rf);
102
+ parsedManifests.push(fp);
103
+ const devFile = DEV_REQ_RE.test(rf);
83
104
  try {
84
105
  const r = P.parseRequirementsTxt(fp);
85
- for (const d of r.deps) add(d, fp);
86
- pinned += r.deps.length; skipped += r.skipped;
87
- sources.push("requirements.txt");
106
+ for (const d of r.deps) add(devFile ? { ...d, isDev: true, scope: "dev" } : d, fp);
107
+ skipped += r.skipped;
108
+ sources.push(rf);
88
109
  for (const miss of (r.missing || [])) warnings.push({ type: "missing-include", manifestPath: fp, message: `referenced requirements file not found: ${miss}` });
89
- } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `requirements.txt parse failed: ${e.message}` }); }
110
+ } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${rf} parse failed: ${e.message}` }); }
90
111
  }
91
112
  if (sources.length) {
92
- warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no lockfile (${sources.join(" + ")}) — best-effort: ${pinned} pinned, ${skipped} range(s) skipped` });
113
+ const pinned = out.size - before;
114
+ const shownSrc = sources.length > 4 ? `${sources.slice(0, 4).join(" + ")} + ${sources.length - 4} more` : sources.join(" + ");
115
+ warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no lockfile (${shownSrc}) — best-effort: ${pinned} pinned, ${skipped} range(s) skipped` });
93
116
  }
94
117
  }
95
- return { deps: out, warnings };
118
+ return { deps: out, warnings, parsedManifests };
96
119
  },
97
120
 
98
121
  coordKey(d) { return coordKeyFor("pypi", "", d.name); },
@@ -56,6 +56,23 @@ const maven = {
56
56
  directSection: "B. Or update the direct dependencies pulling them in",
57
57
  };
58
58
 
59
+ function gradleConstraintsSnippet(items) {
60
+ const inner = items.map(it => ` implementation("${esc(it.groupId)}:${esc(it.artifactId)}:${esc(it.fixVersion)}")`).join("\n");
61
+ return `dependencies {
62
+ constraints {
63
+ ${inner}
64
+ }
65
+ }`;
66
+ }
67
+
68
+ const gradle = {
69
+ label: "Gradle",
70
+ pinSection: "A. Pin vulnerable transitives via a constraints { } block",
71
+ pinIntro: cnt => `Add to the (sub)project <code>build.gradle(.kts)</code> dependencies block to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
72
+ snippet: gradleConstraintsSnippet,
73
+ directSection: "B. Or bump the direct dependency (or its version in <code>gradle/libs.versions.toml</code>)",
74
+ };
75
+
59
76
  const npm = {
60
77
  label: "npm",
61
78
  pinSection: "A. Pin vulnerable transitives via npm overrides",
@@ -140,4 +157,4 @@ const binary = {
140
157
  directSection: "B. Prefer declaring these through a package manager (Maven/npm/NuGet/…)",
141
158
  };
142
159
 
143
- module.exports = { maven, npm, yarn, composer, pypi, nuget, go, ruby, binary, dependencyManagementSnippet, npmOverridesSnippet, yarnResolutionsSnippet, composerRequireSnippet, pipInstallSnippet, dotnetAddSnippet, goGetSnippet, bundleUpdateSnippet };
160
+ module.exports = { maven, gradle, npm, yarn, composer, pypi, nuget, go, ruby, binary, dependencyManagementSnippet, gradleConstraintsSnippet, npmOverridesSnippet, yarnResolutionsSnippet, composerRequireSnippet, pipInstallSnippet, dotnetAddSnippet, goGetSnippet, bundleUpdateSnippet };
@@ -44,7 +44,9 @@ module.exports = {
44
44
  const { deps2Exclude } = opts;
45
45
  const out = new Map();
46
46
  const warnings = [];
47
+ const parsedManifests = [];
47
48
  for (const fp of findGemfileLocks(dir, dirFilter(dir, opts))) {
49
+ parsedManifests.push(fp);
48
50
  try {
49
51
  const { deps } = R.parseGemfileLockFile(fp);
50
52
  for (const d of deps) {
@@ -53,7 +55,7 @@ module.exports = {
53
55
  }
54
56
  } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `Gemfile.lock parse failed: ${e.message}` }); }
55
57
  }
56
- return { deps: out, warnings };
58
+ return { deps: out, warnings, parsedManifests };
57
59
  },
58
60
 
59
61
  coordKey(d) { return coordKeyFor("ruby", "", d.name); },
package/lib/cpe.js CHANGED
@@ -147,6 +147,31 @@ function nodeAffectsDep(node, dep, cpeCoordMap) {
147
147
  return false;
148
148
  }
149
149
 
150
+ /**
151
+ * Does ANY vulnerable cpeMatch in this CVE name the dep's product/vendor — IGNORING the
152
+ * version range? This distinguishes "NVD has a CPE for this product but the dep version is
153
+ * out of range" (a genuine false positive worth filtering) from "NVD's CPE never names this
154
+ * product at all" (a coverage gap — NOT a false positive: the dep's own matcher, OSV or the
155
+ * CVE index, found it on a real coordinate, and CPE simply can't speak to it).
156
+ */
157
+ function cveCpeNamesDep(cveRecord, dep, cpeCoordMap) {
158
+ const map = cpeCoordMap || loadCpeCoordMap();
159
+ const scan = nodes => {
160
+ for (const n of (nodes || [])) {
161
+ for (const m of (n.cpeMatch || n.cpe_match || [])) {
162
+ if (m.vulnerable === false) continue;
163
+ const parsed = parseCpe23Cached(m);
164
+ if (parsed && cpeMatchesDep(parsed, dep, map)) return true;
165
+ }
166
+ if (Array.isArray(n.children) && scan(n.children)) return true;
167
+ }
168
+ return false;
169
+ };
170
+ for (const cfg of (cveRecord?.configurations || [])) if (scan(cfg.nodes)) return true;
171
+ for (const uri of (cveRecord?.cpes || [])) { const p = parseCpe23(uri); if (p && cpeMatchesDep(p, dep, map)) return true; }
172
+ return false;
173
+ }
174
+
150
175
  /**
151
176
  * A node is "context-only" when it carries no vulnerable cpeMatch (recursively) —
152
177
  * e.g. the "…AND runs on linux_kernel" platform node, whose CPEs are all
@@ -309,8 +334,19 @@ function refineMatchesWithCpe(matches, opts = {}) {
309
334
  if (!rec.configurations.length && !rec.cpes.length) continue;
310
335
  const { affected, confidence } = evaluateCveForDep(rec, m.dep, map);
311
336
  m.cpeConfidence = confidence;
312
- if (!affected && rec.configurations.length) {
313
- // Configurations exist but version doesn't satisfy any likely FP
337
+ // Mark a match as a CPE false-positive ONLY for a genuine VERSION contradiction:
338
+ // NVD's CPE names this product but the dep version is outside the vulnerable range.
339
+ // NEVER filter when:
340
+ // - the match is OSV-confirmed — OSV did a precise, ecosystem-native version match
341
+ // (its data is authoritative for the package ecosystems; don't let NVD override it);
342
+ // - NVD's CPE never names this product (`!productNamed`) — that's a coverage gap, not
343
+ // a false positive (covers every pypi/composer/nuget/go/ruby coord, and any maven/
344
+ // npm artifact whose CPE product name simply differs from its coordinate).
345
+ // Without these guards the filter silently hid REAL CVEs (e.g. cryptography 46.0.5,
346
+ // fixed in 46.0.6) by dumping them into the false-positives appendix.
347
+ const isOsv = String(m.source || "").includes("osv");
348
+ const productNamed = cveCpeNamesDep(rec, m.dep, map);
349
+ if (!affected && productNamed && !isOsv) {
314
350
  m.cpeFiltered = true;
315
351
  }
316
352
  if (affected && confidence === "exact" && m.confidence !== "exact") m.confidence = "exact";
@@ -325,6 +361,7 @@ module.exports = {
325
361
  cpeMatchesDep,
326
362
  nodeAffectsDep,
327
363
  evaluateCveForDep,
364
+ cveCpeNamesDep,
328
365
  refineMatchesWithCpe,
329
366
  loadCpeCoordMap,
330
367
  };
package/lib/cve-match.js CHANGED
@@ -122,6 +122,19 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
122
122
  * for transitive deps. This mirrors Maven's behaviour where the root
123
123
  * project's <dependencyManagement> wins over transitive depMgmt.
124
124
  */
125
+ /**
126
+ * A transitive coord is itself a Maven artifact (ecosystem "maven"), but for REPORT
127
+ * grouping we attribute its ecosystemType to the build tool that pulled it in: if every
128
+ * root reaching it (the first node of each via-chain) is a Gradle direct dep, it belongs
129
+ * in the Gradle chapter; a Maven (or mixed/unknown) root keeps it in the Maven chapter.
130
+ * @param viaPaths array of chains [[rootCoord, …], …]; @param rootEcoType Map coordKey→type
131
+ */
132
+ function inheritEcoType(viaPaths, rootEcoType) {
133
+ const roots = (viaPaths || []).map(p => p && p[0]).filter(Boolean);
134
+ const types = new Set(roots.map(r => rootEcoType.get(r)).filter(Boolean));
135
+ return (types.size === 1 && types.has("gradle")) ? "gradle" : "maven";
136
+ }
137
+
125
138
  async function expandWithTransitives(resolvedDeps, opts = {}) {
126
139
  const { verbose } = opts;
127
140
  // Root depMgmt: every dep in the Map that has a concrete version becomes a
@@ -146,6 +159,11 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
146
159
 
147
160
  if (verbose) console.log(`🌳 Resolving transitives for ${directs.length} direct deps…`);
148
161
 
162
+ // Root → ecosystemType, so a transitive pulled only by Gradle roots is reported in the
163
+ // Gradle chapter (not the Maven one) on a pure-Gradle project.
164
+ const rootEcoType = new Map();
165
+ for (const d of directs) rootEcoType.set(`${d.groupId}:${d.artifactId}`, d.ecosystemType || d.ecosystem || "maven");
166
+
149
167
  const transitives = await resolveTransitiveDeps(directs, {
150
168
  ...opts,
151
169
  rootDepMgmt,
@@ -165,7 +183,7 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
165
183
  viaPaths: t.viaPaths || [t.via],
166
184
  depth: t.depth,
167
185
  ecosystem: "maven",
168
- ecosystemType: "maven",
186
+ ecosystemType: inheritEcoType(t.viaPaths || [t.via], rootEcoType),
169
187
  });
170
188
  added++;
171
189
  }
@@ -281,6 +299,7 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
281
299
  module.exports = {
282
300
  collectResolvedDeps,
283
301
  expandWithTransitives,
302
+ inheritEcoType,
284
303
  matchDepsAgainstCves,
285
304
  resolveDepVersion,
286
305
  vendorMatchesGroup,