fad-checker 2.0.0 → 2.1.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 (139) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +147 -25
  3. package/completions/fad-checker.bash +1 -1
  4. package/completions/fad-checker.zsh +14 -1
  5. package/data/eol-mapping.json +5 -5
  6. package/data/license-policy.json +97 -0
  7. package/fad-checker-report/cve-report.doc +630 -0
  8. package/fad-checker-report/cve-report.html +748 -0
  9. package/fad-checker.js +556 -245
  10. package/lib/cache-archive.js +3 -0
  11. package/lib/codecs/codec.interface.js +3 -0
  12. package/lib/codecs/composer/parse.js +3 -0
  13. package/lib/codecs/composer/registry.js +13 -5
  14. package/lib/codecs/composer.codec.js +3 -0
  15. package/lib/codecs/go/parse.js +65 -0
  16. package/lib/codecs/go/registry.js +74 -0
  17. package/lib/codecs/go.codec.js +76 -0
  18. package/lib/codecs/index.js +7 -2
  19. package/lib/codecs/maven/jar-scan.js +199 -0
  20. package/lib/codecs/maven.codec.js +17 -1
  21. package/lib/codecs/npm/collect.js +37 -17
  22. package/lib/codecs/npm/parse.js +117 -10
  23. package/lib/codecs/npm/registry.js +14 -5
  24. package/lib/codecs/npm.codec.js +4 -1
  25. package/lib/codecs/nuget/parse.js +3 -0
  26. package/lib/codecs/nuget/registry.js +11 -4
  27. package/lib/codecs/nuget.codec.js +7 -2
  28. package/lib/codecs/pypi/parse.js +111 -14
  29. package/lib/codecs/pypi/registry.js +24 -5
  30. package/lib/codecs/pypi.codec.js +30 -6
  31. package/lib/codecs/recipes.js +28 -1
  32. package/lib/codecs/ruby/parse.js +42 -0
  33. package/lib/codecs/ruby/registry.js +76 -0
  34. package/lib/codecs/ruby.codec.js +66 -0
  35. package/lib/codecs/select.js +3 -0
  36. package/lib/codecs/yarn.codec.js +3 -0
  37. package/lib/config.js +3 -0
  38. package/lib/core.js +38 -1
  39. package/lib/cpe.js +30 -5
  40. package/lib/csaf-export.js +159 -0
  41. package/lib/cve-download.js +3 -0
  42. package/lib/cve-match.js +27 -6
  43. package/lib/cve-report.js +157 -28
  44. package/lib/dep-record.js +15 -2
  45. package/lib/deps-descriptor.js +113 -0
  46. package/lib/epss.js +115 -0
  47. package/lib/gate.js +45 -0
  48. package/lib/json-export.js +110 -0
  49. package/lib/kev.js +88 -0
  50. package/lib/license-policy.js +110 -0
  51. package/lib/maven-license.js +52 -0
  52. package/lib/maven-repo.js +3 -0
  53. package/lib/maven-version.js +8 -3
  54. package/lib/nvd.js +17 -6
  55. package/lib/osv.js +75 -23
  56. package/lib/outdated.js +10 -2
  57. package/lib/priority.js +90 -0
  58. package/lib/purl.js +77 -0
  59. package/lib/retire.js +80 -13
  60. package/lib/sarif-export.js +134 -0
  61. package/lib/sbom-export.js +153 -0
  62. package/lib/scan-completeness.js +3 -0
  63. package/lib/snyk.js +3 -0
  64. package/lib/suppress.js +113 -0
  65. package/lib/transitive.js +6 -3
  66. package/lib/ui.js +90 -0
  67. package/package.json +49 -2
  68. package/CLAUDE.md +0 -147
  69. package/docs/ARCHITECTURE.md +0 -182
  70. package/docs/USAGE.md +0 -187
  71. package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
  72. package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
  73. package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
  74. package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
  75. package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
  76. package/test/cli-ecosystem.test.js +0 -30
  77. package/test/codec-edge-cases.test.js +0 -131
  78. package/test/codec-integration.test.js +0 -119
  79. package/test/codecs.test.js +0 -92
  80. package/test/composer.test.js +0 -71
  81. package/test/core.test.js +0 -153
  82. package/test/cpe.test.js +0 -166
  83. package/test/cve-download.test.js +0 -39
  84. package/test/cve-match.test.js +0 -217
  85. package/test/cve-report.test.js +0 -180
  86. package/test/dep-record.test.js +0 -31
  87. package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
  88. package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
  89. package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
  90. package/test/fixtures/complex-enterprise/pom.xml +0 -66
  91. package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
  92. package/test/fixtures/csharp-config/packages.config +0 -4
  93. package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
  94. package/test/fixtures/csharp-csproj/app.csproj +0 -7
  95. package/test/fixtures/csharp-lock/packages.lock.json +0 -6
  96. package/test/fixtures/cve-samples/cve-non-java.json +0 -19
  97. package/test/fixtures/cve-samples/cve-product-only.json +0 -31
  98. package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
  99. package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
  100. package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
  101. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
  102. package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
  103. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
  104. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
  105. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
  106. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
  107. package/test/fixtures/monorepo-mixed/pom.xml +0 -29
  108. package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
  109. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
  110. package/test/fixtures/php-app/composer.json +0 -5
  111. package/test/fixtures/php-app/composer.lock +0 -10
  112. package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
  113. package/test/fixtures/polyglot/js/package-lock.json +0 -1
  114. package/test/fixtures/polyglot/js/package.json +0 -1
  115. package/test/fixtures/polyglot/mvn/pom.xml +0 -7
  116. package/test/fixtures/polyglot/php/composer.lock +0 -1
  117. package/test/fixtures/polyglot/py/poetry.lock +0 -3
  118. package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
  119. package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
  120. package/test/fixtures/private-lib-detection/pom.xml +0 -35
  121. package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
  122. package/test/fixtures/python-poetry/poetry.lock +0 -7
  123. package/test/fixtures/python-reqs/requirements.txt +0 -5
  124. package/test/fixtures/python-uv/uv.lock +0 -3
  125. package/test/fixtures/simple/app/pom.xml +0 -28
  126. package/test/fixtures/simple/lib/pom.xml +0 -18
  127. package/test/fixtures/simple/pom.xml +0 -24
  128. package/test/maven-repo.test.js +0 -111
  129. package/test/maven-version.test.js +0 -57
  130. package/test/monorepo.test.js +0 -132
  131. package/test/npm-registry.test.js +0 -64
  132. package/test/npm.test.js +0 -150
  133. package/test/nuget.test.js +0 -66
  134. package/test/osv.test.js +0 -62
  135. package/test/outdated.test.js +0 -101
  136. package/test/pypi.test.js +0 -72
  137. package/test/snyk.test.js +0 -64
  138. package/test/transitive.test.js +0 -305
  139. package/test/webjar.test.js +0 -33
