fad-checker 2.4.3 → 2.4.4

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
@@ -1203,6 +1203,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
1203
1203
  }
1204
1204
  }
1205
1205
 
1206
+ // 6a-bis. Attribute every match to the manifest/module that actually resolves the
1207
+ // version it matched on. A depRecord is coord-wide (versions[] and manifestPaths[]
1208
+ // with no link between them); a match carries ONE version. Must run after ALL match
1209
+ // sources are merged and before anything reads scope/paths (exec summary, charts,
1210
+ // chapters, exports, gate) — otherwise, on a root spanning several independent
1211
+ // projects, every version is reported against every manifest holding the coord.
1212
+ {
1213
+ const { attributeMatchOrigins } = require("./lib/attribution");
1214
+ const reattributed = attributeMatchOrigins(cveMatches);
1215
+ if (reattributed && verbose) console.log(` re-attributed ${reattributed} match(es) to their resolving manifest/module`);
1216
+ }
1217
+
1206
1218
  // 6b. Supply-chain risk lane (pure + offline): flag KNOWN-MALICIOUS advisories
1207
1219
  // (OSV MAL-… already in the match set) always, and detect suspected TYPOSQUATS
1208
1220
  // (opt-in --typosquat, heuristic — names one edit from a popular package).
@@ -0,0 +1,82 @@
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
+ // 1. Recovered by the per-module overlay → a transitive of ONE module.
57
+ const masked = dep.scope !== "transitive" && Array.isArray(dep.maskedVersions)
58
+ ? dep.maskedVersions.find(x => String(x.version) === ver)
59
+ : null;
60
+ if (masked) {
61
+ m.dep = {
62
+ ...withPaths(dep, masked.module ? [masked.module] : []),
63
+ scope: "transitive",
64
+ via: masked.via || [],
65
+ viaPaths: masked.viaPaths || (masked.via ? [masked.via] : []),
66
+ depth: masked.depth,
67
+ };
68
+ fixed++;
69
+ continue;
70
+ }
71
+
72
+ // 2. Declared → narrow to the manifest(s) that declare THIS version.
73
+ const declared = dep.versionPaths && dep.versionPaths[ver];
74
+ if (Array.isArray(declared) && declared.length && !sameList(declared, dep.manifestPaths)) {
75
+ m.dep = withPaths(dep, declared);
76
+ fixed++;
77
+ }
78
+ }
79
+ return fixed;
80
+ }
81
+
82
+ module.exports = { attributeMatchOrigins };
package/lib/cve-match.js CHANGED
@@ -83,6 +83,16 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
83
83
  // A dep is "dev" overall only if every occurrence is test/provided.
84
84
  if (!isDev) existing.isDev = false;
85
85
  }
86
+ // Remember WHICH pom declares THIS version. The record is coord-wide, so
87
+ // this is the only link back from a scanned version to its manifest —
88
+ // without it a match on one version is reported against every pom that
89
+ // declares the coord at any version (lib/attribution.js consumes this).
90
+ if (concrete) {
91
+ const rec = out.get(key);
92
+ if (!rec.versionPaths) rec.versionPaths = {};
93
+ const paths = rec.versionPaths[concrete] || (rec.versionPaths[concrete] = []);
94
+ if (!paths.includes(pomPath)) paths.push(pomPath);
95
+ }
86
96
  }
87
97
  };
88
98
 
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,
@@ -121,27 +121,48 @@ async function buildModuleManagement(pomPath, store, propsByPom, opts = {}) {
121
121
  }
122
122
 
123
123
  /**
124
- * The direct dependencies declared by ONE module, with concrete versions resolved
125
- * (own ${properties} → module's managed map → the global resolved version). These
126
- * seed the per-module transitive walk.
124
+ * The direct dependencies of ONE module, with concrete versions resolved (own
125
+ * ${properties} → module's managed map → the global resolved version). These seed
126
+ * the per-module transitive walk.
127
+ *
128
+ * Includes the <dependencies> INHERITED from the local parent chain. Maven inherits
129
+ * them into the child, where they stay DIRECT (depth-0) deps and therefore beat any
130
+ * transitive of the same coord — but core.js#getAllInheritedProps deliberately does
131
+ * NOT merge parent <dependencies> into propsByPom[child] (collectResolvedDeps merges
132
+ * them globally by g:a instead). Reading only the module's own <dependencies> would
133
+ * hide a parent-declared remediation (e.g. commons-compress:1.27.1 overriding the
134
+ * vulnerable 1.24.0 that minio pulls in) and let the overlay "recover" the very
135
+ * version that declaration overrides — fabricating CVEs against a version no
136
+ * classpath holds.
137
+ *
138
+ * Chain is child-first, so the closest declaration of a g:a wins, like Maven. `props`
139
+ * is the child's merged map (parent props as base, child overrides on top), which is
140
+ * the right context for a parent-declared dep too.
127
141
  */
