echelongraph-mcp 2.6.8 → 2.7.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 +126 -0
- package/README.md +54 -32
- package/dist/http.js +2 -2
- package/dist/httpPolicy.js +13 -9
- package/dist/index.js +133 -62
- package/dist/prompts.js +102 -9
- package/dist/requiredText.js +118 -0
- package/dist/resources.js +2 -0
- package/dist/tools/check_affected.js +41 -18
- package/dist/tools/check_sbom.js +37 -374
- package/dist/tools/cve_intel.js +1 -1
- package/dist/tools/cve_remediation.js +257 -0
- package/dist/tools/epss_history.js +100 -6
- package/dist/tools/kev_recent.js +4 -4
- package/dist/tools/manifests/common.js +48 -0
- package/dist/tools/manifests/gomod.js +168 -0
- package/dist/tools/manifests/index.js +80 -0
- package/dist/tools/manifests/lockfiles.js +176 -0
- package/dist/tools/manifests/npmlock.js +107 -0
- package/dist/tools/manifests/requirements.js +125 -0
- package/dist/tools/manifests/toml.js +299 -0
- package/dist/tools/match_batch.js +432 -0
- package/dist/tools/scan_manifest.js +257 -0
- package/dist/tools/vendor_advisories.js +128 -20
- package/package.json +3 -1
- package/server.json +2 -2
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// go.mod (#2835), read as go.dev/ref/mod#go-mod-file describes it: require, in single-line and
|
|
2
|
+
// block form, `// indirect` ones included; then each requirement's replace and exclude applied, as
|
|
3
|
+
// the go command applies them in the main module.
|
|
4
|
+
// - replace old [v] => new v: the module checked is new at v (a replace naming old's version
|
|
5
|
+
// applies to that version only, and wins over one that names none); replace old => ./dir (or
|
|
6
|
+
// ../dir, /dir) is a local directory: local_path, not checked.
|
|
7
|
+
// - exclude m v: a require of exactly m v is not what the build uses (the go command moves to the
|
|
8
|
+
// next higher version it can find, which this file does not say): version_unresolved.
|
|
9
|
+
// A go directive below 1.17 means the file lists only the main module's direct requirements, not
|
|
10
|
+
// every module the build loads (module graph pruning, go.dev/ref/mod#graph-pruning): a note says
|
|
11
|
+
// so. go.sum is not read here: it lists every version the module graph mentions, not the one the
|
|
12
|
+
// build selects (core-backend's go.sum holds ten golang.org/x/sys versions where its go.mod
|
|
13
|
+
// selects one).
|
|
14
|
+
import { emptyParsed, purlOf, specOf, usableVersion } from "./common.js";
|
|
15
|
+
// One line's tokens, its comment removed (and whether that comment is `// indirect`). Strings are
|
|
16
|
+
// "interpreted" or `raw`, as the grammar allows a module path or file path to be quoted.
|
|
17
|
+
function tokens(line) {
|
|
18
|
+
const toks = [];
|
|
19
|
+
let i = 0;
|
|
20
|
+
let indirect = false;
|
|
21
|
+
while (i < line.length) {
|
|
22
|
+
const c = line[i];
|
|
23
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
else if (line.startsWith("//", i)) {
|
|
27
|
+
indirect = /^indirect(\s|;|$)/.test(line.slice(i + 2).trim());
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
else if (c === '"' || c === "`") {
|
|
31
|
+
let j = i + 1;
|
|
32
|
+
let s = "";
|
|
33
|
+
while (j < line.length && line[j] !== c) {
|
|
34
|
+
if (c === '"' && line[j] === "\\" && j + 1 < line.length)
|
|
35
|
+
j++;
|
|
36
|
+
s += line[j++];
|
|
37
|
+
}
|
|
38
|
+
toks.push({ text: s, quoted: true });
|
|
39
|
+
i = j + 1;
|
|
40
|
+
}
|
|
41
|
+
else if (c === "(" || c === ")") {
|
|
42
|
+
toks.push({ text: c, quoted: false });
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
let j = i;
|
|
47
|
+
while (j < line.length && !/[\s()"`]/.test(line[j]) && !line.startsWith("//", j))
|
|
48
|
+
j++;
|
|
49
|
+
toks.push({ text: line.slice(i, j), quoted: false });
|
|
50
|
+
i = j;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { toks, indirect };
|
|
54
|
+
}
|
|
55
|
+
const isLocal = (p) => /^(\.{1,2}[\\/]|\.{1,2}$|[\\/]|[A-Za-z]:[\\/])/.test(p);
|
|
56
|
+
// Each directive line, a block's lines carrying the block's verb.
|
|
57
|
+
function directives(text) {
|
|
58
|
+
const out = [];
|
|
59
|
+
let block = null;
|
|
60
|
+
text.split("\n").forEach((raw, i) => {
|
|
61
|
+
const { toks, indirect } = tokens(raw);
|
|
62
|
+
if (toks.length === 0)
|
|
63
|
+
return;
|
|
64
|
+
if (block !== null) {
|
|
65
|
+
if (toks.length === 1 && toks[0].text === ")" && !toks[0].quoted) {
|
|
66
|
+
block = null;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
out.push({ verb: block, args: toks, indirect, line: i + 1, raw });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const verb = toks[0].text;
|
|
73
|
+
if (toks.length === 2 && toks[1].text === "(" && !toks[1].quoted) {
|
|
74
|
+
block = verb;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
out.push({ verb, args: toks.slice(1), indirect, line: i + 1, raw });
|
|
78
|
+
});
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
// "1.16" is below 1.17; "1.21.0" and "1.17" are not.
|
|
82
|
+
function below117(v) {
|
|
83
|
+
const m = /^(\d+)\.(\d+)/.exec(v);
|
|
84
|
+
if (!m)
|
|
85
|
+
return false;
|
|
86
|
+
return Number(m[1]) < 1 || (Number(m[1]) === 1 && Number(m[2]) < 17);
|
|
87
|
+
}
|
|
88
|
+
export function readGoMod(text) {
|
|
89
|
+
const p = emptyParsed();
|
|
90
|
+
p.replaced = [];
|
|
91
|
+
const lines = directives(text);
|
|
92
|
+
const requires = [];
|
|
93
|
+
const excluded = new Set();
|
|
94
|
+
// replace, keyed by old path, then by old version ("" for every version).
|
|
95
|
+
const replaces = new Map();
|
|
96
|
+
let goVersion = null;
|
|
97
|
+
for (const l of lines) {
|
|
98
|
+
const a = l.args.map((t) => t.text);
|
|
99
|
+
switch (l.verb) {
|
|
100
|
+
case "go":
|
|
101
|
+
goVersion = a[0] ?? null;
|
|
102
|
+
break;
|
|
103
|
+
case "require":
|
|
104
|
+
requires.push(l);
|
|
105
|
+
break;
|
|
106
|
+
case "exclude":
|
|
107
|
+
if (a.length >= 2)
|
|
108
|
+
excluded.add(`${a[0]}@${a[1]}`);
|
|
109
|
+
break;
|
|
110
|
+
case "replace": {
|
|
111
|
+
const arrow = a.indexOf("=>");
|
|
112
|
+
if (arrow < 1)
|
|
113
|
+
break;
|
|
114
|
+
const [oldPath, oldVersion = ""] = a.slice(0, arrow);
|
|
115
|
+
const [newPath, newVersion] = a.slice(arrow + 1);
|
|
116
|
+
if (!newPath)
|
|
117
|
+
break;
|
|
118
|
+
if (!replaces.has(oldPath))
|
|
119
|
+
replaces.set(oldPath, new Map());
|
|
120
|
+
replaces.get(oldPath).set(oldVersion, { path: newPath, version: newVersion ?? null, line: l.line });
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "module":
|
|
124
|
+
case "toolchain":
|
|
125
|
+
case "godebug":
|
|
126
|
+
case "retract":
|
|
127
|
+
case "tool":
|
|
128
|
+
case "ignore":
|
|
129
|
+
// No dependency version.
|
|
130
|
+
break;
|
|
131
|
+
default:
|
|
132
|
+
p.not_checked.push({ name: null, spec: specOf(l.raw), reason: "unsupported_line", line: l.line, detail: `not a go.mod directive (${l.verb})` });
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const r of requires) {
|
|
137
|
+
const [path, version] = r.args.map((t) => t.text);
|
|
138
|
+
const miss = (reason, detail) => p.not_checked.push({ name: path ?? null, spec: specOf(r.raw), reason, line: r.line, detail });
|
|
139
|
+
if (!path || !usableVersion(version) || r.args.length !== 2) {
|
|
140
|
+
miss("unsupported_line", "not a require line this reader can read (module path, then version)");
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const rep = replaces.get(path)?.get(version) ?? replaces.get(path)?.get("");
|
|
144
|
+
if (rep) {
|
|
145
|
+
if (isLocal(rep.path)) {
|
|
146
|
+
miss("local_path", `replaced by the local directory ${rep.path} (replace on line ${rep.line}), which is not a published module version`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!usableVersion(rep.version)) {
|
|
150
|
+
miss("unsupported_line", `replaced by ${rep.path} without a version (replace on line ${rep.line}), which this reader cannot resolve`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
p.replaced.push({ from: `${path}@${version}`, to: `${rep.path}@${rep.version}` });
|
|
154
|
+
p.pinned.push({ purl: purlOf("golang", rep.path, rep.version), line: r.line });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (excluded.has(`${path}@${version}`)) {
|
|
158
|
+
miss("version_unresolved", "this version is excluded by an exclude directive, so the build uses another version, which go.mod does not name");
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
p.pinned.push({ purl: purlOf("golang", path, version), line: r.line });
|
|
162
|
+
}
|
|
163
|
+
// No go directive is read as go 1.16 (go.dev/ref/mod#go-mod-file-go).
|
|
164
|
+
if (goVersion === null || below117(goVersion)) {
|
|
165
|
+
p.notes.push(`${goVersion === null ? "It has no go directive, which the go command reads as go 1.16" : `Its go directive is ${goVersion}`}, below 1.17: such a go.mod lists the main module's own requirements, not every module the build loads, so modules required only by its dependencies are not checked.`);
|
|
166
|
+
}
|
|
167
|
+
return p;
|
|
168
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Which reader a file goes to (#2835): by its format when the caller names one, else by its
|
|
2
|
+
// basename. A manifest that holds ranges, not resolved versions, is refused with the lockfile to
|
|
3
|
+
// pass instead; formats not read yet are refused as such. Nothing here guesses a format from a
|
|
4
|
+
// file's content.
|
|
5
|
+
import { Unreadable } from "./common.js";
|
|
6
|
+
import { readGoMod } from "./gomod.js";
|
|
7
|
+
import { readCargoLock, readComposerLock, readGemfileLock, readGradleLockfile, readPoetryLock } from "./lockfiles.js";
|
|
8
|
+
import { readNpmLock } from "./npmlock.js";
|
|
9
|
+
import { readRequirements } from "./requirements.js";
|
|
10
|
+
export { NOT_CHECKED_REASONS, Unreadable } from "./common.js";
|
|
11
|
+
// The formats read, by the name the `format` argument takes, each with its purl type's ecosystem.
|
|
12
|
+
export const FORMATS = {
|
|
13
|
+
"requirements.txt": { ecosystem: "PyPI", read: readRequirements },
|
|
14
|
+
"go.mod": { ecosystem: "Go", read: readGoMod },
|
|
15
|
+
"package-lock.json": { ecosystem: "npm", read: readNpmLock },
|
|
16
|
+
"Cargo.lock": { ecosystem: "crates.io", read: readCargoLock },
|
|
17
|
+
"Gemfile.lock": { ecosystem: "RubyGems", read: readGemfileLock },
|
|
18
|
+
"composer.lock": { ecosystem: "Packagist", read: readComposerLock },
|
|
19
|
+
"poetry.lock": { ecosystem: "PyPI", read: readPoetryLock },
|
|
20
|
+
"gradle.lockfile": { ecosystem: "Maven", read: readGradleLockfile },
|
|
21
|
+
};
|
|
22
|
+
export const FORMAT_NAMES = Object.keys(FORMATS);
|
|
23
|
+
// Files that hold ranges or unselected versions, not what was installed: the file to pass instead.
|
|
24
|
+
const REFUSED = {
|
|
25
|
+
"package.json": "package.json holds version ranges, not installed versions: pass package-lock.json or npm-shrinkwrap.json",
|
|
26
|
+
"pyproject.toml": "pyproject.toml holds version ranges, not installed versions: pass poetry.lock, or a requirements.txt of == pins (pip freeze)",
|
|
27
|
+
pipfile: "Pipfile holds version ranges, not installed versions: pass a requirements.txt of == pins (pipenv requirements)",
|
|
28
|
+
gemfile: "Gemfile holds version ranges, not installed versions: pass Gemfile.lock",
|
|
29
|
+
"gems.rb": "gems.rb holds version ranges, not installed versions: pass gems.locked",
|
|
30
|
+
"cargo.toml": "Cargo.toml holds version ranges, not installed versions: pass Cargo.lock",
|
|
31
|
+
"composer.json": "composer.json holds version ranges, not installed versions: pass composer.lock",
|
|
32
|
+
"build.gradle": "build.gradle declares dependencies, not the versions resolved: pass gradle.lockfile (gradle dependencies --write-locks)",
|
|
33
|
+
"build.gradle.kts": "build.gradle.kts declares dependencies, not the versions resolved: pass gradle.lockfile (gradle dependencies --write-locks)",
|
|
34
|
+
"go.sum": "go.sum is refused: it lists every module version the module graph mentions, not the version the build selects: pass go.mod",
|
|
35
|
+
"go.work": "go.work names workspace modules, not dependency versions: pass each module's go.mod",
|
|
36
|
+
// Not read yet (#2835's deferred list).
|
|
37
|
+
"pom.xml": "pom.xml is not read: its versions need the effective POM (parents, properties, dependencyManagement), which this tool does not build",
|
|
38
|
+
"yarn.lock": "yarn.lock is not read yet",
|
|
39
|
+
"pnpm-lock.yaml": "pnpm-lock.yaml is not read yet",
|
|
40
|
+
"pipfile.lock": "Pipfile.lock is not read yet: pass a requirements.txt of == pins (pipenv requirements)",
|
|
41
|
+
};
|
|
42
|
+
const basename = (f) => f.replace(/\\/g, "/").split("/").pop() ?? f;
|
|
43
|
+
// The format a filename names, or why it names none.
|
|
44
|
+
export function detect(filename) {
|
|
45
|
+
const base = basename(filename.trim());
|
|
46
|
+
const lower = base.toLowerCase();
|
|
47
|
+
if (lower === "go.mod")
|
|
48
|
+
return { format: "go.mod" };
|
|
49
|
+
if (lower === "package-lock.json" || lower === "npm-shrinkwrap.json")
|
|
50
|
+
return { format: "package-lock.json" };
|
|
51
|
+
if (lower === "cargo.lock")
|
|
52
|
+
return { format: "Cargo.lock" };
|
|
53
|
+
if (lower === "gemfile.lock" || lower === "gems.locked")
|
|
54
|
+
return { format: "Gemfile.lock" };
|
|
55
|
+
if (lower === "composer.lock")
|
|
56
|
+
return { format: "composer.lock" };
|
|
57
|
+
if (lower === "poetry.lock")
|
|
58
|
+
return { format: "poetry.lock" };
|
|
59
|
+
// gradle.lockfile, buildscript-gradle.lockfile, and the older per-configuration
|
|
60
|
+
// gradle/dependency-locks/<configuration>.lockfile.
|
|
61
|
+
if (lower.endsWith(".lockfile"))
|
|
62
|
+
return { format: "gradle.lockfile" };
|
|
63
|
+
if (lower.endsWith(".txt") && /requirements|constraints/.test(lower))
|
|
64
|
+
return { format: "requirements.txt" };
|
|
65
|
+
if (REFUSED[lower])
|
|
66
|
+
return { refused: REFUSED[lower] };
|
|
67
|
+
return { refused: `${base || "(no filename)"} is not a file name this tool reads; the formats read are ${FORMAT_NAMES.join(", ")} (and npm-shrinkwrap.json, gems.locked): name the file so, or set its format` };
|
|
68
|
+
}
|
|
69
|
+
// One file read, or why it could not be.
|
|
70
|
+
export function readFile(format, content) {
|
|
71
|
+
try {
|
|
72
|
+
// A byte-order mark, which some Windows editors write, is not part of the file's text.
|
|
73
|
+
return FORMATS[format].read(content.replace(/^\uFEFF/, ""));
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
if (e instanceof Unreadable)
|
|
77
|
+
return { unreadable: `it ${e.message}` };
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The resolved-version lockfiles of Rust, Python (Poetry), Ruby, PHP and Gradle (#2835). Each
|
|
2
|
+
// entry is one package at one version as the tool that wrote the file resolved it; entries from a
|
|
3
|
+
// local path or a VCS checkout are listed, not checked.
|
|
4
|
+
//
|
|
5
|
+
// Cargo.lock (v1 to v4) [[package]] tables read as TOML, never by pairing a name line with the
|
|
6
|
+
// next version line (docs/SBOM_COVERAGE_PROGRAM.md row 46): a source of
|
|
7
|
+
// the crates.io index (registry+https://github.com/rust-lang/crates.io-index
|
|
8
|
+
// or sparse+https://index.crates.io/) is checked; no source is a workspace
|
|
9
|
+
// member or path crate (local_path); git+ is vcs_source; another registry
|
|
10
|
+
// unsupported_line.
|
|
11
|
+
// poetry.lock [[package]] tables read as TOML; a [package.source] of type git is
|
|
12
|
+
// vcs_source, directory or file local_path, url unsupported_line; a legacy
|
|
13
|
+
// (private index) source is checked as PyPI.
|
|
14
|
+
// Gemfile.lock Bundler's sections read by indentation: each spec line (four spaces,
|
|
15
|
+
// name (version)) of a GEM section is checked, its platform suffix
|
|
16
|
+
// stripped (nokogiri (1.15.4-x86_64-linux) is nokogiri 1.15.4); a PATH
|
|
17
|
+
// section's specs are local_path and a GIT section's vcs_source.
|
|
18
|
+
// composer.lock packages and packages-dev, each version's leading v stripped
|
|
19
|
+
// (v6.4.1 is 6.4.1); a branch version (dev-main, 2.x-dev) is
|
|
20
|
+
// version_unresolved; a dist of type path local_path.
|
|
21
|
+
// gradle.lockfile group:artifact:version=configurations lines (and the older
|
|
22
|
+
// per-configuration lockfiles' group:artifact:version); empty= lists the
|
|
23
|
+
// configurations with no dependency, and is skipped.
|
|
24
|
+
import { Unreadable, emptyParsed, isObj, pep503, purlOf, seg, specOf, usableVersion } from "./common.js";
|
|
25
|
+
import { readToml } from "./toml.js";
|
|
26
|
+
// [[package]] tables of a TOML lockfile, each with its header's line.
|
|
27
|
+
function packagesOf(text) {
|
|
28
|
+
const { root, lines } = readToml(text);
|
|
29
|
+
const pkgs = root.package;
|
|
30
|
+
if (pkgs === undefined)
|
|
31
|
+
return [];
|
|
32
|
+
if (!Array.isArray(pkgs))
|
|
33
|
+
throw new Unreadable("has a package key that is not an array of [[package]] tables");
|
|
34
|
+
return pkgs.filter(isObj).map((t) => ({ t: t, line: lines.get(t) ?? null }));
|
|
35
|
+
}
|
|
36
|
+
const CRATES_IO = new Set(["registry+https://github.com/rust-lang/crates.io-index", "sparse+https://index.crates.io/"]);
|
|
37
|
+
export function readCargoLock(text) {
|
|
38
|
+
const p = emptyParsed();
|
|
39
|
+
for (const { t, line } of packagesOf(text)) {
|
|
40
|
+
const name = typeof t.name === "string" ? t.name : null;
|
|
41
|
+
const version = typeof t.version === "string" ? t.version : undefined;
|
|
42
|
+
const src = typeof t.source === "string" ? t.source : undefined;
|
|
43
|
+
const miss = (reason, detail) => p.not_checked.push({ name, spec: specOf(`${name ?? "?"} ${version ?? ""}${src ? ` (${src})` : ""}`), reason, line, detail });
|
|
44
|
+
if (!name || !usableVersion(version))
|
|
45
|
+
miss("version_unresolved", "the [[package]] table names no package and version");
|
|
46
|
+
else if (src === undefined)
|
|
47
|
+
miss("local_path", "no source: a workspace member or path dependency, not a crates.io release");
|
|
48
|
+
else if (src.startsWith("git+"))
|
|
49
|
+
miss("vcs_source", "a git dependency, whose code need not match the crates.io release of the same version");
|
|
50
|
+
else if (!CRATES_IO.has(src))
|
|
51
|
+
miss("unsupported_line", "from a registry other than crates.io, whose crates need not be the crates.io crates of the same name");
|
|
52
|
+
else
|
|
53
|
+
p.pinned.push({ purl: purlOf("cargo", name, version), line });
|
|
54
|
+
}
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
export function readPoetryLock(text) {
|
|
58
|
+
const p = emptyParsed();
|
|
59
|
+
for (const { t, line } of packagesOf(text)) {
|
|
60
|
+
const name = typeof t.name === "string" ? t.name : null;
|
|
61
|
+
const version = typeof t.version === "string" ? t.version : undefined;
|
|
62
|
+
const source = isObj(t.source) ? t.source : null;
|
|
63
|
+
const type = source && typeof source.type === "string" ? source.type : null;
|
|
64
|
+
const miss = (reason, detail) => p.not_checked.push({ name, spec: specOf(`${name ?? "?"} ${version ?? ""}${type ? ` (source ${type})` : ""}`), reason, line, detail });
|
|
65
|
+
if (!name || !usableVersion(version))
|
|
66
|
+
miss("version_unresolved", "the [[package]] table names no package and version");
|
|
67
|
+
else if (type === "git")
|
|
68
|
+
miss("vcs_source", "a git dependency, whose code need not match the PyPI release of the same version");
|
|
69
|
+
else if (type === "directory" || type === "file")
|
|
70
|
+
miss("local_path", `a local ${type}, not a PyPI release`);
|
|
71
|
+
else if (type === "url")
|
|
72
|
+
miss("unsupported_line", "installed from a URL, not a PyPI release");
|
|
73
|
+
else
|
|
74
|
+
p.pinned.push({ purl: purlOf("pypi", pep503(name), version), line });
|
|
75
|
+
}
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
export function readGemfileLock(text) {
|
|
79
|
+
const p = emptyParsed();
|
|
80
|
+
let section = null;
|
|
81
|
+
let inSpecs = false;
|
|
82
|
+
text.split(/\r?\n/).forEach((raw, i) => {
|
|
83
|
+
const line = i + 1;
|
|
84
|
+
if (raw.trim() === "")
|
|
85
|
+
return;
|
|
86
|
+
if (!/^\s/.test(raw)) {
|
|
87
|
+
section = raw.trim();
|
|
88
|
+
inSpecs = false;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (/^ {2}specs:\s*$/.test(raw)) {
|
|
92
|
+
inSpecs = true;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (/^ {2}\S/.test(raw)) {
|
|
96
|
+
inSpecs = false;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// A spec: four spaces, then name (version). Six spaces is one of its own requirements.
|
|
100
|
+
if (!inSpecs || !/^ {4}\S/.test(raw))
|
|
101
|
+
return;
|
|
102
|
+
const m = /^ {4}([^\s(]+) \(([^)]+)\)\s*$/.exec(raw);
|
|
103
|
+
const miss = (name, reason, detail) => p.not_checked.push({ name, spec: specOf(raw), reason, line, detail });
|
|
104
|
+
if (!m)
|
|
105
|
+
return miss(null, "unsupported_line", "a spec line this reader cannot read (name (version))");
|
|
106
|
+
const [, name, full] = m;
|
|
107
|
+
// RubyGems versions carry no '-': what follows the first one is the platform.
|
|
108
|
+
const version = full.split("-")[0];
|
|
109
|
+
if (section === "PATH")
|
|
110
|
+
return miss(name, "local_path", "a gem from a local path (PATH section), not a RubyGems release");
|
|
111
|
+
if (section === "GIT")
|
|
112
|
+
return miss(name, "vcs_source", "a gem from a git repository (GIT section), whose code need not match the RubyGems release of the same version");
|
|
113
|
+
if (section !== "GEM")
|
|
114
|
+
return miss(name, "unsupported_line", `a gem from a ${section ?? "unnamed"} section, not a RubyGems source`);
|
|
115
|
+
if (!usableVersion(version))
|
|
116
|
+
return miss(name, "version_unresolved", "the spec carries no version");
|
|
117
|
+
p.pinned.push({ purl: purlOf("gem", name, version), line });
|
|
118
|
+
});
|
|
119
|
+
return p;
|
|
120
|
+
}
|
|
121
|
+
export function readComposerLock(text) {
|
|
122
|
+
let doc;
|
|
123
|
+
try {
|
|
124
|
+
doc = JSON.parse(text);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new Unreadable("is not JSON");
|
|
128
|
+
}
|
|
129
|
+
if (!isObj(doc))
|
|
130
|
+
throw new Unreadable("is not a JSON object");
|
|
131
|
+
if (!Array.isArray(doc.packages) && !Array.isArray(doc["packages-dev"]))
|
|
132
|
+
throw new Unreadable("has neither a packages nor a packages-dev list");
|
|
133
|
+
const p = emptyParsed();
|
|
134
|
+
for (const list of ["packages", "packages-dev"]) {
|
|
135
|
+
const pkgs = doc[list];
|
|
136
|
+
if (!Array.isArray(pkgs))
|
|
137
|
+
continue;
|
|
138
|
+
for (const e of pkgs) {
|
|
139
|
+
if (!isObj(e))
|
|
140
|
+
continue;
|
|
141
|
+
const name = typeof e.name === "string" ? e.name : null;
|
|
142
|
+
const raw = typeof e.version === "string" ? e.version : undefined;
|
|
143
|
+
const miss = (reason, detail) => p.not_checked.push({ name, spec: specOf(`${list}: ${name ?? "?"} ${raw ?? ""}`), reason, line: null, detail });
|
|
144
|
+
const dist = isObj(e.dist) ? e.dist : null;
|
|
145
|
+
if (!name || !usableVersion(raw))
|
|
146
|
+
miss("version_unresolved", "the entry names no package and version");
|
|
147
|
+
else if (dist && dist.type === "path")
|
|
148
|
+
miss("local_path", "installed from a local path, not a Packagist release");
|
|
149
|
+
else if (/^dev-|-dev$/.test(raw))
|
|
150
|
+
miss("version_unresolved", "a branch, not a released version");
|
|
151
|
+
else
|
|
152
|
+
p.pinned.push({ purl: purlOf("composer", name, raw.replace(/^v(?=\d)/, "")), line: null });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return p;
|
|
156
|
+
}
|
|
157
|
+
export function readGradleLockfile(text) {
|
|
158
|
+
const p = emptyParsed();
|
|
159
|
+
text.split(/\r?\n/).forEach((raw, i) => {
|
|
160
|
+
const l = raw.trim();
|
|
161
|
+
if (l === "" || l.startsWith("#"))
|
|
162
|
+
return;
|
|
163
|
+
if (/^empty=/.test(l))
|
|
164
|
+
return;
|
|
165
|
+
const coords = l.split("=")[0];
|
|
166
|
+
const m = /^([^:\s]+):([^:\s]+):([^:\s]+)$/.exec(coords);
|
|
167
|
+
if (!m) {
|
|
168
|
+
p.not_checked.push({ name: null, spec: specOf(raw), reason: "unsupported_line", line: i + 1, detail: "not a group:artifact:version line" });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const [, group, artifact, version] = m;
|
|
172
|
+
// purl-spec maven: the group is the namespace, the artifact the name.
|
|
173
|
+
p.pinned.push({ purl: `pkg:maven/${seg(group)}/${seg(artifact)}@${seg(version)}`, line: i + 1 });
|
|
174
|
+
});
|
|
175
|
+
return p;
|
|
176
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// package-lock.json and npm-shrinkwrap.json (#2835), lockfileVersion 1, 2 and 3, read as JSON
|
|
2
|
+
// (docs.npmjs.com/cli/v10/configuring-npm/package-lock-json).
|
|
3
|
+
//
|
|
4
|
+
// Version 2 and 3 files hold `packages`, keyed by install path: the package's name is what follows
|
|
5
|
+
// the last "node_modules/" in the key, or the entry's own name where it has one (an alias,
|
|
6
|
+
// "lodash-latest": "npm:lodash@4.17.21", installs lodash under another folder name). The root
|
|
7
|
+
// entry ("") is the project itself and is skipped and counted; a key outside node_modules is a
|
|
8
|
+
// workspace member, and a `link: true` entry a symlink to one: local_path. Version 1 files hold
|
|
9
|
+
// only `dependencies`, a tree keyed by name; version 2 files hold both, and `packages` is read.
|
|
10
|
+
// The same installed tree gives the same purls in all three.
|
|
11
|
+
//
|
|
12
|
+
// An entry installed from git (resolved git+…, or a v1 version that is a git URL) is vcs_source;
|
|
13
|
+
// from a directory or file (file:…) local_path; from a tarball URL unsupported_line; an entry
|
|
14
|
+
// without a version version_unresolved. Each is listed, never sent.
|
|
15
|
+
import { Unreadable, emptyParsed, isObj, purlOf, specOf, usableVersion } from "./common.js";
|
|
16
|
+
const NM = "node_modules/";
|
|
17
|
+
const GIT = /^(git(\+[a-z]+)?:|github:|gitlab:|bitbucket:|gist:)/i;
|
|
18
|
+
// Where an entry was installed from, when not the registry.
|
|
19
|
+
function source(s) {
|
|
20
|
+
if (!s)
|
|
21
|
+
return null;
|
|
22
|
+
if (GIT.test(s) || /^[\w.-]+\/[\w.-]+(#.*)?$/.test(s))
|
|
23
|
+
return { reason: "vcs_source", detail: "installed from a git repository, whose code need not match the npm release of the same version" };
|
|
24
|
+
if (/^(file:|link:|\.{1,2}\/|\/)/.test(s))
|
|
25
|
+
return { reason: "local_path", detail: "installed from a local directory or file, not an npm release" };
|
|
26
|
+
if (/^https?:\/\//i.test(s) && !/\/-\/[^/]+\.tgz$/.test(s))
|
|
27
|
+
return { reason: "unsupported_line", detail: "installed from a tarball URL that is not a registry release" };
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export function readNpmLock(text) {
|
|
31
|
+
let doc;
|
|
32
|
+
try {
|
|
33
|
+
doc = JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Unreadable("is not JSON");
|
|
37
|
+
}
|
|
38
|
+
if (!isObj(doc))
|
|
39
|
+
throw new Unreadable("is not a JSON object");
|
|
40
|
+
const p = emptyParsed();
|
|
41
|
+
const lv = doc.lockfileVersion;
|
|
42
|
+
if (isObj(doc.packages)) {
|
|
43
|
+
for (const [key, e] of Object.entries(doc.packages)) {
|
|
44
|
+
if (key === "") {
|
|
45
|
+
p.skipped++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const at = key.lastIndexOf(NM);
|
|
49
|
+
const folder = at >= 0 ? key.slice(at + NM.length) : null;
|
|
50
|
+
const entry = isObj(e) ? e : {};
|
|
51
|
+
const name = typeof entry.name === "string" && entry.name ? entry.name : (folder ?? key);
|
|
52
|
+
const version = typeof entry.version === "string" ? entry.version : undefined;
|
|
53
|
+
const miss = (reason, detail) => p.not_checked.push({ name, spec: specOf(`${key}${version ? ` ${version}` : ""}`), reason, line: null, detail });
|
|
54
|
+
if (folder === null) {
|
|
55
|
+
miss("local_path", "a workspace member of this project, not an npm release");
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (entry.link === true) {
|
|
59
|
+
miss("local_path", `a link to ${typeof entry.resolved === "string" ? entry.resolved : "a local directory"}, not an npm release`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const src = source(typeof entry.resolved === "string" ? entry.resolved : undefined);
|
|
63
|
+
if (src) {
|
|
64
|
+
miss(src.reason, src.detail);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!usableVersion(version) || !name) {
|
|
68
|
+
miss("version_unresolved", "the entry carries no version");
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
p.pinned.push({ purl: purlOf("npm", name, version), line: null });
|
|
72
|
+
}
|
|
73
|
+
return p;
|
|
74
|
+
}
|
|
75
|
+
if (lv !== undefined && lv !== 1)
|
|
76
|
+
throw new Unreadable(`has lockfileVersion ${JSON.stringify(lv)} and no packages object`);
|
|
77
|
+
// Version 1: the dependencies tree, each node's own dependencies nested below it.
|
|
78
|
+
const walk = (deps, at, depth) => {
|
|
79
|
+
if (!isObj(deps))
|
|
80
|
+
return;
|
|
81
|
+
if (depth > 64)
|
|
82
|
+
throw new Unreadable("nests its dependencies deeper than 64 levels");
|
|
83
|
+
for (const [key, e] of Object.entries(deps)) {
|
|
84
|
+
const entry = isObj(e) ? e : {};
|
|
85
|
+
const path = `${at}${NM}${key}`;
|
|
86
|
+
let name = key;
|
|
87
|
+
let version = typeof entry.version === "string" ? entry.version : undefined;
|
|
88
|
+
// An alias: "npm:real-name@1.2.3".
|
|
89
|
+
const alias = version ? /^npm:((?:@[^/@]+\/)?[^@]+)@(.+)$/.exec(version) : null;
|
|
90
|
+
if (alias)
|
|
91
|
+
[, name, version] = alias;
|
|
92
|
+
const miss = (reason, detail) => p.not_checked.push({ name, spec: specOf(`${path}${version ? ` ${version}` : ""}`), reason, line: null, detail });
|
|
93
|
+
const src = source(version) ?? source(typeof entry.resolved === "string" ? entry.resolved : undefined);
|
|
94
|
+
if (src)
|
|
95
|
+
miss(src.reason, src.detail);
|
|
96
|
+
else if (!usableVersion(version))
|
|
97
|
+
miss("version_unresolved", "the entry carries no version");
|
|
98
|
+
else
|
|
99
|
+
p.pinned.push({ purl: purlOf("npm", name, version), line: null });
|
|
100
|
+
walk(entry.dependencies, `${path}/`, depth + 1);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
if (!isObj(doc.dependencies))
|
|
104
|
+
throw new Unreadable("has neither a packages nor a dependencies object");
|
|
105
|
+
walk(doc.dependencies, "", 0);
|
|
106
|
+
return p;
|
|
107
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// requirements.txt (#2835): pip's requirements file format
|
|
2
|
+
// (pip.pypa.io/en/stable/reference/requirements-file-format/), each requirement a PEP 508
|
|
3
|
+
// specifier (packaging.python.org/en/latest/specifications/dependency-specifiers/).
|
|
4
|
+
//
|
|
5
|
+
// Only a requirement pinned to one version by a single == or === clause gives a version
|
|
6
|
+
// (django==4.2.7). A range (django>=4, ~=4.2, a == with a wildcard such as ==1.4.*, or several
|
|
7
|
+
// clauses) is version_unpinned and its bound is never sent as a version: pkg:pypi/django@4 would
|
|
8
|
+
// be checked as if 4 were installed. Extras ([bcrypt]) and environment markers (; python_version
|
|
9
|
+
// < "3.8") are stripped; per-requirement options (--hash) are ignored. -r and -c name another
|
|
10
|
+
// file, which is not read here (unsupported_line: pass it as its own file); -e, a direct URL
|
|
11
|
+
// (name @ https://…) and a bare path or URL are vcs_source, local_path or unsupported_line. Global
|
|
12
|
+
// options (--index-url and the rest) are not dependencies and are not counted.
|
|
13
|
+
import { emptyParsed, pep503, purlOf, specOf, usableVersion } from "./common.js";
|
|
14
|
+
// The logical lines: comments removed (a line starting with #, or whitespace then #), a trailing
|
|
15
|
+
// backslash joining the next line, each with the 1-based number of its first physical line.
|
|
16
|
+
function logicalLines(text) {
|
|
17
|
+
const out = [];
|
|
18
|
+
let buf = "";
|
|
19
|
+
let start = 0;
|
|
20
|
+
text.split(/\r?\n/).forEach((raw, i) => {
|
|
21
|
+
if (buf === "")
|
|
22
|
+
start = i + 1;
|
|
23
|
+
let l = raw;
|
|
24
|
+
if (/^\s*#/.test(l))
|
|
25
|
+
l = "";
|
|
26
|
+
else
|
|
27
|
+
l = l.replace(/\s#.*$/, "");
|
|
28
|
+
if (/\\$/.test(l)) {
|
|
29
|
+
buf += l.slice(0, -1) + " ";
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
buf += l;
|
|
33
|
+
if (buf.trim())
|
|
34
|
+
out.push({ text: buf.trim(), line: start });
|
|
35
|
+
buf = "";
|
|
36
|
+
});
|
|
37
|
+
if (buf.trim())
|
|
38
|
+
out.push({ text: buf.trim(), line: start });
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
const VCS = /^(git|hg|svn|bzr)\+/i;
|
|
42
|
+
const URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
43
|
+
// A local archive or path: pip installs it from the file, not from the index.
|
|
44
|
+
const LOCAL = /^(\.{1,2}([\\/]|$)|[\\/]|~|[A-Za-z]:[\\/]|file:)|\.(whl|tar\.gz|tgz|zip|tar\.bz2)$/i;
|
|
45
|
+
// Where a non-registry source points: VCS, a local path, or another URL.
|
|
46
|
+
function sourceReason(target) {
|
|
47
|
+
if (VCS.test(target))
|
|
48
|
+
return { reason: "vcs_source", detail: "a VCS checkout, whose code need not match the PyPI release of the same version" };
|
|
49
|
+
if (/^file:/i.test(target) || !URL.test(target))
|
|
50
|
+
return { reason: "local_path", detail: "a local path or archive, not a PyPI release" };
|
|
51
|
+
return { reason: "unsupported_line", detail: "a direct URL, not a PyPI release" };
|
|
52
|
+
}
|
|
53
|
+
const NAME = /^([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\s*(\[[^\]]*\])?\s*(.*)$/s;
|
|
54
|
+
const CLAUSE = /^(===|==|!=|~=|<=|>=|<|>)\s*(\S+)$/;
|
|
55
|
+
// Per-requirement options pip takes after a specifier: not part of it.
|
|
56
|
+
const PER_REQUIREMENT = /\s--?(hash|global-option|install-option|config-settings)\b.*$/s;
|
|
57
|
+
export function readRequirements(text) {
|
|
58
|
+
const p = emptyParsed();
|
|
59
|
+
for (const { text: l, line } of logicalLines(text)) {
|
|
60
|
+
const miss = (name, reason, detail) => p.not_checked.push({ name, spec: specOf(l), reason, line, detail });
|
|
61
|
+
if (l.startsWith("-")) {
|
|
62
|
+
const m = /^(-[a-zA-Z]|--[a-z-]+)(?:\s*=\s*|\s+)?(.*)$/s.exec(l);
|
|
63
|
+
const opt = m?.[1] ?? l;
|
|
64
|
+
const value = (m?.[2] ?? "").trim();
|
|
65
|
+
if (["-r", "--requirement", "-c", "--constraint"].includes(opt)) {
|
|
66
|
+
miss(null, "unsupported_line", `names another file (${opt}), which this tool does not read from here: pass that file's text as its own entry in files`);
|
|
67
|
+
}
|
|
68
|
+
else if (["-e", "--editable"].includes(opt)) {
|
|
69
|
+
const s = sourceReason(value);
|
|
70
|
+
miss(/[#&]egg=([A-Za-z0-9._-]+)/.exec(value)?.[1] ?? null, s.reason, `an editable install: ${s.detail}`);
|
|
71
|
+
}
|
|
72
|
+
// Any other option (--index-url, --hash on a line of its own, …) names no dependency.
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const spec = l.replace(PER_REQUIREMENT, "").trim();
|
|
76
|
+
// A bare path, archive or URL; "name @ url" is a named requirement, read below.
|
|
77
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\s*(\[[^\]]*\])?\s*@/.test(spec) && (VCS.test(spec) || URL.test(spec) || LOCAL.test(spec))) {
|
|
78
|
+
const s = sourceReason(spec);
|
|
79
|
+
miss(/[#&]egg=([A-Za-z0-9._-]+)/.exec(spec)?.[1] ?? null, s.reason, s.detail);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// A marker follows ';'; it does not change which version is pinned.
|
|
83
|
+
const req = spec.split(";")[0].trim();
|
|
84
|
+
const m = NAME.exec(req);
|
|
85
|
+
if (!m) {
|
|
86
|
+
miss(null, "unsupported_line", "not a requirement specifier this reader takes (PEP 508 name, then a version specifier)");
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const name = m[1];
|
|
90
|
+
let rest = m[3].trim();
|
|
91
|
+
if (rest.startsWith("@")) {
|
|
92
|
+
const s = sourceReason(rest.slice(1).trim());
|
|
93
|
+
miss(name, s.reason, s.detail);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (rest.startsWith("(") && rest.endsWith(")"))
|
|
97
|
+
rest = rest.slice(1, -1).trim();
|
|
98
|
+
if (rest === "") {
|
|
99
|
+
miss(name, "version_unpinned", "no version is given, so any release may be installed");
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const clauses = rest.split(",").map((c) => c.trim()).filter(Boolean);
|
|
103
|
+
const parsed = clauses.map((c) => CLAUSE.exec(c));
|
|
104
|
+
if (parsed.some((c) => c === null)) {
|
|
105
|
+
miss(name, "unsupported_line", "a version specifier this reader cannot read");
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const only = parsed.length === 1 ? parsed[0] : null;
|
|
109
|
+
const pinned = only && (only[1] === "==" || only[1] === "===") ? only[2] : null;
|
|
110
|
+
if (pinned === null || pinned.includes("*")) {
|
|
111
|
+
miss(name, "version_unpinned", "a range, not one version: pip may install any release it allows, so none is checked");
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (pinned.includes("$")) {
|
|
115
|
+
miss(name, "version_unresolved", "the version is an environment variable, which this tool does not expand");
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!usableVersion(pinned)) {
|
|
119
|
+
miss(name, "unsupported_line", "the pinned version cannot be read");
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
p.pinned.push({ purl: purlOf("pypi", pep503(name), pinned), line });
|
|
123
|
+
}
|
|
124
|
+
return p;
|
|
125
|
+
}
|