fad-checker 1.0.6 → 2.0.1

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +77 -15
  3. package/completions/fad-checker.bash +4 -1
  4. package/completions/fad-checker.zsh +9 -0
  5. package/data/eol-mapping.json +17 -0
  6. package/fad-checker.js +353 -241
  7. package/lib/codecs/codec.interface.js +27 -0
  8. package/lib/codecs/composer/parse.js +59 -0
  9. package/lib/codecs/composer/registry.js +92 -0
  10. package/lib/codecs/composer.codec.js +93 -0
  11. package/lib/codecs/index.js +37 -0
  12. package/lib/codecs/maven.codec.js +71 -0
  13. package/lib/{npm → codecs/npm}/collect.js +58 -35
  14. package/lib/{npm → codecs/npm}/parse.js +114 -10
  15. package/lib/{npm → codecs/npm}/registry.js +4 -3
  16. package/lib/codecs/npm.codec.js +52 -0
  17. package/lib/codecs/nuget/parse.js +75 -0
  18. package/lib/codecs/nuget/registry.js +88 -0
  19. package/lib/codecs/nuget.codec.js +94 -0
  20. package/lib/codecs/pypi/parse.js +163 -0
  21. package/lib/codecs/pypi/registry.js +89 -0
  22. package/lib/codecs/pypi.codec.js +102 -0
  23. package/lib/codecs/recipes.js +108 -0
  24. package/lib/codecs/select.js +27 -0
  25. package/lib/codecs/yarn.codec.js +19 -0
  26. package/lib/core.js +35 -1
  27. package/lib/cve-match.js +18 -11
  28. package/lib/cve-report.js +34 -70
  29. package/lib/dep-record.js +60 -0
  30. package/lib/deps-descriptor.js +110 -0
  31. package/lib/nvd.js +4 -3
  32. package/lib/osv.js +29 -18
  33. package/lib/outdated.js +20 -4
  34. package/lib/retire.js +77 -13
  35. package/lib/transitive.js +3 -3
  36. package/lib/ui.js +87 -0
  37. package/package.json +4 -2
  38. package/CLAUDE.md +0 -129
  39. package/docs/ARCHITECTURE.md +0 -154
  40. package/docs/USAGE.md +0 -179
  41. package/test/core.test.js +0 -153
  42. package/test/cpe.test.js +0 -166
  43. package/test/cve-download.test.js +0 -39
  44. package/test/cve-match.test.js +0 -217
  45. package/test/cve-report.test.js +0 -171
  46. package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
  47. package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
  48. package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
  49. package/test/fixtures/complex-enterprise/pom.xml +0 -66
  50. package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
  51. package/test/fixtures/cve-samples/cve-non-java.json +0 -19
  52. package/test/fixtures/cve-samples/cve-product-only.json +0 -31
  53. package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
  54. package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
  55. package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
  56. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
  57. package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
  58. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
  59. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -9
  60. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
  61. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
  62. package/test/fixtures/monorepo-mixed/pom.xml +0 -29
  63. package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
  64. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
  65. package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
  66. package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
  67. package/test/fixtures/private-lib-detection/pom.xml +0 -35
  68. package/test/fixtures/simple/app/pom.xml +0 -28
  69. package/test/fixtures/simple/lib/pom.xml +0 -18
  70. package/test/fixtures/simple/pom.xml +0 -24
  71. package/test/maven-repo.test.js +0 -111
  72. package/test/maven-version.test.js +0 -57
  73. package/test/monorepo.test.js +0 -132
  74. package/test/npm-registry.test.js +0 -64
  75. package/test/npm.test.js +0 -146
  76. package/test/outdated.test.js +0 -101
  77. package/test/snyk.test.js +0 -64
  78. package/test/transitive.test.js +0 -305
  79. package/test/webjar.test.js +0 -33
