fad-checker 1.0.5 → 2.0.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CLAUDE.md +28 -9
  3. package/README.md +27 -13
  4. package/completions/fad-checker.bash +4 -1
  5. package/completions/fad-checker.zsh +9 -0
  6. package/data/eol-mapping.json +17 -0
  7. package/docs/ARCHITECTURE.md +35 -7
  8. package/docs/USAGE.md +15 -7
  9. package/docs/superpowers/plans/2026-05-29-codec-composer.md +556 -0
  10. package/docs/superpowers/plans/2026-05-29-codec-foundation.md +851 -0
  11. package/docs/superpowers/plans/2026-05-29-codec-nuget.md +432 -0
  12. package/docs/superpowers/plans/2026-05-29-codec-pypi.md +450 -0
  13. package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +251 -0
  14. package/fad-checker.js +109 -83
  15. package/lib/codecs/codec.interface.js +27 -0
  16. package/lib/codecs/composer/parse.js +59 -0
  17. package/lib/codecs/composer/registry.js +92 -0
  18. package/lib/codecs/composer.codec.js +93 -0
  19. package/lib/codecs/index.js +37 -0
  20. package/lib/codecs/maven.codec.js +71 -0
  21. package/lib/{npm → codecs/npm}/collect.js +32 -18
  22. package/lib/codecs/npm.codec.js +52 -0
  23. package/lib/codecs/nuget/parse.js +75 -0
  24. package/lib/codecs/nuget/registry.js +88 -0
  25. package/lib/codecs/nuget.codec.js +92 -0
  26. package/lib/codecs/pypi/parse.js +71 -0
  27. package/lib/codecs/pypi/registry.js +89 -0
  28. package/lib/codecs/pypi.codec.js +81 -0
  29. package/lib/codecs/recipes.js +108 -0
  30. package/lib/codecs/select.js +27 -0
  31. package/lib/codecs/yarn.codec.js +19 -0
  32. package/lib/cve-match.js +30 -14
  33. package/lib/cve-report.js +130 -83
  34. package/lib/dep-record.js +60 -0
  35. package/lib/osv.js +33 -19
  36. package/lib/outdated.js +13 -2
  37. package/package.json +3 -2
  38. package/test/cli-ecosystem.test.js +30 -0
  39. package/test/codec-edge-cases.test.js +131 -0
  40. package/test/codec-integration.test.js +119 -0
  41. package/test/codecs.test.js +92 -0
  42. package/test/composer.test.js +71 -0
  43. package/test/cve-match.test.js +40 -0
  44. package/test/cve-report.test.js +9 -0
  45. package/test/dep-record.test.js +31 -0
  46. package/test/fixtures/csharp-config/packages.config +4 -0
  47. package/test/fixtures/csharp-csproj/Directory.Packages.props +5 -0
  48. package/test/fixtures/csharp-csproj/app.csproj +7 -0
  49. package/test/fixtures/csharp-lock/packages.lock.json +6 -0
  50. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +2 -1
  51. package/test/fixtures/php-app/composer.json +5 -0
  52. package/test/fixtures/php-app/composer.lock +10 -0
  53. package/test/fixtures/polyglot/cs/packages.lock.json +1 -0
  54. package/test/fixtures/polyglot/js/package-lock.json +1 -0
  55. package/test/fixtures/polyglot/js/package.json +1 -0
  56. package/test/fixtures/polyglot/mvn/pom.xml +7 -0
  57. package/test/fixtures/polyglot/php/composer.lock +1 -0
  58. package/test/fixtures/polyglot/py/poetry.lock +3 -0
  59. package/test/fixtures/python-pipenv/Pipfile.lock +1 -0
  60. package/test/fixtures/python-poetry/poetry.lock +7 -0
  61. package/test/fixtures/python-reqs/requirements.txt +5 -0
  62. package/test/fixtures/python-uv/uv.lock +3 -0
  63. package/test/monorepo.test.js +1 -1
  64. package/test/npm-registry.test.js +1 -1
  65. package/test/npm.test.js +8 -4
  66. package/test/nuget.test.js +66 -0
  67. package/test/osv.test.js +62 -0
  68. package/test/pypi.test.js +72 -0
  69. package/test/webjar.test.js +1 -1
  70. /package/lib/{npm → codecs/npm}/parse.js +0 -0
  71. /package/lib/{npm → codecs/npm}/registry.js +0 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * lib/codecs/nuget.codec.js — codec C#/.NET (NuGet).