128
- function buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, opts = {}) {
142
+ function buildModuleDirects(pomPath, store, propsByPom, moduleMgmt, resolvedDeps, opts = {}) {
129
143
  const entry = propsByPom[pomPath];
130
144
  if (!entry) return [];
131
145
  const props = entry.properties || {};
132
146
  const out = [];
133
- for (const node of entry.dependencies || []) {
134
- const d = flattenSafe(node);
135
- if (!d || !d.groupId || !d.artifactId || d.optional || d.isImport) continue;
136
- if (d.scope === "test" && !opts.includeTestDeps) continue;
137
- if (d.scope === "system" || d.scope === "import") continue;
138
- // Interpolate ${…} in the coordinate (e.g. ${project.groupId}) like the version.
139
- const gid = resolveDepVersion(d.groupId, props);
140
- const aid = resolveDepVersion(d.artifactId, props);
141
- let v = resolveDepVersion(d.rawVersion, props);
142
- if (!isConcrete(v)) v = moduleMgmt.get(`${gid}:${aid}`) || resolvedDeps.get(`${gid}:${aid}`)?.version || null;
143
- if (!isConcrete(v)) continue;
144
- out.push({ groupId: gid, artifactId: aid, version: String(v), scope: d.scope, exclusions: d.exclusions });
147
+ const seen = new Set();
148
+ const chain = store ? localChain(pomPath, store).chain : [pomPath];
149
+ for (const pom of chain) {
150
+ for (const node of (propsByPom[pom] || {}).dependencies || []) {
151
+ const d = flattenSafe(node);
152
+ if (!d || !d.groupId || !d.artifactId || d.optional || d.isImport) continue;
153
+ if (d.scope === "test" && !opts.includeTestDeps) continue;
154
+ if (d.scope === "system" || d.scope === "import") continue;
155
+ // Interpolate ${…} in the coordinate (e.g. ${project.groupId}) like the version.
156
+ const gid = resolveDepVersion(d.groupId, props);
157
+ const aid = resolveDepVersion(d.artifactId, props);
158
+ const key = `${gid}:${aid}`;
159
+ if (seen.has(key)) continue;
160
+ let v = resolveDepVersion(d.rawVersion, props);
161
+ if (!isConcrete(v)) v = moduleMgmt.get(key) || resolvedDeps.get(key)?.version || null;
162
+ if (!isConcrete(v)) continue;
163
+ seen.add(key);
164
+ out.push({ groupId: gid, artifactId: aid, version: String(v), scope: d.scope, exclusions: d.exclusions });
165
+ }
145
166
  }
146
167
  return out;
147
168
  }
@@ -163,7 +184,7 @@ async function expandPerModuleOverlay(resolvedDeps, store, propsByPom, opts = {}
163
184
 
164
185
  for (const pomPath of Object.keys(propsByPom)) {
165
186
  const moduleMgmt = await buildModuleManagement(pomPath, store, propsByPom, tOpts);
166
- const directs = buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, tOpts);
187
+ const directs = buildModuleDirects(pomPath, store, propsByPom, moduleMgmt, resolvedDeps, tOpts);
167
188
  if (!directs.length) continue;
168
189
  modules++;
169
190
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fad-checker",
3
- "version": "2.4.3",
3
+ "version": "2.4.4",
4
4
  "description": "Scan ALL Maven, Gradle, npm, Yarn, Composer, Python, C#/.NET, Go & Ruby dependencies — plus embedded JARs (fat-jars/war/ear) — in a source tree ONE SHOT without mvn/python/etc — CVE (EPSS/KEV-prioritised), EOL, obsolete, outdated & licenses, with SBOM/CSAF/SARIF/JSON exports, CI gating and fix recos",
5
5
  "keywords": [
6
6
  "sca",