fad-checker 2.0.1 → 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 (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +90 -16
  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
@@ -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");
@@ -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");
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) {
@@ -0,0 +1,159 @@
1
+ /**
2
+ * lib/csaf-export.js — emit a CSAF 2.0 VEX (csaf_vex) document from the resolved
3
+ * deps + matches. buildCsaf is pure; writeCsaf writes the JSON to disk.
4
+ *
5
+ * Spec: https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html
6
+ *
7
+ * @author: N.BRAUN
8
+ * @email: pp9ping@gmail.com
9
+ */
10
+ const fs = require("fs");
11
+ const { purlFor } = require("./purl");
12
+
13
+ function humanName(dep) {
14
+ const ns = dep.namespace || dep.groupId || "";
15
+ const name = dep.name || dep.artifactId;
16
+ const base = dep.ecosystem === "composer" && ns ? `${ns}/${name}` : (ns && dep.ecosystem === "maven" ? `${ns}:${name}` : name);
17
+ return dep.version ? `${base}@${dep.version}` : base;
18
+ }
19
+
20
+ // NVD "CVSS:3.1" → CVSS v3 JSON `version` field, or null when not a v3 score.
21
+ // Tolerates the legacy "CVSS:V31"/"CVSS:V30" labels still sitting in pre-fix NVD
22
+ // caches (the producer now emits "CVSS:3.1"; this keeps offline caches working).
23
+ function cvssV3Version(cvssVersion) {
24
+ const v = String(cvssVersion || "");
25
+ if (v.includes("3.1") || /v31\b/i.test(v)) return "3.1";
26
+ if (v.includes("3.0") || v === "CVSS:3" || /v30\b/i.test(v)) return "3.0";
27
+ return null;
28
+ }
29
+
30
+ // CVSS v3 `baseSeverity` is a closed enum (NONE|LOW|MEDIUM|HIGH|CRITICAL). OSV/GHSA
31
+ // often yield "UNKNOWN", which fails CSAF schema validation — derive it from the
32
+ // base score's standard v3 bands when the label isn't a valid enum value.
33
+ function cvssV3Severity(severity, score) {
34
+ const s = String(severity || "").toUpperCase();
35
+ if (["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"].includes(s)) return s;
36
+ if (typeof score !== "number") return "NONE";
37
+ if (score === 0) return "NONE";
38
+ if (score < 4) return "LOW";
39
+ if (score < 7) return "MEDIUM";
40
+ if (score < 9) return "HIGH";
41
+ return "CRITICAL";
42
+ }
43
+
44
+ /**
45
+ * Build a CSAF 2.0 csaf_vex object.
46
+ * opts: { projectInfo, toolVersion, timestamp }
47
+ */
48
+ function buildCsaf(resolvedDeps, cveMatches, opts = {}) {
49
+ const { projectInfo = {}, toolVersion = "0", timestamp } = opts;
50
+ const now = timestamp || projectInfo.generatedAt || "1970-01-01T00:00:00Z";
51
+
52
+ // Stable product ids + coordKey → product_id lookup.
53
+ const full_product_names = [];
54
+ const productIdByCoord = new Map();
55
+ let n = 0;
56
+ for (const dep of resolvedDeps.values()) {
57
+ const pid = `PROD-${n++}`;
58
+ const purl = purlFor(dep);
59
+ productIdByCoord.set(dep.coordKey, pid);
60
+ full_product_names.push({
61
+ product_id: pid,
62
+ name: humanName(dep),
63
+ ...(purl ? { product_identification_helper: { purl } } : {}),
64
+ });
65
+ }
66
+
67
+ // One vulnerability per CVE, listing every affected product id.
68
+ const byCve = new Map();
69
+ for (const m of cveMatches || []) {
70
+ const id = m.cve?.id;
71
+ if (!id) continue;
72
+ let pid = productIdByCoord.get(m.dep?.coordKey);
73
+ // A match whose dep isn't in resolvedDeps (e.g. a Snyk-only finding) has no
74
+ // product yet — register it on the fly so the CVE never ends up with an empty
75
+ // known_affected[] (which is schema-invalid: product_status lists need ≥1 item).
76
+ if (!pid && m.dep && m.dep.coordKey) {
77
+ pid = `PROD-${n++}`;
78
+ const purl = purlFor(m.dep);
79
+ productIdByCoord.set(m.dep.coordKey, pid);
80
+ full_product_names.push({
81
+ product_id: pid,
82
+ name: humanName(m.dep),
83
+ ...(purl ? { product_identification_helper: { purl } } : {}),
84
+ });
85
+ }
86
+ let v = byCve.get(id);
87
+ if (!v) {
88
+ const cve = m.cve;
89
+ const notes = [];
90
+ if (cve.description) notes.push({ category: "description", text: cve.description, title: "Description" });
91
+ const extras = [];
92
+ if (cve.epssPercentile != null) extras.push(`EPSS percentile ${Math.round(cve.epssPercentile * 100)}%`);
93
+ if (cve.kev) extras.push(`CISA KEV (known exploited)${cve.kevDueDate ? `, remediate by ${cve.kevDueDate}` : ""}`);
94
+ if (cve.priority?.band) extras.push(`fad-checker priority: ${cve.priority.band} (${cve.priority.score}/100)`);
95
+ if (extras.length) notes.push({ category: "other", text: extras.join("; "), title: "Prioritization" });
96
+
97
+ const v3ver = cvssV3Version(cve.cvssVersion);
98
+ const scores = (v3ver && cve.score != null) ? [{
99
+ cvss_v3: {
100
+ version: v3ver,
101
+ baseScore: cve.score,
102
+ baseSeverity: cvssV3Severity(cve.severity, cve.score),
103
+ // Only carry the vector when it actually IS a v3 vector — a v4 (or
104
+ // mismatched) vector string fails the v3 schema's vectorString pattern.
105
+ ...((cve.cvssVector && /^CVSS:3\.[01]\//.test(cve.cvssVector)) ? { vectorString: cve.cvssVector } : {}),
106
+ },
107
+ products: [],
108
+ }] : [];
109
+
110
+ v = {
111
+ cve: id,
112
+ ...(notes.length ? { notes } : {}),
113
+ product_status: { known_affected: [] },
114
+ ...(scores.length ? { scores } : {}),
115
+ ...(cve.kev ? { flags: [{ label: "exploited", product_ids: [] }] } : {}),
116
+ };
117
+ byCve.set(id, v);
118
+ }
119
+ if (pid && !v.product_status.known_affected.includes(pid)) {
120
+ v.product_status.known_affected.push(pid);
121
+ if (v.scores?.[0]) v.scores[0].products.push(pid);
122
+ if (v.flags?.[0]) v.flags[0].product_ids.push(pid);
123
+ }
124
+ }
125
+
126
+ return {
127
+ document: {
128
+ category: "csaf_vex",
129
+ csaf_version: "2.0",
130
+ title: `Vulnerability disclosure (VEX) — ${projectInfo.name || "project"}`,
131
+ publisher: {
132
+ category: "vendor",
133
+ name: "fad-checker",
134
+ namespace: "https://github.com/nathb2b/fad-checker",
135
+ },
136
+ tracking: {
137
+ id: `fad-checker-${(projectInfo.name || "project").replace(/[^A-Za-z0-9._-]/g, "-")}-${String(now).slice(0, 10)}`,
138
+ status: "final",
139
+ version: "1",
140
+ generator: { engine: { name: "fad-checker", version: String(toolVersion) } },
141
+ initial_release_date: now,
142
+ current_release_date: now,
143
+ revision_history: [{ number: "1", date: now, summary: "Initial automated VEX from fad-checker scan" }],
144
+ },
145
+ },
146
+ product_tree: { full_product_names },
147
+ // Drop any vulnerability that ended up with zero affected products (e.g. a
148
+ // match with no usable dep) — an empty product_status is schema-invalid.
149
+ vulnerabilities: [...byCve.values()].filter(v => v.product_status.known_affected.length > 0),
150
+ };
151
+ }
152
+
153
+ function writeCsaf(resolvedDeps, cveMatches, outputPath, opts = {}) {
154
+ const doc = buildCsaf(resolvedDeps, cveMatches, opts);
155
+ fs.writeFileSync(outputPath, JSON.stringify(doc, null, 2) + "\n");
156
+ return doc;
157
+ }
158
+
159
+ module.exports = { buildCsaf, writeCsaf };
@@ -5,6 +5,9 @@
5
5
  * The bundle is ~500 MB+. We shell out to curl + unzip rather than
6
6
  * bundling a zip library. The extracted JSON is deleted after the index
7
7
  * is built.
8
+ *
9
+ * @author: N.BRAUN
10
+ * @email: pp9ping@gmail.com
8
11
  */
9
12
  const fs = require("fs");
10
13
  const path = require("path");
package/lib/cve-match.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * lib/cve-match.js — collect resolved deps from the parsed POM tree and
3
3
  * match them against a CVE index built by lib/cve-download.js.
4
+ *
5
+ * @author: N.BRAUN
6
+ * @email: pp9ping@gmail.com
4
7
  */
5
8
  const { coord } = require("./core");
6
9
  const { compareMavenVersions, isVersionAffected } = require("./maven-version");
@@ -119,12 +122,17 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
119
122
  // project pins a transitive's version via <dependencyManagement>.
120
123
  const rootDepMgmt = new Map();
121
124
  for (const dep of resolvedDeps.values()) {
125
+ // Embedded-binary coords don't participate in Maven Central resolution (a
126
+ // fat-jar already ships its deps, discovered by recursion) and must not act as
127
+ // a depMgmt override for the declared tree.
128
+ if (dep.provenance === "embedded") continue;
122
129
  if (dep.version && !/\$\{/.test(dep.version)) {
123
130
  rootDepMgmt.set(`${dep.groupId}:${dep.artifactId}`, { version: dep.version });
124
131
  }
125
132
  }
126
133
 
127
134
  const directs = [...resolvedDeps.values()]
135
+ .filter(d => d.provenance !== "embedded")
128
136
  .filter(d => d.version && !/\$\{/.test(d.version))
129
137
  .filter(d => d.scope !== "test" || opts.includeTestDeps)
130
138
  .filter(d => d.scope !== "parent");
@@ -238,7 +246,10 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
238
246
  const seen = new Set();
239
247
  const deduped = [];
240
248
  for (const m of all) {
241
- const k = `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
249
+ // Key by coordKey (not g:a): identical to g:a for declared Maven deps, but
250
+ // keeps embedded-binary findings (coordKey "embedded:<path>") distinct from a
251
+ // declared dep with the same g:a:v so the embedded chapter doesn't lose them.
252
+ const k = `${m.dep.coordKey}:${m.dep.version}|${m.cve.id}`;
242
253
  if (seen.has(k)) continue;
243
254
  seen.add(k);
244
255
  deduped.push(m);