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
@@ -12,6 +12,9 @@
12
12
  *
13
13
  * The format is selected from the file extension. Tar uses native `tar`
14
14
  * binary (zero new deps).
15
+ *
16
+ * @author: N.BRAUN
17
+ * @email: pp9ping@gmail.com
15
18
  */
16
19
  const fs = require("fs");
17
20
  const path = require("path");
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Pas de classe imposée : un codec est un objet litéral exportant ces clés.
5
5
  * Voir docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md.
6
+ *
7
+ * @author: N.BRAUN
8
+ * @email: pp9ping@gmail.com
6
9
  */
7
10
  const REQUIRED_KEYS = [
8
11
  "id", "label", "osvEcosystem", "manifestNames",
@@ -7,6 +7,9 @@
7
7
  * used only as a no-lock fallback (pinned exact versions).
8
8
  *
9
9
  * Composer names are "vendor/package", case-insensitive (lowercased by convention).
10
+ *
11
+ * @author: N.BRAUN
12
+ * @email: pp9ping@gmail.com
10
13
  */
11
14
  const fs = require("fs");
12
15
 
@@ -6,6 +6,9 @@
6
6
  * → { package: { abandoned: bool|string, versions: { "<v>": {...} } } }
7
7
  *
8
8
  * Cached in ~/.fad-checker/packagist-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");
@@ -30,14 +33,18 @@ function cmp(a, b) {
30
33
 
31
34
  // Extract {abandoned, outdated} from a Packagist `package` object.
32
35
  function packagistToFindings(pkg, { version }) {
33
- const out = { abandoned: null, outdated: null };
36
+ const out = { abandoned: null, outdated: null, license: null };
34
37
  if (pkg.abandoned === true) out.abandoned = { replacement: null };
35
38
  else if (typeof pkg.abandoned === "string") out.abandoned = { replacement: pkg.abandoned };
36
- const stable = Object.keys(pkg.versions || {}).map(v => v.replace(/^v/, "")).filter(isStable);
39
+ const versions = pkg.versions || {};
40
+ const stable = Object.keys(versions).map(v => v.replace(/^v/, "")).filter(isStable);
37
41
  if (stable.length) {
38
42
  const latest = stable.sort(cmp).at(-1);
39
43
  if (latest && cmp(latest, String(version).replace(/^v/, "")) > 0) out.outdated = { latest };
40
44
  }
45
+ // Packagist version objects carry a `license` array (e.g. ["MIT"]).
46
+ const vEntry = versions[version] || versions[`v${version}`] || versions[Object.keys(versions)[0]];
47
+ if (vEntry?.license && vEntry.license.length) out.license = vEntry.license;
41
48
  return out;
42
49
  }
43
50
 
@@ -55,7 +62,7 @@ async function fetchPackage(name, { offline }) {
55
62
  async function checkComposerRegistryDeps(deps, opts = {}) {
56
63
  const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
57
64
  const targets = [...deps.values()].filter(d => d.ecosystem === "composer" && d.version);
58
- const result = { deprecated: [], outdated: [] };
65
+ const result = { deprecated: [], outdated: [], licensed: [] };
59
66
  if (!targets.length) return result;
60
67
  const cache = loadCache();
61
68
  const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
@@ -69,12 +76,13 @@ async function checkComposerRegistryDeps(deps, opts = {}) {
69
76
  const pkg = await fetchPackage(name, { offline });
70
77
  if (pkg && !pkg.error) {
71
78
  const f = packagistToFindings(pkg, { version: t.version });
72
- ex = { abandoned: f.abandoned, latest: f.outdated?.latest || null };
79
+ ex = { abandoned: f.abandoned, latest: f.outdated?.latest || null, license: f.license || null };
73
80
  cache.entries[key] = ex;
74
81
  } else {
75
- ex = { abandoned: null, latest: null };
82
+ ex = { abandoned: null, latest: null, license: null };
76
83
  }
77
84
  }
85
+ if (ex.license) result.licensed.push({ dep: t, licenses: ex.license, source: "packagist" });
78
86
  if (ex.abandoned) {
79
87
  result.deprecated.push({ dep: t, severity: "MEDIUM", replacement: ex.abandoned.replacement, reason: "Package marked abandoned on Packagist", source: "packagist" });
80
88
  if (verbose) process.stdout.write(` abandoned: ${name}@${t.version}\n`);
@@ -4,6 +4,9 @@
4
4
  * Vuln scanning is OSV (ecosystem "Packagist", wired in Plan A). This codec adds
5
5
  * collection (composer.lock, composer.json fallback), Packagist registry
6
6
  * (abandoned + outdated), and EOL via endoflife.date.
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
7
10
  */
8
11
  const fs = require("fs");
9
12
  const path = require("path");
@@ -0,0 +1,65 @@
1
+ /**
2
+ * lib/codecs/go/parse.js — parse go.mod (authoritative for selected versions on
3
+ * Go ≥1.17, which lists the full pruned module graph) with go.sum as fallback.
4
+ *
5
+ * Versions are stored WITHOUT the leading "v" (OSV's Go ecosystem and our
6
+ * version comparisons expect bare semver; the purl layer re-adds context).
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
10
+ */
11
+ const fs = require("fs");
12
+ const { compareMavenVersions } = require("../../maven-version");
13
+
14
+ function stripV(v) { return String(v || "").replace(/^v/, ""); }
15
+
16
+ /** Parse go.mod → { module, deps: [{ name, version, scope, isDev }] }. */
17
+ function parseGoMod(text) {
18
+ const out = { module: null, deps: [] };
19
+ const lines = String(text || "").split(/\r?\n/);
20
+ let inRequire = false;
21
+ const seen = new Set();
22
+ const addReq = (name, ver, indirect) => {
23
+ if (!name || !ver) return;
24
+ const key = `${name}@${ver}`;
25
+ if (seen.has(key)) return;
26
+ seen.add(key);
27
+ out.deps.push({ name, version: stripV(ver), scope: indirect ? "transitive" : "compile", isDev: false });
28
+ };
29
+ for (let raw of lines) {
30
+ const noComment = raw.split("//")[0].trim();
31
+ const indirect = /\/\/\s*indirect/.test(raw);
32
+ if (noComment.startsWith("module ")) { out.module = noComment.slice(7).trim(); continue; }
33
+ if (noComment === "require (") { inRequire = true; continue; }
34
+ if (inRequire && noComment === ")") { inRequire = false; continue; }
35
+ if (inRequire) {
36
+ const m = noComment.match(/^(\S+)\s+(\S+)/);
37
+ if (m) addReq(m[1], m[2], indirect);
38
+ continue;
39
+ }
40
+ const single = noComment.match(/^require\s+(\S+)\s+(\S+)/);
41
+ if (single) addReq(single[1], single[2], indirect);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** Parse go.sum → deps (fallback when go.mod has no require list). Highest version per module. */
47
+ function parseGoSum(text) {
48
+ const byMod = new Map();
49
+ for (const raw of String(text || "").split(/\r?\n/)) {
50
+ const m = raw.trim().match(/^(\S+)\s+(v\S+?)(\/go\.mod)?\s+h1:/);
51
+ if (!m) continue;
52
+ const name = m[1];
53
+ const ver = stripV(m[2]);
54
+ // go.sum lists every version in the module graph; keep the highest (the
55
+ // comment promised this but the code kept the first one encountered).
56
+ const prev = byMod.get(name);
57
+ if (!prev || compareMavenVersions(ver, prev) > 0) byMod.set(name, ver);
58
+ }
59
+ return { deps: [...byMod.entries()].map(([name, version]) => ({ name, version, scope: "transitive", isDev: false })) };
60
+ }
61
+
62
+ function parseGoModFile(fp) { return parseGoMod(fs.readFileSync(fp, "utf8")); }
63
+ function parseGoSumFile(fp) { return parseGoSum(fs.readFileSync(fp, "utf8")); }
64
+
65
+ module.exports = { parseGoMod, parseGoSum, parseGoModFile, parseGoSumFile, stripV };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * lib/codecs/go/registry.js — query the Go module proxy for the latest version.
3
+ *
4
+ * API: https://proxy.golang.org/<escaped-module>/@latest → { Version }
5
+ * Module paths are case-encoded (uppercase → !lower) per the proxy protocol.
6
+ * Deprecation/license aren't exposed by the proxy without fetching the module's
7
+ * go.mod, so this only contributes "outdated".
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
11
+ */
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const os = require("os");
15
+ let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
16
+
17
+ const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
18
+ const CACHE_PATH = path.join(CACHE_DIR, "go-proxy-cache.json");
19
+ const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
20
+ const PROXY = "https://proxy.golang.org";
21
+
22
+ function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
23
+ function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
24
+
25
+ // proxy.golang.org case-encoding: uppercase letters → "!" + lowercase.
26
+ function escapeModule(mod) {
27
+ return String(mod).replace(/[A-Z]/g, c => "!" + c.toLowerCase());
28
+ }
29
+
30
+ function cmp(a, b) {
31
+ const pa = String(a).replace(/^v/, "").split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
32
+ const pb = String(b).replace(/^v/, "").split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
33
+ 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; }
34
+ return 0;
35
+ }
36
+
37
+ async function fetchLatest(mod, { offline }) {
38
+ if (offline) return null;
39
+ try {
40
+ const res = await fetch(`${PROXY}/${escapeModule(mod)}/@latest`, { headers: { "User-Agent": "fad-checker-go" } });
41
+ if (!res.ok) return { error: `HTTP ${res.status}` };
42
+ const j = await res.json();
43
+ return { latest: (j.Version || "").replace(/^v/, "") || null };
44
+ } catch (e) { return { error: e.message }; }
45
+ }
46
+
47
+ async function checkGoRegistryDeps(deps, opts = {}) {
48
+ const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
49
+ const targets = [...deps.values()].filter(d => d.ecosystem === "go" && d.version);
50
+ const result = { deprecated: [], outdated: [], licensed: [] };
51
+ if (!targets.length || !allLibs) return result;
52
+ const cache = loadCache();
53
+ const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
54
+ if (!fresh && !offline) cache.entries = {};
55
+ const limit = pLimit(concurrency);
56
+ await Promise.all(targets.map(t => limit(async () => {
57
+ const key = `${t.name}@${t.version}`;
58
+ let ex = cache.entries[key];
59
+ if (!ex) {
60
+ const r = await fetchLatest(t.name, { offline });
61
+ ex = (r && !r.error) ? { latest: r.latest } : { latest: null };
62
+ if (r && !r.error) cache.entries[key] = ex;
63
+ }
64
+ if (ex.latest && cmp(ex.latest, t.version) > 0) {
65
+ result.outdated.push({ dep: t, latest: ex.latest, releaseDate: null });
66
+ if (verbose) process.stdout.write(` outdated: ${t.name} ${t.version} → ${ex.latest}\n`);
67
+ }
68
+ })));
69
+ cache.meta = { fetchedAt: Date.now() };
70
+ saveCache(cache);
71
+ return result;
72
+ }
73
+
74
+ module.exports = { checkGoRegistryDeps, escapeModule };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * lib/codecs/go.codec.js — codec Go (modules).
3
+ *
4
+ * Reads go.mod (authoritative on Go ≥1.17 — lists the full pruned graph) and
5
+ * falls back to go.sum. Vuln recall via OSV (ecosystem "Go"); outdated via the
6
+ * Go module proxy. The full module path is the dep name (namespace = "").
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
10
+ */
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const { makeDepRecord, coordKeyFor } = require("../dep-record");
14
+ const G = require("./go/parse");
15
+
16
+ const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target", "vendor", "testdata"]);
17
+
18
+ function findGoDirs(dir) {
19
+ const groups = [];
20
+ const stack = [dir];
21
+ while (stack.length) {
22
+ const cur = stack.pop();
23
+ let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
24
+ const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
25
+ if (names.has("go.mod") || names.has("go.sum")) groups.push({ dir: cur, names });
26
+ for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
27
+ }
28
+ return groups;
29
+ }
30
+
31
+ module.exports = {
32
+ id: "go",
33
+ label: "Go",
34
+ osvEcosystem: "Go",
35
+ manifestNames: ["go.mod", "go.sum"],
36
+
37
+ detect(dir) { return findGoDirs(dir).length > 0; },
38
+
39
+ async collect(dir, opts = {}) {
40
+ const { deps2Exclude } = opts;
41
+ const out = new Map();
42
+ const warnings = [];
43
+ const add = (d, manifestPath) => {
44
+ if (deps2Exclude && deps2Exclude.test(d.name)) return;
45
+ out.set(coordKeyFor("go", "", d.name), makeDepRecord({ ecosystem: "go", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: false }));
46
+ };
47
+ for (const g of findGoDirs(dir)) {
48
+ if (g.names.has("go.mod")) {
49
+ const fp = path.join(g.dir, "go.mod");
50
+ try {
51
+ const r = G.parseGoModFile(fp);
52
+ if (r.deps.length) { for (const d of r.deps) add(d, fp); continue; }
53
+ // go.mod with no require list (rare / very old) → fall through to go.sum.
54
+ } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `go.mod parse failed: ${e.message}` }); }
55
+ }
56
+ if (g.names.has("go.sum")) {
57
+ const fp = path.join(g.dir, "go.sum");
58
+ try { const r = G.parseGoSumFile(fp); for (const d of r.deps) add(d, fp); }
59
+ catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `go.sum parse failed: ${e.message}` }); }
60
+ }
61
+ }
62
+ return { deps: out, warnings };
63
+ },
64
+
65
+ coordKey(d) { return coordKeyFor("go", "", d.name); },
66
+ formatCoord(d) { return d.version ? `${d.name}@${d.version}` : d.name; },
67
+ osvPackageName(d) { return d.name; },
68
+
69
+ async checkRegistry(deps, opts = {}) {
70
+ const { checkGoRegistryDeps } = require("./go/registry");
71
+ return checkGoRegistryDeps(deps, opts);
72
+ },
73
+ resolveEolProduct() { return null; },
74
+ recipe: require("./recipes").go,
75
+ nativeScanners: [],
76
+ };
@@ -4,6 +4,9 @@
4
4
  * getCodec(id) → le codec, ou null