@@ -0,0 +1,89 @@
1
+ /**
2
+ * lib/python/registry.js — query PyPI JSON for a package's latest version,
3
+ * whether the resolved version is yanked, and the "Inactive" dev-status classifier.
4
+ *
5
+ * API: https://pypi.org/pypi/{name}/json
6
+ * → { info: { version, classifiers[] }, releases: { "<v>": [{ yanked, yanked_reason }] } }
7
+ *
8
+ * Cached in ~/.fad-checker/pypi-cache.json for 24h.
9
+ */
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const os = require("os");
13
+ let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
14
+
15
+ const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
16
+ const CACHE_PATH = path.join(CACHE_DIR, "pypi-cache.json");
17
+ const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
18
+ const API = "https://pypi.org/pypi";
19
+
20
+ function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
21
+ function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
22
+ function cmp(a, b) {
23
+ const pa = String(a).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
24
+ const pb = String(b).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
25
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d > 0 ? 1 : -1; }
26
+ return 0;
27
+ }
28
+
29
+ function pypiToFindings(data, { version }) {
30
+ const out = { outdated: null, yanked: null, inactive: false };
31
+ const latest = data.info?.version;
32
+ if (latest && cmp(latest, version) > 0) out.outdated = { latest };
33
+ const rel = data.releases?.[version];
34
+ if (Array.isArray(rel) && rel.length && rel.every(f => f.yanked)) {
35
+ out.yanked = { reason: rel.find(f => f.yanked_reason)?.yanked_reason || null };
36
+ }
37
+ if ((data.info?.classifiers || []).some(c => /Development Status :: 7 - Inactive/i.test(c))) out.inactive = true;
38
+ return out;
39
+ }
40
+
41
+ async function fetchProject(name, { offline }) {
42
+ if (offline) return null;
43
+ try {
44
+ const res = await fetch(`${API}/${name}/json`, { headers: { "User-Agent": "fad-checker-pypi" } });
45
+ if (!res.ok) return { error: `HTTP ${res.status}` };
46
+ return await res.json();
47
+ } catch (e) { return { error: e.message }; }
48
+ }
49
+
50
+ // Mirror of checkNpmRegistryDeps: returns { deprecated:[], outdated:[] }.
51
+ async function checkPypiRegistryDeps(deps, opts = {}) {
52
+ const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
53
+ const targets = [...deps.values()].filter(d => d.ecosystem === "pypi" && d.version);
54
+ const result = { deprecated: [], outdated: [] };
55
+ if (!targets.length) return result;
56
+ const cache = loadCache();
57
+ const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
58
+ if (!fresh && !offline) cache.entries = {};
59
+ const limit = pLimit(concurrency);
60
+ await Promise.all(targets.map(t => limit(async () => {
61
+ const key = `${t.name}@${t.version}`;
62
+ let ex = cache.entries[key];
63
+ if (!ex) {
64
+ const data = await fetchProject(t.name, { offline });
65
+ if (data && !data.error) {
66
+ const f = pypiToFindings(data, { version: t.version });
67
+ ex = { yanked: f.yanked, inactive: f.inactive, latest: f.outdated?.latest || null };
68
+ cache.entries[key] = ex;
69
+ } else {
70
+ ex = { yanked: null, inactive: false, latest: null };
71
+ }
72
+ }
73
+ if (ex.yanked) {
74
+ result.deprecated.push({ dep: t, severity: "HIGH", replacement: null, reason: `Version yanked on PyPI${ex.yanked.reason ? `: ${ex.yanked.reason}` : ""}`, source: "pypi" });
75
+ if (verbose) process.stdout.write(` yanked: ${t.name}@${t.version}\n`);
76
+ } else if (ex.inactive) {
77
+ result.deprecated.push({ dep: t, severity: "LOW", replacement: null, reason: "Marked 'Development Status :: 7 - Inactive' on PyPI", source: "pypi" });
78
+ }
79
+ if (allLibs && ex.latest) {
80
+ result.outdated.push({ dep: t, latest: ex.latest, releaseDate: null });
81
+ if (verbose) process.stdout.write(` outdated: ${t.name} ${t.version} → ${ex.latest}\n`);
82
+ }
83
+ })));
84
+ cache.meta = { fetchedAt: Date.now() };
85
+ saveCache(cache);
86
+ return result;
87
+ }
88
+
89
+ module.exports = { pypiToFindings, checkPypiRegistryDeps };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * lib/codecs/pypi.codec.js — codec Python/PyPI.
3
+ *
4
+ * Vuln scanning is OSV (ecosystem "PyPI", wired in Plan A). This codec adds
5
+ * collection (poetry.lock/Pipfile.lock/uv.lock/pdm.lock, requirements.txt
6
+ * fallback), PyPI registry (yanked/inactive + outdated), and EOL.
7
+ * Per-directory precedence: a lockfile wins; else requirements.txt best-effort.
8
+ */
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const { makeDepRecord, coordKeyFor } = require("../dep-record");
12
+ const P = require("./pypi/parse");
13
+
14
+ const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target", "__pycache__", ".venv", "venv", ".tox", ".mypy_cache", "site-packages"]);
15
+ const LOCKS = [
16
+ ["poetry.lock", P.parsePoetryLock],
17
+ ["Pipfile.lock", P.parsePipfileLock],
18
+ ["uv.lock", P.parseUvLock],
19
+ ["pdm.lock", P.parsePdmLock],
20
+ ];
21
+
22
+ const FALLBACK_MANIFESTS = ["pyproject.toml", "requirements.txt"];
23
+
24
+ function findPyDirs(dir) {
25
+ const groups = [];
26
+ const stack = [dir];
27
+ while (stack.length) {
28
+ const cur = stack.pop();
29
+ let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
30
+ const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
31
+ if ([...LOCKS.map(l => l[0]), ...FALLBACK_MANIFESTS].some(n => names.has(n))) groups.push({ dir: cur, names });
32
+ for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
33
+ }
34
+ return groups;
35
+ }
36
+
37
+ module.exports = {
38
+ id: "pypi",
39
+ label: "PyPI",
40
+ osvEcosystem: "PyPI",
41
+ manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "pyproject.toml", "requirements.txt"],
42
+
43
+ detect(dir) { return findPyDirs(dir).length > 0; },
44
+
45
+ async collect(dir, opts = {}) {
46
+ const { ignoreTest, deps2Exclude } = opts;
47
+ const out = new Map();
48
+ const warnings = [];
49
+ const add = (d, manifestPath) => {
50
+ if (ignoreTest && d.isDev) return;
51
+ if (deps2Exclude && deps2Exclude.test(d.name)) return;
52
+ out.set(coordKeyFor("pypi", "", d.name), makeDepRecord({ ecosystem: "pypi", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev }));
53
+ };
54
+ for (const g of findPyDirs(dir)) {
55
+ const lock = LOCKS.find(([n]) => g.names.has(n));
56
+ if (lock) {
57
+ const fp = path.join(g.dir, lock[0]);
58
+ try { const { deps } = lock[1](fp); for (const d of deps) add(d, fp); }
59
+ catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${lock[0]} parse failed: ${e.message}` }); }
60
+ continue; // lockfile is authoritative for this directory
61
+ }
62
+ // No lockfile → best-effort from pyproject.toml and/or requirements.txt.
63
+ let pinned = 0, skipped = 0;
64
+ const sources = [];
65
+ if (g.names.has("pyproject.toml")) {
66
+ const fp = path.join(g.dir, "pyproject.toml");
67
+ try {
68
+ const r = P.parsePyprojectToml(fp);
69
+ for (const d of r.deps) add(d, fp);
70
+ pinned += r.deps.length; skipped += r.skipped;
71
+ if (r.deps.length || r.skipped) sources.push("pyproject.toml");
72
+ } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `pyproject.toml parse failed: ${e.message}` }); }
73
+ }
74
+ if (g.names.has("requirements.txt")) {
75
+ const fp = path.join(g.dir, "requirements.txt");
76
+ try {
77
+ const r = P.parseRequirementsTxt(fp);
78
+ for (const d of r.deps) add(d, fp);
79
+ pinned += r.deps.length; skipped += r.skipped;
80
+ sources.push("requirements.txt");
81
+ for (const miss of (r.missing || [])) warnings.push({ type: "missing-include", manifestPath: fp, message: `referenced requirements file not found: ${miss}` });
82
+ } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `requirements.txt parse failed: ${e.message}` }); }
83
+ }
84
+ if (sources.length) {
85
+ warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no lockfile (${sources.join(" + ")}) — best-effort: ${pinned} pinned, ${skipped} range(s) skipped` });
86
+ }
87
+ }
88
+ return { deps: out, warnings };
89
+ },
90
+
91
+ coordKey(d) { return coordKeyFor("pypi", "", d.name); },
92
+ formatCoord(d) { return d.name; },
93
+ osvPackageName(d) { return d.name; },
94
+
95
+ async checkRegistry(deps, opts = {}) {
96
+ const { checkPypiRegistryDeps } = require("./pypi/registry");
97
+ return checkPypiRegistryDeps(deps, opts);
98
+ },
99
+ resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
100
+ recipe: require("./recipes").pypi,
101
+ nativeScanners: [],
102
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * lib/codecs/recipes.js — recettes de fix par écosystème pour le report.
3
+ *
4
+ * Extrait de lib/cve-report.js (ECO_RECIPE + snippet helpers). Clés = id de codec
5
+ * (== ecosystemType). Chaque recette : { label, pinSection, pinIntro(cnt),
6
+ * snippet(items), directSection }. `items` = [{ groupId, artifactId, fixVersion }].
7
+ */
8
+ function esc(s) {
9
+ if (s == null) return "";
10
+ return String(s)
11
+ .replace(/&/g, "&amp;")
12
+ .replace(/</g, "&lt;")
13
+ .replace(/>/g, "&gt;")
14
+ .replace(/"/g, "&quot;");
15
+ }
16
+
17
+ function dependencyManagementSnippet(items) {
18
+ const inner = items.map(it => ` <dependency>
19
+ <groupId>${esc(it.groupId)}</groupId>
20
+ <artifactId>${esc(it.artifactId)}</artifactId>
21
+ <version>${esc(it.fixVersion)}</version>
22
+ </dependency>`).join("\n");
23
+ return `<dependencyManagement>
24
+ <dependencies>
25
+ ${inner}
26
+ </dependencies>
27
+ </dependencyManagement>`;
28
+ }
29
+
30
+ function npmOverridesSnippet(items) {
31
+ const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
32
+ return `{
33
+ "overrides": {
34
+ ${lines}
35
+ }
36
+ }`;
37
+ }
38
+
39
+ function yarnResolutionsSnippet(items) {
40
+ const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
41
+ return `{
42
+ "resolutions": {
43
+ ${lines}
44
+ }
45
+ }`;
46
+ }
47
+
48
+ const maven = {
49
+ label: "Maven",
50
+ pinSection: "A. Pin vulnerable transitives in <dependencyManagement>",
51
+ pinIntro: cnt => `Paste into the root POM to immediately neutralise ${cnt} transitive vulnerabilit${cnt > 1 ? "ies" : "y"}:`,
52
+ snippet: dependencyManagementSnippet,
53
+ directSection: "B. Or update the direct dependencies pulling them in",
54
+ };
55
+
56
+ const npm = {
57
+ label: "npm",
58
+ pinSection: "A. Pin vulnerable transitives via npm overrides",
59
+ pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>npm install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
60
+ snippet: npmOverridesSnippet,
61
+ directSection: "B. Or update the direct dependencies (and run npm install)",
62
+ };
63
+
64
+ const yarn = {
65
+ label: "Yarn",
66
+ pinSection: "A. Pin vulnerable transitives via yarn resolutions",
67
+ pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>yarn install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
68
+ snippet: yarnResolutionsSnippet,
69
+ directSection: "B. Or update the direct dependencies (and run yarn install)",
70
+ };
71
+
72
+ function composerRequireSnippet(items) {
73
+ return items.map(it => `composer require ${it.groupId ? it.groupId + "/" : ""}${it.artifactId}:^${esc(it.fixVersion)}`).join("\n");
74
+ }
75
+
76
+ const composer = {
77
+ label: "Composer",
78
+ pinSection: "A. Update the abandoned / vulnerable packages",
79
+ pinIntro: cnt => `Run for the ${cnt} affected package${cnt > 1 ? "s" : ""}, then commit the updated <code>composer.lock</code>:`,
80
+ snippet: composerRequireSnippet,
81
+ directSection: "B. Or bump them in composer.json and run composer update",
82
+ };
83
+
84
+ function pipInstallSnippet(items) {
85
+ return items.map(it => `pip install '${esc(it.artifactId)}>=${esc(it.fixVersion)}'`).join("\n");
86
+ }
87
+
88
+ const pypi = {
89
+ label: "PyPI",
90
+ pinSection: "A. Upgrade the affected packages",
91
+ pinIntro: cnt => `Upgrade the ${cnt} affected package${cnt > 1 ? "s" : ""}, then re-lock (poetry lock / pip-compile):`,
92
+ snippet: pipInstallSnippet,
93
+ directSection: "B. Or bump them in pyproject.toml / requirements.txt and re-lock",
94
+ };
95
+
96
+ function dotnetAddSnippet(items) {
97
+ return items.map(it => `dotnet add package ${esc(it.artifactId)} --version ${esc(it.fixVersion)}`).join("\n");
98
+ }
99
+
100
+ const nuget = {
101
+ label: "NuGet",
102
+ pinSection: "A. Update the affected packages",
103
+ pinIntro: cnt => `Run for the ${cnt} affected package${cnt > 1 ? "s" : ""} (or bump <code>Directory.Packages.props</code> under Central Package Management):`,
104
+ snippet: dotnetAddSnippet,
105
+ directSection: "B. Then restore and commit packages.lock.json",
106
+ };
107
+
108
+ module.exports = { maven, npm, yarn, composer, pypi, nuget, dependencyManagementSnippet, npmOverridesSnippet, yarnResolutionsSnippet, composerRequireSnippet, pipInstallSnippet, dotnetAddSnippet };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * lib/codecs/select.js — résout la liste des codecs actifs depuis le flag
3
+ * --ecosystem et les flags --no-<id>.
4
+ *
5
+ * requested : "auto" | "all" | "maven" | "maven,npm,pypi" | (legacy "both"|"npm")
6
+ * available : ids candidats — pour "auto", les codecs détectés ; sinon tous.
7
+ * flags : { noCodecs: ["npm", ...], noJs: bool }
8
+ */
9
+ function resolveActiveCodecs(requested, available, flags = {}) {
10
+ const { noCodecs = [], noJs = false } = flags;
11
+ const req = String(requested || "auto").toLowerCase();
12
+ let active;
13
+ if (req === "auto" || req === "all") {
14
+ active = [...available];
15
+ } else if (req === "both") {
16
+ // rétro-compat : "both" == maven + npm/yarn
17
+ active = available.filter(id => ["maven", "npm", "yarn"].includes(id));
18
+ } else {
19
+ const wanted = req.split(",").map(s => s.trim()).filter(Boolean);
20
+ active = available.filter(id => wanted.includes(id));
21
+ }
22
+ const excluded = new Set(noCodecs);
23
+ if (noJs) { excluded.add("npm"); excluded.add("yarn"); }
24
+ return active.filter(id => !excluded.has(id));
25
+ }
26
+
27
+ module.exports = { resolveActiveCodecs };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * lib/codecs/yarn.codec.js — codec Yarn.
3
+ *
4
+ * Yarn partage tout avec npm (même registre npmjs, même OSV ecosystem "npm",
5
+ * mêmes coordKeys "npm:<name>"). Il n'existe que pour fournir son label et sa
6
+ * recette (`resolutions`) au report. Le scan JS est fait UNE SEULE FOIS par le
7
+ * codec npm (qui ramasse package-lock ET yarn.lock) — yarn.collect est un no-op
8
+ * pour éviter le double scan ; le registre des codecs ne l'appelle pas.
9
+ */
10
+ const npm = require("./npm.codec");
11
+
12
+ module.exports = {
13
+ ...npm,
14
+ id: "yarn",
15
+ label: "Yarn",
16
+ recipe: require("./recipes").yarn,
17
+ collectViaSibling: "npm", // documente que le scan est fait par le codec npm
18
+ collect: async () => ({ deps: new Map(), warnings: [] }),
19
+ };
package/lib/core.js CHANGED
@@ -99,10 +99,20 @@ async function parsePom(pomPath, allPomMetadata) {
99
99
  groupId,
100
100
  artifactId,
101
101
  version,
102
+ // Maven model expressions for the current project. These are built-ins,
103
+ // not <properties> — ${project.version} always means THIS project's
104
+ // version (never overridable by a property or an imported BOM). The
105
+ // pom.* aliases mirror Maven's legacy synonyms.
102
106
  localVars: {
103
107
  "project.groupId": groupId,
104
108
  "project.artifactId": artifactId,
105
109
  "project.version": version,
110
+ "pom.groupId": groupId,
111
+ "pom.artifactId": artifactId,
112
+ "pom.version": version,
113
+ ...(parentInfo?.groupId ? { "project.parent.groupId": parentInfo.groupId } : {}),
114
+ ...(parentInfo?.artifactId ? { "project.parent.artifactId": parentInfo.artifactId } : {}),
115
+ ...(parentInfo?.version ? { "project.parent.version": parentInfo.version } : {}),
106
116
  },
107
117
  };
108
118
 
@@ -199,7 +209,31 @@ async function getAllInheritedProps(pomPath, allPomMetadata, cache) {
199
209
  merged.dependencyManagement.push(...imported.dependencyManagement);
200
210
  }
201
211
 
202
- resolveParentPath(pomPath, parentInfo, allPomMetadata);
212
+ // Cache the (still-mutating) object BEFORE recursing into the parent so a
213
+ // malformed parent cycle (A→B→A) returns this partial result instead of
214
+ // recursing forever. `merged` is mutated in place afterwards, so the cached
215
+ // reference ends up fully populated.
216
+ cache[pomPath] = merged;
217
+
218
+ // Inherit <properties> from a LOCAL parent POM as the BASE layer — the child
219
+ // overrides the parent, exactly like native Maven. (dependencyManagement and
220
+ // parent <dependencies> need no special handling here: collectResolvedDeps
221
+ // already merges them across the whole tree by g:a.) External parents
222
+ // (e.g. spring-boot-starter-parent) aren't in the source tree, so their
223
+ // properties can't be inherited without a network fetch — those versions
224
+ // still surface as unresolved, as before.
225
+ const parentPath = resolveParentPath(pomPath, parentInfo, allPomMetadata);
226
+ if (parentPath && parentPath !== pomPath) {
227
+ const parentMerged = await getAllInheritedProps(parentPath, allPomMetadata, cache);
228
+ merged.properties = { ...parentMerged.properties, ...merged.properties };
229
+ }
230
+
231
+ // Built-in project.* coordinates (${project.version}, ${project.groupId}, …).
232
+ // Applied LAST so the current POM's own values win — even over an inherited
233
+ // parent property or an imported BOM's project.* — letting an intra-reactor
234
+ // dep declared "<version>${project.version}</version>" resolve to THIS
235
+ // module's version.
236
+ merged.properties = { ...merged.properties, ...(meta.localVars || {}) };
203
237
 
204
238
  cache[pomPath] = merged;
205
239
  return merged;
package/lib/cve-match.js CHANGED
@@ -5,17 +5,28 @@
5
5
  const { coord } = require("./core");
6
6
  const { compareMavenVersions, isVersionAffected } = require("./maven-version");
7
7
  const { resolveTransitiveDeps } = require("./transitive");
8
+ const { makeDepRecord } = require("./dep-record");
8
9
 
9
10
  const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
10
11
 
11
12
  function resolveDepVersion(rawVersion, allProps) {
12
13
  if (!rawVersion) return null;
13
14
  // Resolve ${prop} references using the merged property map for this POM.
14
- return String(rawVersion).replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
15
- const v = allProps?.[k];
16
- if (Array.isArray(v)) return v[0];
17
- return v != null ? v : m;
18
- });
15
+ // Maven interpolates recursively (e.g. ${project.version} ${revision}
16
+ // "1.0"), so we re-substitute until the string stops changing. Bounded +
17
+ // stop-on-no-progress guards against property cycles. Unknown refs are left
18
+ // verbatim, so they still surface as "unresolved-versions" in chapter 0.
19
+ let v = String(rawVersion);
20
+ for (let i = 0; i < 10 && v.includes("${"); i++) {
21
+ const next = v.replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
22
+ const val = allProps?.[k];
23
+ if (Array.isArray(val)) return val[0];
24
+ return val != null ? val : m;
25
+ });
26
+ if (next === v) break; // no progress → remaining refs are unresolvable
27
+ v = next;
28
+ }
29
+ return v;
19
30
  }