@@ -6,6 +6,9 @@
6
6
  * → { info: { version, classifiers[] }, releases: { "<v>": [{ yanked, yanked_reason }] } }
7
7
  *
8
8
  * Cached in ~/.fad-checker/pypi-cache.json for 24h.
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
9
12
  */
10
13
  const fs = require("fs");
11
14
  const path = require("path");
@@ -26,8 +29,22 @@ function cmp(a, b) {
26
29
  return 0;
27
30
  }
28
31
 
32
+ // PyPI's info.license is notoriously free-form (sometimes the full license text),
33
+ // so prefer the structured "License :: …" trove classifiers; fall back to a short
34
+ // info.license string only.
35
+ function pypiLicense(info) {
36
+ const fromClassifiers = (info?.classifiers || [])
37
+ .filter(c => /^License ::/.test(c) && !/OSI Approved$/.test(c))
38
+ .map(c => c.split("::").pop().trim())
39
+ .filter(Boolean);
40
+ if (fromClassifiers.length) return fromClassifiers;
41
+ const raw = (info?.license || "").trim();
42
+ if (raw && raw.length <= 50 && !raw.includes("\n")) return raw;
43
+ return null;
44
+ }
45
+
29
46
  function pypiToFindings(data, { version }) {
30
- const out = { outdated: null, yanked: null, inactive: false };
47
+ const out = { outdated: null, yanked: null, inactive: false, license: null };
31
48
  const latest = data.info?.version;
32
49
  if (latest && cmp(latest, version) > 0) out.outdated = { latest };
33
50
  const rel = data.releases?.[version];
@@ -35,6 +52,7 @@ function pypiToFindings(data, { version }) {
35
52
  out.yanked = { reason: rel.find(f => f.yanked_reason)?.yanked_reason || null };
36
53
  }
37
54
  if ((data.info?.classifiers || []).some(c => /Development Status :: 7 - Inactive/i.test(c))) out.inactive = true;
55
+ out.license = pypiLicense(data.info);
38
56
  return out;
39
57
  }
