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.
- package/CHANGELOG.md +50 -0
- package/README.md +91 -17
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +14 -1
- package/data/license-policy.json +97 -0
- package/fad-checker-report/cve-report.doc +630 -0
- package/fad-checker-report/cve-report.html +748 -0
- package/fad-checker.js +278 -53
- package/lib/cache-archive.js +3 -0
- package/lib/codecs/codec.interface.js +3 -0
- package/lib/codecs/composer/parse.js +3 -0
- package/lib/codecs/composer/registry.js +13 -5
- package/lib/codecs/composer.codec.js +3 -0
- package/lib/codecs/go/parse.js +65 -0
- package/lib/codecs/go/registry.js +74 -0
- package/lib/codecs/go.codec.js +76 -0
- package/lib/codecs/index.js +7 -2
- package/lib/codecs/maven/jar-scan.js +199 -0
- package/lib/codecs/maven.codec.js +17 -1
- package/lib/codecs/npm/collect.js +11 -0
- package/lib/codecs/npm/parse.js +3 -0
- package/lib/codecs/npm/registry.js +10 -2
- package/lib/codecs/npm.codec.js +3 -0
- package/lib/codecs/nuget/parse.js +3 -0
- package/lib/codecs/nuget/registry.js +11 -4
- package/lib/codecs/nuget.codec.js +3 -0
- package/lib/codecs/pypi/parse.js +7 -2
- package/lib/codecs/pypi/registry.js +24 -5
- package/lib/codecs/pypi.codec.js +3 -0
- package/lib/codecs/recipes.js +28 -1
- package/lib/codecs/ruby/parse.js +42 -0
- package/lib/codecs/ruby/registry.js +76 -0
- package/lib/codecs/ruby.codec.js +66 -0
- package/lib/codecs/select.js +3 -0
- package/lib/codecs/yarn.codec.js +3 -0
- package/lib/config.js +3 -0
- package/lib/core.js +3 -0
- package/lib/cpe.js +30 -5
- package/lib/csaf-export.js +159 -0
- package/lib/cve-download.js +3 -0
- package/lib/cve-match.js +12 -1
- package/lib/cve-report.js +157 -28
- package/lib/dep-record.js +15 -2
- package/lib/deps-descriptor.js +3 -0
- package/lib/epss.js +115 -0
- package/lib/gate.js +45 -0
- package/lib/json-export.js +110 -0
- package/lib/kev.js +88 -0
- package/lib/license-policy.js +110 -0
- package/lib/maven-license.js +52 -0
- package/lib/maven-repo.js +3 -0
- package/lib/maven-version.js +8 -3
- package/lib/nvd.js +13 -3
- package/lib/osv.js +68 -19
- package/lib/outdated.js +3 -0
- package/lib/priority.js +90 -0
- package/lib/purl.js +77 -0
- package/lib/retire.js +3 -0
- package/lib/sarif-export.js +134 -0
- package/lib/sbom-export.js +153 -0
- package/lib/scan-completeness.js +3 -0
- package/lib/snyk.js +3 -0
- package/lib/suppress.js +113 -0
- package/lib/transitive.js +3 -0
- package/lib/ui.js +3 -0
- 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 };
|
package/lib/codecs/pypi.codec.js
CHANGED
|
@@ -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");
|
package/lib/codecs/recipes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/lib/codecs/select.js
CHANGED
|
@@ -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;
|
package/lib/codecs/yarn.codec.js
CHANGED
|
@@ -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
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 };
|
package/lib/cve-download.js
CHANGED
|
@@ -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
|
-
|
|
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);
|