fad-checker 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +91 -17
  3. package/completions/fad-checker.bash +1 -1
  4. package/completions/fad-checker.zsh +14 -1
  5. package/data/license-policy.json +97 -0
  6. package/fad-checker-report/cve-report.doc +630 -0
  7. package/fad-checker-report/cve-report.html +748 -0
  8. package/fad-checker.js +278 -53
  9. package/lib/cache-archive.js +3 -0
  10. package/lib/codecs/codec.interface.js +3 -0
  11. package/lib/codecs/composer/parse.js +3 -0
  12. package/lib/codecs/composer/registry.js +13 -5
  13. package/lib/codecs/composer.codec.js +3 -0
  14. package/lib/codecs/go/parse.js +65 -0
  15. package/lib/codecs/go/registry.js +74 -0
  16. package/lib/codecs/go.codec.js +76 -0
  17. package/lib/codecs/index.js +7 -2
  18. package/lib/codecs/maven/jar-scan.js +199 -0
  19. package/lib/codecs/maven.codec.js +17 -1
  20. package/lib/codecs/npm/collect.js +11 -0
  21. package/lib/codecs/npm/parse.js +3 -0
  22. package/lib/codecs/npm/registry.js +10 -2
  23. package/lib/codecs/npm.codec.js +3 -0
  24. package/lib/codecs/nuget/parse.js +3 -0
  25. package/lib/codecs/nuget/registry.js +11 -4
  26. package/lib/codecs/nuget.codec.js +3 -0
  27. package/lib/codecs/pypi/parse.js +7 -2
  28. package/lib/codecs/pypi/registry.js +24 -5
  29. package/lib/codecs/pypi.codec.js +3 -0
  30. package/lib/codecs/recipes.js +28 -1
  31. package/lib/codecs/ruby/parse.js +42 -0
  32. package/lib/codecs/ruby/registry.js +76 -0
  33. package/lib/codecs/ruby.codec.js +66 -0
  34. package/lib/codecs/select.js +3 -0
  35. package/lib/codecs/yarn.codec.js +3 -0
  36. package/lib/config.js +3 -0
  37. package/lib/core.js +3 -0
  38. package/lib/cpe.js +30 -5
  39. package/lib/csaf-export.js +159 -0
  40. package/lib/cve-download.js +3 -0
  41. package/lib/cve-match.js +12 -1
  42. package/lib/cve-report.js +157 -28
  43. package/lib/dep-record.js +15 -2
  44. package/lib/deps-descriptor.js +3 -0
  45. package/lib/epss.js +115 -0
  46. package/lib/gate.js +45 -0
  47. package/lib/json-export.js +110 -0
  48. package/lib/kev.js +88 -0
  49. package/lib/license-policy.js +110 -0
  50. package/lib/maven-license.js +52 -0
  51. package/lib/maven-repo.js +3 -0
  52. package/lib/maven-version.js +8 -3
  53. package/lib/nvd.js +13 -3
  54. package/lib/osv.js +68 -19
  55. package/lib/outdated.js +3 -0
  56. package/lib/priority.js +90 -0
  57. package/lib/purl.js +77 -0
  58. package/lib/retire.js +3 -0
  59. package/lib/sarif-export.js +134 -0
  60. package/lib/sbom-export.js +153 -0
  61. package/lib/scan-completeness.js +3 -0
  62. package/lib/snyk.js +3 -0
  63. package/lib/suppress.js +113 -0
  64. package/lib/transitive.js +3 -0
  65. package/lib/ui.js +3 -0
  66. package/package.json +48 -2
@@ -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); },
@@ -13,6 +13,9 @@
13
13
  * - lockfile entries WIN over package.json range specs (concrete version)
14
14
  * - if two lockfiles disagree, keep the highest semver-comparable version
15
15
  * - dev/optional/peer downgrade to prod if any manifest puts the dep in prod
16
+ *
17
+ * @author: N.BRAUN
18
+ * @email: pp9ping@gmail.com
16
19
  */
17
20
  const path = require("path");
18
21
  const fs = require("fs");
@@ -91,6 +94,14 @@ function upsert(out, dep, manifestPath, lockType) {
91
94
  } else if (!have && incoming) {
92
95
  existing.version = incoming;
93
96
  }
97
+ // Every DISTINCT concrete version is scanned, not just the highest: a JS tree
98
+ // routinely pins the same package at several versions (nested node_modules),
99
+ // and a CVE may affect only a lower one. cve-match.js / osv.js iterate
100
+ // dep.versions, so we union them here (makeDepRecord seeds `versions` with the
101
+ // record's own concrete version).
102
+ for (const v of record.versions || []) {
103
+ if (!existing.versions.includes(v)) existing.versions.push(v);
104
+ }
94
105
  // Stronger scope wins (prod > peer > optional > dev)
