@4da/mcp-server 6.0.2 → 6.2.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 +433 -0
- package/README.md +26 -18
- package/dist/db.d.ts +73 -8
- package/dist/db.js +180 -43
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +15 -9
- package/dist/embeddings.js +19 -7
- package/dist/gitignore.js +9 -3
- package/dist/index.js +43 -4
- package/dist/live/cache.d.ts +31 -2
- package/dist/live/cache.js +113 -1
- package/dist/live/changelog-classify.d.ts +9 -3
- package/dist/live/changelog-classify.js +125 -12
- package/dist/live/changelog.d.ts +22 -3
- package/dist/live/changelog.js +132 -38
- package/dist/live/dev-scope.d.ts +2 -2
- package/dist/live/fix-paths.d.ts +118 -0
- package/dist/live/fix-paths.js +498 -0
- package/dist/live/github-releases.d.ts +69 -0
- package/dist/live/github-releases.js +252 -0
- package/dist/live/go-registry.d.ts +5 -0
- package/dist/live/go-registry.js +1 -1
- package/dist/live/go-releases.d.ts +48 -0
- package/dist/live/go-releases.js +196 -0
- package/dist/live/index.d.ts +34 -8
- package/dist/live/index.js +66 -7
- package/dist/live/lockfile-parents.d.ts +61 -0
- package/dist/live/lockfile-parents.js +360 -0
- package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
- package/dist/live/lockfile-parsers-pygo.js +178 -28
- package/dist/live/lockfile-parsers.d.ts +4 -1
- package/dist/live/lockfile-parsers.js +57 -24
- package/dist/live/lockfile-types.d.ts +12 -3
- package/dist/live/lockfile-types.js +2 -1
- package/dist/live/osv-ranges.d.ts +85 -0
- package/dist/live/osv-ranges.js +136 -0
- package/dist/live/osv-scanner.d.ts +1 -0
- package/dist/live/osv-scanner.js +85 -78
- package/dist/live/package-archive.d.ts +2 -0
- package/dist/live/package-archive.js +7 -4
- package/dist/live/pypi-releases.d.ts +44 -0
- package/dist/live/pypi-releases.js +103 -0
- package/dist/live/range-satisfies.d.ts +21 -0
- package/dist/live/range-satisfies.js +190 -0
- package/dist/live/rate-limiter.d.ts +6 -0
- package/dist/live/rate-limiter.js +5 -0
- package/dist/live/registry-health.js +2 -0
- package/dist/live/release-metadata.d.ts +17 -8
- package/dist/live/release-metadata.js +6 -3
- package/dist/live/resolution.d.ts +11 -1
- package/dist/live/resolution.js +16 -6
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +10 -2
- package/dist/live/types.d.ts +17 -1
- package/dist/live/upgrade-go.d.ts +62 -0
- package/dist/live/upgrade-go.js +130 -0
- package/dist/live/upgrade-pypi.d.ts +42 -0
- package/dist/live/upgrade-pypi.js +85 -0
- package/dist/live/upgrade-sources.d.ts +30 -11
- package/dist/live/upgrade-sources.js +93 -21
- package/dist/live/zip-archive.d.ts +22 -0
- package/dist/live/zip-archive.js +98 -0
- package/dist/memory-store.d.ts +19 -0
- package/dist/memory-store.js +8 -0
- package/dist/native-bindings.d.ts +7 -2
- package/dist/native-bindings.js +4 -0
- package/dist/project-tree.d.ts +50 -8
- package/dist/project-tree.js +121 -29
- package/dist/schema-registry.js +5 -5
- package/dist/server-instructions.d.ts +6 -0
- package/dist/server-instructions.js +20 -15
- package/dist/sqlite-driver.d.ts +134 -0
- package/dist/sqlite-driver.js +359 -0
- package/dist/tool-dispatch.d.ts +8 -1
- package/dist/tool-dispatch.js +17 -2
- package/dist/tools/agent-memory.js +10 -2
- package/dist/tools/app-plan.d.ts +11 -1
- package/dist/tools/app-plan.js +32 -6
- package/dist/tools/briefing-task-scope.d.ts +21 -2
- package/dist/tools/briefing-task-scope.js +97 -4
- package/dist/tools/decision-memory.js +9 -3
- package/dist/tools/dependency-check-signals.d.ts +23 -5
- package/dist/tools/dependency-check-signals.js +62 -19
- package/dist/tools/dependency-check-versions.d.ts +94 -0
- package/dist/tools/dependency-check-versions.js +213 -0
- package/dist/tools/dependency-check.d.ts +27 -11
- package/dist/tools/dependency-check.js +321 -74
- package/dist/tools/dependency-health.d.ts +1 -1
- package/dist/tools/dependency-health.js +5 -0
- package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
- package/dist/tools/knowledge-gap-ranges.js +1 -1
- package/dist/tools/knowledge-gap-scope.d.ts +2 -2
- package/dist/tools/project-scope.d.ts +45 -0
- package/dist/tools/project-scope.js +119 -0
- package/dist/tools/record-feedback.js +1 -1
- package/dist/tools/upgrade-impact-callsites.d.ts +1 -0
- package/dist/tools/upgrade-impact-callsites.js +23 -5
- package/dist/tools/upgrade-impact-changelog.d.ts +41 -0
- package/dist/tools/upgrade-impact-changelog.js +79 -0
- package/dist/tools/upgrade-impact-imports.d.ts +42 -0
- package/dist/tools/upgrade-impact-imports.js +260 -0
- package/dist/tools/upgrade-impact-literals.js +1 -1
- package/dist/tools/upgrade-impact-report.d.ts +23 -0
- package/dist/tools/upgrade-impact-report.js +45 -6
- package/dist/tools/upgrade-impact.d.ts +13 -4
- package/dist/tools/upgrade-impact.js +236 -85
- package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +31 -3
- package/dist/tools/upgrade-planner.js +74 -19
- package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
- package/dist/tools/vulnerability-scan-concise.js +15 -3
- package/dist/tools/vulnerability-scan-format.d.ts +58 -1
- package/dist/tools/vulnerability-scan-format.js +107 -11
- package/dist/tools/vulnerability-scan.d.ts +104 -13
- package/dist/tools/vulnerability-scan.js +27 -3
- package/dist/tools/what-should-i-know.d.ts +22 -2
- package/dist/tools/what-should-i-know.js +122 -12
- package/package.json +10 -32
|
@@ -19,62 +19,212 @@ import * as fs from "node:fs";
|
|
|
19
19
|
import * as path from "node:path";
|
|
20
20
|
import { emptySource, found, InstanceSet } from "./lockfile-types.js";
|
|
21
21
|
// =============================================================================
|
|
22
|
-
// Python:
|
|
22
|
+
// Python: every lockfile and requirements file in the directory, merged
|
|
23
23
|
// =============================================================================
|
|
24
24
|
/** PEP 503 normalised name: lowercase, runs of `-`, `_`, `.` become one `-`. */
|
|
25
25
|
export function normalizePythonName(name) {
|
|
26
26
|
return name.trim().toLowerCase().replace(/[-_.]+/g, "-");
|
|
27
27
|
}
|
|
28
|
+
/** The TOML lockfiles: arrays of [[package]] tables. */
|
|
29
|
+
const PYTHON_TOML_LOCKS = ["poetry.lock", "uv.lock", "pdm.lock"];
|
|
30
|
+
/**
|
|
31
|
+
* Requirement files in a project directory: `*requirements*.txt` beside it
|
|
32
|
+
* (requirements-dev.txt, dev_requirements.txt, _test_minimum_requirements.txt)
|
|
33
|
+
* and every `.txt` in a `requirements/` folder (pip-compile-multi: superset
|
|
34
|
+
* keeps its 100 runtime pins in requirements/base.txt, which nothing read).
|
|
35
|
+
*/
|
|
36
|
+
export function requirementFiles(cwd) {
|
|
37
|
+
const out = [];
|
|
38
|
+
try {
|
|
39
|
+
for (const name of fs.readdirSync(cwd).sort()) {
|
|
40
|
+
if (/requirements[\w.-]*\.txt$/i.test(name) || includesRequirements(path.join(cwd, name)))
|
|
41
|
+
out.push(path.join(cwd, name));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
for (const name of fs.readdirSync(path.join(cwd, "requirements")).sort()) {
|
|
49
|
+
if (/\.txt$/i.test(name))
|
|
50
|
+
out.push(path.join(cwd, "requirements", name));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// no requirements/ folder
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** Above this a .txt is not read to decide whether it is a requirements file. */
|
|
59
|
+
const MAX_SNIFF_BYTES = 256 * 1024;
|
|
60
|
+
/**
|
|
61
|
+
* A `.txt` whose name does not say "requirements" but that includes one with
|
|
62
|
+
* `-r` and pins packages itself: CTFd's development.txt (`-r requirements.txt`,
|
|
63
|
+
* then pytest==5.4.2 and 21 more pins, 6 advisories pip-audit reports).
|
|
64
|
+
*/
|
|
65
|
+
function includesRequirements(file) {
|
|
66
|
+
if (!/\.txt$/i.test(file) || /requirements[\w.-]*\.txt$/i.test(file))
|
|
67
|
+
return false;
|
|
68
|
+
try {
|
|
69
|
+
const stat = fs.statSync(file);
|
|
70
|
+
if (!stat.isFile() || stat.size > MAX_SNIFF_BYTES)
|
|
71
|
+
return false;
|
|
72
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
73
|
+
return /^\s*(?:-r|--requirement)[\s=]+\S+\.txt\s*$/m.test(content) && parseRequirements(content).length > 0;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether a requirements file is dev-only by name: requirements-dev.txt,
|
|
81
|
+
* dev-requirements.txt, requirements/testing.txt, _test_minimum_requirements.txt,
|
|
82
|
+
* docs-requirements.txt. The convention is near universal, but it is a name:
|
|
83
|
+
* a package any other file also lists keeps an unknown scope, never "dev".
|
|
84
|
+
*/
|
|
85
|
+
export function devRequirementsFile(file) {
|
|
86
|
+
const name = path.basename(file).replace(/\.txt$/i, "");
|
|
87
|
+
return /(?:^|[-_.])(?:dev|devel|develop|development|test|tests|testing|lint|linting|docs?|ci|typing|mypy|bench|benchmarks?)(?:[-_.]|$)/i.test(name);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Every Python source in a directory, merged: poetry.lock, uv.lock, pdm.lock,
|
|
91
|
+
* Pipfile.lock AND the requirement files. One source per ecosystem dropped
|
|
92
|
+
* whatever sat beside the first: langchain's libs/community has a
|
|
93
|
+
* poetry.lock and _test_minimum_requirements.txt, and only the lock was read.
|
|
94
|
+
* The version a direct dependency resolves to comes from a lockfile first.
|
|
95
|
+
*/
|
|
28
96
|
export function resolvePython(cwd) {
|
|
29
|
-
|
|
30
|
-
|
|
97
|
+
const versions = new Map();
|
|
98
|
+
const instances = new InstanceSet();
|
|
99
|
+
const sources = [];
|
|
100
|
+
let lockfile = false;
|
|
101
|
+
const take = (name, version, dev) => {
|
|
102
|
+
instances.add(name, version, dev);
|
|
103
|
+
if (!versions.has(name))
|
|
104
|
+
versions.set(name, version);
|
|
105
|
+
};
|
|
106
|
+
for (const file of PYTHON_TOML_LOCKS) {
|
|
31
107
|
const lockPath = path.join(cwd, file);
|
|
32
108
|
if (!fs.existsSync(lockPath))
|
|
33
109
|
continue;
|
|
34
110
|
try {
|
|
35
111
|
const read = readTomlPackageLock(fs.readFileSync(lockPath, "utf-8"));
|
|
36
|
-
if (read.instances.size
|
|
37
|
-
|
|
112
|
+
if (read.instances.size === 0)
|
|
113
|
+
continue;
|
|
114
|
+
for (const i of read.instances.toArray())
|
|
115
|
+
take(i.name, i.version, i.dev);
|
|
116
|
+
sources.push(lockPath);
|
|
117
|
+
lockfile = true;
|
|
38
118
|
}
|
|
39
|
-
catch { /*
|
|
119
|
+
catch { /* unreadable: the next source */ }
|
|
40
120
|
}
|
|
41
121
|
const pipfileLock = path.join(cwd, "Pipfile.lock");
|
|
42
122
|
if (fs.existsSync(pipfileLock)) {
|
|
43
123
|
try {
|
|
44
124
|
const lock = JSON.parse(fs.readFileSync(pipfileLock, "utf-8"));
|
|
45
|
-
|
|
46
|
-
const instances = new InstanceSet();
|
|
125
|
+
let any = false;
|
|
47
126
|
for (const [section, dev] of [["default", false], ["develop", true]]) {
|
|
48
127
|
for (const [name, info] of Object.entries(lock[section] ?? {})) {
|
|
49
128
|
const version = info.version?.replace(/^===?/, "");
|
|
50
129
|
if (!version)
|
|
51
130
|
continue;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!versions.has(normalized))
|
|
55
|
-
versions.set(normalized, version);
|
|
131
|
+
take(normalizePythonName(name), version, dev);
|
|
132
|
+
any = true;
|
|
56
133
|
}
|
|
57
134
|
}
|
|
58
|
-
if (
|
|
59
|
-
|
|
135
|
+
if (any) {
|
|
136
|
+
sources.push(pipfileLock);
|
|
137
|
+
lockfile = true;
|
|
138
|
+
}
|
|
60
139
|
}
|
|
61
|
-
catch { /*
|
|
140
|
+
catch { /* unreadable */ }
|
|
62
141
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
142
|
+
for (const file of requirementFiles(cwd)) {
|
|
143
|
+
const dev = devRequirementsFile(file) ? true : undefined;
|
|
144
|
+
const pins = readRequirementsFile(file);
|
|
145
|
+
if (pins === null)
|
|
146
|
+
continue;
|
|
147
|
+
for (const { name, version } of pins)
|
|
148
|
+
take(name, version, dev);
|
|
149
|
+
sources.push(file);
|
|
150
|
+
}
|
|
151
|
+
if (sources.length === 0)
|
|
152
|
+
return emptySource();
|
|
153
|
+
return found(versions, instances, sources[0], lockfile ? "lockfile" : "manifest", { extraSources: sources.slice(1) });
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Exact pins from one requirements file, following `-r`/`--requirement`
|
|
157
|
+
* includes (relative to the file) and applying `-c`/`--constraint` files:
|
|
158
|
+
* a constraints file installs nothing by itself, so its pins only give a
|
|
159
|
+
* version to a name the requirements list without one. Null when unreadable.
|
|
160
|
+
*/
|
|
161
|
+
export function readRequirementsFile(file, visited = new Set()) {
|
|
162
|
+
const key = path.resolve(file).toLowerCase();
|
|
163
|
+
if (visited.has(key))
|
|
164
|
+
return [];
|
|
165
|
+
visited.add(key);
|
|
166
|
+
let content;
|
|
167
|
+
try {
|
|
168
|
+
content = fs.readFileSync(file, "utf-8");
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const dir = path.dirname(file);
|
|
174
|
+
const out = parseRequirements(content);
|
|
175
|
+
const constraints = new Map();
|
|
176
|
+
for (const { option, target } of requirementOptions(content)) {
|
|
177
|
+
const included = path.resolve(dir, target);
|
|
178
|
+
if (option === "r")
|
|
179
|
+
out.push(...(readRequirementsFile(included, visited) ?? []));
|
|
180
|
+
else
|
|
181
|
+
for (const pin of parseRequirements(safeRead(included)))
|
|
182
|
+
constraints.set(pin.name, pin.version);
|
|
183
|
+
}
|
|
184
|
+
if (constraints.size > 0) {
|
|
185
|
+
const pinned = new Set(out.map((p) => p.name));
|
|
186
|
+
for (const name of unpinnedRequirementNames(content)) {
|
|
187
|
+
const version = constraints.get(name);
|
|
188
|
+
if (version && !pinned.has(name))
|
|
189
|
+
out.push({ name, version });
|
|
73
190
|
}
|
|
74
|
-
catch { /* skip */ }
|
|
75
|
-
return found(versions, instances, reqPath, "manifest");
|
|
76
191
|
}
|
|
77
|
-
return
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
function safeRead(file) {
|
|
195
|
+
try {
|
|
196
|
+
return fs.readFileSync(file, "utf-8");
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return "";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** `-r file` / `--requirement=file` / `-c file` / `--constraint file` lines (local paths only). */
|
|
203
|
+
function requirementOptions(content) {
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const raw of content.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
|
|
206
|
+
const m = /^\s*(?:-(r|c)|--(requirement|constraint))(?:\s*=\s*|\s+)(\S+)/.exec(raw);
|
|
207
|
+
if (!m || /^[a-z][a-z+]*:\/\//i.test(m[3]))
|
|
208
|
+
continue;
|
|
209
|
+
out.push({ option: (m[1] ?? (m[2] === "requirement" ? "r" : "c")), target: m[3] });
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
/** Names a requirements file lists with no exact pin ("flask", "flask>=2"), PEP 503 normalised. */
|
|
214
|
+
function unpinnedRequirementNames(content) {
|
|
215
|
+
const out = [];
|
|
216
|
+
for (const raw of content.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
|
|
217
|
+
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
218
|
+
if (!line || line.startsWith("-"))
|
|
219
|
+
continue;
|
|
220
|
+
const spec = line.split(";")[0].trim();
|
|
221
|
+
if (/===?/.test(spec))
|
|
222
|
+
continue;
|
|
223
|
+
const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(spec);
|
|
224
|
+
if (m)
|
|
225
|
+
out.push(normalizePythonName(m[1]));
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
78
228
|
}
|
|
79
229
|
/**
|
|
80
230
|
* [[package]] tables from poetry.lock (any version) or uv.lock. A pre-1.2
|
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
import { InstanceSet, type VersionSource } from "./lockfile-types.js";
|
|
26
26
|
import type { OsvEcosystem } from "./types.js";
|
|
27
27
|
export type { PackageInstance, VersionSource } from "./lockfile-types.js";
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Every lock file the resolver may read for this ecosystem, present or not,
|
|
30
|
+
* in priority order; for Python also every requirements file present now.
|
|
31
|
+
*/
|
|
29
32
|
export declare function lockfileCandidates(cwd: string, ecosystem: OsvEcosystem): string[];
|
|
30
33
|
/** The manifest the resolver falls back to for this ecosystem, or null when it has none. */
|
|
31
34
|
export declare function manifestCandidate(cwd: string, ecosystem: OsvEcosystem): string | null;
|
|
@@ -27,22 +27,25 @@ import * as fs from "node:fs";
|
|
|
27
27
|
import * as path from "node:path";
|
|
28
28
|
import { readBunLock } from "./bun-lockfile.js";
|
|
29
29
|
import { readPnpmLock, readYarnLock } from "./js-lockfile-readers.js";
|
|
30
|
-
import { resolveGo, resolvePython } from "./lockfile-parsers-pygo.js";
|
|
30
|
+
import { requirementFiles, resolveGo, resolvePython } from "./lockfile-parsers-pygo.js";
|
|
31
31
|
import { emptySource, found, InstanceSet } from "./lockfile-types.js";
|
|
32
32
|
const LOCKFILES = {
|
|
33
33
|
npm: ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"],
|
|
34
34
|
"crates.io": ["Cargo.lock"],
|
|
35
|
-
PyPI: ["poetry.lock", "uv.lock", "Pipfile.lock"],
|
|
35
|
+
PyPI: ["poetry.lock", "uv.lock", "pdm.lock", "Pipfile.lock"],
|
|
36
36
|
Go: ["go.mod", "go.sum"],
|
|
37
37
|
};
|
|
38
38
|
const MANIFESTS = {
|
|
39
39
|
npm: "package.json",
|
|
40
40
|
"crates.io": "Cargo.toml",
|
|
41
|
-
PyPI: "requirements.txt",
|
|
42
41
|
};
|
|
43
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Every lock file the resolver may read for this ecosystem, present or not,
|
|
44
|
+
* in priority order; for Python also every requirements file present now.
|
|
45
|
+
*/
|
|
44
46
|
export function lockfileCandidates(cwd, ecosystem) {
|
|
45
|
-
|
|
47
|
+
const fixed = (LOCKFILES[ecosystem] ?? []).map((name) => path.join(cwd, name));
|
|
48
|
+
return ecosystem === "PyPI" ? [...fixed, ...requirementFiles(cwd)] : fixed;
|
|
46
49
|
}
|
|
47
50
|
/** The manifest the resolver falls back to for this ecosystem, or null when it has none. */
|
|
48
51
|
export function manifestCandidate(cwd, ecosystem) {
|
|
@@ -67,6 +70,30 @@ export function resolveVersionSource(cwd, ecosystem) {
|
|
|
67
70
|
function manifestInstances(versions) {
|
|
68
71
|
return [...versions].map(([name, version]) => ({ name, version }));
|
|
69
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* A manifest read with no lockfile beside it. Only an exact pin names the
|
|
75
|
+
* version that gets installed; any other requirement ("^1.2.0", "0.8") is
|
|
76
|
+
* kept in `ranges`, and its floor in `versions` is a label, not an install.
|
|
77
|
+
* nushell's samples/wasm/Cargo.toml (no Cargo.lock) was scanned as if its
|
|
78
|
+
* floors were installed and produced 2 false findings (2026-10-10).
|
|
79
|
+
*/
|
|
80
|
+
function declaredRanges(declared, exact, file) {
|
|
81
|
+
const versions = new Map();
|
|
82
|
+
const ranges = new Map();
|
|
83
|
+
for (const [name, spec] of declared) {
|
|
84
|
+
// "0.3" and "1" are ranges too: their floor is 0.3.0 / 1.0.0.
|
|
85
|
+
const partial = /(\d+)(?:\.(\d+))?/.exec(spec);
|
|
86
|
+
const version = extractVersionFromSpec(spec) ?? (partial ? `${partial[1]}.${partial[2] ?? "0"}.0` : null);
|
|
87
|
+
if (!version)
|
|
88
|
+
continue;
|
|
89
|
+
versions.set(name, version);
|
|
90
|
+
if (!exact(spec))
|
|
91
|
+
ranges.set(name, spec);
|
|
92
|
+
}
|
|
93
|
+
// Only exact pins are installs; ranged names are not instances at all.
|
|
94
|
+
const pinned = new Map([...versions].filter(([name]) => !ranges.has(name)));
|
|
95
|
+
return found(versions, manifestInstances(pinned), file, "declared_ranges", { ranges });
|
|
96
|
+
}
|
|
70
97
|
// =============================================================================
|
|
71
98
|
// npm: package-lock.json > npm-shrinkwrap.json > pnpm-lock.yaml > yarn.lock > bun.lock > package.json ranges
|
|
72
99
|
// =============================================================================
|
|
@@ -125,21 +152,20 @@ function resolveNpm(cwd) {
|
|
|
125
152
|
}
|
|
126
153
|
catch { /* fall through */ }
|
|
127
154
|
}
|
|
128
|
-
//
|
|
155
|
+
// No lockfile: package.json declares ranges, not installed versions.
|
|
129
156
|
const pkgPath = path.join(cwd, "package.json");
|
|
130
157
|
if (fs.existsSync(pkgPath)) {
|
|
131
|
-
const
|
|
158
|
+
const declared = new Map();
|
|
132
159
|
try {
|
|
133
160
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (version)
|
|
138
|
-
versions.set(name, version);
|
|
161
|
+
for (const [name, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
|
|
162
|
+
if (typeof spec === "string")
|
|
163
|
+
declared.set(name, spec);
|
|
139
164
|
}
|
|
140
165
|
}
|
|
141
166
|
catch { /* skip */ }
|
|
142
|
-
|
|
167
|
+
// npm installs exactly "1.2.3" (or "=1.2.3"); anything else is a range.
|
|
168
|
+
return declaredRanges(declared, (spec) => /^=?v?\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(spec.trim()), pkgPath);
|
|
143
169
|
}
|
|
144
170
|
return emptySource();
|
|
145
171
|
}
|
|
@@ -259,25 +285,32 @@ function resolveRust(cwd) {
|
|
|
259
285
|
return found(versions, instances, lockPath, "lockfile");
|
|
260
286
|
}
|
|
261
287
|
function resolveRustFromManifest(cwd) {
|
|
262
|
-
const
|
|
288
|
+
const declared = new Map();
|
|
263
289
|
const cargoPath = path.join(cwd, "Cargo.toml");
|
|
264
290
|
if (!fs.existsSync(cargoPath))
|
|
265
291
|
return emptySource();
|
|
266
292
|
try {
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
let
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
293
|
+
// name = "1.0" or name = { version = "1.0", ... }, inside dependency tables
|
|
294
|
+
// only: [package]'s own `version = "0.1.0"` and `edition = "2021"` were read
|
|
295
|
+
// as crates named "version" and "edition".
|
|
296
|
+
let inDeps = false;
|
|
297
|
+
for (const line of fs.readFileSync(cargoPath, "utf-8").split(/\r?\n/)) {
|
|
298
|
+
const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
|
|
299
|
+
if (header) {
|
|
300
|
+
inDeps = /(?:^|\.)(?:dev-|build-)?dependencies$/.test(header[1].trim());
|
|
301
|
+
continue;
|
|
276
302
|
}
|
|
303
|
+
if (!inDeps)
|
|
304
|
+
continue;
|
|
305
|
+
const match = /^\s*([a-zA-Z_][\w-]*)\s*=\s*(?:"([^"]+)"|.*?\bversion\s*=\s*"([^"]+)")/.exec(line);
|
|
306
|
+
const spec = match?.[2] ?? match?.[3];
|
|
307
|
+
if (match && spec && /^[=^~]?\s*\d/.test(spec))
|
|
308
|
+
declared.set(match[1], spec);
|
|
277
309
|
}
|
|
278
310
|
}
|
|
279
311
|
catch { /* skip */ }
|
|
280
|
-
|
|
312
|
+
// Cargo reads "1.0" as "^1.0": only "=1.2.3" names one version.
|
|
313
|
+
return declaredRanges(declared, (spec) => /^=\s*\d+\.\d+\.\d+/.test(spec.trim()), cargoPath);
|
|
281
314
|
}
|
|
282
315
|
// =============================================================================
|
|
283
316
|
// Helpers
|
|
@@ -16,9 +16,18 @@ export interface VersionSource {
|
|
|
16
16
|
instances: PackageInstance[];
|
|
17
17
|
/** Absolute path of the file the versions were read from, or null when none exists. */
|
|
18
18
|
source: string | null;
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `lockfile` = exact versions; `manifest` = exact pins from a requirements
|
|
21
|
+
* file; `declared_ranges` = a manifest with no lockfile (package.json,
|
|
22
|
+
* Cargo.toml): its versions are the floors of declared ranges, not installs.
|
|
23
|
+
*/
|
|
24
|
+
kind: SourceKind | null;
|
|
25
|
+
/** Every further file read for this ecosystem in this directory (Python reads several). */
|
|
26
|
+
extraSources?: string[];
|
|
27
|
+
/** declared_ranges: the declared requirement per name ("^1.2.0"), for names whose version is only a floor. */
|
|
28
|
+
ranges?: Map<string, string>;
|
|
21
29
|
}
|
|
30
|
+
export type SourceKind = "lockfile" | "manifest" | "declared_ranges";
|
|
22
31
|
export declare function emptySource(): VersionSource;
|
|
23
32
|
/**
|
|
24
33
|
* Collects instances deduplicated by name@version. A copy seen as runtime
|
|
@@ -31,4 +40,4 @@ export declare class InstanceSet {
|
|
|
31
40
|
get size(): number;
|
|
32
41
|
toArray(): PackageInstance[];
|
|
33
42
|
}
|
|
34
|
-
export declare function found(versions: Map<string, string>, instances: InstanceSet | PackageInstance[], source: string, kind: "
|
|
43
|
+
export declare function found(versions: Map<string, string>, instances: InstanceSet | PackageInstance[], source: string, kind: SourceKind, extra?: Pick<VersionSource, "extraSources" | "ranges">): VersionSource;
|
|
@@ -32,11 +32,12 @@ export class InstanceSet {
|
|
|
32
32
|
return [...this.byKey.values()];
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
export function found(versions, instances, source, kind) {
|
|
35
|
+
export function found(versions, instances, source, kind, extra = {}) {
|
|
36
36
|
return {
|
|
37
37
|
versions,
|
|
38
38
|
instances: Array.isArray(instances) ? instances : instances.toArray(),
|
|
39
39
|
source,
|
|
40
40
|
kind,
|
|
41
|
+
...extra,
|
|
41
42
|
};
|
|
42
43
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSV affected ranges, read as version spans, and the smallest version that
|
|
3
|
+
* is clean against EVERY known advisory of a package.
|
|
4
|
+
*
|
|
5
|
+
* A fix target used to be the highest per-advisory fix among the advisories
|
|
6
|
+
* that affect the INSTALLED version. That target can itself be vulnerable to
|
|
7
|
+
* an advisory the installed version predates: openssl 0.10.38 was sent to
|
|
8
|
+
* 0.10.79, which GHSA-phqj-4mhp-q6mq affects (0.10.50 up to 0.10.80), so the
|
|
9
|
+
* recommended upgrade swapped one advisory for another (fix-path oracle,
|
|
10
|
+
* 2026-10-10). The target is now checked against all of the package's
|
|
11
|
+
* advisories, not only the ones the installed version has.
|
|
12
|
+
*/
|
|
13
|
+
import type { OsvVulnerability } from "./types.js";
|
|
14
|
+
/**
|
|
15
|
+
* One affected span: [introduced, fixed), or [introduced, lastAffected]
|
|
16
|
+
* when OSV gives a `last_affected` event; both null = still open.
|
|
17
|
+
*/
|
|
18
|
+
export interface AffectedSpan {
|
|
19
|
+
introduced: string;
|
|
20
|
+
fixed: string | null;
|
|
21
|
+
lastAffected: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** What one advisory says about one package: its spans plus any versions it lists explicitly. */
|
|
24
|
+
export interface AdvisoryRanges {
|
|
25
|
+
id: string;
|
|
26
|
+
spans: AffectedSpan[];
|
|
27
|
+
versions: string[];
|
|
28
|
+
/**
|
|
29
|
+
* A maintenance notice (RustSec "unmaintained" and other informational
|
|
30
|
+
* notices), not an exploitable vulnerability. Absent on entries cached
|
|
31
|
+
* before 6.2.
|
|
32
|
+
*/
|
|
33
|
+
maintenance?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Flatten the matching package's version ranges into spans. OSV sorts events
|
|
37
|
+
* within a range; each `introduced` opens a span and the next `fixed` or
|
|
38
|
+
* `last_affected` closes it. GIT ranges hold commit hashes, not versions,
|
|
39
|
+
* and are skipped.
|
|
40
|
+
*/
|
|
41
|
+
export declare function collectAffectedSpans(affected: OsvVulnerability["affected"] | undefined, packageName: string, ecosystem: string): AffectedSpan[];
|
|
42
|
+
/** One advisory's ranges for one package; null when the advisory is withdrawn. */
|
|
43
|
+
export declare function advisoryRanges(vuln: OsvVulnerability, packageName: string, ecosystem: string): AdvisoryRanges | null;
|
|
44
|
+
/** True when `version` lies in a span (or the explicit list) of this advisory. */
|
|
45
|
+
export declare function advisoryAffects(advisory: AdvisoryRanges, version: string, ecosystem: string): boolean;
|
|
46
|
+
/** Whether a version is a prerelease in its ecosystem's own terms. */
|
|
47
|
+
export declare function isPrereleaseVersion(version: string, ecosystem: string): boolean;
|
|
48
|
+
export interface CleanTarget {
|
|
49
|
+
/** The smallest candidate at or above every per-advisory fix that no fixable advisory affects; null when none is clean. */
|
|
50
|
+
target: string | null;
|
|
51
|
+
/** Where the candidates came from. */
|
|
52
|
+
basis: "registry" | "advisory_fixes";
|
|
53
|
+
/** The highest per-advisory fix. */
|
|
54
|
+
floor: string;
|
|
55
|
+
/** Advisories that affect the floor (why the target can be above it). */
|
|
56
|
+
floorAffectedBy: string[];
|
|
57
|
+
/**
|
|
58
|
+
* Advisories that still affect the returned target (only unfixable ones:
|
|
59
|
+
* every candidate has them), or, when `target` is null, the floor.
|
|
60
|
+
*/
|
|
61
|
+
stillAffectedBy: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Advisories no release fixes: they affect the newest candidate too
|
|
64
|
+
* (braces GHSA-vfj7-8cjw-p6xm, last_affected 3.0.3 = the newest release).
|
|
65
|
+
* No target can clear them, so they do not disqualify one; they are
|
|
66
|
+
* reported instead.
|
|
67
|
+
*/
|
|
68
|
+
unfixable: string[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The smallest version at or above the highest per-advisory fix that none
|
|
72
|
+
* of the package's fixable advisories affects.
|
|
73
|
+
*
|
|
74
|
+
* Candidates are the package's published releases when known (prereleases
|
|
75
|
+
* only when the floor is one), otherwise every fix event the advisories
|
|
76
|
+
* name. An advisory that affects the newest candidate has no fixed release
|
|
77
|
+
* and does not disqualify a target (it is listed in `unfixable`). Null when
|
|
78
|
+
* `fixes` is empty.
|
|
79
|
+
*/
|
|
80
|
+
export declare function smallestCleanVersion(input: {
|
|
81
|
+
ecosystem: string;
|
|
82
|
+
fixes: string[];
|
|
83
|
+
advisories: AdvisoryRanges[];
|
|
84
|
+
published: string[] | null;
|
|
85
|
+
}): CleanTarget | null;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* OSV affected ranges, read as version spans, and the smallest version that
|
|
4
|
+
* is clean against EVERY known advisory of a package.
|
|
5
|
+
*
|
|
6
|
+
* A fix target used to be the highest per-advisory fix among the advisories
|
|
7
|
+
* that affect the INSTALLED version. That target can itself be vulnerable to
|
|
8
|
+
* an advisory the installed version predates: openssl 0.10.38 was sent to
|
|
9
|
+
* 0.10.79, which GHSA-phqj-4mhp-q6mq affects (0.10.50 up to 0.10.80), so the
|
|
10
|
+
* recommended upgrade swapped one advisory for another (fix-path oracle,
|
|
11
|
+
* 2026-10-10). The target is now checked against all of the package's
|
|
12
|
+
* advisories, not only the ones the installed version has.
|
|
13
|
+
*/
|
|
14
|
+
import { compareVersions, isComparable, parsePep440, samePackageName } from "./version-compare.js";
|
|
15
|
+
import { parseSemverPrecedence } from "./semver-precedence.js";
|
|
16
|
+
import { isMaintenanceNotice } from "./maintenance.js";
|
|
17
|
+
/**
|
|
18
|
+
* Flatten the matching package's version ranges into spans. OSV sorts events
|
|
19
|
+
* within a range; each `introduced` opens a span and the next `fixed` or
|
|
20
|
+
* `last_affected` closes it. GIT ranges hold commit hashes, not versions,
|
|
21
|
+
* and are skipped.
|
|
22
|
+
*/
|
|
23
|
+
export function collectAffectedSpans(affected, packageName, ecosystem) {
|
|
24
|
+
const spans = [];
|
|
25
|
+
for (const a of affected ?? []) {
|
|
26
|
+
// Names compare the registry's way: PEP 503 for PyPI (`Jinja2` is
|
|
27
|
+
// `jinja2`), `-`/`_` folding for crates.
|
|
28
|
+
if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
|
|
29
|
+
continue;
|
|
30
|
+
for (const range of a.ranges || []) {
|
|
31
|
+
if (range.type === "GIT")
|
|
32
|
+
continue;
|
|
33
|
+
let open = null;
|
|
34
|
+
for (const event of range.events || []) {
|
|
35
|
+
if (event.introduced !== undefined) {
|
|
36
|
+
if (open)
|
|
37
|
+
spans.push(open);
|
|
38
|
+
open = { introduced: event.introduced, fixed: null, lastAffected: null };
|
|
39
|
+
}
|
|
40
|
+
else if (event.fixed !== undefined || event.last_affected !== undefined) {
|
|
41
|
+
const close = { fixed: event.fixed ?? null, lastAffected: event.fixed === undefined ? (event.last_affected ?? null) : null };
|
|
42
|
+
if (open) {
|
|
43
|
+
spans.push({ ...open, ...close });
|
|
44
|
+
open = null;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// A closing event with no preceding `introduced`: affected since
|
|
48
|
+
// inception ("0" = OSV's since-the-beginning sentinel).
|
|
49
|
+
spans.push({ introduced: "0", ...close });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (open)
|
|
54
|
+
spans.push(open);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return spans;
|
|
58
|
+
}
|
|
59
|
+
/** Versions an advisory lists explicitly for this package (`affected[].versions`). */
|
|
60
|
+
function listedVersions(affected, packageName, ecosystem) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const a of affected ?? []) {
|
|
63
|
+
if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
|
|
64
|
+
continue;
|
|
65
|
+
out.push(...(a.versions ?? []));
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** One advisory's ranges for one package; null when the advisory is withdrawn. */
|
|
70
|
+
export function advisoryRanges(vuln, packageName, ecosystem) {
|
|
71
|
+
if (vuln.withdrawn)
|
|
72
|
+
return null;
|
|
73
|
+
const informational = vuln.database_specific?.informational;
|
|
74
|
+
return {
|
|
75
|
+
id: vuln.id,
|
|
76
|
+
spans: collectAffectedSpans(vuln.affected, packageName, ecosystem),
|
|
77
|
+
versions: listedVersions(vuln.affected, packageName, ecosystem),
|
|
78
|
+
maintenance: isMaintenanceNotice({ summary: vuln.summary ?? "" }) || informational === "unmaintained" || informational === "notice",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** True when `version` lies in a span (or the explicit list) of this advisory. */
|
|
82
|
+
export function advisoryAffects(advisory, version, ecosystem) {
|
|
83
|
+
if (advisory.versions.includes(version))
|
|
84
|
+
return true;
|
|
85
|
+
const cmp = (a, b) => compareVersions(a, b, ecosystem);
|
|
86
|
+
return advisory.spans.some((s) => cmp(version, s.introduced) >= 0 &&
|
|
87
|
+
(s.fixed !== null ? cmp(version, s.fixed) < 0 : s.lastAffected !== null ? cmp(version, s.lastAffected) <= 0 : true));
|
|
88
|
+
}
|
|
89
|
+
/** Whether a version is a prerelease in its ecosystem's own terms. */
|
|
90
|
+
export function isPrereleaseVersion(version, ecosystem) {
|
|
91
|
+
if (ecosystem === "PyPI") {
|
|
92
|
+
const p = parsePep440(version);
|
|
93
|
+
return p ? p.pre !== null || p.dev !== null : false;
|
|
94
|
+
}
|
|
95
|
+
const p = parseSemverPrecedence(version.replace(/^v(?=\d)/, ""));
|
|
96
|
+
return p ? p.prerelease.length > 0 : false;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The smallest version at or above the highest per-advisory fix that none
|
|
100
|
+
* of the package's fixable advisories affects.
|
|
101
|
+
*
|
|
102
|
+
* Candidates are the package's published releases when known (prereleases
|
|
103
|
+
* only when the floor is one), otherwise every fix event the advisories
|
|
104
|
+
* name. An advisory that affects the newest candidate has no fixed release
|
|
105
|
+
* and does not disqualify a target (it is listed in `unfixable`). Null when
|
|
106
|
+
* `fixes` is empty.
|
|
107
|
+
*/
|
|
108
|
+
export function smallestCleanVersion(input) {
|
|
109
|
+
const { ecosystem } = input;
|
|
110
|
+
const fixes = input.fixes.filter((f) => isComparable(f, ecosystem));
|
|
111
|
+
if (fixes.length === 0)
|
|
112
|
+
return null;
|
|
113
|
+
const cmp = (a, b) => compareVersions(a, b, ecosystem);
|
|
114
|
+
const floor = fixes.reduce((max, v) => (cmp(v, max) > 0 ? v : max), fixes[0]);
|
|
115
|
+
const allowPre = isPrereleaseVersion(floor, ecosystem);
|
|
116
|
+
const fromRegistry = input.published !== null && input.published.length > 0;
|
|
117
|
+
const pool = fromRegistry
|
|
118
|
+
? input.published
|
|
119
|
+
: [floor, ...input.advisories.flatMap((a) => a.spans.map((s) => s.fixed).filter((f) => f !== null))];
|
|
120
|
+
const candidates = [...new Set(pool)]
|
|
121
|
+
.filter((v) => isComparable(v, ecosystem) && cmp(v, floor) >= 0 && (allowPre || !isPrereleaseVersion(v, ecosystem)))
|
|
122
|
+
.sort(cmp);
|
|
123
|
+
const basis = fromRegistry ? "registry" : "advisory_fixes";
|
|
124
|
+
const newest = candidates[candidates.length - 1];
|
|
125
|
+
const unfixable = newest ? input.advisories.filter((a) => advisoryAffects(a, newest, ecosystem)) : [];
|
|
126
|
+
const fixable = input.advisories.filter((a) => !unfixable.includes(a));
|
|
127
|
+
const unfixableIds = [...new Set(unfixable.map((a) => a.id))];
|
|
128
|
+
const affecting = (v) => [...new Set(input.advisories.filter((a) => advisoryAffects(a, v, ecosystem)).map((a) => a.id))];
|
|
129
|
+
// Clean against everything if any candidate is; otherwise against every advisory a release fixes.
|
|
130
|
+
for (const pool of [input.advisories, fixable]) {
|
|
131
|
+
const candidate = candidates.find((v) => !pool.some((a) => advisoryAffects(a, v, ecosystem)));
|
|
132
|
+
if (candidate)
|
|
133
|
+
return { target: candidate, basis, floor, floorAffectedBy: affecting(floor), stillAffectedBy: affecting(candidate), unfixable: unfixableIds };
|
|
134
|
+
}
|
|
135
|
+
return { target: null, basis, floor, floorAffectedBy: affecting(floor), stillAffectedBy: affecting(floor), unfixable: unfixableIds };
|
|
136
|
+
}
|
|
@@ -22,6 +22,7 @@ export declare class OsvScanner {
|
|
|
22
22
|
* real advisory is never silently dropped just because its detail 404'd.
|
|
23
23
|
*/
|
|
24
24
|
private hydrateDetails;
|
|
25
|
+
/** One advisory's record; retried once after a throttle, a server error or a network failure. Null when it cannot be had. */
|
|
25
26
|
private fetchDetail;
|
|
26
27
|
}
|
|
27
28
|
/**
|