fad-checker 1.0.6 → 2.0.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 +62 -0
- package/README.md +77 -15
- package/completions/fad-checker.bash +4 -1
- package/completions/fad-checker.zsh +9 -0
- package/data/eol-mapping.json +17 -0
- package/fad-checker.js +353 -241
- package/lib/codecs/codec.interface.js +27 -0
- package/lib/codecs/composer/parse.js +59 -0
- package/lib/codecs/composer/registry.js +92 -0
- package/lib/codecs/composer.codec.js +93 -0
- package/lib/codecs/index.js +37 -0
- package/lib/codecs/maven.codec.js +71 -0
- package/lib/{npm → codecs/npm}/collect.js +58 -35
- package/lib/{npm → codecs/npm}/parse.js +114 -10
- package/lib/{npm → codecs/npm}/registry.js +4 -3
- package/lib/codecs/npm.codec.js +52 -0
- package/lib/codecs/nuget/parse.js +75 -0
- package/lib/codecs/nuget/registry.js +88 -0
- package/lib/codecs/nuget.codec.js +94 -0
- package/lib/codecs/pypi/parse.js +163 -0
- package/lib/codecs/pypi/registry.js +89 -0
- package/lib/codecs/pypi.codec.js +102 -0
- package/lib/codecs/recipes.js +108 -0
- package/lib/codecs/select.js +27 -0
- package/lib/codecs/yarn.codec.js +19 -0
- package/lib/core.js +35 -1
- package/lib/cve-match.js +18 -11
- package/lib/cve-report.js +34 -70
- package/lib/dep-record.js +60 -0
- package/lib/deps-descriptor.js +110 -0
- package/lib/nvd.js +4 -3
- package/lib/osv.js +29 -18
- package/lib/outdated.js +20 -4
- package/lib/retire.js +77 -13
- package/lib/transitive.js +3 -3
- package/lib/ui.js +87 -0
- package/package.json +4 -2
- package/CLAUDE.md +0 -129
- package/docs/ARCHITECTURE.md +0 -154
- package/docs/USAGE.md +0 -179
- 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 -171
- 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/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 -9
- 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/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/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 -146
- package/test/outdated.test.js +0 -101
- package/test/snyk.test.js +0 -64
- package/test/transitive.test.js +0 -305
- package/test/webjar.test.js +0 -33
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/python/registry.js — query PyPI JSON for a package's latest version,
|
|
3
|
+
* whether the resolved version is yanked, and the "Inactive" dev-status classifier.
|
|
4
|
+
*
|
|
5
|
+
* API: https://pypi.org/pypi/{name}/json
|
|
6
|
+
* → { info: { version, classifiers[] }, releases: { "<v>": [{ yanked, yanked_reason }] } }
|
|
7
|
+
*
|
|
8
|
+
* Cached in ~/.fad-checker/pypi-cache.json for 24h.
|
|
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, "pypi-cache.json");
|
|
17
|
+
const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
|
|
18
|
+
const API = "https://pypi.org/pypi";
|
|
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 cmp(a, b) {
|
|
23
|
+
const pa = String(a).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
24
|
+
const pb = String(b).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
25
|
+
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; }
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pypiToFindings(data, { version }) {
|
|
30
|
+
const out = { outdated: null, yanked: null, inactive: false };
|
|
31
|
+
const latest = data.info?.version;
|
|
32
|
+
if (latest && cmp(latest, version) > 0) out.outdated = { latest };
|
|
33
|
+
const rel = data.releases?.[version];
|
|
34
|
+
if (Array.isArray(rel) && rel.length && rel.every(f => f.yanked)) {
|
|
35
|
+
out.yanked = { reason: rel.find(f => f.yanked_reason)?.yanked_reason || null };
|
|
36
|
+
}
|
|
37
|
+
if ((data.info?.classifiers || []).some(c => /Development Status :: 7 - Inactive/i.test(c))) out.inactive = true;
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function fetchProject(name, { offline }) {
|
|
42
|
+
if (offline) return null;
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(`${API}/${name}/json`, { headers: { "User-Agent": "fad-checker-pypi" } });
|
|
45
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
46
|
+
return await res.json();
|
|
47
|
+
} catch (e) { return { error: e.message }; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Mirror of checkNpmRegistryDeps: returns { deprecated:[], outdated:[] }.
|
|
51
|
+
async function checkPypiRegistryDeps(deps, opts = {}) {
|
|
52
|
+
const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
|
|
53
|
+
const targets = [...deps.values()].filter(d => d.ecosystem === "pypi" && d.version);
|
|
54
|
+
const result = { deprecated: [], outdated: [] };
|
|
55
|
+
if (!targets.length) return result;
|
|
56
|
+
const cache = loadCache();
|
|
57
|
+
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
|
|
58
|
+
if (!fresh && !offline) cache.entries = {};
|
|
59
|
+
const limit = pLimit(concurrency);
|
|
60
|
+
await Promise.all(targets.map(t => limit(async () => {
|
|
61
|
+
const key = `${t.name}@${t.version}`;
|
|
62
|
+
let ex = cache.entries[key];
|
|
63
|
+
if (!ex) {
|
|
64
|
+
const data = await fetchProject(t.name, { offline });
|
|
65
|
+
if (data && !data.error) {
|
|
66
|
+
const f = pypiToFindings(data, { version: t.version });
|
|
67
|
+
ex = { yanked: f.yanked, inactive: f.inactive, latest: f.outdated?.latest || null };
|
|
68
|
+
cache.entries[key] = ex;
|
|
69
|
+
} else {
|
|
70
|
+
ex = { yanked: null, inactive: false, latest: null };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (ex.yanked) {
|
|
74
|
+
result.deprecated.push({ dep: t, severity: "HIGH", replacement: null, reason: `Version yanked on PyPI${ex.yanked.reason ? `: ${ex.yanked.reason}` : ""}`, source: "pypi" });
|
|
75
|
+
if (verbose) process.stdout.write(` yanked: ${t.name}@${t.version}\n`);
|
|
76
|
+
} else if (ex.inactive) {
|
|
77
|
+
result.deprecated.push({ dep: t, severity: "LOW", replacement: null, reason: "Marked 'Development Status :: 7 - Inactive' on PyPI", source: "pypi" });
|
|
78
|
+
}
|
|
79
|
+
if (allLibs && ex.latest) {
|
|
80
|
+
result.outdated.push({ dep: t, latest: ex.latest, releaseDate: null });
|
|
81
|
+
if (verbose) process.stdout.write(` outdated: ${t.name} ${t.version} → ${ex.latest}\n`);
|
|
82
|
+
}
|
|
83
|
+
})));
|
|
84
|
+
cache.meta = { fetchedAt: Date.now() };
|
|
85
|
+
saveCache(cache);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { pypiToFindings, checkPypiRegistryDeps };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/pypi.codec.js — codec Python/PyPI.
|
|
3
|
+
*
|
|
4
|
+
* Vuln scanning is OSV (ecosystem "PyPI", wired in Plan A). This codec adds
|
|
5
|
+
* collection (poetry.lock/Pipfile.lock/uv.lock/pdm.lock, requirements.txt
|
|
6
|
+
* fallback), PyPI registry (yanked/inactive + outdated), and EOL.
|
|
7
|
+
* Per-directory precedence: a lockfile wins; else requirements.txt best-effort.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { makeDepRecord, coordKeyFor } = require("../dep-record");
|
|
12
|
+
const P = require("./pypi/parse");
|
|
13
|
+
|
|
14
|
+
const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target", "__pycache__", ".venv", "venv", ".tox", ".mypy_cache", "site-packages"]);
|
|
15
|
+
const LOCKS = [
|
|
16
|
+
["poetry.lock", P.parsePoetryLock],
|
|
17
|
+
["Pipfile.lock", P.parsePipfileLock],
|
|
18
|
+
["uv.lock", P.parseUvLock],
|
|
19
|
+
["pdm.lock", P.parsePdmLock],
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const FALLBACK_MANIFESTS = ["pyproject.toml", "requirements.txt"];
|
|
23
|
+
|
|
24
|
+
function findPyDirs(dir) {
|
|
25
|
+
const groups = [];
|
|
26
|
+
const stack = [dir];
|
|
27
|
+
while (stack.length) {
|
|
28
|
+
const cur = stack.pop();
|
|
29
|
+
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
30
|
+
const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
|
|
31
|
+
if ([...LOCKS.map(l => l[0]), ...FALLBACK_MANIFESTS].some(n => names.has(n))) groups.push({ dir: cur, names });
|
|
32
|
+
for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
|
|
33
|
+
}
|
|
34
|
+
return groups;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
id: "pypi",
|
|
39
|
+
label: "PyPI",
|
|
40
|
+
osvEcosystem: "PyPI",
|
|
41
|
+
manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "pyproject.toml", "requirements.txt"],
|
|
42
|
+
|
|
43
|
+
detect(dir) { return findPyDirs(dir).length > 0; },
|
|
44
|
+
|
|
45
|
+
async collect(dir, opts = {}) {
|
|
46
|
+
const { ignoreTest, deps2Exclude } = opts;
|
|
47
|
+
const out = new Map();
|
|
48
|
+
const warnings = [];
|
|
49
|
+
const add = (d, manifestPath) => {
|
|
50
|
+
if (ignoreTest && d.isDev) return;
|
|
51
|
+
if (deps2Exclude && deps2Exclude.test(d.name)) return;
|
|
52
|
+
out.set(coordKeyFor("pypi", "", d.name), makeDepRecord({ ecosystem: "pypi", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev }));
|
|
53
|
+
};
|
|
54
|
+
for (const g of findPyDirs(dir)) {
|
|
55
|
+
const lock = LOCKS.find(([n]) => g.names.has(n));
|
|
56
|
+
if (lock) {
|
|
57
|
+
const fp = path.join(g.dir, lock[0]);
|
|
58
|
+
try { const { deps } = lock[1](fp); for (const d of deps) add(d, fp); }
|
|
59
|
+
catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${lock[0]} parse failed: ${e.message}` }); }
|
|
60
|
+
continue; // lockfile is authoritative for this directory
|
|
61
|
+
}
|
|
62
|
+
// No lockfile → best-effort from pyproject.toml and/or requirements.txt.
|
|
63
|
+
let pinned = 0, skipped = 0;
|
|
64
|
+
const sources = [];
|
|
65
|
+
if (g.names.has("pyproject.toml")) {
|
|
66
|
+
const fp = path.join(g.dir, "pyproject.toml");
|
|
67
|
+
try {
|
|
68
|
+
const r = P.parsePyprojectToml(fp);
|
|
69
|
+
for (const d of r.deps) add(d, fp);
|
|
70
|
+
pinned += r.deps.length; skipped += r.skipped;
|
|
71
|
+
if (r.deps.length || r.skipped) sources.push("pyproject.toml");
|
|
72
|
+
} catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `pyproject.toml parse failed: ${e.message}` }); }
|
|
73
|
+
}
|
|
74
|
+
if (g.names.has("requirements.txt")) {
|
|
75
|
+
const fp = path.join(g.dir, "requirements.txt");
|
|
76
|
+
try {
|
|
77
|
+
const r = P.parseRequirementsTxt(fp);
|
|
78
|
+
for (const d of r.deps) add(d, fp);
|
|
79
|
+
pinned += r.deps.length; skipped += r.skipped;
|
|
80
|
+
sources.push("requirements.txt");
|
|
81
|
+
for (const miss of (r.missing || [])) warnings.push({ type: "missing-include", manifestPath: fp, message: `referenced requirements file not found: ${miss}` });
|
|
82
|
+
} catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `requirements.txt parse failed: ${e.message}` }); }
|
|
83
|
+
}
|
|
84
|
+
if (sources.length) {
|
|
85
|
+
warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no lockfile (${sources.join(" + ")}) — best-effort: ${pinned} pinned, ${skipped} range(s) skipped` });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { deps: out, warnings };
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
coordKey(d) { return coordKeyFor("pypi", "", d.name); },
|
|
92
|
+
formatCoord(d) { return d.name; },
|
|
93
|
+
osvPackageName(d) { return d.name; },
|
|
94
|
+
|
|
95
|
+
async checkRegistry(deps, opts = {}) {
|
|
96
|
+
const { checkPypiRegistryDeps } = require("./pypi/registry");
|
|
97
|
+
return checkPypiRegistryDeps(deps, opts);
|
|
98
|
+
},
|
|
99
|
+
resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
|
|
100
|
+
recipe: require("./recipes").pypi,
|
|
101
|
+
nativeScanners: [],
|
|
102
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/recipes.js — recettes de fix par écosystème pour le report.
|
|
3
|
+
*
|
|
4
|
+
* Extrait de lib/cve-report.js (ECO_RECIPE + snippet helpers). Clés = id de codec
|
|
5
|
+
* (== ecosystemType). Chaque recette : { label, pinSection, pinIntro(cnt),
|
|
6
|
+
* snippet(items), directSection }. `items` = [{ groupId, artifactId, fixVersion }].
|
|
7
|
+
*/
|
|
8
|
+
function esc(s) {
|
|
9
|
+
if (s == null) return "";
|
|
10
|
+
return String(s)
|
|
11
|
+
.replace(/&/g, "&")
|
|
12
|
+
.replace(/</g, "<")
|
|
13
|
+
.replace(/>/g, ">")
|
|
14
|
+
.replace(/"/g, """);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function dependencyManagementSnippet(items) {
|
|
18
|
+
const inner = items.map(it => ` <dependency>
|
|
19
|
+
<groupId>${esc(it.groupId)}</groupId>
|
|
20
|
+
<artifactId>${esc(it.artifactId)}</artifactId>
|
|
21
|
+
<version>${esc(it.fixVersion)}</version>
|
|
22
|
+
</dependency>`).join("\n");
|
|
23
|
+
return `<dependencyManagement>
|
|
24
|
+
<dependencies>
|
|
25
|
+
${inner}
|
|
26
|
+
</dependencies>
|
|
27
|
+
</dependencyManagement>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function npmOverridesSnippet(items) {
|
|
31
|
+
const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
|
|
32
|
+
return `{
|
|
33
|
+
"overrides": {
|
|
34
|
+
${lines}
|
|
35
|
+
}
|
|
36
|
+
}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function yarnResolutionsSnippet(items) {
|
|
40
|
+
const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
|
|
41
|
+
return `{
|
|
42
|
+
"resolutions": {
|
|
43
|
+
${lines}
|
|
44
|
+
}
|
|
45
|
+
}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const maven = {
|
|
49
|
+
label: "Maven",
|
|
50
|
+
pinSection: "A. Pin vulnerable transitives in <dependencyManagement>",
|
|
51
|
+
pinIntro: cnt => `Paste into the root POM to immediately neutralise ${cnt} transitive vulnerabilit${cnt > 1 ? "ies" : "y"}:`,
|
|
52
|
+
snippet: dependencyManagementSnippet,
|
|
53
|
+
directSection: "B. Or update the direct dependencies pulling them in",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const npm = {
|
|
57
|
+
label: "npm",
|
|
58
|
+
pinSection: "A. Pin vulnerable transitives via npm overrides",
|
|
59
|
+
pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>npm install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
|
|
60
|
+
snippet: npmOverridesSnippet,
|
|
61
|
+
directSection: "B. Or update the direct dependencies (and run npm install)",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const yarn = {
|
|
65
|
+
label: "Yarn",
|
|
66
|
+
pinSection: "A. Pin vulnerable transitives via yarn resolutions",
|
|
67
|
+
pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>yarn install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
|
|
68
|
+
snippet: yarnResolutionsSnippet,
|
|
69
|
+
directSection: "B. Or update the direct dependencies (and run yarn install)",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function composerRequireSnippet(items) {
|
|
73
|
+
return items.map(it => `composer require ${it.groupId ? it.groupId + "/" : ""}${it.artifactId}:^${esc(it.fixVersion)}`).join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const composer = {
|
|
77
|
+
label: "Composer",
|
|
78
|
+
pinSection: "A. Update the abandoned / vulnerable packages",
|
|
79
|
+
pinIntro: cnt => `Run for the ${cnt} affected package${cnt > 1 ? "s" : ""}, then commit the updated <code>composer.lock</code>:`,
|
|
80
|
+
snippet: composerRequireSnippet,
|
|
81
|
+
directSection: "B. Or bump them in composer.json and run composer update",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function pipInstallSnippet(items) {
|
|
85
|
+
return items.map(it => `pip install '${esc(it.artifactId)}>=${esc(it.fixVersion)}'`).join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const pypi = {
|
|
89
|
+
label: "PyPI",
|
|
90
|
+
pinSection: "A. Upgrade the affected packages",
|
|
91
|
+
pinIntro: cnt => `Upgrade the ${cnt} affected package${cnt > 1 ? "s" : ""}, then re-lock (poetry lock / pip-compile):`,
|
|
92
|
+
snippet: pipInstallSnippet,
|
|
93
|
+
directSection: "B. Or bump them in pyproject.toml / requirements.txt and re-lock",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function dotnetAddSnippet(items) {
|
|
97
|
+
return items.map(it => `dotnet add package ${esc(it.artifactId)} --version ${esc(it.fixVersion)}`).join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const nuget = {
|
|
101
|
+
label: "NuGet",
|
|
102
|
+
pinSection: "A. Update the affected packages",
|
|
103
|
+
pinIntro: cnt => `Run for the ${cnt} affected package${cnt > 1 ? "s" : ""} (or bump <code>Directory.Packages.props</code> under Central Package Management):`,
|
|
104
|
+
snippet: dotnetAddSnippet,
|
|
105
|
+
directSection: "B. Then restore and commit packages.lock.json",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
module.exports = { maven, npm, yarn, composer, pypi, nuget, dependencyManagementSnippet, npmOverridesSnippet, yarnResolutionsSnippet, composerRequireSnippet, pipInstallSnippet, dotnetAddSnippet };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/select.js — résout la liste des codecs actifs depuis le flag
|
|
3
|
+
* --ecosystem et les flags --no-<id>.
|
|
4
|
+
*
|
|
5
|
+
* requested : "auto" | "all" | "maven" | "maven,npm,pypi" | (legacy "both"|"npm")
|
|
6
|
+
* available : ids candidats — pour "auto", les codecs détectés ; sinon tous.
|
|
7
|
+
* flags : { noCodecs: ["npm", ...], noJs: bool }
|
|
8
|
+
*/
|
|
9
|
+
function resolveActiveCodecs(requested, available, flags = {}) {
|
|
10
|
+
const { noCodecs = [], noJs = false } = flags;
|
|
11
|
+
const req = String(requested || "auto").toLowerCase();
|
|
12
|
+
let active;
|
|
13
|
+
if (req === "auto" || req === "all") {
|
|
14
|
+
active = [...available];
|
|
15
|
+
} else if (req === "both") {
|
|
16
|
+
// rétro-compat : "both" == maven + npm/yarn
|
|
17
|
+
active = available.filter(id => ["maven", "npm", "yarn"].includes(id));
|
|
18
|
+
} else {
|
|
19
|
+
const wanted = req.split(",").map(s => s.trim()).filter(Boolean);
|
|
20
|
+
active = available.filter(id => wanted.includes(id));
|
|
21
|
+
}
|
|
22
|
+
const excluded = new Set(noCodecs);
|
|
23
|
+
if (noJs) { excluded.add("npm"); excluded.add("yarn"); }
|
|
24
|
+
return active.filter(id => !excluded.has(id));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { resolveActiveCodecs };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/yarn.codec.js — codec Yarn.
|
|
3
|
+
*
|
|
4
|
+
* Yarn partage tout avec npm (même registre npmjs, même OSV ecosystem "npm",
|
|
5
|
+
* mêmes coordKeys "npm:<name>"). Il n'existe que pour fournir son label et sa
|
|
6
|
+
* recette (`resolutions`) au report. Le scan JS est fait UNE SEULE FOIS par le
|
|
7
|
+
* codec npm (qui ramasse package-lock ET yarn.lock) — yarn.collect est un no-op
|
|
8
|
+
* pour éviter le double scan ; le registre des codecs ne l'appelle pas.
|
|
9
|
+
*/
|
|
10
|
+
const npm = require("./npm.codec");
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
...npm,
|
|
14
|
+
id: "yarn",
|
|
15
|
+
label: "Yarn",
|
|
16
|
+
recipe: require("./recipes").yarn,
|
|
17
|
+
collectViaSibling: "npm", // documente que le scan est fait par le codec npm
|
|
18
|
+
collect: async () => ({ deps: new Map(), warnings: [] }),
|
|
19
|
+
};
|
package/lib/core.js
CHANGED
|
@@ -99,10 +99,20 @@ async function parsePom(pomPath, allPomMetadata) {
|
|
|
99
99
|
groupId,
|
|
100
100
|
artifactId,
|
|
101
101
|
version,
|
|
102
|
+
// Maven model expressions for the current project. These are built-ins,
|
|
103
|
+
// not <properties> — ${project.version} always means THIS project's
|
|
104
|
+
// version (never overridable by a property or an imported BOM). The
|
|
105
|
+
// pom.* aliases mirror Maven's legacy synonyms.
|
|
102
106
|
localVars: {
|
|
103
107
|
"project.groupId": groupId,
|
|
104
108
|
"project.artifactId": artifactId,
|
|
105
109
|
"project.version": version,
|
|
110
|
+
"pom.groupId": groupId,
|
|
111
|
+
"pom.artifactId": artifactId,
|
|
112
|
+
"pom.version": version,
|
|
113
|
+
...(parentInfo?.groupId ? { "project.parent.groupId": parentInfo.groupId } : {}),
|
|
114
|
+
...(parentInfo?.artifactId ? { "project.parent.artifactId": parentInfo.artifactId } : {}),
|
|
115
|
+
...(parentInfo?.version ? { "project.parent.version": parentInfo.version } : {}),
|
|
106
116
|
},
|
|
107
117
|
};
|
|
108
118
|
|
|
@@ -199,7 +209,31 @@ async function getAllInheritedProps(pomPath, allPomMetadata, cache) {
|
|
|
199
209
|
merged.dependencyManagement.push(...imported.dependencyManagement);
|
|
200
210
|
}
|
|
201
211
|
|
|
202
|
-
|
|
212
|
+
// Cache the (still-mutating) object BEFORE recursing into the parent so a
|
|
213
|
+
// malformed parent cycle (A→B→A) returns this partial result instead of
|
|
214
|
+
// recursing forever. `merged` is mutated in place afterwards, so the cached
|
|
215
|
+
// reference ends up fully populated.
|
|
216
|
+
cache[pomPath] = merged;
|
|
217
|
+
|
|
218
|
+
// Inherit <properties> from a LOCAL parent POM as the BASE layer — the child
|
|
219
|
+
// overrides the parent, exactly like native Maven. (dependencyManagement and
|
|
220
|
+
// parent <dependencies> need no special handling here: collectResolvedDeps
|
|
221
|
+
// already merges them across the whole tree by g:a.) External parents
|
|
222
|
+
// (e.g. spring-boot-starter-parent) aren't in the source tree, so their
|
|
223
|
+
// properties can't be inherited without a network fetch — those versions
|
|
224
|
+
// still surface as unresolved, as before.
|
|
225
|
+
const parentPath = resolveParentPath(pomPath, parentInfo, allPomMetadata);
|
|
226
|
+
if (parentPath && parentPath !== pomPath) {
|
|
227
|
+
const parentMerged = await getAllInheritedProps(parentPath, allPomMetadata, cache);
|
|
228
|
+
merged.properties = { ...parentMerged.properties, ...merged.properties };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Built-in project.* coordinates (${project.version}, ${project.groupId}, …).
|
|
232
|
+
// Applied LAST so the current POM's own values win — even over an inherited
|
|
233
|
+
// parent property or an imported BOM's project.* — letting an intra-reactor
|
|
234
|
+
// dep declared "<version>${project.version}</version>" resolve to THIS
|
|
235
|
+
// module's version.
|
|
236
|
+
merged.properties = { ...merged.properties, ...(meta.localVars || {}) };
|
|
203
237
|
|
|
204
238
|
cache[pomPath] = merged;
|
|
205
239
|
return merged;
|
package/lib/cve-match.js
CHANGED
|
@@ -5,17 +5,28 @@
|
|
|
5
5
|
const { coord } = require("./core");
|
|
6
6
|
const { compareMavenVersions, isVersionAffected } = require("./maven-version");
|
|
7
7
|
const { resolveTransitiveDeps } = require("./transitive");
|
|
8
|
+
const { makeDepRecord } = require("./dep-record");
|
|
8
9
|
|
|
9
10
|
const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOWN: 0 };
|
|
10
11
|
|
|
11
12
|
function resolveDepVersion(rawVersion, allProps) {
|
|
12
13
|
if (!rawVersion) return null;
|
|
13
14
|
// Resolve ${prop} references using the merged property map for this POM.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
// Maven interpolates recursively (e.g. ${project.version} → ${revision} →
|
|
16
|
+
// "1.0"), so we re-substitute until the string stops changing. Bounded +
|
|
17
|
+
// stop-on-no-progress guards against property cycles. Unknown refs are left
|
|
18
|
+
// verbatim, so they still surface as "unresolved-versions" in chapter 0.
|
|
19
|
+
let v = String(rawVersion);
|
|
20
|
+
for (let i = 0; i < 10 && v.includes("${"); i++) {
|
|
21
|
+
const next = v.replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
|
|
22
|
+
const val = allProps?.[k];
|
|
23
|
+
if (Array.isArray(val)) return val[0];
|
|
24
|
+
return val != null ? val : m;
|
|
25
|
+
});
|
|
26
|
+
if (next === v) break; // no progress → remaining refs are unresolvable
|
|
27
|
+
v = next;
|
|
28
|
+
}
|
|
29
|
+
return v;
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
/**
|
|
@@ -49,7 +60,7 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
|
49
60
|
// A concrete (resolved, non-property) version worth scanning on its own.
|
|
50
61
|
const concrete = v && !/\$\{/.test(v) ? v : null;
|
|
51
62
|
if (!existing) {
|
|
52
|
-
out.set(key, {
|
|
63
|
+
out.set(key, makeDepRecord({ ecosystem: "maven", namespace: g, name: a, version: v || null, manifestPath: pomPath, scope, isDev }));
|
|
53
64
|
} else {
|
|
54
65
|
if (!existing.pomPaths.includes(pomPath)) existing.pomPaths.push(pomPath);
|
|
55
66
|
if (existing.scope === "test" && scope !== "test") existing.scope = scope;
|
|
@@ -83,11 +94,7 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
|
83
94
|
if (deps2Exclude && deps2Exclude.test(p.groupId)) continue;
|
|
84
95
|
const key = `${p.groupId}:${p.artifactId}`;
|
|
85
96
|
if (!out.has(key)) {
|
|
86
|
-
out.set(key, {
|
|
87
|
-
groupId: p.groupId, artifactId: p.artifactId, version: p.version || null,
|
|
88
|
-
versions: p.version && !/\$\{/.test(p.version) ? [p.version] : [],
|
|
89
|
-
scope: "parent", pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven",
|
|
90
|
-
});
|
|
97
|
+
out.set(key, makeDepRecord({ ecosystem: "maven", namespace: p.groupId, name: p.artifactId, version: p.version || null, manifestPath: pomPath, scope: "parent" }));
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
100
|
|
package/lib/cve-report.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
const fs = require("fs");
|
|
9
9
|
const path = require("path");
|
|
10
|
+
const { getCodec, allCodecs, ORDER } = require("./codecs");
|
|
10
11
|
|
|
11
12
|
// CWE-id → short human name. Loaded once at module init. Unknown CWEs fall
|
|
12
13
|
// back to the raw id (with a "—" placeholder name where the row asks for one).
|
|
@@ -348,14 +349,24 @@ function makeRelative(absPath, srcRoot) {
|
|
|
348
349
|
} catch { return absPath; }
|
|
349
350
|
}
|
|
350
351
|
|
|
352
|
+
// Présentation des coordonnées, déléguée au codec. Règle générique : le codec
|
|
353
|
+
// fournit la coord canonique (maven "g:a", npm "name", composer "vendor/pkg"…) ;
|
|
354
|
+
// Maven la porte déjà son namespace, les autres reçoivent un tag d'écosystème
|
|
355
|
+
// ("npm:", "composer:", …) pour lever l'ambiguïté dans un report multi-eco.
|
|
356
|
+
function codecFor(dep) { return getCodec(dep?.ecosystemType) || getCodec(dep?.ecosystem); }
|
|
357
|
+
|
|
351
358
|
function depDisplayParts(dep) {
|
|
352
|
-
|
|
353
|
-
return { groupLine: `${dep?.groupId ?? ""}:`, nameLine: dep?.artifactId || "" };
|
|
359
|
+
const c = codecFor(dep);
|
|
360
|
+
if (!c) return { groupLine: `${dep?.groupId ?? ""}:`, nameLine: dep?.artifactId || "" };
|
|
361
|
+
if (dep.ecosystem === "maven") return { groupLine: `${dep.namespace || dep.groupId || ""}:`, nameLine: dep.name || dep.artifactId || "" };
|
|
362
|
+
return { groupLine: `${dep.ecosystem}:`, nameLine: c.formatCoord(dep) };
|
|
354
363
|
}
|
|
355
364
|
|
|
356
365
|
function depDisplayName(dep) {
|
|
357
|
-
|
|
358
|
-
return `${dep?.groupId ?? ""}:${dep?.artifactId || ""}`;
|
|
366
|
+
const c = codecFor(dep);
|
|
367
|
+
if (!c) return `${dep?.groupId ?? ""}:${dep?.artifactId || ""}`;
|
|
368
|
+
const coord = c.formatCoord(dep);
|
|
369
|
+
return dep.ecosystem === "maven" ? coord : `${dep.ecosystem}:${coord}`;
|
|
359
370
|
}
|
|
360
371
|
|
|
361
372
|
const REF_LABELS = {
|
|
@@ -626,65 +637,9 @@ function renderCveTable(cveMatches, opts = {}) {
|
|
|
626
637
|
</table>`;
|
|
627
638
|
}
|
|
628
639
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
*/
|
|
633
|
-
function dependencyManagementSnippet(items) {
|
|
634
|
-
const inner = items.map(it => ` <dependency>
|
|
635
|
-
<groupId>${esc(it.groupId)}</groupId>
|
|
636
|
-
<artifactId>${esc(it.artifactId)}</artifactId>
|
|
637
|
-
<version>${esc(it.fixVersion)}</version>
|
|
638
|
-
</dependency>`).join("\n");
|
|
639
|
-
return `<dependencyManagement>
|
|
640
|
-
<dependencies>
|
|
641
|
-
${inner}
|
|
642
|
-
</dependencies>
|
|
643
|
-
</dependencyManagement>`;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function npmOverridesSnippet(items) {
|
|
647
|
-
const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
|
|
648
|
-
return `{
|
|
649
|
-
"overrides": {
|
|
650
|
-
${lines}
|
|
651
|
-
}
|
|
652
|
-
}`;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function yarnResolutionsSnippet(items) {
|
|
656
|
-
const lines = items.map(it => ` "${esc(it.artifactId)}": "${esc(it.fixVersion)}"`).join(",\n");
|
|
657
|
-
return `{
|
|
658
|
-
"resolutions": {
|
|
659
|
-
${lines}
|
|
660
|
-
}
|
|
661
|
-
}`;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// Per-ecosystem fix-recipe metadata. Keys correspond to ecosystemType values.
|
|
665
|
-
const ECO_RECIPE = {
|
|
666
|
-
maven: {
|
|
667
|
-
label: "Maven",
|
|
668
|
-
pinSection: "A. Pin vulnerable transitives in <dependencyManagement>",
|
|
669
|
-
pinIntro: cnt => `Paste into the root POM to immediately neutralise ${cnt} transitive vulnerabilit${cnt > 1 ? "ies" : "y"}:`,
|
|
670
|
-
snippet: dependencyManagementSnippet,
|
|
671
|
-
directSection: "B. Or update the direct dependencies pulling them in",
|
|
672
|
-
},
|
|
673
|
-
npm: {
|
|
674
|
-
label: "npm",
|
|
675
|
-
pinSection: "A. Pin vulnerable transitives via npm overrides",
|
|
676
|
-
pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>npm install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
|
|
677
|
-
snippet: npmOverridesSnippet,
|
|
678
|
-
directSection: "B. Or update the direct dependencies (and run npm install)",
|
|
679
|
-
},
|
|
680
|
-
yarn: {
|
|
681
|
-
label: "Yarn",
|
|
682
|
-
pinSection: "A. Pin vulnerable transitives via yarn resolutions",
|
|
683
|
-
pinIntro: cnt => `Add to the root <code>package.json</code> and run <code>yarn install</code> to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
|
|
684
|
-
snippet: yarnResolutionsSnippet,
|
|
685
|
-
directSection: "B. Or update the direct dependencies (and run yarn install)",
|
|
686
|
-
},
|
|
687
|
-
};
|
|
640
|
+
// Per-ecosystem fix-recipe metadata, now owned by the codecs (lib/codecs/recipes.js).
|
|
641
|
+
// Keys correspond to ecosystemType values.
|
|
642
|
+
const ECO_RECIPE = require("./codecs/recipes");
|
|
688
643
|
|
|
689
644
|
/**
|
|
690
645
|
* From CVE matches (split direct/transitive) + outdatedResults + resolvedDeps,
|
|
@@ -790,7 +745,9 @@ function renderFixRecommendations(recos, allMatches = [], outdatedResults = [])
|
|
|
790
745
|
}
|
|
791
746
|
|
|
792
747
|
// Group by ecosystemType so each tool gets its own pin/update advice.
|
|
793
|
-
|
|
748
|
+
// Buckets + order come from the codec registry (new ecosystems slot in).
|
|
749
|
+
const byEco = {};
|
|
750
|
+
for (const id of ORDER) byEco[id] = { trans: [], directs: [] };
|
|
794
751
|
for (const t of recos.transitivesList) {
|
|
795
752
|
const et = ecoTypeOf(t);
|
|
796
753
|
(byEco[et] || byEco.maven).trans.push(t);
|
|
@@ -800,7 +757,7 @@ function renderFixRecommendations(recos, allMatches = [], outdatedResults = [])
|
|
|
800
757
|
(byEco[et] || byEco.maven).directs.push(d);
|
|
801
758
|
}
|
|
802
759
|
|
|
803
|
-
const ecoOrder =
|
|
760
|
+
const ecoOrder = ORDER;
|
|
804
761
|
const out = [directsSection];
|
|
805
762
|
let letterIdx = 0;
|
|
806
763
|
for (const eco of ecoOrder) {
|
|
@@ -1430,7 +1387,7 @@ function pickTopCriticalMatches(prodMatches, retireMatches, n) {
|
|
|
1430
1387
|
const out = [];
|
|
1431
1388
|
for (const m of all) {
|
|
1432
1389
|
const dep = m.dep;
|
|
1433
|
-
const k = `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
|
|
1390
|
+
const k = dep.coordKey || `${dep.ecosystem === "npm" ? "npm:" : dep.groupId + ":"}${dep.artifactId}`;
|
|
1434
1391
|
if (seen.has(k)) continue;
|
|
1435
1392
|
seen.add(k);
|
|
1436
1393
|
out.push(m);
|
|
@@ -1459,12 +1416,17 @@ function renderFalsePositives(matches) {
|
|
|
1459
1416
|
return intro + renderCveTable(matches);
|
|
1460
1417
|
}
|
|
1461
1418
|
|
|
1462
|
-
|
|
1463
|
-
|
|
1419
|
+
// Derived from the codec registry. Overrides keep the exact legacy wording where
|
|
1420
|
+
// a codec's plain label/lockfile isn't what the report historically showed.
|
|
1421
|
+
const ECO_LABEL_OVERRIDE = { npm: "npm (package-lock)" };
|
|
1422
|
+
const ECO_MANIFEST_OVERRIDE = { maven: "pom.xml", npm: "package-lock.json", yarn: "yarn.lock" };
|
|
1423
|
+
const ECO_LABELS = Object.fromEntries(allCodecs().map(c => [c.id, ECO_LABEL_OVERRIDE[c.id] || c.label]));
|
|
1424
|
+
const ECO_MANIFEST_KIND = Object.fromEntries(allCodecs().map(c => [c.id, ECO_MANIFEST_OVERRIDE[c.id] || c.manifestNames[c.manifestNames.length - 1]]));
|
|
1464
1425
|
|
|
1465
1426
|
function renderCveBySectionByEco(matches, chapterPrefix, srcRoot) {
|
|
1466
|
-
const ecoOrder =
|
|
1467
|
-
const byEco = {
|
|
1427
|
+
const ecoOrder = ORDER;
|
|
1428
|
+
const byEco = {};
|
|
1429
|
+
for (const id of ORDER) byEco[id] = [];
|
|
1468
1430
|
for (const m of matches) {
|
|
1469
1431
|
const et = m.dep?.ecosystemType || (m.dep?.ecosystem === "npm" ? "npm" : "maven");
|
|
1470
1432
|
(byEco[et] || byEco.maven).push(m);
|
|
@@ -1843,5 +1805,7 @@ module.exports = {
|
|
|
1843
1805
|
generateHtmlReport,
|
|
1844
1806
|
generateWordReport,
|
|
1845
1807
|
writeReports,
|
|
1808
|
+
depDisplayName,
|
|
1809
|
+
depDisplayParts,
|
|
1846
1810
|
esc,
|
|
1847
1811
|
};
|