3
+ *
4
+ * Vuln scanning is OSV (ecosystem "NuGet", wired in Plan A). This codec adds
5
+ * collection (packages.lock.json, else .csproj + Directory.Packages.props CPM,
6
+ * else packages.config), NuGet registration registry (deprecation + outdated),
7
+ * and EOL. NuGet ids are case-insensitive: the key is lowercased, dep.name keeps
8
+ * the original casing for display / OSV.
9
+ */
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { makeDepRecord, coordKeyFor } = require("../dep-record");
13
+ const N = require("./nuget/parse");
14
+
15
+ const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "bin", "obj", "target", "packages"]);
16
+
17
+ function findNugetDirs(dir) {
18
+ const groups = [];
19
+ const stack = [dir];
20
+ while (stack.length) {
21
+ const cur = stack.pop();
22
+ let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
23
+ const files = entries.filter(e => e.isFile()).map(e => e.name);
24
+ const csprojs = files.filter(f => f.toLowerCase().endsWith(".csproj"));
25
+ if (files.includes("packages.lock.json") || files.includes("packages.config") || csprojs.length) {
26
+ groups.push({ dir: cur, files, csprojs });
27
+ }
28
+ for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
29
+ }
30
+ return groups;
31
+ }
32
+
33
+ module.exports = {
34
+ id: "nuget",
35
+ label: "NuGet",
36
+ osvEcosystem: "NuGet",
37
+ manifestNames: ["packages.lock.json", "*.csproj", "packages.config"],
38
+
39
+ detect(dir) { return findNugetDirs(dir).length > 0; },
40
+
41
+ async collect(dir, opts = {}) {
42
+ const { deps2Exclude } = opts;
43
+ const out = new Map();
44
+ const warnings = [];
45
+ const add = (d, manifestPath) => {
46
+ if (deps2Exclude && deps2Exclude.test(d.name)) return;
47
+ out.set(coordKeyFor("nuget", "", d.name), makeDepRecord({ ecosystem: "nuget", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev }));
48
+ };
49
+ for (const g of findNugetDirs(dir)) {
50
+ if (g.files.includes("packages.lock.json")) {
51
+ const fp = path.join(g.dir, "packages.lock.json");
52
+ try { const { deps } = await N.parsePackagesLockJson(fp); for (const d of deps) add(d, fp); }
53
+ catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `packages.lock.json parse failed: ${e.message}` }); }
54
+ continue; // lockfile is authoritative for this directory
55
+ }
56
+ // No lockfile → best-effort from .csproj (+CPM) and packages.config + warning.
57
+ let cpm = {};
58
+ if (g.files.includes("Directory.Packages.props")) {
59
+ try { cpm = await N.parseDirectoryPackagesProps(path.join(g.dir, "Directory.Packages.props")); } catch { /* ignore */ }
60
+ }
61
+ let pinned = 0, skipped = 0;
62
+ for (const cs of g.csprojs) {
63
+ const fp = path.join(g.dir, cs);
64
+ try {
65
+ const { deps, skipped: sk } = await N.parseCsproj(fp, cpm);
66
+ for (const d of deps) { add(d, fp); pinned++; }
67
+ skipped += sk;
68
+ } catch { /* ignore unparsable csproj */ }
69
+ }
70
+ if (g.files.includes("packages.config")) {
71
+ const fp = path.join(g.dir, "packages.config");
72
+ try { const { deps } = await N.parsePackagesConfig(fp); for (const d of deps) { add(d, fp); pinned++; } } catch { /* ignore */ }
73
+ }
74
+ if (g.csprojs.length || g.files.includes("packages.config")) {
75
+ warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no packages.lock.json — best-effort: ${pinned} pinned, ${skipped} floating/unresolved skipped (enable RestorePackagesWithLockFile)` });
76
+ }
77
+ }
78
+ return { deps: out, warnings };
79
+ },
80
+
81
+ coordKey(d) { return coordKeyFor("nuget", "", d.name); },
82
+ formatCoord(d) { return d.name; },
83
+ osvPackageName(d) { return d.name; },
84
+
85
+ async checkRegistry(deps, opts = {}) {
86
+ const { checkNugetRegistryDeps } = require("./nuget/registry");
87
+ return checkNugetRegistryDeps(deps, opts);
88
+ },
89
+ resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
90
+ recipe: require("./recipes").nuget,
91
+ nativeScanners: [],
92
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * lib/python/parse.js — parse Python manifests / lockfiles.
3
+ *
4
+ * poetry.lock / uv.lock / pdm.lock — TOML, [[package]] name/version arrays.
5
+ * Pipfile.lock — JSON, { default:{}, develop:{} }, version "==x".
6
+ * requirements.txt — text; only "==" pins are queryable (fallback).
7
+ *
8
+ * All names are PEP 503 normalized (lowercase, runs of -_. → single -) so they
9
+ * match OSV / PyPI / the resolved-deps key.
10
+ */
11
+ const fs = require("fs");
12
+ const TOML = require("smol-toml");
13
+
14
+ function pep503(name) { return String(name || "").toLowerCase().replace(/[-_.]+/g, "-"); }
15
+
16
+ function stripOp(v) { return String(v || "").replace(/^[=~!<>]+/, "").trim(); }
17
+ function isPinned(spec) { return /^==\s*\d[\w.\-+!]*$/.test(String(spec || "").trim()); }
18
+ function isConcrete(v) { return /^\d+(\.\d+)*([.\-+]\S+)?$/.test(String(v || "")); }
19
+
20
+ // poetry.lock / uv.lock / pdm.lock all use [[package]] name/version arrays.
21
+ function parseTomlPackages(filePath, type) {
22
+ const data = TOML.parse(fs.readFileSync(filePath, "utf8"));
23
+ const pkgs = Array.isArray(data.package) ? data.package : [];
24
+ const deps = [];
25
+ for (const p of pkgs) {
26
+ if (!p.name || !p.version) continue;
27
+ // pdm marks groups; poetry/uv don't reliably → default prod.
28
+ const groups = Array.isArray(p.groups) ? p.groups : null;
29
+ const isDev = groups ? groups.every(g => g === "dev") : false;
30
+ deps.push({ name: pep503(p.name), version: String(p.version), scope: isDev ? "dev" : "prod", isDev });
31
+ }
32
+ return { manifestPath: filePath, manifestType: type, deps };
33
+ }
34
+ const parsePoetryLock = f => parseTomlPackages(f, "poetry.lock");
35
+ const parseUvLock = f => parseTomlPackages(f, "uv.lock");
36
+ const parsePdmLock = f => parseTomlPackages(f, "pdm.lock");
37
+
38
+ function parsePipfileLock(filePath) {
39
+ const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
40
+ const deps = [];
41
+ const push = (obj, scope) => {
42
+ for (const [name, meta] of Object.entries(obj || {})) {
43
+ const v = stripOp(meta.version);
44
+ if (!v) continue;
45
+ deps.push({ name: pep503(name), version: v, scope, isDev: scope === "dev" });
46
+ }
47
+ };
48
+ push(json.default, "prod");
49
+ push(json.develop, "dev");
50
+ return { manifestPath: filePath, manifestType: "Pipfile.lock", deps };
51
+ }
52
+
53
+ function parseRequirementsTxt(filePath) {
54
+ const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
55
+ const deps = [];
56
+ let skipped = 0;
57
+ for (const raw of lines) {
58
+ const line = raw.replace(/#.*$/, "").trim();
59
+ if (!line) continue;
60
+ if (line.startsWith("-")) continue; // -e ., -r other.txt, --flags
61
+ const m = line.match(/^([A-Za-z0-9._-]+)\s*(\[[^\]]*\])?\s*(.*)$/);
62
+ if (!m) continue;
63
+ const name = pep503(m[1]);
64
+ const spec = m[3].split(";")[0].trim(); // drop env markers
65
+ if (isPinned(spec)) deps.push({ name, version: stripOp(spec), scope: "prod", isDev: false });
66
+ else skipped++;
67
+ }
68
+ return { manifestPath: filePath, manifestType: "requirements.txt", deps, skipped };
69
+ }
70
+
71
+ module.exports = { pep503, isConcrete, parsePoetryLock, parseUvLock, parsePdmLock, parsePipfileLock, parseRequirementsTxt };
@@ -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,81 @@
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
+ function findPyDirs(dir) {
23
+ const groups = [];
24
+ const stack = [dir];
25
+ while (stack.length) {
26
+ const cur = stack.pop();
27
+ let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
28
+ const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
29
+ if ([...LOCKS.map(l => l[0]), "requirements.txt"].some(n => names.has(n))) groups.push({ dir: cur, names });
30
+ for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
31
+ }
32
+ return groups;
33
+ }
34
+
35
+ module.exports = {
36
+ id: "pypi",
37
+ label: "PyPI",
38
+ osvEcosystem: "PyPI",
39
+ manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "requirements.txt"],
40
+
41
+ detect(dir) { return findPyDirs(dir).length > 0; },
42
+
43
+ async collect(dir, opts = {}) {
44
+ const { ignoreTest, deps2Exclude } = opts;
45
+ const out = new Map();
46
+ const warnings = [];
47
+ const add = (d, manifestPath) => {
48
+ if (ignoreTest && d.isDev) return;
49
+ if (deps2Exclude && deps2Exclude.test(d.name)) return;
50
+ out.set(coordKeyFor("pypi", "", d.name), makeDepRecord({ ecosystem: "pypi", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev }));
51
+ };
52
+ for (const g of findPyDirs(dir)) {
53
+ const lock = LOCKS.find(([n]) => g.names.has(n));
54
+ if (lock) {
55
+ const fp = path.join(g.dir, lock[0]);
56
+ try { const { deps } = lock[1](fp); for (const d of deps) add(d, fp); }
57
+ catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${lock[0]} parse failed: ${e.message}` }); }
58
+ } else if (g.names.has("requirements.txt")) {
59
+ const fp = path.join(g.dir, "requirements.txt");
60
+ try {
61
+ const { deps, skipped } = P.parseRequirementsTxt(fp);
62
+ for (const d of deps) add(d, fp);
63
+ warnings.push({ type: "no-lockfile", manifestPath: fp, message: `requirements.txt (no lockfile) — best-effort: ${deps.length} pinned, ${skipped} range(s) skipped` });
64
+ } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `requirements.txt parse failed: ${e.message}` }); }
65
+ }
66
+ }
67
+ return { deps: out, warnings };
68
+ },
69
+
70
+ coordKey(d) { return coordKeyFor("pypi", "", d.name); },
71
+ formatCoord(d) { return d.name; },
72
+ osvPackageName(d) { return d.name; },
73
+
74
+ async checkRegistry(deps, opts = {}) {
75
+ const { checkPypiRegistryDeps } = require("./pypi/registry");
76
+ return checkPypiRegistryDeps(deps, opts);
77
+ },
78
+ resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
79
+ recipe: require("./recipes").pypi,
80
+ nativeScanners: [],
81
+ };
@@ -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/cve-match.js CHANGED
@@ -5,6 +5,7 @@
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
 
