fad-checker 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +147 -25
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +14 -1
- package/data/eol-mapping.json +5 -5
- 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 +556 -245
- 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 +37 -17
- package/lib/codecs/npm/parse.js +117 -10
- package/lib/codecs/npm/registry.js +14 -5
- package/lib/codecs/npm.codec.js +4 -1
- package/lib/codecs/nuget/parse.js +3 -0
- package/lib/codecs/nuget/registry.js +11 -4
- package/lib/codecs/nuget.codec.js +7 -2
- package/lib/codecs/pypi/parse.js +111 -14
- package/lib/codecs/pypi/registry.js +24 -5
- package/lib/codecs/pypi.codec.js +30 -6
- 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 +38 -1
- 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 +27 -6
- package/lib/cve-report.js +157 -28
- package/lib/dep-record.js +15 -2
- package/lib/deps-descriptor.js +113 -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 +17 -6
- package/lib/osv.js +75 -23
- package/lib/outdated.js +10 -2
- package/lib/priority.js +90 -0
- package/lib/purl.js +77 -0
- package/lib/retire.js +80 -13
- 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 +6 -3
- package/lib/ui.js +90 -0
- package/package.json +49 -2
- package/CLAUDE.md +0 -147
- package/docs/ARCHITECTURE.md +0 -182
- package/docs/USAGE.md +0 -187
- package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
- package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
- package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
- package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
- package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
- package/test/cli-ecosystem.test.js +0 -30
- package/test/codec-edge-cases.test.js +0 -131
- package/test/codec-integration.test.js +0 -119
- package/test/codecs.test.js +0 -92
- package/test/composer.test.js +0 -71
- package/test/core.test.js +0 -153
- package/test/cpe.test.js +0 -166
- package/test/cve-download.test.js +0 -39
- package/test/cve-match.test.js +0 -217
- package/test/cve-report.test.js +0 -180
- package/test/dep-record.test.js +0 -31
- package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
- package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
- package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
- package/test/fixtures/complex-enterprise/pom.xml +0 -66
- package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
- package/test/fixtures/csharp-config/packages.config +0 -4
- package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
- package/test/fixtures/csharp-csproj/app.csproj +0 -7
- package/test/fixtures/csharp-lock/packages.lock.json +0 -6
- package/test/fixtures/cve-samples/cve-non-java.json +0 -19
- package/test/fixtures/cve-samples/cve-product-only.json +0 -31
- package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
- package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
- package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
- package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
- package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
- package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
- package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
- package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
- package/test/fixtures/monorepo-mixed/pom.xml +0 -29
- package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
- package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
- package/test/fixtures/php-app/composer.json +0 -5
- package/test/fixtures/php-app/composer.lock +0 -10
- package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
- package/test/fixtures/polyglot/js/package-lock.json +0 -1
- package/test/fixtures/polyglot/js/package.json +0 -1
- package/test/fixtures/polyglot/mvn/pom.xml +0 -7
- package/test/fixtures/polyglot/php/composer.lock +0 -1
- package/test/fixtures/polyglot/py/poetry.lock +0 -3
- package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
- package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
- package/test/fixtures/private-lib-detection/pom.xml +0 -35
- package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
- package/test/fixtures/python-poetry/poetry.lock +0 -7
- package/test/fixtures/python-reqs/requirements.txt +0 -5
- package/test/fixtures/python-uv/uv.lock +0 -3
- package/test/fixtures/simple/app/pom.xml +0 -28
- package/test/fixtures/simple/lib/pom.xml +0 -18
- package/test/fixtures/simple/pom.xml +0 -24
- package/test/maven-repo.test.js +0 -111
- package/test/maven-version.test.js +0 -57
- package/test/monorepo.test.js +0 -132
- package/test/npm-registry.test.js +0 -64
- package/test/npm.test.js +0 -150
- package/test/nuget.test.js +0 -66
- package/test/osv.test.js +0 -62
- package/test/outdated.test.js +0 -101
- package/test/pypi.test.js +0 -72
- package/test/snyk.test.js +0 -64
- package/test/transitive.test.js +0 -305
- package/test/webjar.test.js +0 -33
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* → { info: { version, classifiers[] }, releases: { "<v>": [{ yanked, yanked_reason }] } }
|
|
7
7
|
*
|
|
8
8
|
* Cached in ~/.fad-checker/pypi-cache.json for 24h.
|
|
9
|
+
*
|
|
10
|
+
* @author: N.BRAUN
|
|
11
|
+
* @email: pp9ping@gmail.com
|
|
9
12
|
*/
|
|
10
13
|
const fs = require("fs");
|
|
11
14
|
const path = require("path");
|
|
@@ -26,8 +29,22 @@ function cmp(a, b) {
|
|
|
26
29
|
return 0;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
// PyPI's info.license is notoriously free-form (sometimes the full license text),
|
|
33
|
+
// so prefer the structured "License :: …" trove classifiers; fall back to a short
|
|
34
|
+
// info.license string only.
|
|
35
|
+
function pypiLicense(info) {
|
|
36
|
+
const fromClassifiers = (info?.classifiers || [])
|
|
37
|
+
.filter(c => /^License ::/.test(c) && !/OSI Approved$/.test(c))
|
|
38
|
+
.map(c => c.split("::").pop().trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
if (fromClassifiers.length) return fromClassifiers;
|
|
41
|
+
const raw = (info?.license || "").trim();
|
|
42
|
+
if (raw && raw.length <= 50 && !raw.includes("\n")) return raw;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
function pypiToFindings(data, { version }) {
|
|
30
|
-
const out = { outdated: null, yanked: null, inactive: false };
|
|
47
|
+
const out = { outdated: null, yanked: null, inactive: false, license: null };
|
|
31
48
|
const latest = data.info?.version;
|
|
32
49
|
if (latest && cmp(latest, version) > 0) out.outdated = { latest };
|
|
33
50
|
const rel = data.releases?.[version];
|
|
@@ -35,6 +52,7 @@ function pypiToFindings(data, { version }) {
|
|
|
35
52
|
out.yanked = { reason: rel.find(f => f.yanked_reason)?.yanked_reason || null };
|
|
36
53
|
}
|
|
37
54
|
if ((data.info?.classifiers || []).some(c => /Development Status :: 7 - Inactive/i.test(c))) out.inactive = true;
|
|
55
|
+
out.license = pypiLicense(data.info);
|
|
38
56
|
return out;
|
|
39
57
|
}
|
|
40
58
|
|
|
@@ -51,7 +69,7 @@ async function fetchProject(name, { offline }) {
|
|
|
51
69
|
async function checkPypiRegistryDeps(deps, opts = {}) {
|
|
52
70
|
const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
|
|
53
71
|
const targets = [...deps.values()].filter(d => d.ecosystem === "pypi" && d.version);
|
|
54
|
-
const result = { deprecated: [], outdated: [] };
|
|
72
|
+
const result = { deprecated: [], outdated: [], licensed: [] };
|
|
55
73
|
if (!targets.length) return result;
|
|
56
74
|
const cache = loadCache();
|
|
57
75
|
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
|
|
@@ -64,12 +82,13 @@ async function checkPypiRegistryDeps(deps, opts = {}) {
|
|
|
64
82
|
const data = await fetchProject(t.name, { offline });
|
|
65
83
|
if (data && !data.error) {
|
|
66
84
|
const f = pypiToFindings(data, { version: t.version });
|
|
67
|
-
ex = { yanked: f.yanked, inactive: f.inactive, latest: f.outdated?.latest || null };
|
|
85
|
+
ex = { yanked: f.yanked, inactive: f.inactive, latest: f.outdated?.latest || null, license: f.license || null };
|
|
68
86
|
cache.entries[key] = ex;
|
|
69
87
|
} else {
|
|
70
|
-
ex = { yanked: null, inactive: false, latest: null };
|
|
88
|
+
ex = { yanked: null, inactive: false, latest: null, license: null };
|
|
71
89
|
}
|
|
72
90
|
}
|
|
91
|
+
if (ex.license) result.licensed.push({ dep: t, licenses: ex.license, source: "pypi" });
|
|
73
92
|
if (ex.yanked) {
|
|
74
93
|
result.deprecated.push({ dep: t, severity: "HIGH", replacement: null, reason: `Version yanked on PyPI${ex.yanked.reason ? `: ${ex.yanked.reason}` : ""}`, source: "pypi" });
|
|
75
94
|
if (verbose) process.stdout.write(` yanked: ${t.name}@${t.version}\n`);
|
|
@@ -86,4 +105,4 @@ async function checkPypiRegistryDeps(deps, opts = {}) {
|
|
|
86
105
|
return result;
|
|
87
106
|
}
|
|
88
107
|
|
|
89
|
-
module.exports = { pypiToFindings, checkPypiRegistryDeps };
|
|
108
|
+
module.exports = { pypiToFindings, pypiLicense, checkPypiRegistryDeps };
|
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");
|
|
@@ -19,6 +22,8 @@ const LOCKS = [
|
|
|
19
22
|
["pdm.lock", P.parsePdmLock],
|
|
20
23
|
];
|
|
21
24
|
|
|
25
|
+
const FALLBACK_MANIFESTS = ["pyproject.toml", "requirements.txt"];
|
|
26
|
+
|
|
22
27
|
function findPyDirs(dir) {
|
|
23
28
|
const groups = [];
|
|
24
29
|
const stack = [dir];
|
|
@@ -26,7 +31,7 @@ function findPyDirs(dir) {
|
|
|
26
31
|
const cur = stack.pop();
|
|
27
32
|
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
28
33
|
const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
|
|
29
|
-
if ([...LOCKS.map(l => l[0]),
|
|
34
|
+
if ([...LOCKS.map(l => l[0]), ...FALLBACK_MANIFESTS].some(n => names.has(n))) groups.push({ dir: cur, names });
|
|
30
35
|
for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
|
|
31
36
|
}
|
|
32
37
|
return groups;
|
|
@@ -36,7 +41,7 @@ module.exports = {
|
|
|
36
41
|
id: "pypi",
|
|
37
42
|
label: "PyPI",
|
|
38
43
|
osvEcosystem: "PyPI",
|
|
39
|
-
manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "requirements.txt"],
|
|
44
|
+
manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "pyproject.toml", "requirements.txt"],
|
|
40
45
|
|
|
41
46
|
detect(dir) { return findPyDirs(dir).length > 0; },
|
|
42
47
|
|
|
@@ -55,14 +60,33 @@ module.exports = {
|
|
|
55
60
|
const fp = path.join(g.dir, lock[0]);
|
|
56
61
|
try { const { deps } = lock[1](fp); for (const d of deps) add(d, fp); }
|
|
57
62
|
catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${lock[0]} parse failed: ${e.message}` }); }
|
|
58
|
-
|
|
63
|
+
continue; // lockfile is authoritative for this directory
|
|
64
|
+
}
|
|
65
|
+
// No lockfile → best-effort from pyproject.toml and/or requirements.txt.
|
|
66
|
+
let pinned = 0, skipped = 0;
|
|
67
|
+
const sources = [];
|
|
68
|
+
if (g.names.has("pyproject.toml")) {
|
|
69
|
+
const fp = path.join(g.dir, "pyproject.toml");
|
|
70
|
+
try {
|
|
71
|
+
const r = P.parsePyprojectToml(fp);
|
|
72
|
+
for (const d of r.deps) add(d, fp);
|
|
73
|
+
pinned += r.deps.length; skipped += r.skipped;
|
|
74
|
+
if (r.deps.length || r.skipped) sources.push("pyproject.toml");
|
|
75
|
+
} catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `pyproject.toml parse failed: ${e.message}` }); }
|
|
76
|
+
}
|
|
77
|
+
if (g.names.has("requirements.txt")) {
|
|
59
78
|
const fp = path.join(g.dir, "requirements.txt");
|
|
60
79
|
try {
|
|
61
|
-
const
|
|
62
|
-
for (const d of deps) add(d, fp);
|
|
63
|
-
|
|
80
|
+
const r = P.parseRequirementsTxt(fp);
|
|
81
|
+
for (const d of r.deps) add(d, fp);
|
|
82
|
+
pinned += r.deps.length; skipped += r.skipped;
|
|
83
|
+
sources.push("requirements.txt");
|
|
84
|
+
for (const miss of (r.missing || [])) warnings.push({ type: "missing-include", manifestPath: fp, message: `referenced requirements file not found: ${miss}` });
|
|
64
85
|
} catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `requirements.txt parse failed: ${e.message}` }); }
|
|
65
86
|
}
|
|
87
|
+
if (sources.length) {
|
|
88
|
+
warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no lockfile (${sources.join(" + ")}) — best-effort: ${pinned} pinned, ${skipped} range(s) skipped` });
|
|
89
|
+
}
|
|
66
90
|
}
|
|
67
91
|
return { deps: out, warnings };
|
|
68
92
|
},
|
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
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/core.js — pure logic for parsing and cleaning Maven POMs.
|
|
3
3
|
* No CLI, no console formatting. Callers pass options explicitly.
|
|
4
|
+
*
|
|
5
|
+
* @author: N.BRAUN
|
|
6
|
+
* @email: pp9ping@gmail.com
|
|
4
7
|
*/
|
|
5
8
|
const fs = require("fs");
|
|
6
9
|
const path = require("path");
|
|
@@ -99,10 +102,20 @@ async function parsePom(pomPath, allPomMetadata) {
|
|
|
99
102
|
groupId,
|
|
100
103
|
artifactId,
|
|
101
104
|
version,
|
|
105
|
+
// Maven model expressions for the current project. These are built-ins,
|
|
106
|
+
// not <properties> — ${project.version} always means THIS project's
|
|
107
|
+
// version (never overridable by a property or an imported BOM). The
|
|
108
|
+
// pom.* aliases mirror Maven's legacy synonyms.
|
|
102
109
|
localVars: {
|
|
103
110
|
"project.groupId": groupId,
|
|
104
111
|
"project.artifactId": artifactId,
|
|
105
112
|
"project.version": version,
|
|
113
|
+
"pom.groupId": groupId,
|
|
114
|
+
"pom.artifactId": artifactId,
|
|
115
|
+
"pom.version": version,
|
|
116
|
+
...(parentInfo?.groupId ? { "project.parent.groupId": parentInfo.groupId } : {}),
|
|
117
|
+
...(parentInfo?.artifactId ? { "project.parent.artifactId": parentInfo.artifactId } : {}),
|
|
118
|
+
...(parentInfo?.version ? { "project.parent.version": parentInfo.version } : {}),
|
|
106
119
|
},
|
|
107
120
|
};
|
|
108
121
|
|
|
@@ -199,7 +212,31 @@ async function getAllInheritedProps(pomPath, allPomMetadata, cache) {
|
|
|
199
212
|
merged.dependencyManagement.push(...imported.dependencyManagement);
|
|
200
213
|
}
|
|
201
214
|
|
|
202
|
-
|
|
215
|
+
// Cache the (still-mutating) object BEFORE recursing into the parent so a
|
|
216
|
+
// malformed parent cycle (A→B→A) returns this partial result instead of
|
|
217
|
+
// recursing forever. `merged` is mutated in place afterwards, so the cached
|
|
218
|
+
// reference ends up fully populated.
|
|
219
|
+
cache[pomPath] = merged;
|
|
220
|
+
|
|
221
|
+
// Inherit <properties> from a LOCAL parent POM as the BASE layer — the child
|
|
222
|
+
// overrides the parent, exactly like native Maven. (dependencyManagement and
|
|
223
|
+
// parent <dependencies> need no special handling here: collectResolvedDeps
|
|
224
|
+
// already merges them across the whole tree by g:a.) External parents
|
|
225
|
+
// (e.g. spring-boot-starter-parent) aren't in the source tree, so their
|
|
226
|
+
// properties can't be inherited without a network fetch — those versions
|
|
227
|
+
// still surface as unresolved, as before.
|
|
228
|
+
const parentPath = resolveParentPath(pomPath, parentInfo, allPomMetadata);
|
|
229
|
+
if (parentPath && parentPath !== pomPath) {
|
|
230
|
+
const parentMerged = await getAllInheritedProps(parentPath, allPomMetadata, cache);
|
|
231
|
+
merged.properties = { ...parentMerged.properties, ...merged.properties };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Built-in project.* coordinates (${project.version}, ${project.groupId}, …).
|
|
235
|
+
// Applied LAST so the current POM's own values win — even over an inherited
|
|
236
|
+
// parent property or an imported BOM's project.* — letting an intra-reactor
|
|
237
|
+
// dep declared "<version>${project.version}</version>" resolve to THIS
|
|
238
|
+
// module's version.
|
|
239
|
+
merged.properties = { ...merged.properties, ...(meta.localVars || {}) };
|
|
203
240
|
|
|
204
241
|
cache[pomPath] = merged;
|
|
205
242
|
return merged;
|
package/lib/cpe.js
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* and whether we can upgrade the match confidence.
|
|
15
15
|
* 2. Given a dep coord, find CVEs in the index whose CPE configurations
|
|
16
16
|
* it satisfies. (Future use — kept compatible with the data shape.)
|
|
17
|
+
*
|
|
18
|
+
* @author: N.BRAUN
|
|
19
|
+
* @email: pp9ping@gmail.com
|
|
17
20
|
*/
|
|
18
21
|
const { compareMavenVersions } = require("./maven-version");
|
|
19
22
|
|
|
@@ -144,6 +147,21 @@ function nodeAffectsDep(node, dep, cpeCoordMap) {
|
|
|
144
147
|
return false;
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A node is "context-only" when it carries no vulnerable cpeMatch (recursively) —
|
|
152
|
+
* e.g. the "…AND runs on linux_kernel" platform node, whose CPEs are all
|
|
153
|
+
* `vulnerable: false`. In an AND configuration those nodes are runtime context we
|
|
154
|
+
* deliberately ignore (we assume the library is in use), so they must NOT be
|
|
155
|
+
* required for the config to count as affecting the dep.
|
|
156
|
+
*/
|
|
157
|
+
function nodeIsContextOnly(node) {
|
|
158
|
+
if (!node) return true;
|
|
159
|
+
const matches = node.cpeMatch || node.cpe_match || [];
|
|
160
|
+
if (matches.some(m => m.vulnerable !== false)) return false;
|
|
161
|
+
if (Array.isArray(node.children) && node.children.some(c => !nodeIsContextOnly(c))) return false;
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
147
165
|
/**
|
|
148
166
|
* Decide whether a CPE (parsed) refers to the same artifact as a dep.
|
|
149
167
|
* Lookup order:
|
|
@@ -221,11 +239,18 @@ function evaluateCveForDep(cveRecord, dep, cpeCoordMap) {
|
|
|
221
239
|
for (const cfg of configs) {
|
|
222
240
|
const nodes = cfg.nodes || [];
|
|
223
241
|
const op = (cfg.operator || "OR").toUpperCase();
|
|
224
|
-
|
|
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) {
|