20
31
 
21
32
  /**
@@ -49,7 +60,7 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
49
60
  // A concrete (resolved, non-property) version worth scanning on its own.
50
61
  const concrete = v && !/\$\{/.test(v) ? v : null;
51
62
  if (!existing) {
52
- out.set(key, { groupId: g, artifactId: a, version: v || null, versions: concrete ? [concrete] : [], scope, pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven", isDev });
63
+ out.set(key, makeDepRecord({ ecosystem: "maven", namespace: g, name: a, version: v || null, manifestPath: pomPath, scope, isDev }));
53
64
  } else {
54
65
  if (!existing.pomPaths.includes(pomPath)) existing.pomPaths.push(pomPath);
55
66
  if (existing.scope === "test" && scope !== "test") existing.scope = scope;
@@ -83,11 +94,7 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
83
94
  if (deps2Exclude && deps2Exclude.test(p.groupId)) continue;
84
95
  const key = `${p.groupId}:${p.artifactId}`;
85
96
  if (!out.has(key)) {
86
- out.set(key, {
87
- groupId: p.groupId, artifactId: p.artifactId, version: p.version || null,
88
- versions: p.version && !/\$\{/.test(p.version) ? [p.version] : [],
89
- scope: "parent", pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven",
90
- });
97
+ out.set(key, makeDepRecord({ ecosystem: "maven", namespace: p.groupId, name: p.artifactId, version: p.version || null, manifestPath: pomPath, scope: "parent" }));
91
98
  }
92
99
  }
93
100
 
package/lib/cve-report.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  const fs = require("fs");
9
9
  const path = require("path");
10
+ const { getCodec, allCodecs, ORDER } = require("./codecs");
10
11
 
11
12
  // CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
12
13
  // back to the raw id (with a "—" placeholder name where the row asks for one).
@@ -348,14 +349,24 @@ function makeRelative(absPath, srcRoot) {
348
349
  } catch { return absPath; }
349
350
  }
350
351
 
352
+ // Présentation des coordonnées, déléguée au codec. Règle générique : le codec
353
+ // fournit la coord canonique (maven "g:a", npm "name", composer "vendor/pkg"…) ;
354
+ // Maven la porte déjà son namespace, les autres reçoivent un tag d'écosystème
355
+ // ("npm:", "composer:", …) pour lever l'ambiguïté dans un report multi-eco.
356
+ function codecFor(dep) { return getCodec(dep?.ecosystemType) || getCodec(dep?.ecosystem); }
357
+
351
358
  function depDisplayParts(dep) {
352
- if (dep?.ecosystem === "npm") return { groupLine: "npm:", nameLine: dep.artifactId || "" };
353
- return { groupLine: `${dep?.groupId ?? ""}:`, nameLine: dep?.artifactId || "" };
359
+ const c = codecFor(dep);
360
+ if (!c) return { groupLine: `${dep?.groupId ?? ""}:`, nameLine: dep?.artifactId || "" };
361
+ if (dep.ecosystem === "maven") return { groupLine: `${dep.namespace || dep.groupId || ""}:`, nameLine: dep.name || dep.artifactId || "" };
362
+ return { groupLine: `${dep.ecosystem}:`, nameLine: c.formatCoord(dep) };
354
363
  }
355
364
 
356
365
  function depDisplayName(dep) {
357
- if (dep?.ecosystem === "npm") return `npm:${dep.artifactId || ""}`;
358
- return `${dep?.groupId ?? ""}:${dep?.artifactId || ""}`;
366
+ const c = codecFor(dep);
367
+ if (!c) return `${dep?.groupId ?? ""}:${dep?.artifactId || ""}`;
368
+ const coord = c.formatCoord(dep);
369
+ return dep.ecosystem === "maven" ? coord : `${dep.ecosystem}:${coord}`;
359
370
  }
360
371
 
361
372
  const REF_LABELS = {
@@ -626,65 +637,9 @@ function renderCveTable(cveMatches, opts = {}) {
626
637
  </table>`;
627
638
  }
628
639
 
629
- /**
630
- * Build a snippet of pom.xml the user can paste into their root POM's
631
- * <dependencyManagement> to pin a transitive to a safe version.
632
- */
633
- function dependencyManagementSnippet(items) {
634
- const inner = items.map(it => ` <dependency>
635
- <groupId>${esc(it.groupId)}</groupId>
636
- <artifactId>${esc(it.artifactId)}</artifactId>
637
- <version>${esc(it.fixVersion)}</version>
638
- </dependency>`).join("\n");
639
- return `<dependencyManagement>
640
- <dependencies>
641
- ${inner}
642
- </dependencies>
643
- </dependencyManagement>`;
644
- }
645
-
646
- function npmOverridesSnippet(items) {
647
- const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
648
- return `{
649
- "overrides": {
650
- ${lines}
651
- }
652
- }`;
653
- }
654
-
655
- function yarnResolutionsSnippet(items) {
656
- const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
657
- return `{
658
- "resolutions": {
659
- ${lines}
660
- }
661
- }`;
662
- }
663
-
664
- // Per-ecosystem fix-recipe metadata. Keys correspond to ecosystemType values.
665
- const ECO_RECIPE = {
666
- maven: {
667
- label: "Maven",
668
- pinSection: "A. Pin vulnerable transitives in <dependencyManagement>",
669
- pinIntro: cnt => `Paste into the root POM to immediately neutralise ${cnt} transitive vulnerabilit${cnt > 1 ? "ies" : "y"}:`,
670
- snippet: dependencyManagementSnippet,
671
- directSection: "B. Or update the direct dependencies pulling them in",
672
- },
673
- npm: {
674
- label: "npm",
675
- pinSection: "A. Pin vulnerable transitives via npm overrides",
676
- pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>npm install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
677
- snippet: npmOverridesSnippet,
678
- directSection: "B. Or update the direct dependencies (and run npm install)",
679
- },
680
- yarn: {
681
- label: "Yarn",
682
- pinSection: "A. Pin vulnerable transitives via yarn resolutions",
683
- pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>yarn install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
684
- snippet: yarnResolutionsSnippet,
685
- directSection: "B. Or update the direct dependencies (and run yarn install)",
686
- },
687
- };
640
+ // Per-ecosystem fix-recipe metadata, now owned by the codecs (lib/codecs/recipes.js).
641
+ // Keys correspond to ecosystemType values.
642
+ const ECO_RECIPE = require("./codecs/recipes");
688
643
 