95
106
  if (rankScope(record.scope) > rankScope(existing.scope)) existing.scope = record.scope;
96
107
  // A dep is "dev" overall only if every occurrence is dev. Any prod use → not dev.
@@ -21,6 +21,9 @@
21
21
  *
22
22
  * The collector (lib/npm/collect.js) is responsible for merging across
23
23
  * files and applying exclusion rules.
24
+ *
25
+ * @author: N.BRAUN
26
+ * @email: pp9ping@gmail.com
24
27
  */
25
28
  const fs = require("fs");
26
29
  const path = require("path");
@@ -12,6 +12,9 @@
12
12
  *
13
13
  * `packumentToFindings` is the pure extractor (unit-tested without network);
14
14
  * `checkNpmRegistryDeps` is the cached, concurrent driver.
15
+ *
16
+ * @author: N.BRAUN
17
+ * @email: pp9ping@gmail.com
15
18
  */
16
19
  const fs = require("fs");
17
20
  const path = require("path");
@@ -49,10 +52,13 @@ function replacementFromMessage(msg) {
49
52
  * Pure — no network, no cache. Either field is null when not applicable.
50
53
  */
51
54
  function packumentToFindings(packument, dep) {
52
- const out = { deprecated: null, outdated: null };
55
+ const out = { deprecated: null, outdated: null, license: null };
53
56
  if (!packument || typeof packument !== "object") return out;
54
57
 
55
58
  const versionEntry = packument.versions?.[dep.version];
59
+ // License: prefer the resolved version's field, fall back to the packument root.
60
+ const lic = versionEntry?.license ?? versionEntry?.licenses ?? packument.license ?? packument.licenses;
61
+ if (lic) out.license = lic;
56
62
  const depMsg = versionEntry && typeof versionEntry.deprecated === "string"
57
63
  ? versionEntry.deprecated.trim()
58
64
  : "";
@@ -129,7 +135,7 @@ async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
129
135
  const wj = webjarToNpm(d);
130
136
  if (wj?.name) targets.push({ dep: d, npmName: wj.name, version: d.version });
131
137
  }
132
- const result = { deprecated: [], outdated: [] };
138
+ const result = { deprecated: [], outdated: [], licensed: [] };
133
139
  if (!targets.length) return result;
134
140
 
135
141
  const cache = loadCache();
@@ -169,6 +175,7 @@ async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
169
175
  deprecated: f.deprecated ? { reason: f.deprecated.reason, replacement: f.deprecated.replacement } : null,
170
176
  latest: f.outdated ? f.outdated.latest : null,
171
177
  latestDate: f.outdated ? f.outdated.releaseDate : null,
178
+ license: f.license || null,
172
179
  };
173
180
  cache.entries[key] = extracted;
174
181
  } else {
@@ -192,6 +199,7 @@ async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
192
199
  result.outdated.push({ dep, latest: extracted.latest, releaseDate: extracted.latestDate || null });
193
200
  if (verbose) process.stdout.write(` outdated: ${npmName} ${version} → ${extracted.latest}\n`);
194
201
  }
202
+ if (extracted.license) result.licensed.push({ dep, licenses: extracted.license, source: "npm" });
195
203
  })));
196
204
  if (liveCount) printProgress(true);
197
205
 
@@ -8,6 +8,9 @@
8
8
  * npm.collect ramasse package-lock ET yarn.lock — chaque dep porte son
9
9
  * `ecosystemType` ("npm" | "yarn"). Le codec yarn (yarn.codec.js) n'existe que
10
10
  * pour fournir son label/recette au report ; il ne re-scanne pas.
11
+ *
12
+ * @author: N.BRAUN
13
+ * @email: pp9ping@gmail.com
11
14
  */
12
15
  const { collectNpmDeps, hasJsManifests } = require("./npm/collect");
13
16
  const { coordKeyFor } = require("../dep-record");
@@ -9,6 +9,9 @@
9
9
  * packages.config — XML legacy, <package id version targetFramework />.
10
10
  *
11
11
  * NuGet ids are case-insensitive (lowercased for the key; original case kept for display).
12
+ *
13
+ * @author: N.BRAUN
14
+ * @email: pp9ping@gmail.com
12
15
  */
13
16
  const fs = require("fs");
14
17
  const xml2js = require("xml2js");
@@ -6,6 +6,9 @@
6
6
  * → { items: [ { items: [ { catalogEntry: { version, deprecation } } ] } ] }
7
7
  *
8
8
  * Cached in ~/.fad-checker/nuget-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");