40
58
 
@@ -51,7 +69,7 @@ async function fetchProject(name, { offline }) {
51
69
  async function checkPypiRegistryDeps(deps, opts = {}) {
52
70
  const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
53
71
  const targets = [...deps.values()].filter(d => d.ecosystem === "pypi" && d.version);
54
- const result = { deprecated: [], outdated: [] };
72
+ const result = { deprecated: [], outdated: [], licensed: [] };
55
73
  if (!targets.length) return result;
56
74
  const cache = loadCache();
57
75
  const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
@@ -64,12 +82,13 @@ async function checkPypiRegistryDeps(deps, opts = {}) {
64
82
  const data = await fetchProject(t.name, { offline });
65
83
  if (data && !data.error) {
66
84
  const f = pypiToFindings(data, { version: t.version });
67
- ex = { yanked: f.yanked, inactive: f.inactive, latest: f.outdated?.latest || null };
85
+ ex = { yanked: f.yanked, inactive: f.inactive, latest: f.outdated?.latest || null, license: f.license || null };
68
86
  cache.entries[key] = ex;
69
87
  } else {
70
- ex = { yanked: null, inactive: false, latest: null };
88
+ ex = { yanked: null, inactive: false, latest: null, license: null };
71
89
  }
72
90
  }
91
+ if (ex.license) result.licensed.push({ dep: t, licenses: ex.license, source: "pypi" });
73
92
  if (ex.yanked) {
74
93
  result.deprecated.push({ dep: t, severity: "HIGH", replacement: null, reason: `Version yanked on PyPI${ex.yanked.reason ? `: ${ex.yanked.reason}` : ""}`, source: "pypi" });
75
94
  if (verbose) process.stdout.write(` yanked: ${t.name}@${t.version}\n`);
@@ -86,4 +105,4 @@ async function checkPypiRegistryDeps(deps, opts = {}) {
86
105
  return result;
87
106
  }
88
107
 
89
- module.exports = { pypiToFindings, checkPypiRegistryDeps };
108
+ module.exports = { pypiToFindings, pypiLicense, checkPypiRegistryDeps };
@@ -5,6 +5,9 @@
5
5
  * collection (poetry.lock/Pipfile.lock/uv.lock/pdm.lock, requirements.txt
6
6
  * fallback), PyPI registry (yanked/inactive + outdated), and EOL.
7
7
  * Per-directory precedence: a lockfile wins; else requirements.txt best-effort.
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
8
11
  */
9
12
  const fs = require("fs");
10
13
  const path = require("path");
@@ -19,6 +22,8 @@ const LOCKS = [
19
22
  ["pdm.lock", P.parsePdmLock],
20
23
  ];
21
24
 
25
+ const FALLBACK_MANIFESTS = ["pyproject.toml", "requirements.txt"];
26
+
22
27
  function findPyDirs(dir) {
23
28
  const groups = [];
24
29
  const stack = [dir];
@@ -26,7 +31,7 @@ function findPyDirs(dir) {
26
31
  const cur = stack.pop();
27
32
  let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
28
33
  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 });
34
+ if ([...LOCKS.map(l => l[0]), ...FALLBACK_MANIFESTS].some(n => names.has(n))) groups.push({ dir: cur, names });
30
35
  for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
31
36
  }
32
37
  return groups;
@@ -36,7 +41,7 @@ module.exports = {
36
41
  id: "pypi",
37
42
  label: "PyPI",
38
43
  osvEcosystem: "PyPI",
39
- manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "requirements.txt"],
44
+ manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "pyproject.toml", "requirements.txt"],
40
45
 
41
46
  detect(dir) { return findPyDirs(dir).length > 0; },
42
47
 
@@ -55,14 +60,33 @@ module.exports = {
55
60
  const fp = path.join(g.dir, lock[0]);
56
61
  try { const { deps } = lock[1](fp); for (const d of deps) add(d, fp); }
57
62
  catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${lock[0]} parse failed: ${e.message}` }); }
58
- } else if (g.names.has("requirements.txt")) {
63
+ continue; // lockfile is authoritative for this directory
64
+ }
65
+ // No lockfile → best-effort from pyproject.toml and/or requirements.txt.
66
+ let pinned = 0, skipped = 0;
67
+ const sources = [];
68
+ if (g.names.has("pyproject.toml")) {
69
+ const fp = path.join(g.dir, "pyproject.toml");
70
+ try {
71
+ const r = P.parsePyprojectToml(fp);
72
+ for (const d of r.deps) add(d, fp);
73
+ pinned += r.deps.length; skipped += r.skipped;
74
+ if (r.deps.length || r.skipped) sources.push("pyproject.toml");
75
+ } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `pyproject.toml parse failed: ${e.message}` }); }
76
+ }
77
+ if (g.names.has("requirements.txt")) {
59
78
  const fp = path.join(g.dir, "requirements.txt");
60
79
  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` });