5
5
  * allCodecs() → tous les codecs enregistrés, dans l'ordre report stable
6
6
  * detectCodecs(dir) → les codecs dont detect() est vrai sur ce répertoire
7
+ *
8
+ * @author: N.BRAUN
9
+ * @email: pp9ping@gmail.com
7
10
  */
8
11
  const { assertCodecShape } = require("./codec.interface");
9
12
  const maven = require("./maven.codec");
@@ -12,12 +15,14 @@ const yarn = require("./yarn.codec");
12
15
  const composer = require("./composer.codec");
13
16
  const pypi = require("./pypi.codec");
14
17
  const nuget = require("./nuget.codec");
18
+ const go = require("./go.codec");
19
+ const ruby = require("./ruby.codec");
15
20
 
16
21
  // Ordre stable pour le report (maven, npm, yarn, puis les nouveaux écosystèmes).
17
- const ORDER = ["maven", "npm", "yarn", "nuget", "composer", "pypi"];
22
+ const ORDER = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby"];
18
23
 
19
24
  const REGISTRY = new Map();
20
- for (const c of [maven, npm, yarn, composer, pypi, nuget]) {
25
+ for (const c of [maven, npm, yarn, composer, pypi, nuget, go, ruby]) {
21
26
  assertCodecShape(c);
22
27
  REGISTRY.set(c.id, c);
23
28
  }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * lib/codecs/maven/jar-scan.js — discover Maven coordinates from embedded JARs.
3
+ *
4
+ * Many projects ship binary archives committed straight into the tree — a
5
+ * vendored `lib/*.jar`, a Spring-Boot fat-jar under `BOOT-INF/lib/`, an uber-jar
6
+ * with shaded dependencies, or jars inside a `.war`/`.ear`. None of those appear
7
+ * in a `pom.xml`, so the manifest-based scan misses them. This module walks the
8
+ * tree, reads every archive IN MEMORY (via fflate — a nested jar is just a zip
9
+ * Buffer, so recursion needs no temp files and there is no zip-slip risk because
10
+ * nothing is ever written to disk), and extracts each artifact's coordinate from,
11
+ * in order of trust:
12
+ * 1. META-INF/maven/<groupId>/<artifactId>/pom.properties (authoritative)
13
+ * 2. META-INF/MANIFEST.MF (Implementation-* / OSGi Bundle-* headers)
14
+ * 3. the file name (commons-lang3-3.12.0.jar → commons-lang3 @ 3.12.0)
15
+ * An archive whose coordinate can't be determined is reported as a warning, not
16
+ * scanned blindly.
17
+ *
18
+ * Output deps carry provenance:"embedded" + a manifestPath using the `!/` nesting
19
+ * notation (e.g. dist/app.jar!/BOOT-INF/lib/log4j-core-2.14.0.jar) so the report
20
+ * can group them under their containing archive.
21
+ *
22
+ * @author: N.BRAUN
23
+ * @email: pp9ping@gmail.com
24
+ */
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+ const { unzipSync, strFromU8 } = require("fflate");
28
+ const { makeDepRecord } = require("../../dep-record");
29
+
30
+ const ARCHIVE_RE = /\.(jar|war|ear)$/i;
31
+ const SKIP_DIRS = new Set(["node_modules", ".git", "target", "build", "out", "dist-newstyle", ".gradle", ".m2"]);
32
+ // A fat-jar can be large; cap what we read into memory and how deep we recurse.
33
+ const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
34
+ const MAX_DEPTH = 8;
35
+
36
+ /** Parse a META-INF/maven/.../pom.properties body → { groupId, artifactId, version } | null. */
37
+ function coordFromPomProperties(text) {
38
+ const get = re => (String(text || "").match(re) || [])[1]?.trim();
39
+ const groupId = get(/^groupId=(.+)$/m);
40
+ const artifactId = get(/^artifactId=(.+)$/m);
41
+ const version = get(/^version=(.+)$/m);
42
+ if (groupId && artifactId && version) return { groupId, artifactId, version };
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Parse a MANIFEST.MF body → { groupId, artifactId, version } | null.
48
+ * Tries Implementation-* first, then OSGi Bundle-*. groupId is best-effort: the
49
+ * manifest rarely carries it, so we fall back to the OSGi symbolic name or "".
50
+ */
51
+ function coordFromManifest(text) {
52
+ // MANIFEST.MF folds long lines with a leading space on continuation lines.
53
+ const unfolded = String(text || "").replace(/\r?\n /g, "");
54
+ const h = name => (unfolded.match(new RegExp(`^${name}:\\s*(.+)$`, "im")) || [])[1]?.trim();
55
+ const title = h("Implementation-Title") || h("Bundle-Name");
56
+ const implVer = h("Implementation-Version") || h("Bundle-Version") || h("Specification-Version");
57
+ const symbolic = h("Bundle-SymbolicName");
58
+ const vendor = h("Implementation-Vendor-Id") || h("Implementation-Vendor");
59
+ // OSGi symbolic name (org.apache.commons.lang3) is the most coordinate-like.
60
+ let groupId = "", artifactId = title || symbolic;
61
+ if (symbolic && symbolic.includes(".")) {
62
+ const parts = symbolic.split(";")[0].split(".");
63
+ artifactId = parts.pop();
64
+ groupId = parts.join(".");
65
+ } else if (vendor && vendor.includes(".")) {
66
+ groupId = vendor;
67
+ }
68
+ if (artifactId && implVer) return { groupId: groupId || vendor || "", artifactId, version: implVer };
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Parse an archive file name → { groupId:"", artifactId, version } | null.
74
+ * Handles `name-1.2.3.jar`, `name-1.2.3-SNAPSHOT.jar`, `name-1.2.3-classifier.jar`.
75
+ * The version token is the first dash-separated segment that starts with a digit.
76
+ */
77
+ function coordFromFilename(fileName) {
78
+ const base = String(fileName || "").replace(ARCHIVE_RE, "");
79
+ const m = base.match(/^(.+?)-(\d[\w.]*(?:-[A-Za-z0-9]+)?)$/);
80
+ if (!m) return null;
81
+ return { groupId: "", artifactId: m[1], version: m[2] };
82
+ }
83
+
84
+ /** Read the named entries from an in-memory zip buffer. Returns {} on a corrupt zip. */
85
+ function readEntries(buf, filter) {
86
+ try {
87
+ return unzipSync(buf, filter ? { filter: f => filter(f.name) } : undefined);
88
+ } catch {
89
+ return null; // not a valid zip / unsupported (e.g. ZIP64 edge) — caller warns
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Resolve a single archive's own coordinate + confidence from its entries.
95
+ * Returns { coord, source } | null. `source` ∈ pom.properties|manifest|filename.
96
+ */
97
+ function coordForArchive(entries, fileName) {
98
+ // 1. pom.properties (there may be several in a fat-jar; the archive's OWN is the
99
+ // one whose path matches its identity — but for the top artifact we take the
100
+ // first. Nested libs are handled separately as their own archives.)
101
+ for (const name of Object.keys(entries)) {
102
+ if (/^META-INF\/maven\/[^/]+\/[^/]+\/pom\.properties$/.test(name)) {
103
+ const coord = coordFromPomProperties(strFromU8(entries[name]));
104
+ if (coord) return { coord, source: "pom.properties" };
105
+ }
106
+ }
107
+ const mf = entries["META-INF/MANIFEST.MF"];
108
+ if (mf) {
109
+ const coord = coordFromManifest(strFromU8(mf));
110
+ if (coord) return { coord, source: "manifest" };
111
+ }
112
+ const coord = coordFromFilename(fileName);
113
+ if (coord) return { coord, source: "filename" };
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Recursively scan one archive buffer. Pushes embedded depRecords into `out` and
119
+ * warnings into `warnings`. `displayPath` is the `!/`-joined logical path.
120
+ */
121
+ function scanArchiveBuffer(buf, displayPath, fileName, out, warnings, depth) {
122
+ if (depth > MAX_DEPTH) { warnings.push({ type: "embedded-jar", message: `nesting too deep, skipped: ${displayPath}` }); return; }
123
+ if (buf.length > MAX_ARCHIVE_BYTES) { warnings.push({ type: "embedded-jar", message: `archive too large (${Math.round(buf.length / 1048576)}MB), skipped: ${displayPath}` }); return; }
124
+
125
+ // Read only what we need: this archive's identity files + any nested archives.
126
+ const entries = readEntries(buf, name =>
127
+ /^META-INF\/maven\/[^/]+\/[^/]+\/pom\.properties$/.test(name) ||
128
+ name === "META-INF/MANIFEST.MF" ||
129
+ ARCHIVE_RE.test(name));
130
+ if (entries === null) { warnings.push({ type: "embedded-jar", message: `could not read archive (corrupt/unsupported): ${displayPath}` }); return; }
131
+
132
+ const resolved = coordForArchive(entries, fileName);
133
+ if (resolved) {
134
+ const { coord, source } = resolved;
135
+ out.push(makeDepRecord({
136
+ ecosystem: "maven",
137
+ namespace: coord.groupId || "",
138
+ name: coord.artifactId,
139
+ version: coord.version,
140
+ manifestPath: displayPath,
141
+ scope: "embedded",
142
+ provenance: "embedded",
143
+ }));
144
+ } else {
145
+ warnings.push({ type: "embedded-jar", message: `embedded archive with no resolvable Maven coordinate (not scanned): ${displayPath}` });
146
+ }
147
+
148
+ // Recurse into nested archives (fat-jar libs).
149
+ for (const name of Object.keys(entries)) {
150
+ if (!ARCHIVE_RE.test(name)) continue;
151
+ const nestedBuf = entries[name];
152
+ if (!nestedBuf || !nestedBuf.length) continue;
153
+ scanArchiveBuffer(Buffer.from(nestedBuf), `${displayPath}!/${name}`, path.basename(name), out, warnings, depth + 1);
154
+ }
155
+ }
156
+
157
+ /** Recursively list archive file paths under `dir`. */
158
+ function findArchives(dir, acc = []) {
159
+ let entries;
160
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
161
+ catch { return acc; }
162
+ for (const e of entries) {
163
+ if (e.isDirectory && e.isDirectory()) {
164
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue;
165
+ findArchives(path.join(dir, e.name), acc);
166
+ } else if (e.isFile() && ARCHIVE_RE.test(e.name)) {
167
+ acc.push(path.join(dir, e.name));
168
+ }
169
+ }
170
+ return acc;
171
+ }
172
+
173
+ /**
174
+ * Scan `rootDir` for embedded JAR/WAR/EAR coordinates.
175
+ * @returns { deps: depRecord[], warnings: [{type,message}] }
176
+ */
177
+ function scanEmbeddedJars(rootDir, opts = {}) {
178
+ const out = [];
179
+ const warnings = [];
180
+ const archives = findArchives(rootDir);
181
+ for (const abs of archives) {
182
+ let buf;
183
+ try { buf = fs.readFileSync(abs); }
184
+ catch (e) { warnings.push({ type: "embedded-jar", message: `could not read ${abs}: ${e.message}` }); continue; }
185
+ const rel = opts.srcRoot ? path.relative(opts.srcRoot, abs) : abs;
186
+ scanArchiveBuffer(buf, rel.split(path.sep).join("/"), path.basename(abs), out, warnings, 0);
187
+ }
188
+ return { deps: out, warnings };
189
+ }
190
+
191
+ module.exports = {
192
+ scanEmbeddedJars,
193
+ findArchives,
194
+ coordFromPomProperties,
195
+ coordFromManifest,
196
+ coordFromFilename,
197
+ coordForArchive,
198
+ scanArchiveBuffer,
199
+ };
@@ -5,6 +5,9 @@
5
5
  * lib/core.js (parse, parent, merge multi-profils, dependencyManagement, imports
6
6
  * scope=import), le collecteur lib/cve-match.js, le walker transitif
7
7
  * lib/transitive.js et le scanner natif CVE-index (cvelistV5).
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
8
11
  */
9
12
  const core = require("../core");
10
13
  const { collectResolvedDeps, expandWithTransitives, matchDepsAgainstCves } = require("../cve-match");
@@ -47,7 +50,20 @@ module.exports = {
47
50
  try { await core.getAllInheritedProps(pom, store, propsByPom); } catch { /* logged */ }
48
51
  }
49
52
  const deps = collectResolvedDeps(store, propsByPom, { ignoreTest: opts.ignoreTest, deps2Exclude: opts.deps2Exclude });
50
- return { deps, warnings: [], _maven: { store, propsByPom, pomFiles } };
53
+ const warnings = [];
54
+ // Embedded binaries: discover Maven coordinates inside committed .jar/.war/.ear
55
+ // archives (vendored libs, fat-jars). Keyed by physical location so they don't
56
+ // merge with declared deps (they get their own report chapter). Default on;
57
+ // opts.scanJars === false (--no-jars) disables it.
58
+ if (opts.scanJars !== false) {
59
+ try {
60
+ const { scanEmbeddedJars } = require("./maven/jar-scan");
61
+ const { deps: embedded, warnings: jarWarnings } = scanEmbeddedJars(dir, { srcRoot: opts.srcRoot || dir });
62
+ for (const rec of embedded) deps.set(rec.coordKey, rec);
63
+ warnings.push(...jarWarnings);
64
+ } catch (e) { warnings.push({ type: "embedded-jar", message: `embedded-jar scan failed: ${e.message}` }); }
65
+ }
66
+ return { deps, warnings, _maven: { store, propsByPom, pomFiles } };
51
67
  },
52
68
 
53
69
  coordKey(d) { return coordKeyFor("maven", d.namespace || d.groupId, d.name || d.artifactId); },