@@ -31,11 +34,14 @@ function cmp(a, b) {
31
34
  function nugetRegistrationToFindings(reg, { version }) {
32
35
  const entries = [];
33
36
  for (const page of reg.items || []) for (const leaf of page.items || []) if (leaf.catalogEntry) entries.push(leaf.catalogEntry);
34
- const out = { outdated: null, deprecated: null };
37
+ const out = { outdated: null, deprecated: null, license: null };
35
38
  const stable = entries.map(e => e.version).filter(isStable);
36
39
  if (stable.length) { const latest = stable.sort(cmp).at(-1); if (latest && cmp(latest, version) > 0) out.outdated = { latest }; }
37
40
  const mine = entries.find(e => String(e.version) === String(version));
38
41
  if (mine?.deprecation) out.deprecated = { reason: (mine.deprecation.reasons || []).join(", ") || "deprecated", replacement: mine.deprecation.alternatePackage?.id || null };
42
+ // Modern packages expose an SPDX licenseExpression; older ones only a licenseUrl (→ unknown).
43
+ const licEntry = mine || entries.at(-1);
44
+ if (licEntry?.licenseExpression) out.license = licEntry.licenseExpression;
39
45
  return out;
40
46
  }
41
47
 
@@ -52,7 +58,7 @@ async function fetchRegistration(name, { offline }) {
52
58
  async function checkNugetRegistryDeps(deps, opts = {}) {
53
59
  const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
54
60
  const targets = [...deps.values()].filter(d => d.ecosystem === "nuget" && d.version);
55
- const result = { deprecated: [], outdated: [] };
61
+ const result = { deprecated: [], outdated: [], licensed: [] };
56
62
  if (!targets.length) return result;
57
63
  const cache = loadCache();
58
64
  const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
@@ -65,12 +71,13 @@ async function checkNugetRegistryDeps(deps, opts = {}) {
65
71
  const reg = await fetchRegistration(t.name, { offline });
66
72
  if (reg && !reg.error) {
67
73
  const f = nugetRegistrationToFindings(reg, { version: t.version });
68
- ex = { deprecated: f.deprecated, latest: f.outdated?.latest || null };
74
+ ex = { deprecated: f.deprecated, latest: f.outdated?.latest || null, license: f.license || null };
69
75
  cache.entries[key] = ex;
70
76
  } else {
71
- ex = { deprecated: null, latest: null };
77
+ ex = { deprecated: null, latest: null, license: null };
72
78
  }
73
79
  }
80
+ if (ex.license) result.licensed.push({ dep: t, licenses: ex.license, source: "nuget" });
74
81
  if (ex.deprecated) {
75
82
  result.deprecated.push({ dep: t, severity: "MEDIUM", replacement: ex.deprecated.replacement, reason: ex.deprecated.reason, source: "nuget" });
76
83
  if (verbose) process.stdout.write(` deprecated: ${t.name}@${t.version}\n`);
@@ -6,6 +6,9 @@
6
6
  * else packages.config), NuGet registration registry (deprecation + outdated),
7
7
  * and EOL. NuGet ids are case-insensitive: the key is lowercased, dep.name keeps
8
8
  * the original casing for display / OSV.
9
+ *
10
+ * @author: N.BRAUN
11
+ * @email: pp9ping@gmail.com
9
12
  */
10
13
  const fs = require("fs");
11
14
  const path = require("path");
@@ -10,6 +10,9 @@
10
10
  *
11
11
  * All names are PEP 503 normalized (lowercase, runs of -_. → single -) so they
12
12
  * match OSV / PyPI / the resolved-deps key.
13
+ *
14
+ * @author: N.BRAUN
15
+ * @email: pp9ping@gmail.com
13
16
  */
14
17
  const fs = require("fs");
15
18
  const path = require("path");
@@ -37,9 +40,11 @@ function parseTomlPackages(filePath, type) {
37
40
  const deps = [];
38
41
  for (const p of pkgs) {
39
42
  if (!p.name || !p.version) continue;
40
- // pdm marks groups; poetry/uv don't reliably default prod.
43
+ // pdm/poetry≥1.5 use `groups`; classic poetry.lock (≤1.4) uses `category = "dev"`.
41
44
  const groups = Array.isArray(p.groups) ? p.groups : null;
42
- const isDev = groups ? groups.every(g => g === "dev") : false;
45
+ const isDev = groups
46
+ ? groups.every(g => g === "dev")
47
+ : (p.category ? String(p.category).toLowerCase() === "dev" : false);
43
48
  deps.push({ name: pep503(p.name), version: String(p.version), scope: isDev ? "dev" : "prod", isDev });
44
49
  }
45
50
  return { manifestPath: filePath, manifestType: type, deps };