80
+ const r = P.parseRequirementsTxt(fp);
81
+ for (const d of r.deps) add(d, fp);
82
+ pinned += r.deps.length; skipped += r.skipped;
83
+ sources.push("requirements.txt");
84
+ for (const miss of (r.missing || [])) warnings.push({ type: "missing-include", manifestPath: fp, message: `referenced requirements file not found: ${miss}` });
64
85
  } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `requirements.txt parse failed: ${e.message}` }); }
65
86
  }
87
+ if (sources.length) {
88
+ warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no lockfile (${sources.join(" + ")}) — best-effort: ${pinned} pinned, ${skipped} range(s) skipped` });
89
+ }
66
90
  }
67
91
  return { deps: out, warnings };
68
92
  },
@@ -4,6 +4,9 @@
4
4
  * Extrait de lib/cve-report.js (ECO_RECIPE + snippet helpers). Clés = id de codec
5
5
  * (== ecosystemType). Chaque recette : { label, pinSection, pinIntro(cnt),
6
6
  * snippet(items), directSection }. `items` = [{ groupId, artifactId, fixVersion }].
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
7
10
  */
8
11
  function esc(s) {
9
12
  if (s == null) return "";
@@ -105,4 +108,28 @@ const nuget = {
105
108
  directSection: "B. Then restore and commit packages.lock.json",
106
109
  };
107
110
 
108
- module.exports = { maven, npm, yarn, composer, pypi, nuget, dependencyManagementSnippet, npmOverridesSnippet, yarnResolutionsSnippet, composerRequireSnippet, pipInstallSnippet, dotnetAddSnippet };
111
+ function goGetSnippet(items) {
112
+ return items.map(it => `go get ${esc(it.artifactId)}@v${esc(it.fixVersion)}`).join("\n");
113
+ }
114
+
115
+ const go = {
116
+ label: "Go",
117
+ pinSection: "A. Upgrade the affected modules",
118
+ pinIntro: cnt => `Run for the ${cnt} affected module${cnt > 1 ? "s" : ""}, then commit go.mod / go.sum (go mod tidy):`,
119
+ snippet: goGetSnippet,
120
+ directSection: "B. Or bump them in go.mod and run go mod tidy",
121
+ };
122
+
123
+ function bundleUpdateSnippet(items) {
124
+ return items.map(it => `bundle update ${esc(it.artifactId)} --conservative # to >= ${esc(it.fixVersion)}`).join("\n");
125
+ }
126
+
127
+ const ruby = {
128
+ label: "Ruby",
129
+ pinSection: "A. Update the affected gems",
130
+ pinIntro: cnt => `Run for the ${cnt} affected gem${cnt > 1 ? "s" : ""}, then commit Gemfile.lock:`,
131
+ snippet: bundleUpdateSnippet,
132
+ directSection: "B. Or pin them in the Gemfile and run bundle update",
133
+ };
134
+
135
+ module.exports = { maven, npm, yarn, composer, pypi, nuget, go, ruby, dependencyManagementSnippet, npmOverridesSnippet, yarnResolutionsSnippet, composerRequireSnippet, pipInstallSnippet, dotnetAddSnippet, goGetSnippet, bundleUpdateSnippet };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * lib/codecs/ruby/parse.js — parse Gemfile.lock.
3
+ *
4
+ * The GEM section's `specs:` block lists resolved gems at 4-space indent
5
+ * (`name (version)`); their transitive requirements sit at 6-space indent and
6
+ * are skipped (they reappear as their own 4-space spec entry).
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
10
+ */
11
+ const fs = require("fs");
12
+
13
+ /** Parse Gemfile.lock text → { deps: [{ name, version, scope, isDev }] }. */
14
+ function parseGemfileLock(text) {
15
+ const deps = [];
16
+ const seen = new Set();
17
+ const lines = String(text || "").split(/\r?\n/);
18
+ let inSpecs = false;
19
+ for (const raw of lines) {
20
+ const trimmed = raw.trim();
21
+ if (/^(GEM|GIT|PATH|PLATFORMS|DEPENDENCIES|BUNDLED WITH|CHECKSUMS)\b/.test(trimmed) && !raw.startsWith(" ")) {
22
+ inSpecs = false;
23
+ }
24
+ if (trimmed === "specs:") { inSpecs = true; continue; }
25
+ if (!inSpecs) continue;
26
+ // Exactly 4 leading spaces = a resolved gem; 6+ = its dependency (skip).
27
+ const m = raw.match(/^ {4}([A-Za-z0-9._-]+) \(([^)]+)\)\s*$/);
28
+ if (!m) continue;
29
+ const name = m[1];
30
+ // Version may carry a platform suffix ("1.2.3-x86_64-linux"); keep the version.
31
+ const version = m[2].split(/[ -]/)[0];
32
+ const key = `${name}@${version}`;
33
+ if (seen.has(key)) continue;
34
+ seen.add(key);
35
+ deps.push({ name, version, scope: "compile", isDev: false });
36
+ }
37
+ return { deps };
38
+ }
39
+
40
+ function parseGemfileLockFile(fp) { return parseGemfileLock(fs.readFileSync(fp, "utf8")); }
41
+
42
+ module.exports = { parseGemfileLock, parseGemfileLockFile };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * lib/codecs/ruby/registry.js — query RubyGems for latest version + licenses.
3
+ *
4
+ * API: https://rubygems.org/api/v1/gems/<gem>.json → { version, licenses[] }
5
+ * One call yields both outdated (latest stable) and the SPDX-ish license list.
6
+ *
7
+ * @author: N.BRAUN
8
+ * @email: pp9ping@gmail.com
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, "rubygems-cache.json");
17
+ const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
18
+ const API = "https://rubygems.org/api/v1/gems";
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 isStable(v) { return /^\d+(\.\d+)*$/.test(String(v || "")); }
23
+ function cmp(a, b) {
24
+ const pa = String(a).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
25
+ const pb = String(b).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
26
+ 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; }
27
+ return 0;
28
+ }
29
+
30
+ // Pure extractor: RubyGems gem JSON → { latest, license }.
31
+ function gemToFindings(data) {
32
+ const out = { latest: null, license: null };
33
+ if (data?.version && isStable(data.version)) out.latest = data.version;
34
+ const lic = data?.licenses;
35
+ if (Array.isArray(lic) && lic.length) out.license = lic;
36
+ return out;
37
+ }
38
+
39
+ async function fetchGem(name, { offline }) {
40
+ if (offline) return null;
41
+ try {
42
+ const res = await fetch(`${API}/${encodeURIComponent(name)}.json`, { headers: { "User-Agent": "fad-checker-rubygems" } });
43
+ if (!res.ok) return { error: `HTTP ${res.status}` };
44
+ return await res.json();
45
+ } catch (e) { return { error: e.message }; }
46
+ }
47
+
48
+ async function checkRubyRegistryDeps(deps, opts = {}) {
49
+ const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
50
+ const targets = [...deps.values()].filter(d => d.ecosystem === "ruby" && d.version);
51
+ const result = { deprecated: [], outdated: [], licensed: [] };
52
+ if (!targets.length) return result;
53
+ const cache = loadCache();
54
+ const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
55
+ if (!fresh && !offline) cache.entries = {};
56
+ const limit = pLimit(concurrency);
57
+ await Promise.all(targets.map(t => limit(async () => {
58
+ const key = `${t.name}@${t.version}`;
59
+ let ex = cache.entries[key];
60
+ if (!ex) {
61
+ const data = await fetchGem(t.name, { offline });
62
+ ex = (data && !data.error) ? gemToFindings(data) : { latest: null, license: null };
63
+ if (data && !data.error) cache.entries[key] = ex;
64
+ }
65
+ if (ex.license) result.licensed.push({ dep: t, licenses: ex.license, source: "rubygems" });
66
+ if (allLibs && ex.latest && cmp(ex.latest, t.version) > 0) {
67
+ result.outdated.push({ dep: t, latest: ex.latest, releaseDate: null });
68
+ if (verbose) process.stdout.write(` outdated: ${t.name} ${t.version} → ${ex.latest}\n`);
69
+ }
70
+ })));
71
+ cache.meta = { fetchedAt: Date.now() };
72
+ saveCache(cache);
73
+ return result;
74
+ }
75
+
76
+ module.exports = { gemToFindings, checkRubyRegistryDeps };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * lib/codecs/ruby.codec.js — codec Ruby (Bundler).
3
+ *
4
+ * Reads Gemfile.lock (authoritative resolved versions). Vuln recall via OSV
5
+ * (ecosystem "RubyGems"); outdated + licenses via the RubyGems API.
6
+ *
7
+ * @author: N.BRAUN
8
+ * @email: pp9ping@gmail.com
9
+ */
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { makeDepRecord, coordKeyFor } = require("../dep-record");
13
+ const R = require("./ruby/parse");
14
+
15
+ const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target", "vendor", "tmp"]);
16
+
17
+ function findGemfileLocks(dir) {
18
+ const found = [];
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
+ for (const e of entries) {
24
+ if (e.isFile() && e.name === "Gemfile.lock") found.push(path.join(cur, e.name));
25
+ else if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
26
+ }
27
+ }
28
+ return found;
29
+ }
30
+
31
+ module.exports = {
32
+ id: "ruby",
33
+ label: "Ruby",
34
+ osvEcosystem: "RubyGems",
35
+ manifestNames: ["Gemfile.lock"],
36
+
37
+ detect(dir) { return findGemfileLocks(dir).length > 0; },
38
+
39
+ async collect(dir, opts = {}) {
40
+ const { deps2Exclude } = opts;
41
+ const out = new Map();
42
+ const warnings = [];
43
+ for (const fp of findGemfileLocks(dir)) {
44
+ try {
45
+ const { deps } = R.parseGemfileLockFile(fp);
46
+ for (const d of deps) {
47
+ if (deps2Exclude && deps2Exclude.test(d.name)) continue;
48
+ out.set(coordKeyFor("ruby", "", d.name), makeDepRecord({ ecosystem: "ruby", namespace: "", name: d.name, version: d.version, manifestPath: fp, scope: d.scope, isDev: false }));
49
+ }
50
+ } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `Gemfile.lock parse failed: ${e.message}` }); }
51
+ }
52
+ return { deps: out, warnings };
53
+ },
54
+
55
+ coordKey(d) { return coordKeyFor("ruby", "", d.name); },
56
+ formatCoord(d) { return d.name; },
57
+ osvPackageName(d) { return d.name; },
58
+
59
+ async checkRegistry(deps, opts = {}) {
60
+ const { checkRubyRegistryDeps } = require("./ruby/registry");
61
+ return checkRubyRegistryDeps(deps, opts);
62
+ },
63
+ resolveEolProduct() { return null; },
64
+ recipe: require("./recipes").ruby,
65
+ nativeScanners: [],
66
+ };
@@ -5,6 +5,9 @@
5
5
  * requested : "auto" | "all" | "maven" | "maven,npm,pypi" | (legacy "both"|"npm")