689
644
  /**
690
645
  * From CVE matches (split direct/transitive) + outdatedResults + resolvedDeps,
@@ -790,7 +745,9 @@ function renderFixRecommendations(recos, allMatches = [], outdatedResults = [])
790
745
  }
791
746
 
792
747
  // Group by ecosystemType so each tool gets its own pin/update advice.
793
- const byEco = { maven: { trans: [], directs: [] }, npm: { trans: [], directs: [] }, yarn: { trans: [], directs: [] } };
748
+ // Buckets + order come from the codec registry (new ecosystems slot in).
749
+ const byEco = {};
750
+ for (const id of ORDER) byEco[id] = { trans: [], directs: [] };
794
751
  for (const t of recos.transitivesList) {
795
752
  const et = ecoTypeOf(t);
796
753
  (byEco[et] || byEco.maven).trans.push(t);
@@ -800,7 +757,7 @@ function renderFixRecommendations(recos, allMatches = [], outdatedResults = [])
800
757
  (byEco[et] || byEco.maven).directs.push(d);
801
758
  }
802
759
 
803
- const ecoOrder = ["maven", "npm", "yarn"];
760
+ const ecoOrder = ORDER;
804
761
  const out = [directsSection];
805
762
  let letterIdx = 0;
806
763
  for (const eco of ecoOrder) {
@@ -1430,7 +1387,7 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
1430
1387
  const out = [];
1431
1388
  for (const m of all) {
1432
1389
  const dep = m.dep;
1433
- const k = `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
1390
+ const k = dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
1434
1391
  if (seen.has(k)) continue;
1435
1392
  seen.add(k);
1436
1393
  out.push(m);
@@ -1459,12 +1416,17 @@ function renderFalsePositives(matches) {
1459
1416
  return intro + renderCveTable(matches);
1460
1417
  }
1461
1418
 
1462
- const ECO_LABELS = { maven: "Maven", npm: "npm (package-lock)", yarn: "Yarn" };
1463
- const ECO_MANIFEST_KIND = { maven: "pom.xml", npm: "package-lock.json", yarn: "yarn.lock" };
1419
+ // Derived from the codec registry. Overrides keep the exact legacy wording where
1420
+ // a codec's plain label/lockfile isn't what the report historically showed.
1421
+ const ECO_LABEL_OVERRIDE = { npm: "npm (package-lock)" };
1422
+ const ECO_MANIFEST_OVERRIDE = { maven: "pom.xml", npm: "package-lock.json", yarn: "yarn.lock" };
1423
+ const ECO_LABELS = Object.fromEntries(allCodecs().map(c => [c.id, ECO_LABEL_OVERRIDE[c.id] || c.label]));
1424
+ const ECO_MANIFEST_KIND = Object.fromEntries(allCodecs().map(c => [c.id, ECO_MANIFEST_OVERRIDE[c.id] || c.manifestNames[c.manifestNames.length - 1]]));
1464
1425
 
1465
1426
  function renderCveBySectionByEco(matches, chapterPrefix, srcRoot) {
1466
- const ecoOrder = ["maven", "npm", "yarn"];
1467
- const byEco = { maven: [], npm: [], yarn: [] };
1427
+ const ecoOrder = ORDER;
1428
+ const byEco = {};
1429
+ for (const id of ORDER) byEco[id] = [];
1468
1430
  for (const m of matches) {
1469
1431
  const et = m.dep?.ecosystemType || (m.dep?.ecosystem === "npm" ? "npm" : "maven");
1470
1432
  (byEco[et] || byEco.maven).push(m);
@@ -1843,5 +1805,7 @@ module.exports = {
1843
1805
  generateHtmlReport,
1844
1806
  generateWordReport,
1845
1807
  writeReports,
1808
+ depDisplayName,
1809
+ depDisplayParts,
1846
1810
  esc,
1847
1811
  };