@@ -20,10 +21,13 @@ function resolveDepVersion(rawVersion, allProps) {
20
21
 
21
22
  /**
22
23
  * Walk allPomMetadata.byPath, collect every {groupId,artifactId,version,scope}
23
- * triple, dedupe by groupId:artifactId keeping the highest version seen,
24
- * also include external parent POMs as scope='parent'.
24
+ * triple, dedupe by groupId:artifactId. `version` is the highest seen
25
+ * (representative for display/EOL/outdated), while `versions` keeps EVERY
26
+ * distinct concrete version (e.g. two profiles pinning the same g:a) so the
27
+ * CVE/OSV scanners check each one, not just the highest. Also includes
28
+ * external parent POMs as scope='parent'.
25
29
  *
26
- * Returns Map<key, { groupId, artifactId, version, scope, pomPaths }>
30
+ * Returns Map<key, { groupId, artifactId, version, versions, scope, pomPaths }>
27
31
  */
28
32
  function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
29
33
  const { ignoreTest, deps2Exclude } = opts;
@@ -43,14 +47,19 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
43
47
  const key = `${g}:${a}`;
44
48
  const existing = out.get(key);
45
49
  const isDev = scope === "test" || scope === "provided";
50
+ // A concrete (resolved, non-property) version worth scanning on its own.
51
+ const concrete = v && !/\$\{/.test(v) ? v : null;
46
52
  if (!existing) {
47
- out.set(key, { groupId: g, artifactId: a, version: v || null, scope, pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven", isDev });
53
+ out.set(key, makeDepRecord({ ecosystem: "maven", namespace: g, name: a, version: v || null, manifestPath: pomPath, scope, isDev }));
48
54
  } else {
49
55
  if (!existing.pomPaths.includes(pomPath)) existing.pomPaths.push(pomPath);
50
56
  if (existing.scope === "test" && scope !== "test") existing.scope = scope;
51
57
  if (v && (!existing.version || compareMavenVersions(v, existing.version) > 0)) {
52
58
  existing.version = v;
53
59
  }
60
+ // Track every distinct concrete version (e.g. different profiles
61
+ // pinning the same g:a) so each gets scanned, not just the highest.
62
+ if (concrete && !existing.versions.includes(concrete)) existing.versions.push(concrete);
54
63
  // A dep is "dev" overall only if every occurrence is test/provided.
55
64
  if (!isDev) existing.isDev = false;
56
65
  }
@@ -75,10 +84,7 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
75
84
  if (deps2Exclude && deps2Exclude.test(p.groupId)) continue;
76
85
  const key = `${p.groupId}:${p.artifactId}`;
77
86
  if (!out.has(key)) {
78
- out.set(key, {
79
- groupId: p.groupId, artifactId: p.artifactId, version: p.version || null,
80
- scope: "parent", pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven",
81
- });
87
+ out.set(key, makeDepRecord({ ecosystem: "maven", namespace: p.groupId, name: p.artifactId, version: p.version || null, manifestPath: pomPath, scope: "parent" }));
82
88
  }
83
89
  }
84
90
 
@@ -127,6 +133,7 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
127
133
  groupId: t.groupId,
128
134
  artifactId: t.artifactId,
129
135
  version: t.version,
136
+ versions: t.version && !/\$\{/.test(t.version) ? [t.version] : [],
130
137
  scope: "transitive",
131
138
  pomPaths: [],
132
139
  via: t.via,
@@ -151,12 +158,21 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
151
158
  function matchOne(dep, entries, confidence) {
152
159
  const matches = [];
153
160
  if (!entries) return matches;
161
+ // Scan every distinct version the dep resolves to (e.g. two profiles pinning
162
+ // the same g:a), not just the representative highest — otherwise a CVE that
163
+ // only affects a lower-versioned profile variant would be missed.
164
+ const versions = (dep.versions && dep.versions.length) ? dep.versions : [dep.version];
154
165
  for (const e of entries) {
155
- const affected = (e.ranges || []).some(r => {
156
- if (!dep.version) return r.status === "affected"; // unknown version → assume affected
157
- return isVersionAffected(dep.version, r);
158
- });
159
- if (affected) matches.push({ dep, cve: { ...e }, confidence });
166
+ for (const ver of versions) {
167
+ const affected = (e.ranges || []).some(r => {
168
+ if (!ver) return r.status === "affected"; // unknown version → assume affected
169
+ return isVersionAffected(ver, r);
170
+ });
171
+ if (affected) {
172
+ const vdep = ver === dep.version ? dep : { ...dep, version: ver };
173
+ matches.push({ dep: vdep, cve: { ...e }, confidence });
174
+ }
175
+ }
160
176
  }
161
177
  return matches;
162
178
  }
@@ -212,7 +228,7 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
212
228
  const seen = new Set();
213
229
  const deduped = [];
214
230
  for (const m of all) {
215
- const k = `${m.dep.groupId}:${m.dep.artifactId}|${m.cve.id}`;
231
+ const k = `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
216
232
  if (seen.has(k)) continue;
217
233
  seen.add(k);
218
234
  deduped.push(m);