fad-checker 2.2.1 → 2.2.2
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 +74 -11
- package/lib/cve-match.js +10 -5
- package/lib/manifest-copy.js +75 -0
- package/lib/maven-bom.js +104 -0
- package/lib/transitive.js +9 -2
- package/lib/version-overlay.js +193 -0
- package/package.json +1 -1
- package/fad-checker-report/cve-report.doc +0 -1168
- package/fad-checker-report/cve-report.html +0 -1293
package/fad-checker.js
CHANGED
|
@@ -597,6 +597,18 @@ async function timedPhase(label, fn) {
|
|
|
597
597
|
}
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
+
// Mirror every non-Maven lockfile/manifest (npm/yarn/pnpm, composer, pypi, nuget,
|
|
601
|
+
// go, ruby) into the cleaned tree so `snyk test --all-projects` scans the WHOLE
|
|
602
|
+
// polyglot project, not just the cleaned POMs. Maven POMs are the rewrite above.
|
|
603
|
+
let copiedManifests = 0;
|
|
604
|
+
if (!readOnly) {
|
|
605
|
+
try {
|
|
606
|
+
const { copyEcosystemManifests } = require("./lib/manifest-copy");
|
|
607
|
+
const r = await copyEcosystemManifests(options.src, options.target, { excludePath, defaultExcludes });
|
|
608
|
+
copiedManifests = r.copied;
|
|
609
|
+
} catch (err) { console.error(chalk.red(" ✗ manifest copy failed:"), err.message); }
|
|
610
|
+
}
|
|
611
|
+
|
|
600
612
|
// ---------- Maven POM analysis summary (parents missing / excluded) ----------
|
|
601
613
|
let privateLibIds = [];
|
|
602
614
|
if (runMaven && mavenCtx) {
|
|
@@ -674,11 +686,15 @@ async function timedPhase(label, fn) {
|
|
|
674
686
|
else ui.info(chalk.dim(`${wrotePom} POM(s) cleanable (read-only — pass -t <dir> to write them)`));
|
|
675
687
|
}
|
|
676
688
|
|
|
689
|
+
if (!readOnly && copiedManifests) {
|
|
690
|
+
ui.ok(`${chalk.bold(copiedManifests)} non-Maven lockfile/manifest(s) mirrored → ${chalk.white(options.target)} ${chalk.dim("(so snyk --all-projects scans every ecosystem)")}`);
|
|
691
|
+
}
|
|
692
|
+
|
|
677
693
|
// ---------- Scan flow (CVE / EOL / Obsolete) ----------
|
|
678
694
|
// The scan always runs — it feeds the terminal summary, the file outputs and the
|
|
679
695
|
// CI gate. Which files get written is decided by the --report-* family inside
|
|
680
696
|
// (HTML + .doc by default; --no-report writes nothing).
|
|
681
|
-
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings });
|
|
697
|
+
await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, regMap, collectWarnings, mavenPropsByPom: mavenCtx?.propsByPom, mavenStore: mavenCtx?.store });
|
|
682
698
|
if (!readOnly) {
|
|
683
699
|
ui.section("Next step");
|
|
684
700
|
ui.info(`run Snyk on the cleaned tree:`);
|
|
@@ -687,7 +703,7 @@ async function timedPhase(label, fn) {
|
|
|
687
703
|
})();
|
|
688
704
|
|
|
689
705
|
async function runReportFlow(resolved, ecoFlags = {}) {
|
|
690
|
-
const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [] } = ecoFlags;
|
|
706
|
+
const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], regMap = {}, collectWarnings = [], mavenPropsByPom = null, mavenStore = null } = ecoFlags;
|
|
691
707
|
const registriesFor = eco => regMap[eco] || [];
|
|
692
708
|
const { expandWithTransitives } = require("./lib/cve-match");
|
|
693
709
|
const { writeReports, computeStats } = require("./lib/cve-report");
|
|
@@ -701,13 +717,9 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
701
717
|
const npmWarnings = collectWarnings || [];
|
|
702
718
|
let scanWarnings = [];
|
|
703
719
|
const directCount = resolved.size;
|
|
704
|
-
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
if (runMaven) {
|
|
708
|
-
const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
|
|
709
|
-
scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
|
|
710
|
-
}
|
|
720
|
+
// NOTE: scan-completeness (unresolved-versions) is computed LATER — after the BOM
|
|
721
|
+
// version-resolution step backfills external-BOM-managed versions — so it reflects
|
|
722
|
+
// what's *genuinely* still unresolved, not what a Maven Central BOM fetch will fix.
|
|
711
723
|
|
|
712
724
|
// ---- Vulnerability database update (global step progress) ----
|
|
713
725
|
ui.section("Vulnerability database update");
|
|
@@ -726,6 +738,17 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
726
738
|
const otherRegistryIds = activeIds.filter(id => id !== "maven" && id !== "npm" && id !== "yarn" && getCodec(id)?.checkRegistry);
|
|
727
739
|
const willCve = !!cveScanner && (!(options.cveOffline || offline) || cveIndexExists);
|
|
728
740
|
const willTransitive = !!(options.transitive && runMaven);
|
|
741
|
+
// Per-module version mediation overlay: recover transitive versions the global
|
|
742
|
+
// transitive pass masks via cross-module depMgmt bleed. Runs after (and only when)
|
|
743
|
+
// the global pass runs, and needs the parsed store + per-module props.
|
|
744
|
+
const willOverlay = willTransitive && !!mavenPropsByPom && !!mavenStore;
|
|
745
|
+
// External import BOMs (e.g. spring-boot-dependencies): resolve their managed
|
|
746
|
+
// versions to backfill declared deps that pin no version of their own (the usual
|
|
747
|
+
// Spring Boot setup). Cached via poms-cache; offline-aware (uses warmed POMs,
|
|
748
|
+
// never the network — same as transitive resolution).
|
|
749
|
+
const importBoms = (runMaven && mavenPropsByPom)
|
|
750
|
+
? require("./lib/maven-bom").collectImportBoms(mavenPropsByPom) : [];
|
|
751
|
+
const willBom = importBoms.length > 0;
|
|
729
752
|
const willOsv = !!options.osv;
|
|
730
753
|
const willOutdated = !!options.allLibs;
|
|
731
754
|
const willNvd = !!options.nvd;
|
|
@@ -737,9 +760,18 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
737
760
|
const willBinaryId = [...resolved.values()].some(d => d.provenance === "binary");
|
|
738
761
|
// License detection piggybacks on the registry passes (same fetched metadata),
|
|
739
762
|
// so it adds no progress step of its own.
|
|
740
|
-
const totalSteps = [willTransitive, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
|
|
763
|
+
const totalSteps = [willBom, willTransitive, willOverlay, willCve, /*EOL*/ true, willOutdated, /*npm reg*/ true, ...otherRegistryIds.map(() => true), willOsv, willNvd, willEpss, willKev, willRetire, willBinaryId].filter(Boolean).length;
|
|
741
764
|
const progress = new ui.Progress(totalSteps);
|
|
742
765
|
|
|
766
|
+
if (willBom) {
|
|
767
|
+
const st = progress.start("BOM version resolution (Maven Central)");
|
|
768
|
+
try {
|
|
769
|
+
const { resolveAndBackfill } = require("./lib/maven-bom");
|
|
770
|
+
const r = await resolveAndBackfill(mavenPropsByPom, resolved, { repos: mavenRepos, offline, verbose });
|
|
771
|
+
st.done(`${r.filled} dep version(s) from ${r.boms} import BOM(s)`);
|
|
772
|
+
} catch (err) { st.fail(err.message); }
|
|
773
|
+
}
|
|
774
|
+
|
|
743
775
|
if (willTransitive) {
|
|
744
776
|
const st = progress.start("Transitive resolution (Maven Central)");
|
|
745
777
|
await expandWithTransitives(resolved, {
|
|
@@ -752,6 +784,32 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
752
784
|
st.done(`+${resolved.size - directCount} transitive (total ${resolved.size})`);
|
|
753
785
|
}
|
|
754
786
|
|
|
787
|
+
if (willOverlay) {
|
|
788
|
+
const st = progress.start("Per-module version mediation (masked transitives)");
|
|
789
|
+
try {
|
|
790
|
+
const { expandPerModuleOverlay } = require("./lib/version-overlay");
|
|
791
|
+
const ov = await expandPerModuleOverlay(resolved, mavenStore, mavenPropsByPom, {
|
|
792
|
+
verbose,
|
|
793
|
+
offline,
|
|
794
|
+
maxDepth: parseInt(options.transitiveDepth, 10) || 6,
|
|
795
|
+
includeTestDeps: !options.ignoreTest,
|
|
796
|
+
repos: mavenRepos,
|
|
797
|
+
});
|
|
798
|
+
st.done(`+${ov.appended} masked version(s) recovered across ${ov.modules} module(s)`);
|
|
799
|
+
if (verbose && ov.recovered.length) {
|
|
800
|
+
for (const r of ov.recovered) console.log(` ↳ ${r.coord}: +${r.version} (had ${r.had}) via ${r.module}`);
|
|
801
|
+
}
|
|
802
|
+
} catch (err) { st.fail(err.message); }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Scan-completeness signals — computed NOW (after BOM backfill) so only the deps
|
|
806
|
+
// still without a concrete version (external BOM truly unreachable, or offline)
|
|
807
|
+
// are flagged, not the ones we just resolved from spring-boot-dependencies & co.
|
|
808
|
+
if (runMaven) {
|
|
809
|
+
const { detectScanCompletenessWarnings } = require("./lib/scan-completeness");
|
|
810
|
+
scanWarnings = detectScanCompletenessWarnings(resolved, { ranSnyk: !!options.snyk, ranTransitive: !!options.transitive });
|
|
811
|
+
}
|
|
812
|
+
|
|
755
813
|
// 1. CVE — native scanner contributed by the maven codec (local cvelistV5 index).
|
|
756
814
|
let cveMatches = [];
|
|
757
815
|
let cveDataDate = null;
|
|
@@ -1078,7 +1136,12 @@ async function runReportFlow(resolved, ecoFlags = {}) {
|
|
|
1078
1136
|
ui.warn(`${scanWarnings.length} scan-completeness note(s) — a real Maven/Snyk run may surface more:`);
|
|
1079
1137
|
for (const w of scanWarnings) {
|
|
1080
1138
|
ui.info(chalk.dim(`[${w.type}] ${w.message}`));
|
|
1081
|
-
|
|
1139
|
+
// Items may be plain strings or { id, manifestPaths } objects (the
|
|
1140
|
+
// unresolved-versions warning carries the defining manifest paths).
|
|
1141
|
+
for (const it of (w.items || []).slice(0, 4)) {
|
|
1142
|
+
const id = typeof it === "string" ? it : it.id;
|
|
1143
|
+
console.log(" " + chalk.dim(`· ${id}`));
|
|
1144
|
+
}
|
|
1082
1145
|
if ((w.items || []).length > 4) console.log(" " + chalk.dim(`· …and ${w.items.length - 4} more`));
|
|
1083
1146
|
}
|
|
1084
1147
|
}
|
package/lib/cve-match.js
CHANGED
|
@@ -180,12 +180,17 @@ function matchOne(dep, entries, confidence) {
|
|
|
180
180
|
// the same g:a), not just the representative highest — otherwise a CVE that
|
|
181
181
|
// only affects a lower-versioned profile variant would be missed.
|
|
182
182
|
const versions = (dep.versions && dep.versions.length) ? dep.versions : [dep.version];
|
|
183
|
+
// Only CONCRETE, resolved versions can be matched. A dep whose version is null
|
|
184
|
+
// or an unresolved `${property}` (typically pinned by an external BOM like
|
|
185
|
+
// spring-boot-dependencies) carries NO version we can compare — matching it
|
|
186
|
+
// against every "affected" range would flag it as vulnerable to ALL the
|
|
187
|
+
// coordinate's CVEs (a flood of false CRITICALs with version "?"). Skip it;
|
|
188
|
+
// it's already reported in chapter 0 as an "unresolved-versions" warning.
|
|
189
|
+
const concrete = versions.filter(v => v && !/\$\{/.test(String(v)));
|
|
190
|
+
if (!concrete.length) return matches;
|
|
183
191
|
for (const e of entries) {
|
|
184
|
-
for (const ver of
|
|
185
|
-
const affected = (e.ranges || []).some(r =>
|
|
186
|
-
if (!ver) return r.status === "affected"; // unknown version → assume affected
|
|
187
|
-
return isVersionAffected(ver, r);
|
|
188
|
-
});
|
|
192
|
+
for (const ver of concrete) {
|
|
193
|
+
const affected = (e.ranges || []).some(r => isVersionAffected(ver, r));
|
|
189
194
|
if (affected) {
|
|
190
195
|
const vdep = ver === dep.version ? dep : { ...dep, version: ver };
|
|
191
196
|
matches.push({ dep: vdep, cve: { ...e }, confidence });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/manifest-copy.js — when writing a cleaned tree (`-t`), copy every NON-Maven
|
|
3
|
+
* ecosystem manifest / lockfile from the source into the target at the same relative
|
|
4
|
+
* path, so `snyk test --all-projects` on the cleaned tree scans every ecosystem — not
|
|
5
|
+
* just the cleaned Maven POMs (which are written separately by core.rewritePoms).
|
|
6
|
+
*
|
|
7
|
+
* Maven is excluded (its cleaned POMs are the whole point of the rewrite); the binary
|
|
8
|
+
* codec is excluded (its files aren't dependency manifests). node_modules / vendor and
|
|
9
|
+
* friends are pruned so we copy the project's own lockfiles, not vendored ones.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const { makeDirFilter } = require("./path-filter");
|
|
14
|
+
const { allCodecs } = require("./codecs");
|
|
15
|
+
|
|
16
|
+
// Ecosystems whose manifests/lockfiles Snyk reads off disk.
|
|
17
|
+
const COPY_ECOSYSTEMS = new Set(["npm", "nuget", "composer", "pypi", "go", "ruby"]);
|
|
18
|
+
// Companion files that aren't a codec's primary manifest but Snyk/the build need.
|
|
19
|
+
const COMPANION_FILES = ["Directory.Packages.props", "Directory.Build.props", "nuget.config", "Gemfile", "Pipfile", "go.work", "go.work.sum"];
|
|
20
|
+
// Directories never worth copying manifests from (installed/vendored trees).
|
|
21
|
+
const SKIP_DIRS = new Set([
|
|
22
|
+
"node_modules", "bower_components", "jspm_packages", "vendor",
|
|
23
|
+
".git", ".svn", ".hg", ".idea", ".vscode", ".gradle", ".mvn",
|
|
24
|
+
"target", "dist", "build", "out", "bin", "obj", ".next", ".nuxt", "coverage",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** Pure: the exact filenames + extensions that count as a copyable manifest. */
|
|
28
|
+
function manifestMatchers() {
|
|
29
|
+
const exact = new Set(COMPANION_FILES);
|
|
30
|
+
const exts = [];
|
|
31
|
+
for (const c of allCodecs()) {
|
|
32
|
+
if (!COPY_ECOSYSTEMS.has(c.id)) continue;
|
|
33
|
+
for (const n of c.manifestNames || []) {
|
|
34
|
+
if (n.startsWith("*.")) exts.push(n.slice(1)); // "*.csproj" → ".csproj"
|
|
35
|
+
else exact.add(n);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { exact, exts };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Pure: is this basename a manifest we copy? */
|
|
42
|
+
function isManifestName(name, matchers = manifestMatchers()) {
|
|
43
|
+
return matchers.exact.has(name) || matchers.exts.some(e => name.endsWith(e));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Copy every matching manifest from srcRoot into targetRoot at the same relative path.
|
|
48
|
+
* @returns { copied: number, files: string[] } (files are relative paths)
|
|
49
|
+
*/
|
|
50
|
+
async function copyEcosystemManifests(srcRoot, targetRoot, opts = {}) {
|
|
51
|
+
const matchers = manifestMatchers();
|
|
52
|
+
const skipDir = makeDirFilter({ srcRoot, defaultSkip: SKIP_DIRS, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
|
|
53
|
+
const files = [];
|
|
54
|
+
const walk = async (dir) => {
|
|
55
|
+
let entries;
|
|
56
|
+
try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const abs = path.join(dir, e.name);
|
|
59
|
+
if (e.isDirectory()) { if (!skipDir(abs, e.name)) await walk(abs); continue; }
|
|
60
|
+
if (!e.isFile()) continue;
|
|
61
|
+
if (!isManifestName(e.name, matchers)) continue;
|
|
62
|
+
const rel = path.relative(srcRoot, abs);
|
|
63
|
+
const dest = path.join(targetRoot, rel);
|
|
64
|
+
try {
|
|
65
|
+
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
66
|
+
await fs.promises.copyFile(abs, dest);
|
|
67
|
+
files.push(rel);
|
|
68
|
+
} catch { /* best effort per file */ }
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
await walk(srcRoot);
|
|
72
|
+
return { copied: files.length, files };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { manifestMatchers, isManifestName, copyEcosystemManifests, COPY_ECOSYSTEMS };
|
package/lib/maven-bom.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/maven-bom.js — resolve EXTERNAL import-scope BOMs (e.g. spring-boot-dependencies)
|
|
3
|
+
* from Maven Central and backfill the versions of declared deps that the BOM manages.
|
|
4
|
+
*
|
|
5
|
+
* core.js follows LOCAL `<scope>import</scope>` BOMs (their POM is in the source tree)
|
|
6
|
+
* but cannot enumerate an external BOM's managed-version table without fetching it. So
|
|
7
|
+
* in a typical Spring Boot project every `spring-boot-starter-*` (declared without a
|
|
8
|
+
* version, version pinned by the imported BOM) ends up unresolved — flooding chapter 0
|
|
9
|
+
* and dropping out of the CVE/EOL/outdated scans.
|
|
10
|
+
*
|
|
11
|
+
* This module fetches each external import BOM via transitive.js#effectivePom (which
|
|
12
|
+
* already merges the parent chain, resolves `${properties}` and recursively expands
|
|
13
|
+
* nested import BOMs), builds a g:a → version map, and fills it into the versionless
|
|
14
|
+
* declared deps. Network + cached (poms-cache, immutable) + offline-aware (the caller
|
|
15
|
+
* skips it offline). Pure except for the injected/real effectivePom fetch.
|
|
16
|
+
*/
|
|
17
|
+
const transitive = require("./transitive");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract the distinct external import-BOM coordinates from the parsed poms' merged
|
|
21
|
+
* dependencyManagement. `version` is resolved against each pom's property map; entries
|
|
22
|
+
* that stay unresolved (`${…}`) or lack a g/a are skipped.
|
|
23
|
+
* @param propsByPom map pomPath → { properties, dependencyManagement } (xml2js-shaped)
|
|
24
|
+
* @returns [{ groupId, artifactId, version }] deduped by g:a:v
|
|
25
|
+
*/
|
|
26
|
+
function collectImportBoms(propsByPom) {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const pom of Object.keys(propsByPom || {})) {
|
|
30
|
+
const entry = propsByPom[pom];
|
|
31
|
+
if (!entry) continue;
|
|
32
|
+
const props = entry.properties || {};
|
|
33
|
+
for (const d of (entry.dependencyManagement || [])) {
|
|
34
|
+
if (d?.scope?.[0] !== "import") continue;
|
|
35
|
+
const g = transitive.resolveProps(d.groupId?.[0], props);
|
|
36
|
+
const a = transitive.resolveProps(d.artifactId?.[0], props);
|
|
37
|
+
const v = transitive.resolveProps(d.version?.[0], props);
|
|
38
|
+
if (!g || !a || !v || /\$\{/.test(String(v))) continue;
|
|
39
|
+
const k = `${g}:${a}:${v}`;
|
|
40
|
+
if (seen.has(k)) continue;
|
|
41
|
+
seen.add(k);
|
|
42
|
+
out.push({ groupId: g, artifactId: a, version: v });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve each import BOM to its managed-version table and merge them into one map.
|
|
50
|
+
* First BOM to manage a given g:a wins (mirrors Maven's declaration-order precedence).
|
|
51
|
+
*/
|
|
52
|
+
async function resolveBomManagedVersions(boms, opts = {}) {
|
|
53
|
+
const effectivePom = opts.effectivePom || transitive.effectivePom;
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const bom of boms) {
|
|
56
|
+
let eff = null;
|
|
57
|
+
try { eff = await effectivePom(bom.groupId, bom.artifactId, bom.version, opts); }
|
|
58
|
+
catch { eff = null; }
|
|
59
|
+
if (!eff || !eff.depMgmt) continue;
|
|
60
|
+
for (const d of eff.depMgmt) {
|
|
61
|
+
if (!d.groupId || !d.artifactId) continue;
|
|
62
|
+
const k = `${d.groupId}:${d.artifactId}`;
|
|
63
|
+
if (map.has(k)) continue;
|
|
64
|
+
if (d.version && !/\$\{/.test(String(d.version))) map.set(k, String(d.version));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return map;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fill the version of every Maven dep that has no concrete version (null or `${…}`)
|
|
72
|
+
* from the BOM-managed map. Mutates the resolvedDeps Map entries in place.
|
|
73
|
+
* @returns number of deps filled
|
|
74
|
+
*/
|
|
75
|
+
function backfillVersions(resolvedDeps, mgmtMap) {
|
|
76
|
+
let filled = 0;
|
|
77
|
+
for (const dep of resolvedDeps.values()) {
|
|
78
|
+
if (dep.ecosystem !== "maven") continue;
|
|
79
|
+
if (dep.provenance === "embedded" || dep.provenance === "binary") continue;
|
|
80
|
+
if (dep.version && !/\$\{/.test(String(dep.version))) continue; // already concrete
|
|
81
|
+
const v = mgmtMap.get(`${dep.groupId}:${dep.artifactId}`);
|
|
82
|
+
if (!v) continue;
|
|
83
|
+
dep.version = v;
|
|
84
|
+
if (!Array.isArray(dep.versions)) dep.versions = [];
|
|
85
|
+
if (!dep.versions.includes(v)) dep.versions.push(v);
|
|
86
|
+
filled++;
|
|
87
|
+
}
|
|
88
|
+
return filled;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One-shot: collect external import BOMs from the poms, resolve their managed versions
|
|
93
|
+
* online, and backfill the versionless declared deps.
|
|
94
|
+
* @returns { boms, filled, mgmtSize }
|
|
95
|
+
*/
|
|
96
|
+
async function resolveAndBackfill(propsByPom, resolvedDeps, opts = {}) {
|
|
97
|
+
const boms = collectImportBoms(propsByPom);
|
|
98
|
+
if (!boms.length) return { boms: 0, filled: 0, mgmtSize: 0 };
|
|
99
|
+
const map = await resolveBomManagedVersions(boms, opts);
|
|
100
|
+
const filled = backfillVersions(resolvedDeps, map);
|
|
101
|
+
return { boms: boms.length, filled, mgmtSize: map.size };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { collectImportBoms, resolveBomManagedVersions, backfillVersions, resolveAndBackfill };
|
package/lib/transitive.js
CHANGED
|
@@ -183,12 +183,18 @@ function resolveProps(value, props, builtins, depth = 0) {
|
|
|
183
183
|
async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
|
|
184
184
|
const key = `${g}:${a}:${v}`;
|
|
185
185
|
if (seen.has(key)) return null;
|
|
186
|
+
// Opt-in cross-call memo (used by the per-module overlay, which resolves the
|
|
187
|
+
// same shared parents/BOMs once per module). Immutable POMs → the effective
|
|
188
|
+
// result for a g:a:v is stable, so caching the finished object is safe; callers
|
|
189
|
+
// only READ eff.depMgmt/eff.deps. Only active when opts.effCache is supplied,
|
|
190
|
+
// so existing single-shot callers are byte-for-byte unchanged.
|
|
191
|
+
if (opts.effCache && opts.effCache.has(key)) return opts.effCache.get(key);
|
|
186
192
|
seen.add(key);
|
|
187
193
|
|
|
188
194
|
const xml = await fetchPom(g, a, v, opts);
|
|
189
|
-
if (!xml) return null;
|
|
195
|
+
if (!xml) { if (opts.effCache) opts.effCache.set(key, null); return null; }
|
|
190
196
|
const pom = await parsePomXml(xml);
|
|
191
|
-
if (!pom) return null;
|
|
197
|
+
if (!pom) { if (opts.effCache) opts.effCache.set(key, null); return null; }
|
|
192
198
|
|
|
193
199
|
let merged = {
|
|
194
200
|
groupId: pom.groupId,
|
|
@@ -243,6 +249,7 @@ async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
|
|
|
243
249
|
}
|
|
244
250
|
merged.depMgmt = expanded;
|
|
245
251
|
|
|
252
|
+
if (opts.effCache) opts.effCache.set(key, merged);
|
|
246
253
|
return merged;
|
|
247
254
|
}
|
|
248
255
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/version-overlay.js — recover transitive dependency versions that Maven's
|
|
3
|
+
* PER-MODULE mediation keeps but fad's GLOBAL transitive pass masks.
|
|
4
|
+
*
|
|
5
|
+
* The problem: `expandWithTransitives` (cve-match.js) resolves the whole reactor as
|
|
6
|
+
* ONE tree with ONE global `rootDepMgmt` (the highest version of every coord seen
|
|
7
|
+
* anywhere). So a `<dependencyManagement>` pin in module A is force-applied to a
|
|
8
|
+
* transitive of the unrelated module B — e.g. `controller` pins poi 5.4.1 and the
|
|
9
|
+
* stress-tests island's `jmeter → poi 3.11` gets rewritten to 5.4.1, hiding
|
|
10
|
+
* CVE-2017-12626. Maven applies depMgmt only inside the subtree that declares it.
|
|
11
|
+
*
|
|
12
|
+
* The fix (additive overlay): keep the global pass UNCHANGED as the base (no
|
|
13
|
+
* regression), then re-resolve EACH module independently with ONLY that module's
|
|
14
|
+
* own effective depMgmt (its local parent chain + its external parent/import-BOMs),
|
|
15
|
+
* and APPEND any (g:a, version) it finds that isn't already in the coord's
|
|
16
|
+
* `versions[]`. Never removes, never reseeds — so it can only ADD coverage, and the
|
|
17
|
+
* per-module version it finds is the one genuinely on that module's classpath
|
|
18
|
+
* (so it's a true positive, not a force-elevated one).
|
|
19
|
+
*
|
|
20
|
+
* Offline-aware (cache-first via transitive.js#fetchPom) and memoised across modules
|
|
21
|
+
* with a shared `effCache` so 25 modules stay fast.
|
|
22
|
+
*/
|
|
23
|
+
const core = require("./core");
|
|
24
|
+
const { resolveTransitiveDeps, effectivePom } = require("./transitive");
|
|
25
|
+
const { resolveDepVersion } = require("./cve-match");
|
|
26
|
+
|
|
27
|
+
const coord = core.coord;
|
|
28
|
+
const isConcrete = v => v != null && !/\$\{/.test(String(v));
|
|
29
|
+
|
|
30
|
+
/** xml2js dependency node → flat descriptor (groupId/artifactId/version/scope/…). */
|
|
31
|
+
function flattenDep(d) {
|
|
32
|
+
return {
|
|
33
|
+
groupId: coord(d.groupId?.[0]),
|
|
34
|
+
artifactId: coord(d.artifactId?.[0]),
|
|
35
|
+
rawVersion: coord(d.version?.[0]),
|
|
36
|
+
scope: coord(d.scope?.[0]) || "compile",
|
|
37
|
+
optional: d.optional?.[0] === "true",
|
|
38
|
+
isImport: d.scope?.[0] === "import",
|
|
39
|
+
exclusions: (d.exclusions?.[0]?.exclusion || []).map(e => ({
|
|
40
|
+
groupId: coord(e.groupId?.[0]),
|
|
41
|
+
artifactId: coord(e.artifactId?.[0]),
|
|
42
|
+
})),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Walk a module's LOCAL parent chain (child first). Stops at the first external
|
|
48
|
+
* parent (one not present in the source tree) and reports it separately, so the
|
|
49
|
+
* caller can resolve its managed table from Maven Central.
|
|
50
|
+
* @returns { chain: [pomPath, parentPath, …], externalParent: {groupId,artifactId,version}|null }
|
|
51
|
+
*/
|
|
52
|
+
function localChain(pomPath, store) {
|
|
53
|
+
const chain = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
let cur = pomPath;
|
|
56
|
+
let externalParent = null;
|
|
57
|
+
while (cur && !seen.has(cur)) {
|
|
58
|
+
seen.add(cur);
|
|
59
|
+
chain.push(cur);
|
|
60
|
+
const meta = store.byPath[cur];
|
|
61
|
+
if (!meta) break;
|
|
62
|
+
const parentPath = core.resolveParentPath(cur, meta.parentInfo, store);
|
|
63
|
+
if (!parentPath) {
|
|
64
|
+
const p = meta.parentInfo;
|
|
65
|
+
if (p?.groupId && p?.artifactId && p?.version) externalParent = { groupId: p.groupId, artifactId: p.artifactId, version: p.version };
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
cur = parentPath;
|
|
69
|
+
}
|
|
70
|
+
return { chain, externalParent };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the EFFECTIVE managed-version map for ONE module — exactly the depMgmt that
|
|
75
|
+
* Maven would apply when resolving THIS module's dependencies, and no other module's.
|
|
76
|
+
* Closest-declared pin wins (set-if-absent while climbing child → parent).
|
|
77
|
+
*
|
|
78
|
+
* Sources, in precedence order:
|
|
79
|
+
* 1. each local pom in the parent chain's own <dependencyManagement> (local
|
|
80
|
+
* scope=import BOMs are already expanded inline by core.getAllInheritedProps);
|
|
81
|
+
* 2. the chain's external <parent> (e.g. spring-boot-starter-parent) managed table;
|
|
82
|
+
* 3. external scope=import BOMs (e.g. spring-boot-dependencies) declared in the chain.
|
|
83
|
+
* @returns Map<"g:a", version>
|
|
84
|
+
*/
|
|
85
|
+
async function buildModuleManagement(pomPath, store, propsByPom, opts = {}) {
|
|
86
|
+
const map = new Map();
|
|
87
|
+
const setIf = (k, v) => { if (k && isConcrete(v) && !map.has(k)) map.set(k, String(v)); };
|
|
88
|
+
const { chain, externalParent } = localChain(pomPath, store);
|
|
89
|
+
const externalBoms = [];
|
|
90
|
+
|
|
91
|
+
for (const pom of chain) {
|
|
92
|
+
const entry = propsByPom[pom];
|
|
93
|
+
if (!entry) continue;
|
|
94
|
+
const props = entry.properties || {};
|
|
95
|
+
for (const node of entry.dependencyManagement || []) {
|
|
96
|
+
const g = coord(node.groupId?.[0]);
|
|
97
|
+
const a = coord(node.artifactId?.[0]);
|
|
98
|
+
if (!g || !a) continue;
|
|
99
|
+
const v = resolveDepVersion(coord(node.version?.[0]), props);
|
|
100
|
+
if (node.scope?.[0] === "import") {
|
|
101
|
+
// Local import BOMs are already expanded inline by getAllInheritedProps;
|
|
102
|
+
// only chase EXTERNAL ones (not present in the source tree).
|
|
103
|
+
const local = (v && store.byId[`${g}:${a}:${v}`]) || store.byId[`${g}:${a}`];
|
|
104
|
+
if (!local && isConcrete(v)) externalBoms.push({ groupId: g, artifactId: a, version: v });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
setIf(`${g}:${a}`, v);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// External parent + external import BOMs: pull their managed tables from Maven
|
|
112
|
+
// Central (cache-first, memoised). Closest local pins already set win.
|
|
113
|
+
const externals = externalParent ? [externalParent, ...externalBoms] : externalBoms;
|
|
114
|
+
for (const ext of externals) {
|
|
115
|
+
let eff = null;
|
|
116
|
+
try { eff = await effectivePom(ext.groupId, ext.artifactId, ext.version, opts); } catch { eff = null; }
|
|
117
|
+
if (eff?.depMgmt) for (const d of eff.depMgmt) setIf(`${d.groupId}:${d.artifactId}`, d.version);
|
|
118
|
+
}
|
|
119
|
+
return map;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The direct dependencies declared by ONE module, with concrete versions resolved
|
|
124
|
+
* (own ${properties} → module's managed map → the global resolved version). These
|
|
125
|
+
* seed the per-module transitive walk.
|
|
126
|
+
*/
|
|
127
|
+
function buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, opts = {}) {
|
|
128
|
+
const entry = propsByPom[pomPath];
|
|
129
|
+
if (!entry) return [];
|
|
130
|
+
const props = entry.properties || {};
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const node of entry.dependencies || []) {
|
|
133
|
+
const d = flattenSafe(node);
|
|
134
|
+
if (!d || !d.groupId || !d.artifactId || d.optional || d.isImport) continue;
|
|
135
|
+
if (d.scope === "test" && !opts.includeTestDeps) continue;
|
|
136
|
+
if (d.scope === "system" || d.scope === "import") continue;
|
|
137
|
+
let v = resolveDepVersion(d.rawVersion, props);
|
|
138
|
+
if (!isConcrete(v)) v = moduleMgmt.get(`${d.groupId}:${d.artifactId}`) || resolvedDeps.get(`${d.groupId}:${d.artifactId}`)?.version || null;
|
|
139
|
+
if (!isConcrete(v)) continue;
|
|
140
|
+
out.push({ groupId: d.groupId, artifactId: d.artifactId, version: String(v), scope: d.scope, exclusions: d.exclusions });
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
// flattenSafe guards against malformed nodes (xml2js can yield odd shapes).
|
|
145
|
+
function flattenSafe(node) { try { return flattenDep(node); } catch { return null; } }
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Additive per-module overlay. Mutates `resolvedDeps`: for every coord already in
|
|
149
|
+
* the scan set, appends any concrete version found by a faithful per-module
|
|
150
|
+
* resolution that the global pass masked. Returns a small diagnostics object incl.
|
|
151
|
+
* the (g:a, version) pairs recovered (used for the false-positive measurement).
|
|
152
|
+
*/
|
|
153
|
+
async function expandPerModuleOverlay(resolvedDeps, store, propsByPom, opts = {}) {
|
|
154
|
+
if (!store || !propsByPom) return { appended: 0, modules: 0, recovered: [] };
|
|
155
|
+
const effCache = opts.effCache || new Map();
|
|
156
|
+
const tOpts = { ...opts, effCache };
|
|
157
|
+
const recovered = [];
|
|
158
|
+
let modules = 0;
|
|
159
|
+
|
|
160
|
+
for (const pomPath of Object.keys(propsByPom)) {
|
|
161
|
+
const moduleMgmt = await buildModuleManagement(pomPath, store, propsByPom, tOpts);
|
|
162
|
+
const directs = buildModuleDirects(pomPath, propsByPom, moduleMgmt, resolvedDeps, tOpts);
|
|
163
|
+
if (!directs.length) continue;
|
|
164
|
+
modules++;
|
|
165
|
+
|
|
166
|
+
let trans;
|
|
167
|
+
try {
|
|
168
|
+
trans = await resolveTransitiveDeps(directs, {
|
|
169
|
+
...tOpts,
|
|
170
|
+
rootDepMgmt: moduleMgmt,
|
|
171
|
+
maxDepth: opts.maxDepth || 6,
|
|
172
|
+
includedScopes: ["compile", "runtime", "provided"],
|
|
173
|
+
});
|
|
174
|
+
} catch { continue; }
|
|
175
|
+
|
|
176
|
+
for (const [key, t] of trans) {
|
|
177
|
+
const v = t.version;
|
|
178
|
+
if (!isConcrete(v)) continue;
|
|
179
|
+
const existing = resolvedDeps.get(key);
|
|
180
|
+
if (!existing) continue; // additive to coords already scanned
|
|
181
|
+
if (existing.provenance === "embedded" || existing.provenance === "binary") continue;
|
|
182
|
+
if (!Array.isArray(existing.versions)) existing.versions = isConcrete(existing.version) ? [existing.version] : [];
|
|
183
|
+
if (existing.versions.includes(String(v))) continue; // already scanned this version
|
|
184
|
+
existing.versions.push(String(v));
|
|
185
|
+
existing.maskedVersions = existing.maskedVersions || [];
|
|
186
|
+
existing.maskedVersions.push({ version: String(v), via: t.via, viaPaths: t.viaPaths, module: pomPath, depth: t.depth });
|
|
187
|
+
recovered.push({ coord: key, version: String(v), module: pomPath, had: existing.version });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { appended: recovered.length, modules, recovered };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { expandPerModuleOverlay, buildModuleManagement, buildModuleDirects, localChain };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
4
4
|
"description": "Scan ALL Maven, 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",
|