fad-checker 2.2.4 → 2.3.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/README.md +7 -7
- package/fad-checker.js +59 -22
- package/lib/codecs/composer.codec.js +4 -1
- package/lib/codecs/go.codec.js +4 -1
- package/lib/codecs/gradle/catalog.js +91 -0
- package/lib/codecs/gradle/parse.js +211 -0
- package/lib/codecs/gradle.codec.js +155 -0
- package/lib/codecs/index.js +3 -2
- package/lib/codecs/maven.codec.js +1 -1
- package/lib/codecs/npm/collect.js +3 -0
- package/lib/codecs/npm.codec.js +1 -1
- package/lib/codecs/nuget.codec.js +8 -2
- package/lib/codecs/pypi.codec.js +37 -14
- package/lib/codecs/recipes.js +18 -1
- package/lib/codecs/ruby.codec.js +3 -1
- package/lib/cpe.js +39 -2
- package/lib/cve-match.js +20 -1
- package/lib/cve-report.js +57 -6
- package/lib/deps-descriptor.js +2 -2
- package/lib/osv-db.js +1 -1
- package/lib/retire.js +1 -1
- package/package.json +2 -3
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/codecs/gradle.codec.js — codec Gradle.
|
|
3
|
+
*
|
|
4
|
+
* A Gradle dependency IS a Maven coordinate resolved from Maven repositories, so records
|
|
5
|
+
* are emitted with `ecosystem: "maven"` (bare `g:a` coordKey → the Maven CVE-index, OSV
|
|
6
|
+
* "Maven", transitive resolution, import-BOM backfill, outdated and EOL all treat them
|
|
7
|
+
* unchanged) but `ecosystemType: "gradle"` so the report gives them a dedicated "Gradle"
|
|
8
|
+
* chapter and a Gradle fix recipe (cve-report's codecFor() resolves by ecosystemType).
|
|
9
|
+
*
|
|
10
|
+
* Parsing is lockfile-first (gradle.lockfile = resolved, authoritative) and otherwise
|
|
11
|
+
* best-effort over the build scripts + version catalog (see ./gradle/parse.js). `platform()`
|
|
12
|
+
* BOMs are surfaced in `_gradle.platformBoms` for the orchestrator to feed into the existing
|
|
13
|
+
* lib/maven-bom.js backfill, mirroring Maven's `<scope>import</scope>` BOMs.
|
|
14
|
+
*
|
|
15
|
+
* @author: N.BRAUN
|
|
16
|
+
* @email: pp9ping@gmail.com
|
|
17
|
+
*/
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const { makeDepRecord, coordKeyFor } = require("../dep-record");
|
|
21
|
+
const { parseGradleLockfile, parseGradleProperties, parseBuildScript } = require("./gradle/parse");
|
|
22
|
+
const { parseVersionCatalog } = require("./gradle/catalog");
|
|
23
|
+
|
|
24
|
+
const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "out", "target", "build", ".gradle", ".mvn", "bin"]);
|
|
25
|
+
const BUILD_FILES = new Set(["build.gradle", "build.gradle.kts"]);
|
|
26
|
+
const SETTINGS_FILES = new Set(["settings.gradle", "settings.gradle.kts"]);
|
|
27
|
+
const DETECT_HITS = new Set([...BUILD_FILES, ...SETTINGS_FILES, "gradle.lockfile", "libs.versions.toml"]);
|
|
28
|
+
|
|
29
|
+
function readSafe(fp) { try { return fs.readFileSync(fp, "utf8"); } catch { return ""; } }
|
|
30
|
+
function inBuildSrc(fp) { return /(^|[\\/])buildSrc[\\/]/.test(fp); }
|
|
31
|
+
|
|
32
|
+
function dirFilter(dir, opts) {
|
|
33
|
+
return require("../path-filter").makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: SKIP, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One walk → classify every relevant Gradle file.
|
|
37
|
+
function walkGradle(dir, skipDir) {
|
|
38
|
+
const buildFiles = [], catalogFiles = [], propsFiles = [];
|
|
39
|
+
const lockByDir = new Map();
|
|
40
|
+
const stack = [dir];
|
|
41
|
+
while (stack.length) {
|
|
42
|
+
const cur = stack.pop();
|
|
43
|
+
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
const p = path.join(cur, e.name);
|
|
46
|
+
if (e.isDirectory()) { if (!skipDir(p, e.name)) stack.push(p); continue; }
|
|
47
|
+
if (!e.isFile()) continue;
|
|
48
|
+
if (BUILD_FILES.has(e.name)) buildFiles.push(p);
|
|
49
|
+
else if (SETTINGS_FILES.has(e.name)) { /* structure only; not a dep source */ }
|
|
50
|
+
else if (e.name === "gradle.lockfile") lockByDir.set(cur, p);
|
|
51
|
+
else if (e.name === "libs.versions.toml" || e.name.endsWith(".versions.toml")) catalogFiles.push(p);
|
|
52
|
+
else if (e.name === "gradle.properties") propsFiles.push(p);
|
|
53
|
+
else if (e.name.endsWith(".gradle.kts") || e.name.endsWith(".gradle")) buildFiles.push(p); // precompiled convention plugins (buildSrc)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { buildFiles, catalogFiles, propsFiles, lockByDir };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Merge every version catalog in the tree into one resolution table (root + buildSrc).
|
|
60
|
+
function mergeCatalogs(files) {
|
|
61
|
+
const merged = { versions: {}, libraries: {}, plugins: {}, _byAccessor: {} };
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
const c = parseVersionCatalog(readSafe(f));
|
|
64
|
+
Object.assign(merged.versions, c.versions);
|
|
65
|
+
Object.assign(merged.libraries, c.libraries);
|
|
66
|
+
Object.assign(merged.plugins, c.plugins);
|
|
67
|
+
Object.assign(merged._byAccessor, c._byAccessor);
|
|
68
|
+
}
|
|
69
|
+
return merged;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
id: "gradle",
|
|
74
|
+
label: "Gradle",
|
|
75
|
+
osvEcosystem: "Maven",
|
|
76
|
+
manifestNames: ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "gradle.lockfile", "*.versions.toml"],
|
|
77
|
+
|
|
78
|
+
detect(dir) {
|
|
79
|
+
const skipDir = (p, name) => SKIP.has(name);
|
|
80
|
+
const stack = [dir];
|
|
81
|
+
while (stack.length) {
|
|
82
|
+
const cur = stack.pop();
|
|
83
|
+
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
84
|
+
for (const e of entries) {
|
|
85
|
+
if (e.isFile() && (DETECT_HITS.has(e.name) || e.name.endsWith(".versions.toml"))) return true;
|
|
86
|
+
if (e.isDirectory() && !skipDir(path.join(cur, e.name), e.name)) stack.push(path.join(cur, e.name));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async collect(dir, opts = {}) {
|
|
93
|
+
const { deps2Exclude } = opts;
|
|
94
|
+
const skipDir = dirFilter(dir, opts);
|
|
95
|
+
const { buildFiles, catalogFiles, propsFiles, lockByDir } = walkGradle(dir, skipDir);
|
|
96
|
+
|
|
97
|
+
const catalog = mergeCatalogs(catalogFiles);
|
|
98
|
+
const properties = {};
|
|
99
|
+
for (const f of propsFiles) Object.assign(properties, parseGradleProperties(readSafe(f)));
|
|
100
|
+
|
|
101
|
+
const out = new Map();
|
|
102
|
+
const warnings = [];
|
|
103
|
+
const platformBoms = [];
|
|
104
|
+
|
|
105
|
+
const addRec = (d, manifestPath) => {
|
|
106
|
+
if (!d.group || !d.name) return;
|
|
107
|
+
if (deps2Exclude && deps2Exclude.test(d.name)) return;
|
|
108
|
+
const rec = makeDepRecord({ ecosystem: "maven", ecosystemType: "gradle", namespace: d.group, name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev });
|
|
109
|
+
const existing = out.get(rec.coordKey);
|
|
110
|
+
if (!existing) { out.set(rec.coordKey, rec); return; }
|
|
111
|
+
for (const p of rec.manifestPaths) if (!existing.manifestPaths.includes(p)) existing.manifestPaths.push(p);
|
|
112
|
+
for (const v of rec.versions) if (!existing.versions.includes(v)) existing.versions.push(v);
|
|
113
|
+
if (!existing.version && rec.version) existing.version = rec.version;
|
|
114
|
+
if (rec.isDev === false) existing.isDev = false; // prod scope wins over dev
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// gradle.lockfile = authoritative resolved versions (transitives incl.) for its project.
|
|
118
|
+
const lockedDirs = new Set(lockByDir.keys());
|
|
119
|
+
for (const [, fp] of lockByDir) {
|
|
120
|
+
for (const d of parseGradleLockfile(readSafe(fp)).deps) addRec(d, fp);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Build scripts: best-effort deps (skipped for a lock-governed top-level project) +
|
|
124
|
+
// platform() BOMs (always collected — they drive the import-BOM backfill).
|
|
125
|
+
for (const fp of buildFiles) {
|
|
126
|
+
const d = path.dirname(fp);
|
|
127
|
+
const r = parseBuildScript(readSafe(fp), { catalog, properties, kotlin: fp.endsWith(".kts") });
|
|
128
|
+
for (const b of r.platformBoms) if (b.group && b.name && !platformBoms.some(x => x.group === b.group && x.name === b.name)) platformBoms.push(b);
|
|
129
|
+
const lockGoverned = lockedDirs.has(d) && !inBuildSrc(fp);
|
|
130
|
+
if (lockGoverned) continue; // lockfile already provided resolved deps for this dir
|
|
131
|
+
for (const dep of r.deps) addRec(dep, fp);
|
|
132
|
+
if (!inBuildSrc(fp)) warnings.push({ type: "gradle-no-lockfile", manifestPath: fp, message: `no gradle.lockfile next to ${path.relative(dir, fp) || path.basename(fp)} — versions resolved best-effort from the build script + version catalog (dynamic/programmatic deps may be missed). Enable Gradle dependency locking for exact coverage.` });
|
|
133
|
+
for (const u of r.unresolved) warnings.push({ type: "unresolved-versions", manifestPath: fp, message: `could not resolve the version variable for ${u.group}:${u.name} (${u.raw}) — excluded from CVE matching` });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const parsedManifests = [...buildFiles, ...lockByDir.values(), ...catalogFiles, ...propsFiles];
|
|
137
|
+
return { deps: out, warnings, parsedManifests, _gradle: { platformBoms } };
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
coordKey(d) { return coordKeyFor("maven", d.namespace || d.groupId, d.name || d.artifactId); },
|
|
141
|
+
formatCoord(d) { return `${d.namespace || d.groupId}:${d.name || d.artifactId}`; },
|
|
142
|
+
osvPackageName(d) { return `${d.namespace || d.groupId}:${d.name || d.artifactId}`; },
|
|
143
|
+
|
|
144
|
+
async checkRegistry(deps, opts = {}) {
|
|
145
|
+
const outdated = require("../outdated");
|
|
146
|
+
const out = opts.allLibs ? await outdated.checkOutdatedDeps(deps, opts) : [];
|
|
147
|
+
const deprecated = outdated.checkObsoleteDeps(deps);
|
|
148
|
+
return { outdated: out, deprecated };
|
|
149
|
+
},
|
|
150
|
+
resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
|
|
151
|
+
|
|
152
|
+
recipe: require("./recipes").gradle,
|
|
153
|
+
|
|
154
|
+
nativeScanners: [],
|
|
155
|
+
};
|
package/lib/codecs/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const fs = require("fs");
|
|
|
12
12
|
const path = require("path");
|
|
13
13
|
const { assertCodecShape } = require("./codec.interface");
|
|
14
14
|
const maven = require("./maven.codec");
|
|
15
|
+
const gradle = require("./gradle.codec");
|
|
15
16
|
const npm = require("./npm.codec");
|
|
16
17
|
const yarn = require("./yarn.codec");
|
|
17
18
|
const composer = require("./composer.codec");
|
|
@@ -23,10 +24,10 @@ const binary = require("./binary.codec");
|
|
|
23
24
|
|
|
24
25
|
// Ordre stable pour le report (maven, npm, yarn, puis les nouveaux écosystèmes).
|
|
25
26
|
// "binary" (committed native libs, no manifest) reste en dernier.
|
|
26
|
-
const ORDER = ["maven", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby", "binary"];
|
|
27
|
+
const ORDER = ["maven", "gradle", "npm", "yarn", "nuget", "composer", "pypi", "go", "ruby", "binary"];
|
|
27
28
|
|
|
28
29
|
const REGISTRY = new Map();
|
|
29
|
-
for (const c of [maven, npm, yarn, composer, pypi, nuget, go, ruby, binary]) {
|
|
30
|
+
for (const c of [maven, gradle, npm, yarn, composer, pypi, nuget, go, ruby, binary]) {
|
|
30
31
|
assertCodecShape(c);
|
|
31
32
|
REGISTRY.set(c.id, c);
|
|
32
33
|
}
|
|
@@ -65,7 +65,7 @@ module.exports = {
|
|
|
65
65
|
warnings.push(...jarWarnings);
|
|
66
66
|
} catch (e) { warnings.push({ type: "embedded-jar", message: `embedded-jar scan failed: ${e.message}` }); }
|
|
67
67
|
}
|
|
68
|
-
return { deps, warnings, _maven: { store, propsByPom, pomFiles } };
|
|
68
|
+
return { deps, warnings, parsedManifests: pomFiles.slice(), _maven: { store, propsByPom, pomFiles } };
|
|
69
69
|
},
|
|
70
70
|
|
|
71
71
|
coordKey(d) { return coordKeyFor("maven", d.namespace || d.groupId, d.name || d.artifactId); },
|
|
@@ -141,8 +141,10 @@ function collectNpmDeps(rootDir, opts = {}) {
|
|
|
141
141
|
// findJsManifestsAsync walk) to avoid a second, serial tree traversal.
|
|
142
142
|
const manifestGroups = opts.manifestGroups || findJsManifests(rootDir);
|
|
143
143
|
let parsedCount = 0;
|
|
144
|
+
const parsedManifests = [];
|
|
144
145
|
|
|
145
146
|
for (const group of manifestGroups) {
|
|
147
|
+
for (const f of [group.packageJson, group.packageLock, group.yarnLock, group.pnpmLock]) if (f) parsedManifests.push(f);
|
|
146
148
|
const pj = group.packageJson ? safeParse(parsePackageJson, group.packageJson, verbose) : null;
|
|
147
149
|
const pl = group.packageLock ? safeParse(parsePackageLock, group.packageLock, verbose) : null;
|
|
148
150
|
const yl = group.yarnLock ? safeParse(parseYarnLockV1, group.yarnLock, verbose) : null;
|
|
@@ -224,6 +226,7 @@ function collectNpmDeps(rootDir, opts = {}) {
|
|
|
224
226
|
// Backward compat: the Map carries the warnings under a non-enumerable prop
|
|
225
227
|
// so existing callers that iterate `for (const [k,v] of map)` still work.
|
|
226
228
|
Object.defineProperty(out, "warnings", { value: warnings, enumerable: false });
|
|
229
|
+
Object.defineProperty(out, "parsedManifests", { value: parsedManifests, enumerable: false });
|
|
227
230
|
return out;
|
|
228
231
|
}
|
|
229
232
|
|
package/lib/codecs/npm.codec.js
CHANGED
|
@@ -56,6 +56,6 @@ module.exports = {
|
|
|
56
56
|
const skipDir = makeDirFilter({ srcRoot: opts.srcRoot || dir, defaultSkip: DEFAULT_JS_SKIP_DIRS, excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false });
|
|
57
57
|
const manifestGroups = await findJsManifestsAsync(dir, { skipDir });
|
|
58
58
|
const deps = collectNpmDeps(dir, { ...opts, manifestGroups });
|
|
59
|
-
return { deps, warnings: deps.warnings || [] };
|
|
59
|
+
return { deps, warnings: deps.warnings || [], parsedManifests: deps.parsedManifests || [] };
|
|
60
60
|
},
|
|
61
61
|
};
|
|
@@ -51,6 +51,7 @@ module.exports = {
|
|
|
51
51
|
const { deps2Exclude } = opts;
|
|
52
52
|
const out = new Map();
|
|
53
53
|
const warnings = [];
|
|
54
|
+
const parsedManifests = [];
|
|
54
55
|
const add = (d, manifestPath) => {
|
|
55
56
|
if (deps2Exclude && deps2Exclude.test(d.name)) return;
|
|
56
57
|
out.set(coordKeyFor("nuget", "", d.name), makeDepRecord({ ecosystem: "nuget", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev }));
|
|
@@ -58,6 +59,7 @@ module.exports = {
|
|
|
58
59
|
for (const g of findNugetDirs(dir, dirFilter(dir, opts))) {
|
|
59
60
|
if (g.files.includes("packages.lock.json")) {
|
|
60
61
|
const fp = path.join(g.dir, "packages.lock.json");
|
|
62
|
+
parsedManifests.push(fp);
|
|
61
63
|
try { const { deps } = await N.parsePackagesLockJson(fp); for (const d of deps) add(d, fp); }
|
|
62
64
|
catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `packages.lock.json parse failed: ${e.message}` }); }
|
|
63
65
|
continue; // lockfile is authoritative for this directory
|
|
@@ -65,11 +67,14 @@ module.exports = {
|
|
|
65
67
|
// No lockfile → best-effort from .csproj (+CPM) and packages.config + warning.
|
|
66
68
|
let cpm = {};
|
|
67
69
|
if (g.files.includes("Directory.Packages.props")) {
|
|
68
|
-
|
|
70
|
+
const cpmFp = path.join(g.dir, "Directory.Packages.props");
|
|
71
|
+
parsedManifests.push(cpmFp);
|
|
72
|
+
try { cpm = await N.parseDirectoryPackagesProps(cpmFp); } catch { /* ignore */ }
|
|
69
73
|
}
|
|
70
74
|
let pinned = 0, skipped = 0;
|
|
71
75
|
for (const cs of g.csprojs) {
|
|
72
76
|
const fp = path.join(g.dir, cs);
|
|
77
|
+
parsedManifests.push(fp);
|
|
73
78
|
try {
|
|
74
79
|
const { deps, skipped: sk } = await N.parseCsproj(fp, cpm);
|
|
75
80
|
for (const d of deps) { add(d, fp); pinned++; }
|
|
@@ -78,13 +83,14 @@ module.exports = {
|
|
|
78
83
|
}
|
|
79
84
|
if (g.files.includes("packages.config")) {
|
|
80
85
|
const fp = path.join(g.dir, "packages.config");
|
|
86
|
+
parsedManifests.push(fp);
|
|
81
87
|
try { const { deps } = await N.parsePackagesConfig(fp); for (const d of deps) { add(d, fp); pinned++; } } catch { /* ignore */ }
|
|
82
88
|
}
|
|
83
89
|
if (g.csprojs.length || g.files.includes("packages.config")) {
|
|
84
90
|
warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no packages.lock.json — best-effort: ${pinned} pinned, ${skipped} floating/unresolved skipped (enable RestorePackagesWithLockFile)` });
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
|
-
return { deps: out, warnings };
|
|
93
|
+
return { deps: out, warnings, parsedManifests };
|
|
88
94
|
},
|
|
89
95
|
|
|
90
96
|
coordKey(d) { return coordKeyFor("nuget", "", d.name); },
|
package/lib/codecs/pypi.codec.js
CHANGED
|
@@ -22,7 +22,13 @@ const LOCKS = [
|
|
|
22
22
|
["pdm.lock", P.parsePdmLock],
|
|
23
23
|
];
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// The whole requirements*.txt family, not just the literal "requirements.txt": projects
|
|
26
|
+
// using pip-compile/Dependabot keep their actual pins in requirements-pinned.txt (or
|
|
27
|
+
// similar) while requirements.txt holds only ranges. requirements-(dev|test|lint|docs|…)
|
|
28
|
+
// are dev/optional tooling.
|
|
29
|
+
const REQ_RE = /^requirements.*\.txt$/i;
|
|
30
|
+
const DEV_REQ_RE = /requirements[-_.](dev|test|tests|lint|docs?|typing|type|check|ci|mypy|style|format)\b/i;
|
|
31
|
+
const reqFilesIn = names => [...names].filter(n => REQ_RE.test(n)).sort();
|
|
26
32
|
|
|
27
33
|
function findPyDirs(dir, skipDir = (child, name) => SKIP.has(name)) {
|
|
28
34
|
const groups = [];
|
|
@@ -31,7 +37,8 @@ function findPyDirs(dir, skipDir = (child, name) => SKIP.has(name)) {
|
|
|
31
37
|
const cur = stack.pop();
|
|
32
38
|
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
33
39
|
const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
|
|
34
|
-
|
|
40
|
+
const qualifies = LOCKS.some(l => names.has(l[0])) || names.has("pyproject.toml") || reqFilesIn(names).length > 0;
|
|
41
|
+
if (qualifies) groups.push({ dir: cur, names });
|
|
35
42
|
for (const e of entries) if (e.isDirectory() && !skipDir(path.join(cur, e.name), e.name)) stack.push(path.join(cur, e.name));
|
|
36
43
|
}
|
|
37
44
|
return groups;
|
|
@@ -53,46 +60,62 @@ module.exports = {
|
|
|
53
60
|
const { ignoreTest, deps2Exclude } = opts;
|
|
54
61
|
const out = new Map();
|
|
55
62
|
const warnings = [];
|
|
63
|
+
const parsedManifests = [];
|
|
56
64
|
const add = (d, manifestPath) => {
|
|
57
65
|
if (ignoreTest && d.isDev) return;
|
|
58
66
|
if (deps2Exclude && deps2Exclude.test(d.name)) return;
|
|
59
|
-
|
|
67
|
+
const key = coordKeyFor("pypi", "", d.name);
|
|
68
|
+
const rec = makeDepRecord({ ecosystem: "pypi", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev });
|
|
69
|
+
const existing = out.get(key);
|
|
70
|
+
if (!existing) { out.set(key, rec); return; }
|
|
71
|
+
// Same package across several requirements files: merge manifests, keep a concrete
|
|
72
|
+
// version, and let prod win over dev (declared prod anywhere ⇒ prod).
|
|
73
|
+
for (const p of rec.manifestPaths) if (!existing.manifestPaths.includes(p)) existing.manifestPaths.push(p);
|
|
74
|
+
if (!existing.version && rec.version) { existing.version = rec.version; if (!existing.versions.includes(rec.version)) existing.versions.push(rec.version); }
|
|
75
|
+
if (existing.isDev && !rec.isDev) { existing.isDev = false; existing.scope = rec.scope || "prod"; }
|
|
60
76
|
};
|
|
61
77
|
for (const g of findPyDirs(dir, pyDirFilter(dir, opts))) {
|
|
62
78
|
const lock = LOCKS.find(([n]) => g.names.has(n));
|
|
63
79
|
if (lock) {
|
|
64
80
|
const fp = path.join(g.dir, lock[0]);
|
|
81
|
+
parsedManifests.push(fp);
|
|
65
82
|
try { const { deps } = lock[1](fp); for (const d of deps) add(d, fp); }
|
|
66
83
|
catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${lock[0]} parse failed: ${e.message}` }); }
|
|
67
84
|
continue; // lockfile is authoritative for this directory
|
|
68
85
|
}
|
|
69
|
-
// No lockfile → best-effort from pyproject.toml and
|
|
70
|
-
let
|
|
86
|
+
// No lockfile → best-effort from pyproject.toml and EVERY requirements*.txt file.
|
|
87
|
+
let skipped = 0;
|
|
71
88
|
const sources = [];
|
|
89
|
+
const before = out.size;
|
|
72
90
|
if (g.names.has("pyproject.toml")) {
|
|
73
91
|
const fp = path.join(g.dir, "pyproject.toml");
|
|
92
|
+
parsedManifests.push(fp);
|
|
74
93
|
try {
|
|
75
94
|
const r = P.parsePyprojectToml(fp);
|
|
76
95
|
for (const d of r.deps) add(d, fp);
|
|
77
|
-
|
|
96
|
+
skipped += r.skipped;
|
|
78
97
|
if (r.deps.length || r.skipped) sources.push("pyproject.toml");
|
|
79
98
|
} catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `pyproject.toml parse failed: ${e.message}` }); }
|
|
80
99
|
}
|
|
81
|
-
|
|
82
|
-
const fp = path.join(g.dir,
|
|
100
|
+
for (const rf of reqFilesIn(g.names)) {
|
|
101
|
+
const fp = path.join(g.dir, rf);
|
|
102
|
+
parsedManifests.push(fp);
|
|
103
|
+
const devFile = DEV_REQ_RE.test(rf);
|
|
83
104
|
try {
|
|
84
105
|
const r = P.parseRequirementsTxt(fp);
|
|
85
|
-
for (const d of r.deps) add(d, fp);
|
|
86
|
-
|
|
87
|
-
sources.push(
|
|
106
|
+
for (const d of r.deps) add(devFile ? { ...d, isDev: true, scope: "dev" } : d, fp);
|
|
107
|
+
skipped += r.skipped;
|
|
108
|
+
sources.push(rf);
|
|
88
109
|
for (const miss of (r.missing || [])) warnings.push({ type: "missing-include", manifestPath: fp, message: `referenced requirements file not found: ${miss}` });
|
|
89
|
-
} catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message:
|
|
110
|
+
} catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${rf} parse failed: ${e.message}` }); }
|
|
90
111
|
}
|
|
91
112
|
if (sources.length) {
|
|
92
|
-
|
|
113
|
+
const pinned = out.size - before;
|
|
114
|
+
const shownSrc = sources.length > 4 ? `${sources.slice(0, 4).join(" + ")} + ${sources.length - 4} more` : sources.join(" + ");
|
|
115
|
+
warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no lockfile (${shownSrc}) — best-effort: ${pinned} pinned, ${skipped} range(s) skipped` });
|
|
93
116
|
}
|
|
94
117
|
}
|
|
95
|
-
return { deps: out, warnings };
|
|
118
|
+
return { deps: out, warnings, parsedManifests };
|
|
96
119
|
},
|
|
97
120
|
|
|
98
121
|
coordKey(d) { return coordKeyFor("pypi", "", d.name); },
|
package/lib/codecs/recipes.js
CHANGED
|
@@ -56,6 +56,23 @@ const maven = {
|
|
|
56
56
|
directSection: "B. Or update the direct dependencies pulling them in",
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
function gradleConstraintsSnippet(items) {
|
|
60
|
+
const inner = items.map(it => ` implementation("${esc(it.groupId)}:${esc(it.artifactId)}:${esc(it.fixVersion)}")`).join("\n");
|
|
61
|
+
return `dependencies {
|
|
62
|
+
constraints {
|
|
63
|
+
${inner}
|
|
64
|
+
}
|
|
65
|
+
}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const gradle = {
|
|
69
|
+
label: "Gradle",
|
|
70
|
+
pinSection: "A. Pin vulnerable transitives via a constraints { } block",
|
|
71
|
+
pinIntro: cnt => `Add to the (sub)project <code>build.gradle(.kts)</code> dependencies block to force ${cnt} transitive${cnt > 1 ? "s" : ""} to a fixed version:`,
|
|
72
|
+
snippet: gradleConstraintsSnippet,
|
|
73
|
+
directSection: "B. Or bump the direct dependency (or its version in <code>gradle/libs.versions.toml</code>)",
|
|
74
|
+
};
|
|
75
|
+
|
|
59
76
|
const npm = {
|
|
60
77
|
label: "npm",
|
|
61
78
|
pinSection: "A. Pin vulnerable transitives via npm overrides",
|
|
@@ -140,4 +157,4 @@ const binary = {
|
|
|
140
157
|
directSection: "B. Prefer declaring these through a package manager (Maven/npm/NuGet/…)",
|
|
141
158
|
};
|
|
142
159
|
|
|
143
|
-
module.exports = { maven, npm, yarn, composer, pypi, nuget, go, ruby, binary, dependencyManagementSnippet, npmOverridesSnippet, yarnResolutionsSnippet, composerRequireSnippet, pipInstallSnippet, dotnetAddSnippet, goGetSnippet, bundleUpdateSnippet };
|
|
160
|
+
module.exports = { maven, gradle, npm, yarn, composer, pypi, nuget, go, ruby, binary, dependencyManagementSnippet, gradleConstraintsSnippet, npmOverridesSnippet, yarnResolutionsSnippet, composerRequireSnippet, pipInstallSnippet, dotnetAddSnippet, goGetSnippet, bundleUpdateSnippet };
|
package/lib/codecs/ruby.codec.js
CHANGED
|
@@ -44,7 +44,9 @@ module.exports = {
|
|
|
44
44
|
const { deps2Exclude } = opts;
|
|
45
45
|
const out = new Map();
|
|
46
46
|
const warnings = [];
|
|
47
|
+
const parsedManifests = [];
|
|
47
48
|
for (const fp of findGemfileLocks(dir, dirFilter(dir, opts))) {
|
|
49
|
+
parsedManifests.push(fp);
|
|
48
50
|
try {
|
|
49
51
|
const { deps } = R.parseGemfileLockFile(fp);
|
|
50
52
|
for (const d of deps) {
|
|
@@ -53,7 +55,7 @@ module.exports = {
|
|
|
53
55
|
}
|
|
54
56
|
} catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `Gemfile.lock parse failed: ${e.message}` }); }
|
|
55
57
|
}
|
|
56
|
-
return { deps: out, warnings };
|
|
58
|
+
return { deps: out, warnings, parsedManifests };
|
|
57
59
|
},
|
|
58
60
|
|
|
59
61
|
coordKey(d) { return coordKeyFor("ruby", "", d.name); },
|
package/lib/cpe.js
CHANGED
|
@@ -147,6 +147,31 @@ function nodeAffectsDep(node, dep, cpeCoordMap) {
|
|
|
147
147
|
return false;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Does ANY vulnerable cpeMatch in this CVE name the dep's product/vendor — IGNORING the
|
|
152
|
+
* version range? This distinguishes "NVD has a CPE for this product but the dep version is
|
|
153
|
+
* out of range" (a genuine false positive worth filtering) from "NVD's CPE never names this
|
|
154
|
+
* product at all" (a coverage gap — NOT a false positive: the dep's own matcher, OSV or the
|
|
155
|
+
* CVE index, found it on a real coordinate, and CPE simply can't speak to it).
|
|
156
|
+
*/
|
|
157
|
+
function cveCpeNamesDep(cveRecord, dep, cpeCoordMap) {
|
|
158
|
+
const map = cpeCoordMap || loadCpeCoordMap();
|
|
159
|
+
const scan = nodes => {
|
|
160
|
+
for (const n of (nodes || [])) {
|
|
161
|
+
for (const m of (n.cpeMatch || n.cpe_match || [])) {
|
|
162
|
+
if (m.vulnerable === false) continue;
|
|
163
|
+
const parsed = parseCpe23Cached(m);
|
|
164
|
+
if (parsed && cpeMatchesDep(parsed, dep, map)) return true;
|
|
165
|
+
}
|
|
166
|
+
if (Array.isArray(n.children) && scan(n.children)) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
};
|
|
170
|
+
for (const cfg of (cveRecord?.configurations || [])) if (scan(cfg.nodes)) return true;
|
|
171
|
+
for (const uri of (cveRecord?.cpes || [])) { const p = parseCpe23(uri); if (p && cpeMatchesDep(p, dep, map)) return true; }
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
150
175
|
/**
|
|
151
176
|
* A node is "context-only" when it carries no vulnerable cpeMatch (recursively) —
|
|
152
177
|
* e.g. the "…AND runs on linux_kernel" platform node, whose CPEs are all
|
|
@@ -309,8 +334,19 @@ function refineMatchesWithCpe(matches, opts = {}) {
|
|
|
309
334
|
if (!rec.configurations.length && !rec.cpes.length) continue;
|
|
310
335
|
const { affected, confidence } = evaluateCveForDep(rec, m.dep, map);
|
|
311
336
|
m.cpeConfidence = confidence;
|
|
312
|
-
|
|
313
|
-
|
|
337
|
+
// Mark a match as a CPE false-positive ONLY for a genuine VERSION contradiction:
|
|
338
|
+
// NVD's CPE names this product but the dep version is outside the vulnerable range.
|
|
339
|
+
// NEVER filter when:
|
|
340
|
+
// - the match is OSV-confirmed — OSV did a precise, ecosystem-native version match
|
|
341
|
+
// (its data is authoritative for the package ecosystems; don't let NVD override it);
|
|
342
|
+
// - NVD's CPE never names this product (`!productNamed`) — that's a coverage gap, not
|
|
343
|
+
// a false positive (covers every pypi/composer/nuget/go/ruby coord, and any maven/
|
|
344
|
+
// npm artifact whose CPE product name simply differs from its coordinate).
|
|
345
|
+
// Without these guards the filter silently hid REAL CVEs (e.g. cryptography 46.0.5,
|
|
346
|
+
// fixed in 46.0.6) by dumping them into the false-positives appendix.
|
|
347
|
+
const isOsv = String(m.source || "").includes("osv");
|
|
348
|
+
const productNamed = cveCpeNamesDep(rec, m.dep, map);
|
|
349
|
+
if (!affected && productNamed && !isOsv) {
|
|
314
350
|
m.cpeFiltered = true;
|
|
315
351
|
}
|
|
316
352
|
if (affected && confidence === "exact" && m.confidence !== "exact") m.confidence = "exact";
|
|
@@ -325,6 +361,7 @@ module.exports = {
|
|
|
325
361
|
cpeMatchesDep,
|
|
326
362
|
nodeAffectsDep,
|
|
327
363
|
evaluateCveForDep,
|
|
364
|
+
cveCpeNamesDep,
|
|
328
365
|
refineMatchesWithCpe,
|
|
329
366
|
loadCpeCoordMap,
|
|
330
367
|
};
|
package/lib/cve-match.js
CHANGED
|
@@ -122,6 +122,19 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
|
122
122
|
* for transitive deps. This mirrors Maven's behaviour where the root
|
|
123
123
|
* project's <dependencyManagement> wins over transitive depMgmt.
|
|
124
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* A transitive coord is itself a Maven artifact (ecosystem "maven"), but for REPORT
|
|
127
|
+
* grouping we attribute its ecosystemType to the build tool that pulled it in: if every
|
|
128
|
+
* root reaching it (the first node of each via-chain) is a Gradle direct dep, it belongs
|
|
129
|
+
* in the Gradle chapter; a Maven (or mixed/unknown) root keeps it in the Maven chapter.
|
|
130
|
+
* @param viaPaths array of chains [[rootCoord, …], …]; @param rootEcoType Map coordKey→type
|
|
131
|
+
*/
|
|
132
|
+
function inheritEcoType(viaPaths, rootEcoType) {
|
|
133
|
+
const roots = (viaPaths || []).map(p => p && p[0]).filter(Boolean);
|
|
134
|
+
const types = new Set(roots.map(r => rootEcoType.get(r)).filter(Boolean));
|
|
135
|
+
return (types.size === 1 && types.has("gradle")) ? "gradle" : "maven";
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
126
139
|
const { verbose } = opts;
|
|
127
140
|
// Root depMgmt: every dep in the Map that has a concrete version becomes a
|
|
@@ -146,6 +159,11 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
146
159
|
|
|
147
160
|
if (verbose) console.log(`🌳 Resolving transitives for ${directs.length} direct deps…`);
|
|
148
161
|
|
|
162
|
+
// Root → ecosystemType, so a transitive pulled only by Gradle roots is reported in the
|
|
163
|
+
// Gradle chapter (not the Maven one) on a pure-Gradle project.
|
|
164
|
+
const rootEcoType = new Map();
|
|
165
|
+
for (const d of directs) rootEcoType.set(`${d.groupId}:${d.artifactId}`, d.ecosystemType || d.ecosystem || "maven");
|
|
166
|
+
|
|
149
167
|
const transitives = await resolveTransitiveDeps(directs, {
|
|
150
168
|
...opts,
|
|
151
169
|
rootDepMgmt,
|
|
@@ -165,7 +183,7 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
165
183
|
viaPaths: t.viaPaths || [t.via],
|
|
166
184
|
depth: t.depth,
|
|
167
185
|
ecosystem: "maven",
|
|
168
|
-
ecosystemType:
|
|
186
|
+
ecosystemType: inheritEcoType(t.viaPaths || [t.via], rootEcoType),
|
|
169
187
|
});
|
|
170
188
|
added++;
|
|
171
189
|
}
|
|
@@ -281,6 +299,7 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
|
|
|
281
299
|
module.exports = {
|
|
282
300
|
collectResolvedDeps,
|
|
283
301
|
expandWithTransitives,
|
|
302
|
+
inheritEcoType,
|
|
284
303
|
matchDepsAgainstCves,
|
|
285
304
|
resolveDepVersion,
|
|
286
305
|
vendorMatchesGroup,
|