6
6
  * available : ids candidats — pour "auto", les codecs détectés ; sinon tous.
7
7
  * flags : { noCodecs: ["npm", ...], noJs: bool }
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
8
11
  */
9
12
  function resolveActiveCodecs(requested, available, flags = {}) {
10
13
  const { noCodecs = [], noJs = false } = flags;
@@ -6,6 +6,9 @@
6
6
  * recette (`resolutions`) au report. Le scan JS est fait UNE SEULE FOIS par le
7
7
  * codec npm (qui ramasse package-lock ET yarn.lock) — yarn.collect est un no-op
8
8
  * pour éviter le double scan ; le registre des codecs ne l'appelle pas.
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
9
12
  */
10
13
  const npm = require("./npm.codec");
11
14
 
package/lib/config.js CHANGED
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Stores credentials and per-user preferences that should survive across runs.
5
5
  * Currently: NVD API key (so users don't have to re-export the env var).
6
+ *
7
+ * @author: N.BRAUN
8
+ * @email: pp9ping@gmail.com
6
9
  */
7
10
  const fs = require("fs");
8
11
  const path = require("path");
package/lib/core.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * lib/core.js — pure logic for parsing and cleaning Maven POMs.
3
3
  * No CLI, no console formatting. Callers pass options explicitly.
4
+ *
5
+ * @author: N.BRAUN
6
+ * @email: pp9ping@gmail.com
4
7
  */
5
8
  const fs = require("fs");
6
9
  const path = require("path");
@@ -99,10 +102,20 @@ async function parsePom(pomPath, allPomMetadata) {
99
102
  groupId,
100
103
  artifactId,
101
104
  version,
105
+ // Maven model expressions for the current project. These are built-ins,
106
+ // not <properties> — ${project.version} always means THIS project's
107
+ // version (never overridable by a property or an imported BOM). The
108
+ // pom.* aliases mirror Maven's legacy synonyms.
102
109
  localVars: {
103
110
  "project.groupId": groupId,
104
111
  "project.artifactId": artifactId,
105
112
  "project.version": version,
113
+ "pom.groupId": groupId,
114
+ "pom.artifactId": artifactId,
115
+ "pom.version": version,
116
+ ...(parentInfo?.groupId ? { "project.parent.groupId": parentInfo.groupId } : {}),
117
+ ...(parentInfo?.artifactId ? { "project.parent.artifactId": parentInfo.artifactId } : {}),
118
+ ...(parentInfo?.version ? { "project.parent.version": parentInfo.version } : {}),
106
119
  },
107
120
  };
108
121
 
@@ -199,7 +212,31 @@ async function getAllInheritedProps(pomPath, allPomMetadata, cache) {
199
212
  merged.dependencyManagement.push(...imported.dependencyManagement);
200
213
  }
201
214
 
202
- resolveParentPath(pomPath, parentInfo, allPomMetadata);
215
+ // Cache the (still-mutating) object BEFORE recursing into the parent so a
216
+ // malformed parent cycle (A→B→A) returns this partial result instead of
217
+ // recursing forever. `merged` is mutated in place afterwards, so the cached
218
+ // reference ends up fully populated.
219
+ cache[pomPath] = merged;
220
+
221
+ // Inherit <properties> from a LOCAL parent POM as the BASE layer — the child
222
+ // overrides the parent, exactly like native Maven. (dependencyManagement and
223
+ // parent <dependencies> need no special handling here: collectResolvedDeps
224
+ // already merges them across the whole tree by g:a.) External parents
225
+ // (e.g. spring-boot-starter-parent) aren't in the source tree, so their
226
+ // properties can't be inherited without a network fetch — those versions
227
+ // still surface as unresolved, as before.
228
+ const parentPath = resolveParentPath(pomPath, parentInfo, allPomMetadata);
229
+ if (parentPath && parentPath !== pomPath) {
230
+ const parentMerged = await getAllInheritedProps(parentPath, allPomMetadata, cache);
231
+ merged.properties = { ...parentMerged.properties, ...merged.properties };
232
+ }
233
+
234
+ // Built-in project.* coordinates (${project.version}, ${project.groupId}, …).
235
+ // Applied LAST so the current POM's own values win — even over an inherited
236
+ // parent property or an imported BOM's project.* — letting an intra-reactor
237
+ // dep declared "<version>${project.version}</version>" resolve to THIS
238
+ // module's version.
239
+ merged.properties = { ...merged.properties, ...(meta.localVars || {}) };
203
240
 
204
241
  cache[pomPath] = merged;
205
242
  return merged;
package/lib/cpe.js CHANGED
@@ -14,6 +14,9 @@
14
14
  * and whether we can upgrade the match confidence.
15
15
  * 2. Given a dep coord, find CVEs in the index whose CPE configurations
16
16
  * it satisfies. (Future use — kept compatible with the data shape.)
17
+ *
18
+ * @author: N.BRAUN
19
+ * @email: pp9ping@gmail.com
17
20
  */
18
21
  const { compareMavenVersions } = require("./maven-version");
19
22
 
@@ -144,6 +147,21 @@ function nodeAffectsDep(node, dep, cpeCoordMap) {
144
147
  return false;
145
148
  }
146
149
 
150
+ /**
151
+ * A node is "context-only" when it carries no vulnerable cpeMatch (recursively) —
152
+ * e.g. the "…AND runs on linux_kernel" platform node, whose CPEs are all
153
+ * `vulnerable: false`. In an AND configuration those nodes are runtime context we
154
+ * deliberately ignore (we assume the library is in use), so they must NOT be
155
+ * required for the config to count as affecting the dep.
156
+ */
157
+ function nodeIsContextOnly(node) {
158
+ if (!node) return true;
159
+ const matches = node.cpeMatch || node.cpe_match || [];
160
+ if (matches.some(m => m.vulnerable !== false)) return false;
161
+ if (Array.isArray(node.children) && node.children.some(c => !nodeIsContextOnly(c))) return false;
162
+ return true;
163
+ }
164
+
147
165
  /**
148
166
  * Decide whether a CPE (parsed) refers to the same artifact as a dep.
149
167
  * Lookup order:
@@ -221,11 +239,18 @@ function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
221
239
  for (const cfg of configs) {
222
240
  const nodes = cfg.nodes || [];
223
241
  const op = (cfg.operator || "OR").toUpperCase();
224
- const evals = nodes.map(n => {
225
- const hit = nodeAffectsDep(n, dep, map);
226
- return hit;
227
- });
228
- const passes = op === "AND" ? evals.every(Boolean) : evals.some(Boolean);
242
+ let passes;
243
+ if (op === "AND") {
244
+ // AND configs are typically (vulnerable software) AND (platform/runtime
245
+ // context). We assume the library is in use, so context-only nodes are not
246
+ // required only the vulnerable node(s) must match the dep version. Without
247
+ // this, a "vulnerable:false" platform node makes .every() false and the real
248
+ // finding is wrongly dropped as a CPE false-positive.
249
+ const vulnNodes = nodes.filter(n => !nodeIsContextOnly(n));
250
+ passes = vulnNodes.length > 0 && vulnNodes.every(n => nodeAffectsDep(n, dep, map));
251
+ } else {
252
+ passes = nodes.some(n => nodeAffectsDep(n, dep, map));
253
+ }
229
254
  if (passes) {
230
255
  // Determine confidence: scan vulnerable cpeMatches that hit
231
256
  for (const n of nodes) {