@4da/mcp-server 6.0.2 → 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 +265 -0
- package/README.md +13 -10
- 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.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/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 +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 +5 -27
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Fix paths for vulnerable dependencies: WHICH version to move to, and HOW.
|
|
4
|
+
*
|
|
5
|
+
* Two defects found by the fix-path oracle (2026-10-10, 19 public repos;
|
|
6
|
+
* each recommendation applied in a copy, re-resolved, re-scanned):
|
|
7
|
+
*
|
|
8
|
+
* 1. The target could itself be vulnerable. It was the highest fix among the
|
|
9
|
+
* advisories that affect the installed version, so an advisory the
|
|
10
|
+
* installed version predates was never consulted: openssl 0.10.38 was sent
|
|
11
|
+
* to 0.10.79, which GHSA-phqj-4mhp-q6mq affects (0.10.50 up to 0.10.80).
|
|
12
|
+
* Now: the smallest published version at or above every per-advisory fix
|
|
13
|
+
* that NO advisory of the package affects (osv-ranges.ts).
|
|
14
|
+
* 2. Every transitive finding was "waiting on upstream", 3 wrong out of 3:
|
|
15
|
+
* minimist 1.2.5 (npm), braces 3.0.2 (pnpm) and mio 0.8.0 (Cargo) all had
|
|
16
|
+
* parents whose declared requirement already admitted the fix, and a
|
|
17
|
+
* lockfile refresh fixed each one. Now: the parents' requirements are
|
|
18
|
+
* read (from the lockfile, or from the parent's registry metadata for
|
|
19
|
+
* pnpm and Cargo) and a refresh is recommended when they all admit a
|
|
20
|
+
* clean version.
|
|
21
|
+
*
|
|
22
|
+
* Network: api.osv.dev (one /v1/query per vulnerable package) and the
|
|
23
|
+
* package's own registry; package names and versions only. Every lookup is
|
|
24
|
+
* cached, and any failure degrades to the old answer, never to a guess.
|
|
25
|
+
*/
|
|
26
|
+
import { fetchWithTimeout } from "./http-utils.js";
|
|
27
|
+
import { escapeModulePath } from "./go-registry.js";
|
|
28
|
+
import { advisoryRanges, advisoryAffects, isPrereleaseVersion, smallestCleanVersion } from "./osv-ranges.js";
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import { readLockfileParents } from "./lockfile-parents.js";
|
|
31
|
+
import { parseCargoLockPackages } from "./lockfile-parsers.js";
|
|
32
|
+
import { cargoReqAdmits, npmRangeAdmits } from "./range-satisfies.js";
|
|
33
|
+
import { compareVersions } from "./version-compare.js";
|
|
34
|
+
import { CRATES_USER_AGENT } from "./crates-versions.js";
|
|
35
|
+
const TIMEOUT_MS = 15_000;
|
|
36
|
+
const ADVISORY_TTL = 6 * 3600;
|
|
37
|
+
const VERSIONS_TTL = 6 * 3600;
|
|
38
|
+
/** A published version's manifest never changes. */
|
|
39
|
+
const VERSION_DEPS_TTL = 30 * 86_400;
|
|
40
|
+
const MAX_OSV_PAGES = 10;
|
|
41
|
+
/** Bump when the shape of anything this module caches changes. */
|
|
42
|
+
const CACHE_VERSION = 2;
|
|
43
|
+
export class FixPathSources {
|
|
44
|
+
cache;
|
|
45
|
+
rateLimiter;
|
|
46
|
+
constructor(cache, rateLimiter) {
|
|
47
|
+
this.cache = cache;
|
|
48
|
+
this.rateLimiter = rateLimiter;
|
|
49
|
+
}
|
|
50
|
+
/** Every non-withdrawn OSV advisory for a package, as ranges; null when OSV could not be read. */
|
|
51
|
+
async packageAdvisories(ecosystem, name) {
|
|
52
|
+
const key = `fixpath:v${CACHE_VERSION}:osv-package:${ecosystem}:${name}`;
|
|
53
|
+
const hit = this.cache.get(key);
|
|
54
|
+
if (hit)
|
|
55
|
+
return hit;
|
|
56
|
+
if (!this.rateLimiter.consume("osv-package"))
|
|
57
|
+
return null;
|
|
58
|
+
const out = [];
|
|
59
|
+
let token;
|
|
60
|
+
try {
|
|
61
|
+
for (let page = 0; page < MAX_OSV_PAGES; page++) {
|
|
62
|
+
const res = await fetchWithTimeout("https://api.osv.dev/v1/query", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ package: { name, ecosystem }, ...(token ? { page_token: token } : {}) }),
|
|
66
|
+
}, TIMEOUT_MS);
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
return null;
|
|
69
|
+
const body = (await res.json());
|
|
70
|
+
for (const v of body.vulns ?? []) {
|
|
71
|
+
const ranges = advisoryRanges(v, name, ecosystem);
|
|
72
|
+
if (ranges)
|
|
73
|
+
out.push(ranges);
|
|
74
|
+
}
|
|
75
|
+
token = body.next_page_token;
|
|
76
|
+
if (!token)
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (token)
|
|
80
|
+
return null; // more pages than the cap: an incomplete list cannot prove a version clean
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
this.cache.set(key, out, "osv", ADVISORY_TTL);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Published, installable versions (not yanked); null when the registry could not be read or is not supported. */
|
|
89
|
+
async publishedVersions(ecosystem, name) {
|
|
90
|
+
switch (ecosystem) {
|
|
91
|
+
case "npm":
|
|
92
|
+
return this.npmVersions(name);
|
|
93
|
+
case "crates.io":
|
|
94
|
+
return (await this.cratesSparse(name))?.versions ?? null;
|
|
95
|
+
case "PyPI":
|
|
96
|
+
return this.pypiVersions(name);
|
|
97
|
+
case "Go":
|
|
98
|
+
return this.goVersions(name);
|
|
99
|
+
default:
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The requirements `parent@parentVersion` declares on `dep`, from the
|
|
105
|
+
* registry: npm (pnpm lockfiles record none) and crates.io (Cargo.lock
|
|
106
|
+
* records none). Null when unknown; [] when the parent declares none.
|
|
107
|
+
*/
|
|
108
|
+
async parentRequirements(ecosystem, parent, parentVersion, dep) {
|
|
109
|
+
if (ecosystem === "crates.io") {
|
|
110
|
+
const sparse = await this.cratesSparse(parent);
|
|
111
|
+
const deps = sparse?.reqs[parentVersion];
|
|
112
|
+
return deps ? (deps[dep] ?? []).map((d) => d.req) : null;
|
|
113
|
+
}
|
|
114
|
+
if (ecosystem !== "npm")
|
|
115
|
+
return null;
|
|
116
|
+
const key = `fixpath:v${CACHE_VERSION}:npm-version-deps:${parent}@${parentVersion}`;
|
|
117
|
+
let deps = this.cache.get(key);
|
|
118
|
+
if (!deps) {
|
|
119
|
+
if (!this.rateLimiter.consume("fixpath-registry"))
|
|
120
|
+
return null;
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetchWithTimeout(`https://registry.npmjs.org/${encodeURIComponent(parent)}/${encodeURIComponent(parentVersion)}`, { headers: { Accept: "application/json" } }, TIMEOUT_MS);
|
|
123
|
+
if (!res.ok)
|
|
124
|
+
return null;
|
|
125
|
+
const doc = (await res.json());
|
|
126
|
+
deps = { ...doc.peerDependencies, ...doc.optionalDependencies, ...doc.dependencies };
|
|
127
|
+
this.cache.set(key, deps, "npm", VERSION_DEPS_TTL);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return typeof deps[dep] === "string" ? [deps[dep]] : [];
|
|
134
|
+
}
|
|
135
|
+
async npmVersions(name) {
|
|
136
|
+
const key = `fixpath:v${CACHE_VERSION}:npm-versions:${name}`;
|
|
137
|
+
const hit = this.cache.get(key);
|
|
138
|
+
if (hit)
|
|
139
|
+
return hit;
|
|
140
|
+
if (!this.rateLimiter.consume("fixpath-registry"))
|
|
141
|
+
return null;
|
|
142
|
+
try {
|
|
143
|
+
// The abbreviated document: versions without READMEs.
|
|
144
|
+
const res = await fetchWithTimeout(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { headers: { Accept: "application/vnd.npm.install-v1+json" } }, TIMEOUT_MS);
|
|
145
|
+
if (!res.ok)
|
|
146
|
+
return null;
|
|
147
|
+
const doc = (await res.json());
|
|
148
|
+
const versions = Object.keys(doc.versions ?? {});
|
|
149
|
+
this.cache.set(key, versions, "npm", VERSIONS_TTL);
|
|
150
|
+
return versions;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** A crate's sparse-index record: versions and per-version dependency requirements. */
|
|
157
|
+
async cratesSparse(name) {
|
|
158
|
+
const key = `fixpath:v${CACHE_VERSION}:crates-sparse:${name}`;
|
|
159
|
+
const hit = this.cache.get(key);
|
|
160
|
+
if (hit)
|
|
161
|
+
return hit;
|
|
162
|
+
if (!this.rateLimiter.consume("fixpath-registry"))
|
|
163
|
+
return null;
|
|
164
|
+
try {
|
|
165
|
+
const res = await fetchWithTimeout(sparseIndexUrl(name), { headers: { "User-Agent": CRATES_USER_AGENT } }, TIMEOUT_MS);
|
|
166
|
+
if (!res.ok)
|
|
167
|
+
return null;
|
|
168
|
+
const out = { versions: [], reqs: {} };
|
|
169
|
+
for (const line of (await res.text()).split("\n")) {
|
|
170
|
+
if (!line.trim())
|
|
171
|
+
continue;
|
|
172
|
+
try {
|
|
173
|
+
const entry = JSON.parse(line);
|
|
174
|
+
if (!entry.yanked)
|
|
175
|
+
out.versions.push(entry.vers);
|
|
176
|
+
const reqs = {};
|
|
177
|
+
for (const d of entry.deps ?? []) {
|
|
178
|
+
if (d.kind === "dev")
|
|
179
|
+
continue;
|
|
180
|
+
const crate = d.package ?? d.name;
|
|
181
|
+
(reqs[crate] ??= []).push({ req: d.req, optional: d.optional === true });
|
|
182
|
+
}
|
|
183
|
+
out.reqs[entry.vers] = reqs;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// a malformed line
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
this.cache.set(key, out, "crates", VERSIONS_TTL);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async pypiVersions(name) {
|
|
197
|
+
const key = `fixpath:v${CACHE_VERSION}:pypi-versions:${name}`;
|
|
198
|
+
const hit = this.cache.get(key);
|
|
199
|
+
if (hit)
|
|
200
|
+
return hit;
|
|
201
|
+
if (!this.rateLimiter.consume("fixpath-registry"))
|
|
202
|
+
return null;
|
|
203
|
+
try {
|
|
204
|
+
const res = await fetchWithTimeout(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, {}, TIMEOUT_MS);
|
|
205
|
+
if (!res.ok)
|
|
206
|
+
return null;
|
|
207
|
+
const doc = (await res.json());
|
|
208
|
+
// A release with files, not all of them yanked.
|
|
209
|
+
const versions = Object.entries(doc.releases ?? {})
|
|
210
|
+
.filter(([, files]) => files.length > 0 && files.some((f) => !f.yanked))
|
|
211
|
+
.map(([v]) => v);
|
|
212
|
+
this.cache.set(key, versions, "pypi", VERSIONS_TTL);
|
|
213
|
+
return versions;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async goVersions(name) {
|
|
220
|
+
const key = `fixpath:v${CACHE_VERSION}:go-versions:${name}`;
|
|
221
|
+
const hit = this.cache.get(key);
|
|
222
|
+
if (hit)
|
|
223
|
+
return hit;
|
|
224
|
+
if (!this.rateLimiter.consume("fixpath-registry"))
|
|
225
|
+
return null;
|
|
226
|
+
try {
|
|
227
|
+
const res = await fetchWithTimeout(`https://proxy.golang.org/${escapeModulePath(name)}/@v/list`, {}, TIMEOUT_MS);
|
|
228
|
+
if (!res.ok)
|
|
229
|
+
return null;
|
|
230
|
+
// OSV's Go ranges carry no `v`.
|
|
231
|
+
const versions = (await res.text()).split("\n").map((v) => v.trim().replace(/^v/, "")).filter(Boolean);
|
|
232
|
+
this.cache.set(key, versions, "go", VERSIONS_TTL);
|
|
233
|
+
return versions;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function sparseIndexUrl(name) {
|
|
241
|
+
const n = name.toLowerCase();
|
|
242
|
+
switch (n.length) {
|
|
243
|
+
case 1: return `https://index.crates.io/1/${n}`;
|
|
244
|
+
case 2: return `https://index.crates.io/2/${n}`;
|
|
245
|
+
case 3: return `https://index.crates.io/3/${n[0]}/${n}`;
|
|
246
|
+
default: return `https://index.crates.io/${n.slice(0, 2)}/${n.slice(2, 4)}/${n}`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** The minimal clean target for one installed copy, or null when the advisories could not be read. */
|
|
250
|
+
export async function cleanFixTarget(sources, ecosystem, name, perAdvisoryFixes) {
|
|
251
|
+
if (perAdvisoryFixes.length === 0)
|
|
252
|
+
return null;
|
|
253
|
+
const [advisories, published] = await Promise.all([
|
|
254
|
+
sources.packageAdvisories(ecosystem, name),
|
|
255
|
+
sources.publishedVersions(ecosystem, name),
|
|
256
|
+
]);
|
|
257
|
+
if (!advisories)
|
|
258
|
+
return null;
|
|
259
|
+
return smallestCleanVersion({ ecosystem, fixes: perAdvisoryFixes, advisories, published });
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Verified targets for many installed copies at once, within a time budget:
|
|
263
|
+
* the copies that could not be checked in time (or offline) are simply
|
|
264
|
+
* absent from the result, and the caller says so. Keyed by `key`.
|
|
265
|
+
*/
|
|
266
|
+
export async function verifyFixTargets(sources, items, budgetMs, concurrency = 12) {
|
|
267
|
+
const out = new Map();
|
|
268
|
+
const todo = items.filter((i) => i.fixes.length > 0);
|
|
269
|
+
if (!sources || todo.length === 0)
|
|
270
|
+
return out;
|
|
271
|
+
const deadline = Date.now() + budgetMs;
|
|
272
|
+
let next = 0;
|
|
273
|
+
const worker = async () => {
|
|
274
|
+
while (next < todo.length && Date.now() < deadline) {
|
|
275
|
+
const item = todo[next++];
|
|
276
|
+
let timer;
|
|
277
|
+
const late = new Promise((resolve) => {
|
|
278
|
+
timer = setTimeout(() => resolve(null), Math.max(0, deadline - Date.now()));
|
|
279
|
+
});
|
|
280
|
+
try {
|
|
281
|
+
const clean = await Promise.race([cleanFixTarget(sources, item.ecosystem, item.name, item.fixes).catch(() => null), late]);
|
|
282
|
+
if (clean)
|
|
283
|
+
out.set(item.key, clean);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
if (timer !== undefined)
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, todo.length) }, worker));
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
/** One clause for a target that is not the highest per-advisory fix, or null when it is. */
|
|
295
|
+
export function targetNote(clean) {
|
|
296
|
+
const parts = [];
|
|
297
|
+
if (clean.target && clean.target !== clean.floor) {
|
|
298
|
+
parts.push(`${clean.target} is the smallest version clear of every advisory a release fixes; ${clean.floor}, the highest per-advisory fix, ${clean.floorAffectedBy.length > 0 ? `is itself affected by ${clean.floorAffectedBy.join(", ")}` : "is not a published release"}`);
|
|
299
|
+
}
|
|
300
|
+
if (clean.target && clean.stillAffectedBy.length > 0) {
|
|
301
|
+
parts.push(`${clean.stillAffectedBy.join(", ")} also affect${clean.stillAffectedBy.length === 1 ? "s" : ""} it and every newer release (no fix published)`);
|
|
302
|
+
}
|
|
303
|
+
return parts.length > 0 ? parts.join("; ") : null;
|
|
304
|
+
}
|
|
305
|
+
/** The refresh command for one lockfile format. */
|
|
306
|
+
export function refreshCommand(format, name, installed, target) {
|
|
307
|
+
switch (format) {
|
|
308
|
+
case "package-lock":
|
|
309
|
+
return `npm update ${name}`;
|
|
310
|
+
case "pnpm":
|
|
311
|
+
return `pnpm update ${name}`;
|
|
312
|
+
case "yarn-berry":
|
|
313
|
+
return `yarn up -R ${name}`;
|
|
314
|
+
case "yarn":
|
|
315
|
+
return `delete the ${name}@ entries from yarn.lock, then run yarn install`;
|
|
316
|
+
case "bun":
|
|
317
|
+
return `delete the ${name} entries from bun.lock, then run bun install`;
|
|
318
|
+
case "cargo":
|
|
319
|
+
return `cargo update -p ${name}@${installed} --precise ${target}`;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Whether refreshing `lockfile` moves `name@installed` to a clean version
|
|
324
|
+
* without any parent changing its declared requirement. Null when the
|
|
325
|
+
* lockfile or a requirement cannot be read (the caller keeps "waiting on
|
|
326
|
+
* upstream", the conservative answer).
|
|
327
|
+
*/
|
|
328
|
+
export async function refreshVerdict(sources, ecosystem, lockfile, name, installed, target, advisories, published) {
|
|
329
|
+
const parents = readLockfileParents(lockfile, name, installed);
|
|
330
|
+
if (!parents || parents.edges.length === 0)
|
|
331
|
+
return null;
|
|
332
|
+
const isCargo = parents.format === "cargo";
|
|
333
|
+
const admits = (range, version) => (isCargo ? cargoReqAdmits(range, version) : npmRangeAdmits(range, version));
|
|
334
|
+
const clean = (v) => !advisories || !advisories.some((a) => advisoryAffects(a, v, ecosystem));
|
|
335
|
+
const releases = (published ?? []).filter((v) => !isPrereleaseVersion(v, ecosystem));
|
|
336
|
+
const blocking = [];
|
|
337
|
+
let resolvesTo = null;
|
|
338
|
+
const edgeReqs = await Promise.all(parents.edges.map((edge) => requirementsOf(sources, ecosystem, edge, name)));
|
|
339
|
+
for (const [i, edge] of parents.edges.entries()) {
|
|
340
|
+
const all = edgeReqs[i];
|
|
341
|
+
if (all === null || all.length === 0)
|
|
342
|
+
return null;
|
|
343
|
+
// A parent can declare the same crate twice under different names (feature-gated
|
|
344
|
+
// `mio-0_6` / `mio-0_7` aliases): only the requirements this copy satisfies apply.
|
|
345
|
+
const matching = all.filter((req) => admits(req, installed) === true);
|
|
346
|
+
const reqs = matching.length > 0 ? [...new Set(matching)] : [...new Set(all)];
|
|
347
|
+
for (const req of reqs) {
|
|
348
|
+
if (isCargo) {
|
|
349
|
+
// `cargo update --precise` moves to exactly the clean target, if every requirement admits it.
|
|
350
|
+
const ok = admits(req, target);
|
|
351
|
+
if (ok === null)
|
|
352
|
+
return null;
|
|
353
|
+
if (!ok)
|
|
354
|
+
blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
|
|
355
|
+
else
|
|
356
|
+
resolvesTo = target;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
// npm, pnpm, yarn and bun resolve each requirement to its newest match.
|
|
360
|
+
if (releases.length > 0) {
|
|
361
|
+
const newest = releases
|
|
362
|
+
.filter((v) => admits(req, v) === true)
|
|
363
|
+
.sort((a, b) => compareVersions(b, a, ecosystem))[0];
|
|
364
|
+
if (!newest || compareVersions(newest, target, ecosystem) < 0 || !clean(newest)) {
|
|
365
|
+
blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
|
|
366
|
+
}
|
|
367
|
+
else if (!resolvesTo || compareVersions(newest, resolvesTo, ecosystem) < 0) {
|
|
368
|
+
resolvesTo = newest;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
const ok = admits(req, target);
|
|
373
|
+
if (ok === null)
|
|
374
|
+
return null;
|
|
375
|
+
if (!ok)
|
|
376
|
+
blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
let command = refreshCommand(parents.format, name, installed, target);
|
|
381
|
+
if (isCargo && blocking.length === 0) {
|
|
382
|
+
let content;
|
|
383
|
+
try {
|
|
384
|
+
content = fs.readFileSync(lockfile, "utf-8");
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const prereqs = await cargoPrecisePrerequisites(sources, content, name, installed, target);
|
|
390
|
+
if (prereqs === null)
|
|
391
|
+
return null;
|
|
392
|
+
if (prereqs.length > 0)
|
|
393
|
+
command = `cargo update ${prereqs.map((p) => `-p ${p}`).join(" ")} && ${command}`;
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
refreshFixes: blocking.length === 0,
|
|
397
|
+
format: parents.format,
|
|
398
|
+
command,
|
|
399
|
+
blocking,
|
|
400
|
+
resolvesTo: blocking.length === 0 ? resolvesTo : null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
async function requirementsOf(sources, ecosystem, edge, name) {
|
|
404
|
+
if (edge.range !== null)
|
|
405
|
+
return [edge.range];
|
|
406
|
+
if (edge.parentVersion === null)
|
|
407
|
+
return null;
|
|
408
|
+
return sources.parentRequirements(ecosystem, edge.parent, edge.parentVersion, name);
|
|
409
|
+
}
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// Cargo: what `--precise` needs moved first
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
/** Cargo's compatibility line: one copy per line per lockfile ("1", "0.10", "0.0.3"). */
|
|
414
|
+
function cargoLine(version) {
|
|
415
|
+
const [major, minor, patch] = version.split(/[.+-]/);
|
|
416
|
+
return major !== "0" ? major : minor !== "0" ? `0.${minor}` : `0.0.${patch}`;
|
|
417
|
+
}
|
|
418
|
+
const MAX_CLOSURE_CRATES = 60;
|
|
419
|
+
/**
|
|
420
|
+
* The locked crates `cargo update -p name@installed --precise target` cannot
|
|
421
|
+
* move by itself, as `crate@lockedVersion` specs to update first; null when it
|
|
422
|
+
* cannot be worked out (a registry read failed, or the walk is too large).
|
|
423
|
+
*
|
|
424
|
+
* Measured on nushell (2026-10-10): `cargo update -p openssl@0.10.38
|
|
425
|
+
* --precise 0.10.80` fails. 0.10.80 needs openssl-macros 0.1.1 -> syn 2 ->
|
|
426
|
+
* quote ^1.0.25, and quote 1.0.15 is locked by rstest, outside openssl's own
|
|
427
|
+
* dependency tree, so cargo keeps it and reports a conflict. `cargo update -p
|
|
428
|
+
* quote` first, then the precise update, works. Crates in the installed
|
|
429
|
+
* version's own tree may move with it (openssl-sys did) and are not listed.
|
|
430
|
+
*
|
|
431
|
+
* The walk follows the target's normal and build dependencies (optional ones
|
|
432
|
+
* only when the crate is already in the lockfile): a requirement a locked copy
|
|
433
|
+
* satisfies is reused; one whose compatibility line holds a locked copy that
|
|
434
|
+
* does not satisfy it names that copy; anything else is a new crate, walked
|
|
435
|
+
* at its newest matching version.
|
|
436
|
+
*/
|
|
437
|
+
export async function cargoPrecisePrerequisites(sources, lockContent, name, installed, target) {
|
|
438
|
+
const packages = parseCargoLockPackages(lockContent);
|
|
439
|
+
const locked = new Map();
|
|
440
|
+
for (const p of packages)
|
|
441
|
+
locked.set(p.name, [...(locked.get(p.name) ?? []), p.version]);
|
|
442
|
+
// The installed version's own dependency tree, which cargo may move with it.
|
|
443
|
+
const byKey = new Map(packages.map((p) => [`${p.name}@${p.version}`, p]));
|
|
444
|
+
const own = new Set();
|
|
445
|
+
const stack = [`${name}@${installed}`];
|
|
446
|
+
while (stack.length > 0) {
|
|
447
|
+
const key = stack.pop();
|
|
448
|
+
if (own.has(key))
|
|
449
|
+
continue;
|
|
450
|
+
own.add(key);
|
|
451
|
+
for (const dep of byKey.get(key)?.dependencies ?? []) {
|
|
452
|
+
const [depName, depVersion] = dep.split(" ");
|
|
453
|
+
const version = depVersion ?? (locked.get(depName)?.length === 1 ? locked.get(depName)[0] : null);
|
|
454
|
+
if (version)
|
|
455
|
+
stack.push(`${depName}@${version}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const prereqs = new Set();
|
|
459
|
+
const visited = new Set();
|
|
460
|
+
const queue = [[name, target]];
|
|
461
|
+
while (queue.length > 0) {
|
|
462
|
+
const [crate, version] = queue.shift();
|
|
463
|
+
if (visited.has(`${crate}@${version}`))
|
|
464
|
+
continue;
|
|
465
|
+
visited.add(`${crate}@${version}`);
|
|
466
|
+
if (visited.size > MAX_CLOSURE_CRATES)
|
|
467
|
+
return null;
|
|
468
|
+
const deps = (await sources.cratesSparse(crate))?.reqs[version];
|
|
469
|
+
if (!deps)
|
|
470
|
+
return null;
|
|
471
|
+
for (const [dep, entries] of Object.entries(deps)) {
|
|
472
|
+
const lockedVersions = locked.get(dep) ?? [];
|
|
473
|
+
for (const { req, optional } of entries) {
|
|
474
|
+
if (optional && lockedVersions.length === 0)
|
|
475
|
+
continue; // a feature this build does not enable
|
|
476
|
+
if (lockedVersions.some((v) => cargoReqAdmits(req, v) === true))
|
|
477
|
+
continue;
|
|
478
|
+
const available = (await sources.cratesSparse(dep))?.versions;
|
|
479
|
+
if (!available)
|
|
480
|
+
return null;
|
|
481
|
+
const best = available
|
|
482
|
+
.filter((v) => !isPrereleaseVersion(v, "crates.io") && cargoReqAdmits(req, v) === true)
|
|
483
|
+
.sort((a, b) => compareVersions(b, a, "crates.io"))[0];
|
|
484
|
+
if (!best)
|
|
485
|
+
return null;
|
|
486
|
+
const sameLine = lockedVersions.find((v) => cargoLine(v) === cargoLine(best));
|
|
487
|
+
if (sameLine) {
|
|
488
|
+
if (!own.has(`${dep}@${sameLine}`))
|
|
489
|
+
prereqs.add(`${dep}@${sameLine}`);
|
|
490
|
+
}
|
|
491
|
+
else if (!visited.has(`${dep}@${best}`)) {
|
|
492
|
+
queue.push([dep, best]);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return [...prereqs].sort();
|
|
498
|
+
}
|
|
@@ -16,3 +16,8 @@ export declare class GoRegistry {
|
|
|
16
16
|
constructor(cache: LiveCache, rateLimiter: RateLimiter);
|
|
17
17
|
getPackageInfo(name: string, currentVersion: string | null, isDev: boolean): Promise<RegistryPackageInfo>;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Escape uppercase letters in Go module paths for the proxy URL.
|
|
21
|
+
* Go proxy convention: uppercase 'X' becomes '!x'.
|
|
22
|
+
*/
|
|
23
|
+
export declare function escapeModulePath(mod: string): string;
|
package/dist/live/go-registry.js
CHANGED
|
@@ -93,7 +93,7 @@ export class GoRegistry {
|
|
|
93
93
|
* Escape uppercase letters in Go module paths for the proxy URL.
|
|
94
94
|
* Go proxy convention: uppercase 'X' becomes '!x'.
|
|
95
95
|
*/
|
|
96
|
-
function escapeModulePath(mod) {
|
|
96
|
+
export function escapeModulePath(mod) {
|
|
97
97
|
return mod.replace(/[A-Z]/g, (c) => "!" + c.toLowerCase());
|
|
98
98
|
}
|
|
99
99
|
/**
|
package/dist/live/index.d.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* Privacy: Only sends package names/versions (public) and tech keywords (generic).
|
|
8
8
|
* Set FOURDA_OFFLINE=true to disable all network calls.
|
|
9
9
|
*/
|
|
10
|
-
import type
|
|
10
|
+
import type { SqliteDatabase } from "../sqlite-driver.js";
|
|
11
|
+
import { FixPathSources } from "./fix-paths.js";
|
|
11
12
|
import type { ReleaseMetadataSource } from "./release-metadata.js";
|
|
12
13
|
import { type ResolutionGroup } from "./resolution.js";
|
|
14
|
+
import type { TreeDiscovery } from "../project-tree.js";
|
|
13
15
|
import type { InstallDriftRecord, ResolutionProvenance, ResolvedDependency, RegistryPackageInfo, VulnerabilityScanResult, LiveHeadline, LiveIntelligenceStatus } from "./types.js";
|
|
14
16
|
export type { VulnerabilityScanResult, VulnerabilityEntry, LiveHeadline, LiveIntelligenceStatus, RegistryPackageInfo, DependencyHealthResult, InstallDriftRecord, ResolutionProvenance, } from "./types.js";
|
|
15
17
|
export declare class LiveIntelligence {
|
|
@@ -24,6 +26,7 @@ export declare class LiveIntelligence {
|
|
|
24
26
|
private goRegistry;
|
|
25
27
|
private npmPackuments;
|
|
26
28
|
private cratesVersions;
|
|
29
|
+
private fixPathSources;
|
|
27
30
|
private enabled;
|
|
28
31
|
private lastVulnScan;
|
|
29
32
|
/** Whether the stored scan included direct devDependencies. */
|
|
@@ -37,6 +40,8 @@ export declare class LiveIntelligence {
|
|
|
37
40
|
* runs, 2026-10-03 pre-publish verification).
|
|
38
41
|
*/
|
|
39
42
|
private scanRequests;
|
|
43
|
+
/** Scans running now, by generation, scope and path: a request for one of them awaits it. */
|
|
44
|
+
private scansInFlight;
|
|
40
45
|
private storedScanRequest;
|
|
41
46
|
private lastHeadlines;
|
|
42
47
|
private resolvedDeps;
|
|
@@ -45,6 +50,8 @@ export declare class LiveIntelligence {
|
|
|
45
50
|
private projectRoot;
|
|
46
51
|
/** One entry per resolution group: its inputs, result, and the file signatures that say when it went stale. */
|
|
47
52
|
private groupResults;
|
|
53
|
+
/** The project-tree walk's report of what it left out, when the groups came from one. */
|
|
54
|
+
private discovery;
|
|
48
55
|
/** When the dependency set in use was assembled: at init, or at the last re-resolution. */
|
|
49
56
|
private resolvedAt;
|
|
50
57
|
/**
|
|
@@ -61,7 +68,15 @@ export declare class LiveIntelligence {
|
|
|
61
68
|
private warmup;
|
|
62
69
|
/** When the last warmup came back offline; gates the retry so an offline host is not re-probed on every call. */
|
|
63
70
|
private warmupFailedAt;
|
|
64
|
-
constructor(db:
|
|
71
|
+
constructor(db: SqliteDatabase | null);
|
|
72
|
+
/** Advisory, registry and parent-requirement lookups for fix paths; null when offline. */
|
|
73
|
+
getFixPathSources(): FixPathSources | null;
|
|
74
|
+
/**
|
|
75
|
+
* The lockfile one manifest directory resolved this ecosystem from, or null
|
|
76
|
+
* when it resolved from a manifest or nothing. `dir` is spelled as the
|
|
77
|
+
* resolution group spells it (the scan's sourceDirs).
|
|
78
|
+
*/
|
|
79
|
+
lockfileFor(dir: string, ecosystem: string): string | null;
|
|
65
80
|
/**
|
|
66
81
|
* Initialize with project data. Call once after project scan.
|
|
67
82
|
* @deprecated Use initFromMultiEcosystem for correct per-ecosystem resolution.
|
|
@@ -81,7 +96,18 @@ export declare class LiveIntelligence {
|
|
|
81
96
|
* project-tree.ts): each group resolves from its own directory and keeps
|
|
82
97
|
* its manifest's platform targets; `root` is the reported project root.
|
|
83
98
|
*/
|
|
84
|
-
initFromProjectTree(root: string, groups: ResolutionGroup[]): void;
|
|
99
|
+
initFromProjectTree(root: string, groups: ResolutionGroup[], discovery?: TreeDiscovery | null): void;
|
|
100
|
+
/**
|
|
101
|
+
* Manifests with no lockfile beside them, whose ranged dependencies were not
|
|
102
|
+
* scanned: their versions are declared ranges, not installs.
|
|
103
|
+
*/
|
|
104
|
+
getUnresolvedManifests(): Array<{
|
|
105
|
+
dir: string;
|
|
106
|
+
manifest: string;
|
|
107
|
+
ranges: number;
|
|
108
|
+
}>;
|
|
109
|
+
/** What the project-tree walk left out (lockfiles past its bounds); null when the set did not come from a walk. */
|
|
110
|
+
getDiscovery(): TreeDiscovery | null;
|
|
85
111
|
/**
|
|
86
112
|
* Initialize from dependency groups that each carry their own resolution
|
|
87
113
|
* directory. Used in 4DA database mode, where dependencies span multiple
|
|
@@ -133,6 +159,7 @@ export declare class LiveIntelligence {
|
|
|
133
159
|
includeDev?: boolean;
|
|
134
160
|
forceRefresh?: boolean;
|
|
135
161
|
}): Promise<VulnerabilityScanResult>;
|
|
162
|
+
private runScan;
|
|
136
163
|
/**
|
|
137
164
|
* Start the vulnerability scan in the background without blocking startup.
|
|
138
165
|
* Never rejects: a failed scan resolves to an empty OFFLINE result so the
|