@4da/mcp-server 6.0.1 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +316 -0
- package/README.md +19 -13
- package/dist/db.d.ts +88 -7
- package/dist/db.js +248 -63
- 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 +54 -7
- package/dist/live/cache.d.ts +31 -2
- package/dist/live/cache.js +113 -1
- package/dist/live/changelog-classify.js +4 -1
- package/dist/live/changelog.d.ts +15 -0
- package/dist/live/changelog.js +67 -11
- 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/go-registry.d.ts +5 -0
- package/dist/live/go-registry.js +1 -1
- package/dist/live/index.d.ts +30 -3
- package/dist/live/index.js +59 -6
- 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 +79 -0
- package/dist/live/osv-ranges.js +133 -0
- package/dist/live/osv-scanner.d.ts +1 -0
- package/dist/live/osv-scanner.js +85 -78
- 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/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-sources.d.ts +2 -0
- package/dist/live/upgrade-sources.js +7 -2
- 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 +2 -2
- package/dist/setup.d.ts +76 -3
- package/dist/setup.js +313 -130
- package/dist/sqlite-driver.d.ts +134 -0
- package/dist/sqlite-driver.js +359 -0
- package/dist/tool-dispatch.d.ts +10 -1
- package/dist/tool-dispatch.js +37 -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 +7 -0
- package/dist/tools/dependency-check-signals.js +17 -2
- package/dist/tools/dependency-check.d.ts +1 -1
- package/dist/tools/dependency-check.js +1 -1
- 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-literals.js +1 -1
- package/dist/tools/upgrade-impact-report.d.ts +10 -0
- package/dist/tools/upgrade-impact-report.js +32 -2
- package/dist/tools/upgrade-impact.js +1 -1
- 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 +121 -11
- package/package.json +10 -31
package/dist/live/osv-scanner.js
CHANGED
|
@@ -7,18 +7,48 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Privacy: sends only package names + versions (public manifest data).
|
|
9
9
|
*/
|
|
10
|
-
import { fetchWithTimeout
|
|
10
|
+
import { fetchWithTimeout } from "./http-utils.js";
|
|
11
11
|
import { cvssBaseScore } from "./cvss.js";
|
|
12
|
-
import { compareVersions, isComparable, maxVersion
|
|
12
|
+
import { compareVersions, isComparable, maxVersion } from "./version-compare.js";
|
|
13
|
+
import { collectAffectedSpans } from "./osv-ranges.js";
|
|
13
14
|
const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
|
|
14
15
|
const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
|
|
15
16
|
const OSV_TIMEOUT_MS = 15_000;
|
|
16
17
|
const OSV_CACHE_TTL = 3600; // 1 hour
|
|
17
18
|
const OSV_DETAIL_TTL = 86_400; // 24 hours — advisory details change rarely
|
|
18
19
|
const MAX_BATCH_SIZE = 1000;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Advisory details have no batch endpoint: one GET /v1/vulns/{id} each. They
|
|
22
|
+
* were fetched in rounds of 8, each round waiting for its slowest request, so
|
|
23
|
+
* a project with 900 distinct advisories (superset) made ~113 sequential
|
|
24
|
+
* round trips. Now a pool of HYDRATE_CONCURRENCY workers keeps that many
|
|
25
|
+
* requests in flight (osv-scanner itself hydrates in parallel), a failed or
|
|
26
|
+
* throttled request is retried once, and every result is cached for 24 h.
|
|
27
|
+
*/
|
|
28
|
+
const HYDRATE_CONCURRENCY = 24;
|
|
29
|
+
/** querybatch chunks (1,000 dependencies each) sent at once. */
|
|
30
|
+
const BATCH_CONCURRENCY = 4;
|
|
31
|
+
const DETAIL_RETRY_DELAY_MS = 750;
|
|
32
|
+
/**
|
|
33
|
+
* Detail fetches in flight, shared by every scanner in the process. The
|
|
34
|
+
* startup warm-up scan and the first tool call's scan used to run side by
|
|
35
|
+
* side and fetch the same advisories twice, both before either could cache
|
|
36
|
+
* them; a concurrent scan now waits on the request already made.
|
|
37
|
+
*/
|
|
38
|
+
const detailsInFlight = new Map();
|
|
39
|
+
/** Run `work` over `items` with at most `limit` in flight; results in input order. */
|
|
40
|
+
async function pool(items, limit, work) {
|
|
41
|
+
const out = new Array(items.length);
|
|
42
|
+
let next = 0;
|
|
43
|
+
const worker = async () => {
|
|
44
|
+
while (next < items.length) {
|
|
45
|
+
const i = next++;
|
|
46
|
+
out[i] = await work(items[i]);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
22
52
|
export class OsvScanner {
|
|
23
53
|
cache;
|
|
24
54
|
rateLimiter;
|
|
@@ -92,12 +122,18 @@ export class OsvScanner {
|
|
|
92
122
|
// Phase 1 — enumerate advisory IDs per dep via the lightweight batch index.
|
|
93
123
|
// querybatch returns only `{ id, modified }`; the rich fields (severity,
|
|
94
124
|
// fixed version, summary, references) come from per-advisory hydration below.
|
|
95
|
-
const
|
|
96
|
-
for (let offset = 0; offset < deps.length; offset += MAX_BATCH_SIZE)
|
|
97
|
-
|
|
125
|
+
const chunks = [];
|
|
126
|
+
for (let offset = 0; offset < deps.length; offset += MAX_BATCH_SIZE)
|
|
127
|
+
chunks.push(deps.slice(offset, offset + MAX_BATCH_SIZE));
|
|
128
|
+
// Every chunk is answered or the scan fails: a partial answer would call
|
|
129
|
+
// the unanswered dependencies clean. The rate limit is checked for all of
|
|
130
|
+
// them before any is sent.
|
|
131
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
132
|
+
if (!this.rateLimiter.consume("osv")) {
|
|
98
133
|
throw new Error("OSV rate limit reached before all dependency batches completed");
|
|
99
134
|
}
|
|
100
|
-
|
|
135
|
+
}
|
|
136
|
+
const answered = await pool(chunks, BATCH_CONCURRENCY, async (chunk) => {
|
|
101
137
|
const queries = chunk.map((d) => ({
|
|
102
138
|
package: { name: d.name, ecosystem: d.ecosystem },
|
|
103
139
|
...(d.version ? { version: d.version } : {}),
|
|
@@ -110,29 +146,27 @@ export class OsvScanner {
|
|
|
110
146
|
if (!response.ok) {
|
|
111
147
|
throw new Error(`OSV API error: ${response.status}`);
|
|
112
148
|
}
|
|
113
|
-
this.rateLimiter.consume("osv");
|
|
114
149
|
const data = (await response.json());
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
}
|
|
150
|
+
return chunk.map((dep, i) => ({ dep, ids: (data.results[i]?.vulns ?? []).map((v) => v.id) }));
|
|
151
|
+
});
|
|
152
|
+
const matches = answered.flat();
|
|
120
153
|
// Phase 2 — hydrate each unique advisory once (cached, bounded concurrency).
|
|
121
154
|
const uniqueIds = [...new Set(matches.flatMap((m) => m.ids))];
|
|
122
155
|
const detailById = await this.hydrateDetails(uniqueIds);
|
|
123
156
|
// Phase 3 — map to entries and cache per-dep (empty results cached too, so
|
|
124
157
|
// clean deps are not re-queried).
|
|
125
158
|
const results = [];
|
|
159
|
+
const rows = [];
|
|
126
160
|
for (const { dep, ids } of matches) {
|
|
127
161
|
// Drop advisories OSV has withdrawn/retracted — surfacing them is a false
|
|
128
162
|
// positive. Un-hydrated ids (detail fetch failed) are kept: we can't know,
|
|
129
163
|
// and dropping a real advisory is worse than keeping an un-enriched one.
|
|
130
164
|
const liveIds = ids.filter((id) => !detailById.get(id)?.withdrawn);
|
|
131
165
|
const depVulns = liveIds.map((id) => mapVulnerability(detailById.get(id) ?? { id }, dep));
|
|
132
|
-
|
|
133
|
-
this.cache.set(cacheKey, depVulns, "osv", OSV_CACHE_TTL);
|
|
166
|
+
rows.push([`osv:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
|
|
134
167
|
results.push(...depVulns);
|
|
135
168
|
}
|
|
169
|
+
this.cache.setMany(rows, "osv", OSV_CACHE_TTL);
|
|
136
170
|
return results;
|
|
137
171
|
}
|
|
138
172
|
/**
|
|
@@ -151,26 +185,40 @@ export class OsvScanner {
|
|
|
151
185
|
else
|
|
152
186
|
toFetch.push(id);
|
|
153
187
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
188
|
+
const fetched = [];
|
|
189
|
+
await pool(toFetch, HYDRATE_CONCURRENCY, async (id) => {
|
|
190
|
+
let pending = detailsInFlight.get(id);
|
|
191
|
+
if (!pending) {
|
|
192
|
+
pending = this.fetchDetail(id).finally(() => detailsInFlight.delete(id));
|
|
193
|
+
detailsInFlight.set(id, pending);
|
|
194
|
+
}
|
|
195
|
+
const detail = await pending;
|
|
196
|
+
if (detail) {
|
|
197
|
+
out.set(id, detail);
|
|
198
|
+
fetched.push([`osv:detail:${id}`, detail]);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
this.cache.setMany(fetched, "osv", OSV_DETAIL_TTL);
|
|
165
202
|
return out;
|
|
166
203
|
}
|
|
204
|
+
/** One advisory's record; retried once after a throttle, a server error or a network failure. Null when it cannot be had. */
|
|
167
205
|
async fetchDetail(id) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
206
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetchWithTimeout(`${OSV_VULN_URL}${encodeURIComponent(id)}`, {}, OSV_TIMEOUT_MS);
|
|
209
|
+
if (response.ok)
|
|
210
|
+
return (await response.json());
|
|
211
|
+
// 404 and other client errors are answers, not failures to retry.
|
|
212
|
+
if (response.status !== 429 && response.status < 500)
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// network failure or timeout: retry once
|
|
217
|
+
}
|
|
218
|
+
if (attempt === 0)
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, DETAIL_RETRY_DELAY_MS));
|
|
173
220
|
}
|
|
221
|
+
return null;
|
|
174
222
|
}
|
|
175
223
|
}
|
|
176
224
|
/**
|
|
@@ -313,7 +361,7 @@ function cvssToSeverity(score) {
|
|
|
313
361
|
export function extractFixedVersion(affected, packageName, ecosystem, installedVersion) {
|
|
314
362
|
if (!affected)
|
|
315
363
|
return null;
|
|
316
|
-
const intervals =
|
|
364
|
+
const intervals = collectAffectedSpans(affected, packageName, ecosystem);
|
|
317
365
|
const fixes = intervals
|
|
318
366
|
.map((i) => i.fixed)
|
|
319
367
|
.filter((f) => f !== null);
|
|
@@ -330,7 +378,9 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
|
|
|
330
378
|
// unreadable bound compares as 0, which keeps those intervals eligible on
|
|
331
379
|
// the introduced side only; "0" (since inception) orders below everything.
|
|
332
380
|
const containing = intervals.find((i) => cmp(installedVersion, i.introduced) >= 0 &&
|
|
333
|
-
(i.fixed
|
|
381
|
+
(i.fixed !== null
|
|
382
|
+
? cmp(installedVersion, i.fixed) < 0
|
|
383
|
+
: i.lastAffected === null || cmp(installedVersion, i.lastAffected) <= 0));
|
|
334
384
|
if (containing?.fixed && cmp(containing.fixed, installedVersion) > 0) {
|
|
335
385
|
return containing.fixed;
|
|
336
386
|
}
|
|
@@ -341,46 +391,3 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
|
|
|
341
391
|
return null;
|
|
342
392
|
return above.reduce((min, v) => (cmp(v, min) < 0 ? v : min), above[0]);
|
|
343
393
|
}
|
|
344
|
-
/**
|
|
345
|
-
* Flatten the matching package's version ranges into [introduced, fixed)
|
|
346
|
-
* intervals. OSV sorts events within a range; each `introduced` opens an
|
|
347
|
-
* interval and the next `fixed` closes it. GIT ranges hold commit hashes,
|
|
348
|
-
* not versions, and are skipped.
|
|
349
|
-
*/
|
|
350
|
-
function collectVulnerableIntervals(affected, packageName, ecosystem) {
|
|
351
|
-
const intervals = [];
|
|
352
|
-
for (const a of affected) {
|
|
353
|
-
// Names compare the registry's way: PEP 503 for PyPI (`Jinja2` is
|
|
354
|
-
// `jinja2`), `-`/`_` folding for crates. An exact compare found no range
|
|
355
|
-
// for Jinja2 or PyYAML and reported "no fix version published" for both.
|
|
356
|
-
if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
|
|
357
|
-
continue;
|
|
358
|
-
for (const range of a.ranges || []) {
|
|
359
|
-
if (range.type === "GIT")
|
|
360
|
-
continue;
|
|
361
|
-
let open = null;
|
|
362
|
-
for (const event of range.events || []) {
|
|
363
|
-
if (event.introduced !== undefined) {
|
|
364
|
-
if (open)
|
|
365
|
-
intervals.push(open);
|
|
366
|
-
open = { introduced: event.introduced, fixed: null };
|
|
367
|
-
}
|
|
368
|
-
else if (event.fixed !== undefined) {
|
|
369
|
-
if (open) {
|
|
370
|
-
open.fixed = event.fixed;
|
|
371
|
-
intervals.push(open);
|
|
372
|
-
open = null;
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
// `fixed` with no preceding `introduced` — treat as vulnerable
|
|
376
|
-
// since inception ("0" = OSV's since-the-beginning sentinel).
|
|
377
|
-
intervals.push({ introduced: "0", fixed: event.fixed });
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
if (open)
|
|
382
|
-
intervals.push(open);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
return intervals;
|
|
386
|
-
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a declared requirement admit a version? npm ranges (package.json,
|
|
3
|
+
* package-lock `dependencies`, yarn) and Cargo version requirements.
|
|
4
|
+
*
|
|
5
|
+
* Used to tell "the parent already allows the fixed version, so a lockfile
|
|
6
|
+
* refresh is enough" from "waiting on upstream". The planner labelled
|
|
7
|
+
* minimist 1.2.5, braces 3.0.2 and mio 0.8.0 "waiting on upstream" although
|
|
8
|
+
* every parent's range admitted the fix, and a refresh fixed each one
|
|
9
|
+
* (fix-path oracle, 2026-10-10).
|
|
10
|
+
*
|
|
11
|
+
* Unknown syntax (git and file specs, `npm:` aliases, dist-tags) yields null,
|
|
12
|
+
* never a guess.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Whether an npm range admits `version`. Null when the range is not a
|
|
16
|
+
* registry semver range (git, file, link, workspace, alias, tag) or the
|
|
17
|
+
* version is unreadable.
|
|
18
|
+
*/
|
|
19
|
+
export declare function npmRangeAdmits(range: string, version: string): boolean | null;
|
|
20
|
+
/** Whether a Cargo version requirement ("0.8", "^1.2", ">=0.10, <0.11", "=1.0.5") admits `version`. Null when unreadable. */
|
|
21
|
+
export declare function cargoReqAdmits(req: string, version: string): boolean | null;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Does a declared requirement admit a version? npm ranges (package.json,
|
|
4
|
+
* package-lock `dependencies`, yarn) and Cargo version requirements.
|
|
5
|
+
*
|
|
6
|
+
* Used to tell "the parent already allows the fixed version, so a lockfile
|
|
7
|
+
* refresh is enough" from "waiting on upstream". The planner labelled
|
|
8
|
+
* minimist 1.2.5, braces 3.0.2 and mio 0.8.0 "waiting on upstream" although
|
|
9
|
+
* every parent's range admitted the fix, and a refresh fixed each one
|
|
10
|
+
* (fix-path oracle, 2026-10-10).
|
|
11
|
+
*
|
|
12
|
+
* Unknown syntax (git and file specs, `npm:` aliases, dist-tags) yields null,
|
|
13
|
+
* never a guess.
|
|
14
|
+
*/
|
|
15
|
+
import { comparePrecedence, parseSemverPrecedence } from "./semver-precedence.js";
|
|
16
|
+
const WILD = /^[xX*]$/;
|
|
17
|
+
function parsePartial(raw) {
|
|
18
|
+
const text = raw.trim().replace(/^v/, "").replace(/\+.*$/, "");
|
|
19
|
+
if (text === "" || WILD.test(text))
|
|
20
|
+
return { major: null, minor: null, patch: null, prerelease: [] };
|
|
21
|
+
const m = /^(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(text);
|
|
22
|
+
if (!m)
|
|
23
|
+
return null;
|
|
24
|
+
const num = (s) => (s === undefined || WILD.test(s) ? null : Number(s));
|
|
25
|
+
const major = num(m[1]);
|
|
26
|
+
const minor = major === null ? null : num(m[2]);
|
|
27
|
+
const patch = minor === null ? null : num(m[3]);
|
|
28
|
+
return { major, minor, patch, prerelease: m[4] && patch !== null ? m[4].split(".") : [] };
|
|
29
|
+
}
|
|
30
|
+
const sv = (major, minor, patch, prerelease = []) => ({ major, minor, patch, prerelease });
|
|
31
|
+
/** The lowest version of the next line: the `-0` prerelease, so 2.0.0-beta is excluded by "<2.0.0-0". */
|
|
32
|
+
const below = (major, minor, patch) => ({ op: "<", v: sv(major, minor, patch, ["0"]) });
|
|
33
|
+
function floorOf(p) {
|
|
34
|
+
return sv(p.major ?? 0, p.minor ?? 0, p.patch ?? 0, p.prerelease);
|
|
35
|
+
}
|
|
36
|
+
function caret(p) {
|
|
37
|
+
if (p.major === null)
|
|
38
|
+
return [];
|
|
39
|
+
const lo = { op: ">=", v: floorOf(p) };
|
|
40
|
+
if (p.major > 0)
|
|
41
|
+
return [lo, below(p.major + 1, 0, 0)];
|
|
42
|
+
if (p.minor === null)
|
|
43
|
+
return [lo, below(1, 0, 0)];
|
|
44
|
+
if (p.minor > 0)
|
|
45
|
+
return [lo, below(0, p.minor + 1, 0)];
|
|
46
|
+
if (p.patch === null)
|
|
47
|
+
return [lo, below(0, 1, 0)];
|
|
48
|
+
return [lo, below(0, 0, p.patch + 1)];
|
|
49
|
+
}
|
|
50
|
+
function tilde(p) {
|
|
51
|
+
if (p.major === null)
|
|
52
|
+
return [];
|
|
53
|
+
const lo = { op: ">=", v: floorOf(p) };
|
|
54
|
+
if (p.minor === null)
|
|
55
|
+
return [lo, below(p.major + 1, 0, 0)];
|
|
56
|
+
return [lo, below(p.major, p.minor + 1, 0)];
|
|
57
|
+
}
|
|
58
|
+
/** An x-range or partial version as a range: "1.2" = >=1.2.0 <1.3.0-0. */
|
|
59
|
+
function xrange(p) {
|
|
60
|
+
if (p.major === null)
|
|
61
|
+
return [];
|
|
62
|
+
if (p.minor === null)
|
|
63
|
+
return [{ op: ">=", v: sv(p.major, 0, 0) }, below(p.major + 1, 0, 0)];
|
|
64
|
+
if (p.patch === null)
|
|
65
|
+
return [{ op: ">=", v: sv(p.major, p.minor, 0) }, below(p.major, p.minor + 1, 0)];
|
|
66
|
+
return [{ op: "=", v: floorOf(p) }];
|
|
67
|
+
}
|
|
68
|
+
function primitive(op, p) {
|
|
69
|
+
if (op === "=")
|
|
70
|
+
return xrange(p);
|
|
71
|
+
if (p.major === null)
|
|
72
|
+
return op === "<" || op === ">" ? null : [];
|
|
73
|
+
const full = p.patch !== null;
|
|
74
|
+
if (full)
|
|
75
|
+
return [{ op, v: floorOf(p) }];
|
|
76
|
+
// Partial bounds widen or narrow to the whole line, as node-semver does.
|
|
77
|
+
const next = p.minor === null ? sv(p.major + 1, 0, 0) : sv(p.major, p.minor + 1, 0);
|
|
78
|
+
switch (op) {
|
|
79
|
+
case ">=":
|
|
80
|
+
return [{ op: ">=", v: floorOf(p) }];
|
|
81
|
+
case ">":
|
|
82
|
+
return [{ op: ">=", v: next }];
|
|
83
|
+
case "<":
|
|
84
|
+
return [{ op: "<", v: sv(p.major, p.minor ?? 0, 0, ["0"]) }];
|
|
85
|
+
case "<=":
|
|
86
|
+
return [{ op: "<", v: { ...next, prerelease: ["0"] } }];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** One comparator token: "^1.2", ">=1.0.0", "~0.3", "1.x". `bareIsCaret` = Cargo's reading of "1.2.3". */
|
|
90
|
+
function comparatorSet(token, bareIsCaret) {
|
|
91
|
+
const m = /^(\^|~>?|>=|<=|>|<|=)?\s*(.*)$/.exec(token.trim());
|
|
92
|
+
if (!m)
|
|
93
|
+
return null;
|
|
94
|
+
const p = parsePartial(m[2]);
|
|
95
|
+
if (!p)
|
|
96
|
+
return null;
|
|
97
|
+
switch (m[1]) {
|
|
98
|
+
case "^":
|
|
99
|
+
return caret(p);
|
|
100
|
+
case "~":
|
|
101
|
+
case "~>":
|
|
102
|
+
return tilde(p);
|
|
103
|
+
case ">=":
|
|
104
|
+
case "<=":
|
|
105
|
+
case ">":
|
|
106
|
+
case "<":
|
|
107
|
+
return primitive(m[1], p);
|
|
108
|
+
case "=":
|
|
109
|
+
return xrange(p);
|
|
110
|
+
default:
|
|
111
|
+
return bareIsCaret && p.major !== null ? caret(p) : xrange(p);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function test(set, version) {
|
|
115
|
+
for (const c of set) {
|
|
116
|
+
const d = comparePrecedence(version, c.v);
|
|
117
|
+
const ok = c.op === ">=" ? d >= 0 : c.op === ">" ? d > 0 : c.op === "<" ? d < 0 : c.op === "<=" ? d <= 0 : d === 0;
|
|
118
|
+
if (!ok)
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (version.prerelease.length === 0)
|
|
122
|
+
return true;
|
|
123
|
+
// A prerelease satisfies only a set that names a prerelease of the same
|
|
124
|
+
// MAJOR.MINOR.PATCH (node-semver and Cargo agree on this).
|
|
125
|
+
return set.some((c) => c.v.prerelease.length > 0 &&
|
|
126
|
+
!(c.v.prerelease.length === 1 && c.v.prerelease[0] === "0" && c.op === "<") &&
|
|
127
|
+
c.v.major === version.major &&
|
|
128
|
+
c.v.minor === version.minor &&
|
|
129
|
+
c.v.patch === version.patch);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Whether an npm range admits `version`. Null when the range is not a
|
|
133
|
+
* registry semver range (git, file, link, workspace, alias, tag) or the
|
|
134
|
+
* version is unreadable.
|
|
135
|
+
*/
|
|
136
|
+
export function npmRangeAdmits(range, version) {
|
|
137
|
+
const v = parseSemverPrecedence(version);
|
|
138
|
+
if (!v)
|
|
139
|
+
return null;
|
|
140
|
+
// yarn berry writes "npm:^1.2.0"; an alias ("npm:other@^1") names another package.
|
|
141
|
+
const text = range.trim().replace(/^npm:(?=[\^~<>=\dvxX*\s]|$)/, "");
|
|
142
|
+
// Protocols (git+https:, file:, link:, workspace:, npm:alias@), paths, GitHub shorthand.
|
|
143
|
+
if (/^[a-z][a-z+]*:/i.test(text) || text.includes("/"))
|
|
144
|
+
return null;
|
|
145
|
+
// A dist-tag ("latest", "next", "beta").
|
|
146
|
+
if (/^[a-z][\w.-]*$/i.test(text) && !WILD.test(text))
|
|
147
|
+
return null;
|
|
148
|
+
for (const alt of text.split("||")) {
|
|
149
|
+
const part = alt.trim();
|
|
150
|
+
let set = [];
|
|
151
|
+
const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(part);
|
|
152
|
+
if (hyphen) {
|
|
153
|
+
const lo = parsePartial(hyphen[1]);
|
|
154
|
+
const hi = parsePartial(hyphen[2]);
|
|
155
|
+
if (!lo || !hi)
|
|
156
|
+
return null;
|
|
157
|
+
set.push({ op: ">=", v: floorOf(lo) });
|
|
158
|
+
const upper = hi.patch !== null ? [{ op: "<=", v: floorOf(hi) }] : primitive("<=", hi);
|
|
159
|
+
if (!upper)
|
|
160
|
+
return null;
|
|
161
|
+
set.push(...upper);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// ">= 1.2.3" with a space after the operator is one comparator.
|
|
165
|
+
for (const token of part.replace(/([<>=~^])\s+/g, "$1").split(/\s+/).filter(Boolean)) {
|
|
166
|
+
const c = comparatorSet(token, false);
|
|
167
|
+
if (c === null)
|
|
168
|
+
return null;
|
|
169
|
+
set = set.concat(c);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (test(set, v))
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
/** Whether a Cargo version requirement ("0.8", "^1.2", ">=0.10, <0.11", "=1.0.5") admits `version`. Null when unreadable. */
|
|
178
|
+
export function cargoReqAdmits(req, version) {
|
|
179
|
+
const v = parseSemverPrecedence(version);
|
|
180
|
+
if (!v)
|
|
181
|
+
return null;
|
|
182
|
+
let set = [];
|
|
183
|
+
for (const token of req.split(",").map((t) => t.trim()).filter(Boolean)) {
|
|
184
|
+
const c = comparatorSet(token, true);
|
|
185
|
+
if (c === null)
|
|
186
|
+
return null;
|
|
187
|
+
set = set.concat(c);
|
|
188
|
+
}
|
|
189
|
+
return test(set, v);
|
|
190
|
+
}
|
|
@@ -47,6 +47,11 @@ export class RateLimiter {
|
|
|
47
47
|
}
|
|
48
48
|
export const DEFAULT_RATE_LIMITS = {
|
|
49
49
|
osv: { maxPerMinute: 10 },
|
|
50
|
+
// Per-package advisory lists for fix-path checks (/v1/query), one request per vulnerable package.
|
|
51
|
+
"osv-package": { maxPerMinute: 300 },
|
|
52
|
+
// Fix-path lookups on registry CDNs (crates.io sparse index, abbreviated npm
|
|
53
|
+
// documents): separate from the API buckets so a plan never starves health checks.
|
|
54
|
+
"fixpath-registry": { maxPerMinute: 600 },
|
|
50
55
|
hn: { maxPerMinute: 30 },
|
|
51
56
|
npm: { maxPerMinute: 120 },
|
|
52
57
|
crates: { maxPerMinute: 50 },
|
|
@@ -10,6 +10,7 @@ function unavailable(dep, fetchError) {
|
|
|
10
10
|
latestVersion: null, latestStableVersion: null, versionsBehind: null,
|
|
11
11
|
deprecated: false, deprecationMessage: null, lastPublished: null,
|
|
12
12
|
license: null, weeklyDownloads: null, isDev: dep.isDev, fetchError,
|
|
13
|
+
...(dep.declaredRange ? { declaredRange: dep.declaredRange } : {}),
|
|
13
14
|
};
|
|
14
15
|
}
|
|
15
16
|
export async function fetchRegistryHealthFor(deps, registries, enabled) {
|
|
@@ -70,5 +71,6 @@ function restampRegistryContext(info, dep) {
|
|
|
70
71
|
currentVersion: dep.version,
|
|
71
72
|
isDev: dep.isDev,
|
|
72
73
|
versionsBehind: dep.version && latest ? computeSemverDistance(dep.version, latest) : null,
|
|
74
|
+
...(dep.declaredRange ? { declaredRange: dep.declaredRange } : {}),
|
|
73
75
|
};
|
|
74
76
|
}
|
|
@@ -33,8 +33,18 @@ export interface GroupResolution {
|
|
|
33
33
|
/** The full lockfile set plus drift entries (vulnerability scanning). */
|
|
34
34
|
audit: ResolvedDependency[];
|
|
35
35
|
drift: InstallDriftRecord[];
|
|
36
|
-
/** The file the versions were read from, or null when the group resolved from nothing. */
|
|
36
|
+
/** The (first) file the versions were read from, or null when the group resolved from nothing. */
|
|
37
37
|
source: ResolutionSourceRecord | null;
|
|
38
|
+
/** Every file read, `source` first (Python merges its lockfiles and requirement files). */
|
|
39
|
+
sources: ResolutionSourceRecord[];
|
|
40
|
+
/**
|
|
41
|
+
* Set when no lockfile pins this group's versions: the manifest and how many
|
|
42
|
+
* of its dependencies are declared ranges, which are not sent to OSV.
|
|
43
|
+
*/
|
|
44
|
+
unresolved: {
|
|
45
|
+
manifest: string;
|
|
46
|
+
ranges: number;
|
|
47
|
+
} | null;
|
|
38
48
|
signatures: Map<string, FileSignature>;
|
|
39
49
|
resolvedAt: string;
|
|
40
50
|
}
|
package/dist/live/resolution.js
CHANGED
|
@@ -30,11 +30,20 @@ export function resolveGroup(group) {
|
|
|
30
30
|
...(manifest ? [manifest] : []),
|
|
31
31
|
]);
|
|
32
32
|
const read = resolveVersionSource(group.dir, ecosystem);
|
|
33
|
+
const readFiles = read.source ? [read.source, ...(read.extraSources ?? [])] : [];
|
|
33
34
|
// The manifest only matters when the resolver fell back to it; editing a
|
|
34
35
|
// script in package.json must not throw away a scan of an unchanged lockfile.
|
|
35
|
-
if (manifest &&
|
|
36
|
+
if (manifest && !readFiles.includes(manifest))
|
|
36
37
|
signatures.delete(manifest);
|
|
37
38
|
let resolved = resolveVersionsFrom(group.dir, group.deps, group.devDeps, ecosystem, read.versions, group.targets);
|
|
39
|
+
// No lockfile: a ranged dependency's version is the range's floor, a label.
|
|
40
|
+
const ranges = read.kind === "declared_ranges" ? read.ranges ?? new Map() : null;
|
|
41
|
+
if (ranges && ranges.size > 0) {
|
|
42
|
+
resolved = resolved.map((dep) => {
|
|
43
|
+
const declaredRange = ranges.get(dep.name);
|
|
44
|
+
return declaredRange ? { ...dep, declaredRange } : dep;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
38
47
|
let drift = [];
|
|
39
48
|
let driftAudit = [];
|
|
40
49
|
// Only a lockfile pins exact versions; comparing node_modules against a
|
|
@@ -50,11 +59,12 @@ export function resolveGroup(group) {
|
|
|
50
59
|
const audit = [
|
|
51
60
|
...resolveAuditVersionsFrom(group.dir, group.deps, group.devDeps, ecosystem, read.versions, group.targets, resolved, read.instances),
|
|
52
61
|
...driftAudit,
|
|
53
|
-
];
|
|
54
|
-
const
|
|
55
|
-
? { path:
|
|
56
|
-
:
|
|
57
|
-
|
|
62
|
+
].filter((dep) => !dep.declaredRange);
|
|
63
|
+
const sources = read.kind
|
|
64
|
+
? readFiles.map((file) => ({ path: file, kind: read.kind, mtimeMs: signatureMtime(signatures.get(file)) }))
|
|
65
|
+
: [];
|
|
66
|
+
const unresolved = ranges && ranges.size > 0 && read.source ? { manifest: read.source, ranges: ranges.size } : null;
|
|
67
|
+
return { group, resolved, audit, drift, source: sources[0] ?? null, sources, unresolved, signatures, resolvedAt };
|
|
58
68
|
}
|
|
59
69
|
/** True when any file the resolution depended on changed, appeared, or vanished. */
|
|
60
70
|
export function groupIsStale(result) {
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { SemverDistance } from "./types.js";
|
|
2
2
|
export declare function parseSemver(version: string): [number, number, number] | null;
|
|
3
|
+
/**
|
|
4
|
+
* How far `current` is behind `latest`. `label` is the kind of bump, by the
|
|
5
|
+
* caret rules npm and Cargo apply: below 1.0 a minor bump is breaking (^0.8.6
|
|
6
|
+
* does not admit 0.9.0) and below 0.1 a patch bump is too (^0.0.3 is =0.0.3),
|
|
7
|
+
* so both are labelled "major", the same as upgrade_impact and dependency_check.
|
|
8
|
+
* Until 6.0.2 sqlx 0.8 -> 0.9 and reqwest 0.12 -> 0.13 were labelled "minor".
|
|
9
|
+
* The numeric fields stay literal (0.8.6 -> 0.9.0 is major 0, minor 1).
|
|
10
|
+
*/
|
|
3
11
|
export declare function computeSemverDistance(current: string, latest: string): SemverDistance | null;
|
|
4
12
|
export declare function isPreRelease(version: string): boolean;
|
|
5
13
|
/**
|
|
@@ -5,6 +5,14 @@ export function parseSemver(version) {
|
|
|
5
5
|
return null;
|
|
6
6
|
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* How far `current` is behind `latest`. `label` is the kind of bump, by the
|
|
10
|
+
* caret rules npm and Cargo apply: below 1.0 a minor bump is breaking (^0.8.6
|
|
11
|
+
* does not admit 0.9.0) and below 0.1 a patch bump is too (^0.0.3 is =0.0.3),
|
|
12
|
+
* so both are labelled "major", the same as upgrade_impact and dependency_check.
|
|
13
|
+
* Until 6.0.2 sqlx 0.8 -> 0.9 and reqwest 0.12 -> 0.13 were labelled "minor".
|
|
14
|
+
* The numeric fields stay literal (0.8.6 -> 0.9.0 is major 0, minor 1).
|
|
15
|
+
*/
|
|
8
16
|
export function computeSemverDistance(current, latest) {
|
|
9
17
|
const c = parseSemver(current);
|
|
10
18
|
const l = parseSemver(latest);
|
|
@@ -17,9 +25,9 @@ export function computeSemverDistance(current, latest) {
|
|
|
17
25
|
if (major > 0)
|
|
18
26
|
label = "major";
|
|
19
27
|
else if (minor > 0)
|
|
20
|
-
label = "minor";
|
|
28
|
+
label = c[0] === 0 ? "major" : "minor";
|
|
21
29
|
else if (patch > 0)
|
|
22
|
-
label = "patch";
|
|
30
|
+
label = c[0] === 0 && c[1] === 0 ? "major" : "patch";
|
|
23
31
|
return { major: Math.max(0, major), minor: Math.max(0, minor), patch: Math.max(0, patch), label };
|
|
24
32
|
}
|
|
25
33
|
export function isPreRelease(version) {
|
package/dist/live/types.d.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface ResolvedDependency {
|
|
|
37
37
|
installDriftOf?: string;
|
|
38
38
|
/** On drift entries: the command that reinstalls from the lockfile. */
|
|
39
39
|
installFix?: InstallFixCommand;
|
|
40
|
+
/**
|
|
41
|
+
* Set when no lockfile pins this dependency: the requirement the manifest
|
|
42
|
+
* declares ("^1.2.0"). `version` is then that range's floor, NOT an
|
|
43
|
+
* installed version, and the dependency is never sent to OSV as if it were.
|
|
44
|
+
*/
|
|
45
|
+
declaredRange?: string;
|
|
40
46
|
}
|
|
41
47
|
/**
|
|
42
48
|
* One npm direct dependency whose installed copy differs from its lockfile.
|
|
@@ -54,7 +60,8 @@ export interface InstallDriftRecord {
|
|
|
54
60
|
export interface ResolutionSourceRecord {
|
|
55
61
|
/** Absolute path. */
|
|
56
62
|
path: string;
|
|
57
|
-
|
|
63
|
+
/** lockfile = exact versions; manifest = exact requirement pins; declared_ranges = no lockfile, range floors only. */
|
|
64
|
+
kind: "lockfile" | "manifest" | "declared_ranges";
|
|
58
65
|
/** Modification time when it was read, or null if it vanished since. */
|
|
59
66
|
mtimeMs: number | null;
|
|
60
67
|
}
|
|
@@ -88,8 +95,12 @@ export interface OsvVulnerability {
|
|
|
88
95
|
events: Array<{
|
|
89
96
|
introduced?: string;
|
|
90
97
|
fixed?: string;
|
|
98
|
+
last_affected?: string;
|
|
99
|
+
limit?: string;
|
|
91
100
|
}>;
|
|
92
101
|
}>;
|
|
102
|
+
/** Versions listed explicitly, in addition to (or instead of) the ranges. */
|
|
103
|
+
versions?: string[];
|
|
93
104
|
}>;
|
|
94
105
|
references?: Array<{
|
|
95
106
|
type: string;
|
|
@@ -191,6 +202,11 @@ export interface RegistryPackageInfo {
|
|
|
191
202
|
weeklyDownloads: number | null;
|
|
192
203
|
isDev: boolean;
|
|
193
204
|
fetchError: string | null;
|
|
205
|
+
/**
|
|
206
|
+
* No lockfile: the declared requirement ("^1.2.0"); `currentVersion` is then
|
|
207
|
+
* its floor, not an installed version.
|
|
208
|
+
*/
|
|
209
|
+
declaredRange?: string;
|
|
194
210
|
/**
|
|
195
211
|
* The version node_modules actually holds, present only when it differs
|
|
196
212
|
* from `currentVersion` (the lockfile's). Several differing installs across
|
|
@@ -59,6 +59,8 @@ export declare function isValidPackageName(name: string, ecosystem: UpgradeEcosy
|
|
|
59
59
|
export declare function getRegistryIndex(net: UpgradeNet, ecosystem: UpgradeEcosystem, name: string): Promise<RegistryIndex | null>;
|
|
60
60
|
/** The registry archive URL for one version, or null when it is not on the package's own registry. */
|
|
61
61
|
export declare function archiveUrl(index: RegistryIndex, version: PublishedVersion): string | null;
|
|
62
|
+
/** Cache key of one version's parsed changelog; it carries CHANGELOG_PARSER_VERSION. */
|
|
63
|
+
export declare function changelogCacheKey(ecosystem: string, name: string, version: string): string;
|
|
62
64
|
/** Parsed changelog shipped inside one version's archive (cached 7 days per version). */
|
|
63
65
|
export declare function getChangelog(net: UpgradeNet, index: RegistryIndex, version: PublishedVersion): Promise<ChangelogResult>;
|
|
64
66
|
/** OSV advisory ids affecting one version, or null when OSV could not be reached. */
|