@4da/mcp-server 6.2.4 → 6.2.5
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 +484 -0
- package/README.md +22 -6
- package/dist/db.d.ts +38 -0
- package/dist/db.js +78 -23
- package/dist/doctor.js +6 -3
- package/dist/embeddings.d.ts +1 -1
- package/dist/embeddings.js +16 -14
- package/dist/index.js +73 -103
- package/dist/live/advisory-groups.d.ts +68 -0
- package/dist/live/advisory-groups.js +131 -0
- package/dist/live/cache.js +11 -3
- package/dist/live/cargo-platform.d.ts +6 -0
- package/dist/live/cargo-platform.js +8 -2
- package/dist/live/changelog.js +38 -6
- package/dist/live/dependency-set.d.ts +9 -1
- package/dist/live/dependency-set.js +10 -2
- package/dist/live/fix-paths.d.ts +258 -9
- package/dist/live/fix-paths.js +790 -45
- package/dist/live/github-releases.js +3 -0
- package/dist/live/held-dependencies.d.ts +93 -0
- package/dist/live/held-dependencies.js +212 -0
- package/dist/live/held-targets.d.ts +30 -0
- package/dist/live/held-targets.js +107 -0
- package/dist/live/http-utils.js +13 -3
- package/dist/live/index.d.ts +7 -4
- package/dist/live/index.js +24 -19
- package/dist/live/install-state.js +6 -2
- package/dist/live/js-lockfile-readers.d.ts +8 -1
- package/dist/live/js-lockfile-readers.js +43 -6
- package/dist/live/lockfile-parents.d.ts +15 -0
- package/dist/live/lockfile-parents.js +85 -10
- package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
- package/dist/live/lockfile-parsers-pygo.js +230 -12
- package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
- package/dist/live/lockfile-parsers-rbphp.js +123 -0
- package/dist/live/lockfile-parsers.d.ts +6 -0
- package/dist/live/lockfile-parsers.js +116 -4
- package/dist/live/lockfile-types.d.ts +28 -1
- package/dist/live/maintenance.d.ts +20 -4
- package/dist/live/maintenance.js +31 -2
- package/dist/live/malware.d.ts +20 -0
- package/dist/live/malware.js +31 -0
- package/dist/live/npm-packument.d.ts +4 -1
- package/dist/live/npm-packument.js +5 -2
- package/dist/live/offered-release.d.ts +53 -0
- package/dist/live/offered-release.js +117 -0
- package/dist/live/osv-ranges.d.ts +111 -0
- package/dist/live/osv-ranges.js +173 -14
- package/dist/live/osv-scanner.d.ts +87 -5
- package/dist/live/osv-scanner.js +310 -59
- package/dist/live/pinned-advisories.d.ts +2 -2
- package/dist/live/pinned-advisories.js +3 -3
- package/dist/live/pypi-advice.d.ts +36 -0
- package/dist/live/pypi-advice.js +158 -0
- package/dist/live/rate-limiter.d.ts +7 -0
- package/dist/live/rate-limiter.js +22 -1
- package/dist/live/release-metadata.d.ts +5 -0
- package/dist/live/resolution.d.ts +3 -0
- package/dist/live/resolution.js +6 -3
- package/dist/live/scan-coverage.d.ts +26 -0
- package/dist/live/scan-coverage.js +70 -0
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +26 -3
- package/dist/live/severity-scope.d.ts +26 -1
- package/dist/live/severity-scope.js +32 -0
- package/dist/live/shell-safe.d.ts +19 -0
- package/dist/live/shell-safe.js +36 -0
- package/dist/live/types.d.ts +134 -8
- package/dist/live/version-compare.d.ts +49 -0
- package/dist/live/version-compare.js +131 -0
- package/dist/live/withdrawn-releases.d.ts +50 -0
- package/dist/live/withdrawn-releases.js +125 -0
- package/dist/project-manifests.d.ts +7 -0
- package/dist/project-manifests.js +45 -5
- package/dist/project-scanner.js +6 -4
- package/dist/project-tree.d.ts +56 -0
- package/dist/project-tree.js +174 -5
- package/dist/schema-registry.d.ts +47 -4
- package/dist/schema-registry.js +76 -9
- package/dist/server-instructions.js +4 -2
- package/dist/setup.d.ts +8 -0
- package/dist/setup.js +21 -2
- package/dist/tool-dispatch.js +3 -3
- package/dist/tools/agent-memory.d.ts +5 -0
- package/dist/tools/agent-memory.js +9 -0
- package/dist/tools/briefing-task-scope.js +5 -2
- package/dist/tools/decision-memory.js +2 -0
- package/dist/tools/dependency-check-held.d.ts +44 -0
- package/dist/tools/dependency-check-held.js +98 -0
- package/dist/tools/dependency-check-signals.d.ts +19 -2
- package/dist/tools/dependency-check-signals.js +84 -13
- package/dist/tools/dependency-check-versions.d.ts +37 -4
- package/dist/tools/dependency-check-versions.js +48 -13
- package/dist/tools/dependency-check.d.ts +11 -1
- package/dist/tools/dependency-check.js +81 -29
- package/dist/tools/dependency-health.js +209 -45
- package/dist/tools/get-context.d.ts +3 -0
- package/dist/tools/get-context.js +4 -0
- package/dist/tools/project-scope.d.ts +8 -0
- package/dist/tools/project-scope.js +21 -0
- package/dist/tools/upgrade-impact-features.d.ts +37 -0
- package/dist/tools/upgrade-impact-features.js +176 -0
- package/dist/tools/upgrade-impact-literals.js +26 -4
- package/dist/tools/upgrade-impact-report.d.ts +5 -1
- package/dist/tools/upgrade-impact-report.js +26 -5
- package/dist/tools/upgrade-impact.js +10 -3
- package/dist/tools/upgrade-planner-fixpaths.d.ts +162 -5
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +59 -4
- package/dist/tools/upgrade-planner.js +237 -61
- package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
- package/dist/tools/vulnerability-scan-concise.js +37 -3
- package/dist/tools/vulnerability-scan-format.d.ts +126 -24
- package/dist/tools/vulnerability-scan-format.js +193 -26
- package/dist/tools/vulnerability-scan.d.ts +224 -63
- package/dist/tools/vulnerability-scan.js +50 -15
- package/dist/tools/what-should-i-know.d.ts +6 -1
- package/dist/tools/what-should-i-know.js +72 -15
- package/package.json +2 -2
package/dist/live/fix-paths.js
CHANGED
|
@@ -23,28 +23,37 @@
|
|
|
23
23
|
* package's own registry; package names and versions only. Every lookup is
|
|
24
24
|
* cached, and any failure degrades to the old answer, never to a guess.
|
|
25
25
|
*/
|
|
26
|
+
import { isShellSafeCargoTarget, isShellSafeName, isShellSafeVersion } from "./shell-safe.js";
|
|
26
27
|
import { fetchWithTimeout } from "./http-utils.js";
|
|
27
28
|
import { escapeModulePath } from "./go-registry.js";
|
|
28
|
-
import { advisoryRanges, advisoryAffects, isPrereleaseVersion, smallestCleanVersion } from "./osv-ranges.js";
|
|
29
|
+
import { advisoryRanges, advisoryAffects, installableFix, remainsText, isPrereleaseVersion, smallestCleanVersion } from "./osv-ranges.js";
|
|
29
30
|
import * as fs from "node:fs";
|
|
30
31
|
import * as path from "node:path";
|
|
31
32
|
import { lockfileFormat, readLockfileParents } from "./lockfile-parents.js";
|
|
32
33
|
import { parseCargoLockPackages } from "./lockfile-parsers.js";
|
|
33
|
-
import {
|
|
34
|
+
import { compiledRequirements, confinedInclude, requirementOptions } from "./lockfile-parsers-pygo.js";
|
|
35
|
+
import { cargoReqAdmits, npmRangeAdmits } from "./range-satisfies.js";
|
|
34
36
|
import { refreshRule, requirementAdmits } from "./refresh-rule.js";
|
|
35
|
-
import { compareVersions } from "./version-compare.js";
|
|
37
|
+
import { compareVersions, maxVersion } from "./version-compare.js";
|
|
36
38
|
import { CRATES_USER_AGENT } from "./crates-versions.js";
|
|
39
|
+
import { isNpmDeprecated, npmExcludedVersions, sayWithdrawn, withdrawalText, withheldNote } from "./withdrawn-releases.js";
|
|
40
|
+
import { heldSpecsOf, heldText } from "./held-dependencies.js";
|
|
41
|
+
import { withHeldTargets } from "./held-targets.js";
|
|
37
42
|
const TIMEOUT_MS = 15_000;
|
|
38
43
|
const ADVISORY_TTL = 6 * 3600;
|
|
39
44
|
const VERSIONS_TTL = 6 * 3600;
|
|
40
45
|
/** A published version's manifest never changes. */
|
|
41
46
|
const VERSION_DEPS_TTL = 30 * 86_400;
|
|
42
47
|
const MAX_OSV_PAGES = 10;
|
|
43
|
-
/** Bump when the shape of anything this module caches changes. */
|
|
44
|
-
const CACHE_VERSION =
|
|
48
|
+
/** Bump when the shape of anything this module caches changes (10: npm facts carry `heldSpecs`, audit 6.2.5 D-4). */
|
|
49
|
+
const CACHE_VERSION = 10;
|
|
50
|
+
/** How long, and how many, npm documents are kept in memory between the lookups of one plan. */
|
|
51
|
+
const NPM_DOC_MEMORY_MS = 120_000;
|
|
52
|
+
const NPM_DOCS_KEPT = 16;
|
|
45
53
|
export class FixPathSources {
|
|
46
54
|
cache;
|
|
47
55
|
rateLimiter;
|
|
56
|
+
npmDocs = new Map();
|
|
48
57
|
constructor(cache, rateLimiter) {
|
|
49
58
|
this.cache = cache;
|
|
50
59
|
this.rateLimiter = rateLimiter;
|
|
@@ -91,11 +100,11 @@ export class FixPathSources {
|
|
|
91
100
|
async publishedVersions(ecosystem, name) {
|
|
92
101
|
switch (ecosystem) {
|
|
93
102
|
case "npm":
|
|
94
|
-
return this.
|
|
103
|
+
return (await this.npmFacts(name))?.versions ?? null;
|
|
95
104
|
case "crates.io":
|
|
96
105
|
return (await this.cratesSparse(name))?.versions ?? null;
|
|
97
106
|
case "PyPI":
|
|
98
|
-
return this.
|
|
107
|
+
return (await this.pypiFacts(name))?.versions ?? null;
|
|
99
108
|
case "Go":
|
|
100
109
|
return this.goVersions(name);
|
|
101
110
|
default:
|
|
@@ -125,7 +134,9 @@ export class FixPathSources {
|
|
|
125
134
|
if (!res.ok)
|
|
126
135
|
return null;
|
|
127
136
|
const doc = (await res.json());
|
|
128
|
-
|
|
137
|
+
// Not peerDependencies, as in the 4DA app: a peer is resolved from whoever provides it
|
|
138
|
+
// higher in the tree, so the parent's peer range does not decide which copy a refresh picks.
|
|
139
|
+
deps = { ...doc.optionalDependencies, ...doc.dependencies };
|
|
129
140
|
this.cache.set(key, deps, "npm", VERSION_DEPS_TTL);
|
|
130
141
|
}
|
|
131
142
|
catch {
|
|
@@ -134,26 +145,145 @@ export class FixPathSources {
|
|
|
134
145
|
}
|
|
135
146
|
return typeof deps[dep] === "string" ? [deps[dep]] : [];
|
|
136
147
|
}
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
/**
|
|
149
|
+
* What every published release of `parent` requires of `dep`, from the
|
|
150
|
+
* registry: version -> requirements (`reqs: []` when that release does not
|
|
151
|
+
* depend on it). npm reads dependencies and optionalDependencies of the
|
|
152
|
+
* abbreviated document (as `parentRequirements`), and, when neither holds
|
|
153
|
+
* it, peerDependencies, flagged `peer`: npm 7+ installs a missing peer, so
|
|
154
|
+
* moving a package into peerDependencies is not dropping it (readiness
|
|
155
|
+
* review 2026-10-17, finding 10). crates.io: the sparse index. Null when
|
|
156
|
+
* unknown or unsupported. Used to find the smallest parent release that
|
|
157
|
+
* admits a clean version (audit H1).
|
|
158
|
+
*/
|
|
159
|
+
async requirementHistory(ecosystem, parent, dep) {
|
|
160
|
+
if (ecosystem === "crates.io") {
|
|
161
|
+
const sparse = await this.cratesSparse(parent);
|
|
162
|
+
if (!sparse)
|
|
163
|
+
return null;
|
|
164
|
+
return Object.fromEntries(Object.entries(sparse.reqs).map(([v, deps]) => [v, { reqs: (deps[dep] ?? []).map((d) => d.req), peer: false }]));
|
|
165
|
+
}
|
|
166
|
+
if (ecosystem !== "npm")
|
|
167
|
+
return null;
|
|
168
|
+
const key = `fixpath:v${CACHE_VERSION}:npm-requirement-history:${parent}:${dep}`;
|
|
139
169
|
const hit = this.cache.get(key);
|
|
140
170
|
if (hit)
|
|
141
171
|
return hit;
|
|
142
|
-
|
|
172
|
+
const doc = await this.npmDocument(parent);
|
|
173
|
+
if (!doc)
|
|
143
174
|
return null;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const [version, m] of Object.entries(doc.versions ?? {})) {
|
|
177
|
+
const req = m?.dependencies?.[dep] ?? m?.optionalDependencies?.[dep];
|
|
178
|
+
const peer = m?.peerDependencies?.[dep];
|
|
179
|
+
out[version] =
|
|
180
|
+
typeof req === "string" ? { reqs: [req], peer: false } : typeof peer === "string" ? { reqs: [peer], peer: true } : { reqs: [], peer: false };
|
|
181
|
+
}
|
|
182
|
+
this.cache.set(key, out, "npm", VERSIONS_TTL);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* One npm abbreviated document per package, shared by the release facts and the requirement
|
|
187
|
+
* history of a plan (a parent's document was downloaded twice: NodeGoat's mongodb, ~600
|
|
188
|
+
* releases). Kept in memory for a short while, at most a few at a time; a failure is not kept.
|
|
189
|
+
*/
|
|
190
|
+
npmDocument(name) {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
for (const [n, e] of this.npmDocs)
|
|
193
|
+
if (now - e.at > NPM_DOC_MEMORY_MS)
|
|
194
|
+
this.npmDocs.delete(n);
|
|
195
|
+
const hit = this.npmDocs.get(name);
|
|
196
|
+
if (hit)
|
|
197
|
+
return hit.doc;
|
|
198
|
+
if (!this.rateLimiter.consume("fixpath-registry"))
|
|
199
|
+
return Promise.resolve(null);
|
|
200
|
+
const doc = (async () => {
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetchWithTimeout(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { headers: { Accept: "application/vnd.npm.install-v1+json" } }, TIMEOUT_MS);
|
|
203
|
+
if (!res.ok)
|
|
204
|
+
return null;
|
|
205
|
+
return (await res.json());
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
148
208
|
return null;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
209
|
+
}
|
|
210
|
+
})();
|
|
211
|
+
this.npmDocs.set(name, { at: now, doc });
|
|
212
|
+
void doc.then((d) => {
|
|
213
|
+
if (d === null)
|
|
214
|
+
this.npmDocs.delete(name);
|
|
215
|
+
});
|
|
216
|
+
while (this.npmDocs.size > NPM_DOCS_KEPT)
|
|
217
|
+
this.npmDocs.delete(this.npmDocs.keys().next().value);
|
|
218
|
+
return doc;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Release facts for a parent or a fix target: what is installable, what is
|
|
222
|
+
* deprecated or yanked, and the latest release. Null when the registry
|
|
223
|
+
* could not be read or is not supported.
|
|
224
|
+
*/
|
|
225
|
+
async releaseFacts(ecosystem, name) {
|
|
226
|
+
if (ecosystem === "npm")
|
|
227
|
+
return this.npmFacts(name);
|
|
228
|
+
const highest = (versions) => versions.filter((v) => !isPrereleaseVersion(v, ecosystem)).sort((a, b) => compareVersions(a, b, ecosystem)).pop() ?? null;
|
|
229
|
+
if (ecosystem === "crates.io") {
|
|
230
|
+
const sparse = await this.cratesSparse(name);
|
|
231
|
+
if (!sparse)
|
|
232
|
+
return null;
|
|
233
|
+
const yanked = sparse.yanked ?? [];
|
|
234
|
+
const withdrawnWhy = Object.fromEntries(yanked.map((v) => [v, withdrawalText("crates.io", v)]));
|
|
235
|
+
return { versions: sparse.versions, withdrawn: yanked, withdrawnWhy, latest: highest(sparse.versions), deprecatedLatest: null };
|
|
153
236
|
}
|
|
154
|
-
|
|
237
|
+
if (ecosystem === "PyPI") {
|
|
238
|
+
const facts = await this.pypiFacts(name);
|
|
239
|
+
return facts ? { ...facts, latest: highest(facts.versions), deprecatedLatest: null } : null;
|
|
240
|
+
}
|
|
241
|
+
// Go: `retract` lives in the newest version's go.mod, which is not read (as in the 4DA app).
|
|
242
|
+
const versions = await this.publishedVersions(ecosystem, name);
|
|
243
|
+
if (!versions)
|
|
155
244
|
return null;
|
|
245
|
+
return { versions, withdrawn: [], withdrawnWhy: {}, latest: highest(versions), deprecatedLatest: null };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* npm's abbreviated document: versions without READMEs, with each
|
|
249
|
+
* version's `deprecated` message. A withdrawn release is never offered as a
|
|
250
|
+
* fix target: lodash 4.18.0 ("Bad release. Please use lodash@4.17.21
|
|
251
|
+
* instead.") was recommended by vulnerability_scan (audit B-8, 2026-10-12).
|
|
252
|
+
* Withdrawn means the app's rule (withdrawn-releases.ts): a deprecated
|
|
253
|
+
* release with a later clean one on its caret line. A whole-major notice
|
|
254
|
+
* (glob, tar 6.x) withdraws nothing, or glob 10.4.5 is sent to 12.0.0.
|
|
255
|
+
*/
|
|
256
|
+
async npmFacts(name) {
|
|
257
|
+
const key = `fixpath:v${CACHE_VERSION}:npm-facts:${name}`;
|
|
258
|
+
const hit = this.cache.get(key);
|
|
259
|
+
if (hit)
|
|
260
|
+
return hit;
|
|
261
|
+
const doc = await this.npmDocument(name);
|
|
262
|
+
if (!doc)
|
|
263
|
+
return null;
|
|
264
|
+
const versions = doc.versions ?? {};
|
|
265
|
+
const latest = doc["dist-tags"]?.latest ?? null;
|
|
266
|
+
const withdrawn = npmExcludedVersions(Object.fromEntries(Object.entries(versions).map(([v, m]) => [v, m?.deprecated])), latest);
|
|
267
|
+
const out = new Set(withdrawn);
|
|
268
|
+
const message = (v) => (typeof versions[v]?.deprecated === "string" ? versions[v].deprecated : null);
|
|
269
|
+
const noticed = Object.keys(versions).filter((v) => !out.has(v) && isNpmDeprecated(versions[v]?.deprecated));
|
|
270
|
+
const heldSpecs = {};
|
|
271
|
+
for (const [v, m] of Object.entries(versions)) {
|
|
272
|
+
const held = heldSpecsOf(m?.dependencies, m?.optionalDependencies);
|
|
273
|
+
if (Object.keys(held).length > 0)
|
|
274
|
+
heldSpecs[v] = held;
|
|
156
275
|
}
|
|
276
|
+
const facts = {
|
|
277
|
+
versions: Object.keys(versions).filter((v) => !out.has(v)),
|
|
278
|
+
withdrawn,
|
|
279
|
+
withdrawnWhy: Object.fromEntries(withdrawn.map((v) => [v, withdrawalText("npm", v, message(v))])),
|
|
280
|
+
latest,
|
|
281
|
+
deprecatedLatest: latest && isNpmDeprecated(versions[latest]?.deprecated) ? (message(latest) ?? "deprecated") : null,
|
|
282
|
+
...(noticed.length > 0 ? { lineNotices: Object.fromEntries(noticed.map((v) => [v, message(v) ?? "deprecated"])) } : {}),
|
|
283
|
+
heldSpecs,
|
|
284
|
+
};
|
|
285
|
+
this.cache.set(key, facts, "npm", VERSIONS_TTL);
|
|
286
|
+
return facts;
|
|
157
287
|
}
|
|
158
288
|
/** A crate's sparse-index record: versions and per-version dependency requirements. */
|
|
159
289
|
async cratesSparse(name) {
|
|
@@ -167,7 +297,7 @@ export class FixPathSources {
|
|
|
167
297
|
const res = await fetchWithTimeout(sparseIndexUrl(name), { headers: { "User-Agent": CRATES_USER_AGENT } }, TIMEOUT_MS);
|
|
168
298
|
if (!res.ok)
|
|
169
299
|
return null;
|
|
170
|
-
const out = { versions: [], reqs: {} };
|
|
300
|
+
const out = { versions: [], yanked: [], reqs: {} };
|
|
171
301
|
for (const line of (await res.text()).split("\n")) {
|
|
172
302
|
if (!line.trim())
|
|
173
303
|
continue;
|
|
@@ -175,6 +305,8 @@ export class FixPathSources {
|
|
|
175
305
|
const entry = JSON.parse(line);
|
|
176
306
|
if (!entry.yanked)
|
|
177
307
|
out.versions.push(entry.vers);
|
|
308
|
+
else
|
|
309
|
+
out.yanked.push(entry.vers);
|
|
178
310
|
const reqs = {};
|
|
179
311
|
for (const d of entry.deps ?? []) {
|
|
180
312
|
if (d.kind === "dev")
|
|
@@ -195,8 +327,12 @@ export class FixPathSources {
|
|
|
195
327
|
return null;
|
|
196
328
|
}
|
|
197
329
|
}
|
|
198
|
-
|
|
199
|
-
|
|
330
|
+
/**
|
|
331
|
+
* PyPI's JSON API: every release with files; one whose every file is yanked is withdrawn
|
|
332
|
+
* (PEP 592, the app's `parse_pypi`). A release with no files cannot be installed and is not listed.
|
|
333
|
+
*/
|
|
334
|
+
async pypiFacts(name) {
|
|
335
|
+
const key = `fixpath:v${CACHE_VERSION}:pypi-facts:${name}`;
|
|
200
336
|
const hit = this.cache.get(key);
|
|
201
337
|
if (hit)
|
|
202
338
|
return hit;
|
|
@@ -207,12 +343,20 @@ export class FixPathSources {
|
|
|
207
343
|
if (!res.ok)
|
|
208
344
|
return null;
|
|
209
345
|
const doc = (await res.json());
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
346
|
+
const out = { versions: [], withdrawn: [], withdrawnWhy: {} };
|
|
347
|
+
for (const [version, files] of Object.entries(doc.releases ?? {})) {
|
|
348
|
+
if (!Array.isArray(files) || files.length === 0)
|
|
349
|
+
continue;
|
|
350
|
+
if (files.every((f) => f.yanked === true)) {
|
|
351
|
+
out.withdrawn.push(version);
|
|
352
|
+
out.withdrawnWhy[version] = withdrawalText("PyPI", version, files.find((f) => f.yanked_reason)?.yanked_reason ?? null);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
out.versions.push(version);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
this.cache.set(key, out, "pypi", VERSIONS_TTL);
|
|
359
|
+
return out;
|
|
216
360
|
}
|
|
217
361
|
catch {
|
|
218
362
|
return null;
|
|
@@ -248,26 +392,165 @@ function sparseIndexUrl(name) {
|
|
|
248
392
|
default: return `https://index.crates.io/${n.slice(0, 2)}/${n.slice(2, 4)}/${n}`;
|
|
249
393
|
}
|
|
250
394
|
}
|
|
251
|
-
/**
|
|
252
|
-
|
|
253
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Whether a checked target may be reported as checked against every advisory (fix_checked
|
|
397
|
+
* `all_advisories`): its own advisories were read, and, for npm, what it installs at a fixed
|
|
398
|
+
* version was judged clean (audit 6.2.5 D-4).
|
|
399
|
+
*/
|
|
400
|
+
export function targetCheckedAll(clean) {
|
|
401
|
+
return !clean.advisoriesUnread && !clean.heldBlocked && clean.heldUnchecked === undefined;
|
|
402
|
+
}
|
|
403
|
+
/** The words for a target's held dependencies (FixTargetResult.heldFrom / heldBlocked / heldUnchecked), or null when there is nothing to say. */
|
|
404
|
+
export function heldNote(clean) {
|
|
405
|
+
if (clean.heldFrom && clean.heldPast && clean.heldPast.length > 0 && clean.target) {
|
|
406
|
+
const [first, ...rest] = clean.heldPast;
|
|
407
|
+
const names = [...new Set(rest.flatMap((p) => p.affected.map((d) => d.name)))];
|
|
408
|
+
const later = rest.length === 0
|
|
409
|
+
? ""
|
|
410
|
+
: `; ${rest.length === 1 ? rest[0].version : `${rest.length} later releases (${rest[0].version} to ${rest[rest.length - 1].version})`} still bring${rest.length === 1 ? "s" : ""} in an affected ${names.join(", ")}`;
|
|
411
|
+
return `${heldText(first.version, first.affected)}${later}; so ${clean.target}, the smallest later release on its line whose own advisories and held dependencies are clean`;
|
|
412
|
+
}
|
|
413
|
+
if (clean.heldBlocked && clean.target) {
|
|
414
|
+
const last = clean.heldBlocked.searched[clean.heldBlocked.searched.length - 1];
|
|
415
|
+
const upTo = last && last !== clean.target ? `up to ${last} ` : "";
|
|
416
|
+
return `${heldText(clean.target, clean.heldBlocked.affected)}; no release on its line ${upTo}that its own advisories leave clean clears ${clean.heldBlocked.affected.length === 1 ? "it" : "them"}, so ${clean.target} is NOT a clean fix: confirm a release with dependency_check before pinning it`;
|
|
417
|
+
}
|
|
418
|
+
if (clean.heldUnchecked !== undefined && clean.target) {
|
|
419
|
+
return `whether the dependencies ${clean.target} exact-pins or narrowly ranges are affected was NOT CHECKED (${clean.heldUnchecked}): confirm it with dependency_check before pinning it`;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* The minimal clean target for one installed copy, or null when neither the
|
|
425
|
+
* advisories nor anything that changes the answer could be read.
|
|
426
|
+
*
|
|
427
|
+
* Advisories unreadable, registry readable (audit H5: OSV /v1/query down):
|
|
428
|
+
* a deprecated or yanked release is still never the target. lodash 4.17.20
|
|
429
|
+
* went to 4.18.0 ("Bad release") because the whole check was skipped; the
|
|
430
|
+
* registry facts alone move it to 4.18.1, flagged `advisoriesUnread`.
|
|
431
|
+
*
|
|
432
|
+
* With no per-advisory fix at all (advisories that give only `last_affected`,
|
|
433
|
+
* audit A6), and the installed version known, the registry is asked for the
|
|
434
|
+
* first published release above the installed one that no known advisory
|
|
435
|
+
* affects: a fix candidate the advisories never named, flagged
|
|
436
|
+
* `noAdvisoryFix`, instead of "no fix".
|
|
437
|
+
*/
|
|
438
|
+
export async function cleanFixTarget(sources, ecosystem, name, perAdvisoryFixes, installed) {
|
|
439
|
+
if (perAdvisoryFixes.length === 0 && !installed)
|
|
254
440
|
return null;
|
|
255
|
-
const [advisories,
|
|
441
|
+
const [advisories, facts] = await Promise.all([
|
|
256
442
|
sources.packageAdvisories(ecosystem, name),
|
|
257
|
-
sources.
|
|
443
|
+
sources.releaseFacts(ecosystem, name),
|
|
258
444
|
]);
|
|
445
|
+
if (!advisories && !facts && perAdvisoryFixes.length > 0 && WITHDRAWALS_READ.has(ecosystem)) {
|
|
446
|
+
// Neither OSV's list nor the registry answered: the highest per-advisory fix stands, checked
|
|
447
|
+
// against the installed version's advisories only, and the answer says the withdrawal check
|
|
448
|
+
// did not run (lodash 4.18.0, "Bad release", went out with no word of it: final audit 7a).
|
|
449
|
+
const floor = maxVersion(perAdvisoryFixes, ecosystem) ?? perAdvisoryFixes[0];
|
|
450
|
+
return { target: floor, basis: "advisory_fixes", floor, floorAffectedBy: [], stillAffectedBy: [], unfixable: [], withheld: [], advisoriesUnread: true, registryUnread: true };
|
|
451
|
+
}
|
|
259
452
|
if (!advisories)
|
|
453
|
+
return perAdvisoryFixes.length > 0 && facts ? registryOnlyTarget(ecosystem, perAdvisoryFixes, facts) : null;
|
|
454
|
+
// Every release the registry lists, the withdrawn ones included so the result can name them;
|
|
455
|
+
// `excluded` keeps them from ever being the target (FX-H5).
|
|
456
|
+
const published = facts ? [...facts.versions, ...facts.withdrawn] : null;
|
|
457
|
+
const excluded = facts?.withdrawn ?? [];
|
|
458
|
+
const malware = advisories.filter((a) => a.malware).map((a) => a.id);
|
|
459
|
+
if (perAdvisoryFixes.length > 0) {
|
|
460
|
+
const clean = smallestCleanVersion({ ecosystem, fixes: perAdvisoryFixes, advisories, published, excluded, installed });
|
|
461
|
+
if (!clean)
|
|
462
|
+
return null;
|
|
463
|
+
if (malware.length > 0)
|
|
464
|
+
Object.assign(clean, { malware });
|
|
465
|
+
if (clean.target === null && clean.maliciousLanding)
|
|
466
|
+
Object.assign(clean, { landingNote: malwareText(name, clean.maliciousLanding) });
|
|
467
|
+
return withRegistryFacts({ ...clean, ...(facts?.withdrawn.includes(clean.floor) ? { floorWithdrawn: true } : {}) }, ecosystem, perAdvisoryFixes, advisories, facts);
|
|
468
|
+
}
|
|
469
|
+
// Only a registry list can show a release past a last_affected bound.
|
|
470
|
+
if (!published || published.length === 0)
|
|
260
471
|
return null;
|
|
261
|
-
|
|
472
|
+
const clean = smallestCleanVersion({ ecosystem, fixes: [installed], advisories, published, excluded, installed });
|
|
473
|
+
if (!clean || clean.target === null || clean.target === installed || clean.stillAffectedBy.length > 0)
|
|
474
|
+
return null;
|
|
475
|
+
return withRegistryFacts({ ...clean, noAdvisoryFix: true }, ecosystem, [], advisories, facts);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* The registry side of a target: that the registry was not read (so withdrawn releases were
|
|
479
|
+
* not checked), or the words for each withdrawn release and the installable fix for each
|
|
480
|
+
* per-advisory fix the registry withdrew (the app's `installable_fix`: the lowest allowed
|
|
481
|
+
* release above it that the advisories fixed at it do not affect).
|
|
482
|
+
*/
|
|
483
|
+
function withRegistryFacts(clean, ecosystem, fixes, advisories, facts) {
|
|
484
|
+
// Only the registries whose withdrawals are read (as in the app: Go `retract` and the RubyGems /
|
|
485
|
+
// Packagist readers are not); for the rest there was nothing to check, so nothing to say.
|
|
486
|
+
if (!facts)
|
|
487
|
+
return WITHDRAWALS_READ.has(ecosystem) ? { ...clean, registryUnread: true } : clean;
|
|
488
|
+
const lineFixes = {};
|
|
489
|
+
const published = [...facts.versions, ...facts.withdrawn];
|
|
490
|
+
for (const fix of new Set(fixes)) {
|
|
491
|
+
if (!facts.withdrawn.includes(fix))
|
|
492
|
+
continue;
|
|
493
|
+
const fixedHere = advisories.filter((a) => a.spans.some((s) => s.fixed !== null && compareVersions(s.fixed, fix, ecosystem) === 0));
|
|
494
|
+
lineFixes[fix] = installableFix({ ecosystem, fix, advisories: fixedHere, published, excluded: facts.withdrawn });
|
|
495
|
+
}
|
|
496
|
+
const named = new Set([...clean.withheld, ...Object.values(lineFixes).flatMap((l) => l.withheld)]);
|
|
497
|
+
const withheldWhy = Object.fromEntries([...named].map((v) => [v, facts.withdrawnWhy[v] ?? withdrawalText(ecosystem, v)]));
|
|
498
|
+
const notice = clean.target ? facts.lineNotices?.[clean.target] : undefined;
|
|
499
|
+
return {
|
|
500
|
+
...clean,
|
|
501
|
+
...(named.size > 0 ? { withheldWhy } : {}),
|
|
502
|
+
...(Object.keys(lineFixes).length > 0 ? { lineFixes } : {}),
|
|
503
|
+
...(notice ? { targetNotice: lineNoticeText(clean.target, notice) } : {}),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* With no advisory list: the smallest installable release at or above the
|
|
508
|
+
* highest per-advisory fix, only when that fix itself is withdrawn or not
|
|
509
|
+
* published (otherwise the unchecked answer stands as it is). Null when
|
|
510
|
+
* nothing changes or no installable release qualifies.
|
|
511
|
+
*/
|
|
512
|
+
function registryOnlyTarget(ecosystem, fixes, facts) {
|
|
513
|
+
if (facts.versions.length === 0)
|
|
514
|
+
return null;
|
|
515
|
+
const published = [...facts.versions, ...facts.withdrawn];
|
|
516
|
+
const clean = smallestCleanVersion({ ecosystem, fixes, advisories: [], published, excluded: facts.withdrawn });
|
|
517
|
+
if (!clean || (clean.target !== null && clean.target === clean.floor))
|
|
518
|
+
return null;
|
|
519
|
+
if (clean.target === null && clean.withheld.length === 0)
|
|
520
|
+
return null;
|
|
521
|
+
return withRegistryFacts({ ...clean, floorWithdrawn: facts.withdrawn.includes(clean.floor), advisoriesUnread: true }, ecosystem, fixes, [], facts);
|
|
522
|
+
}
|
|
523
|
+
/** #981 (b): an advisory whose ranges cannot be read leaves the release unknown, never skipped silently. */
|
|
524
|
+
export function unreadableText(ids) {
|
|
525
|
+
return `the affected ranges of ${ids.join(", ")} cannot be read, so whether any release clears ${ids.length === 1 ? "it" : "them"} is unknown: no release is named`;
|
|
526
|
+
}
|
|
527
|
+
/** The 4DA lead's rule (2026-10-18): no release is offered that brings in an advisory the installed version does not have. */
|
|
528
|
+
export function introducesText(ids) {
|
|
529
|
+
return `every fixing release brings in ${ids.join(", ")}, which the installed version does not have, so no release is named`;
|
|
530
|
+
}
|
|
531
|
+
/** #981 round 3: an installed copy a malicious advisory affects names no release; the advice is to remove or replace it. */
|
|
532
|
+
export function malwareText(name, ids) {
|
|
533
|
+
return `${name} is flagged malicious (${ids.join(", ")}): remove or replace ${name}`;
|
|
534
|
+
}
|
|
535
|
+
/** The words for a target that carries a line-wide deprecation notice (FixTargetResult.targetNotice). */
|
|
536
|
+
export function lineNoticeText(version, message) {
|
|
537
|
+
const quoted = message.length > 120 ? `${message.slice(0, 117)}...` : message;
|
|
538
|
+
return `${version} is deprecated on npm ("${quoted}"), a notice on its whole line rather than a withdrawn release (no later release on the line is free of it), so it stays the smallest fix without a major upgrade; dependency_check says review for it: tell the user it is deprecated`;
|
|
539
|
+
}
|
|
540
|
+
/** The ecosystems whose registry withdrawals (yanked, deprecated) are read. */
|
|
541
|
+
const WITHDRAWALS_READ = new Set(["npm", "crates.io", "PyPI"]);
|
|
542
|
+
/** `withheldNote` for a checked target: the withdrawn releases passed, in plain words; null when none. */
|
|
543
|
+
export function withheldText(clean, ecosystem) {
|
|
544
|
+
return withheldNote(clean.withheld, clean.target, clean.withheldWhy ?? {}, ecosystem, clean.stillAffectedBy.length === 0);
|
|
262
545
|
}
|
|
263
546
|
/**
|
|
264
547
|
* Verified targets for many installed copies at once, within a time budget:
|
|
265
548
|
* the copies that could not be checked in time (or offline) are simply
|
|
266
549
|
* absent from the result, and the caller says so. Keyed by `key`.
|
|
267
550
|
*/
|
|
268
|
-
export async function verifyFixTargets(sources, items, budgetMs, concurrency = 12) {
|
|
551
|
+
export async function verifyFixTargets(sources, items, budgetMs, concurrency = 12, options = {}) {
|
|
269
552
|
const out = new Map();
|
|
270
|
-
const todo = items.filter((i) => i.fixes.length > 0);
|
|
553
|
+
const todo = items.filter((i) => i.fixes.length > 0 || i.installed);
|
|
271
554
|
if (!sources || todo.length === 0)
|
|
272
555
|
return out;
|
|
273
556
|
const deadline = Date.now() + budgetMs;
|
|
@@ -280,7 +563,7 @@ export async function verifyFixTargets(sources, items, budgetMs, concurrency = 1
|
|
|
280
563
|
timer = setTimeout(() => resolve(null), Math.max(0, deadline - Date.now()));
|
|
281
564
|
});
|
|
282
565
|
try {
|
|
283
|
-
const clean = await Promise.race([cleanFixTarget(sources, item.ecosystem, item.name, item.fixes).catch(() => null), late]);
|
|
566
|
+
const clean = await Promise.race([cleanFixTarget(sources, item.ecosystem, item.name, item.fixes, item.installed).catch(() => null), late]);
|
|
284
567
|
if (clean)
|
|
285
568
|
out.set(item.key, clean);
|
|
286
569
|
}
|
|
@@ -291,19 +574,102 @@ export async function verifyFixTargets(sources, items, budgetMs, concurrency = 1
|
|
|
291
574
|
}
|
|
292
575
|
};
|
|
293
576
|
await Promise.all(Array.from({ length: Math.min(concurrency, todo.length) }, worker));
|
|
577
|
+
if (options.heldQuery) {
|
|
578
|
+
// npm: what each target installs at a fixed version, in one advisory lookup for all of them
|
|
579
|
+
// (audit 6.2.5 D-4). It gets at least HELD_FLOOR_MS even when the targets used the budget up:
|
|
580
|
+
// without it a cold scan would downgrade every npm target that holds a dependency.
|
|
581
|
+
const npm = todo.filter((i) => i.ecosystem === "npm" && out.has(i.key)).map((i) => ({ key: i.key, name: i.name, installed: i.installed, clean: out.get(i.key) }));
|
|
582
|
+
const held = await withHeldTargets(sources, options.heldQuery, npm, Math.max(deadline, Date.now() + HELD_FLOOR_MS));
|
|
583
|
+
for (const [key, clean] of held)
|
|
584
|
+
out.set(key, clean);
|
|
585
|
+
}
|
|
294
586
|
return out;
|
|
295
587
|
}
|
|
296
|
-
/**
|
|
297
|
-
|
|
588
|
+
/** The least time the held-dependency check of a scan's npm targets gets (verifyFixTargets). */
|
|
589
|
+
const HELD_FLOOR_MS = 3_000;
|
|
590
|
+
/** Said whenever the registry could not be read: withdrawn releases are then not ruled out, never silently. */
|
|
591
|
+
export const REGISTRY_UNREAD_NOTE = "the registry could not be read, so whether it yanked or deprecated this release was not checked";
|
|
592
|
+
/**
|
|
593
|
+
* One clause for a target that is not the highest per-advisory fix, or null when it is.
|
|
594
|
+
* Withdrawn releases passed on the way are named in the registry's own words (FX-H5).
|
|
595
|
+
*/
|
|
596
|
+
export function targetNote(clean, ecosystem = "") {
|
|
597
|
+
if (clean.heldFrom) {
|
|
598
|
+
// The advisory-side reasoning is about the release the advisories pointed at; the held step follows it.
|
|
599
|
+
const { heldFrom, heldPast: _past, targetNotice, ...rest } = clean;
|
|
600
|
+
const base = targetNote({ ...rest, target: heldFrom }, ecosystem);
|
|
601
|
+
return [base, heldNote(clean), targetNotice ?? null].filter((p) => Boolean(p)).join("; ") || null;
|
|
602
|
+
}
|
|
603
|
+
const held = heldNote(clean);
|
|
604
|
+
if (held) {
|
|
605
|
+
const { heldBlocked: _b, heldUnchecked: _u, ...rest } = clean;
|
|
606
|
+
return [targetNote(rest, ecosystem), held].filter((p) => Boolean(p)).join("; ");
|
|
607
|
+
}
|
|
608
|
+
const withheld = withheldText(clean, ecosystem);
|
|
609
|
+
if (clean.advisoriesUnread) {
|
|
610
|
+
const unchecked = "not checked against the package's other advisories (OSV.dev did not answer)";
|
|
611
|
+
if (clean.registryUnread)
|
|
612
|
+
return `${clean.target} is the highest per-advisory fix, ${unchecked}; ${REGISTRY_UNREAD_NOTE}`;
|
|
613
|
+
// Not "clean": nothing but the installed version's advisories was checked.
|
|
614
|
+
if (withheld && clean.target)
|
|
615
|
+
return `${withheld.replace(/ is the smallest clean release$/, " is the smallest installable release past it")}; ${unchecked}`;
|
|
616
|
+
if (withheld)
|
|
617
|
+
return `${withheld}; ${unchecked}`;
|
|
618
|
+
return `${clean.target} is the smallest installable release at or above ${clean.floor}, the highest per-advisory fix, which ${clean.floorWithdrawn ? "is deprecated or yanked on the registry" : "is not a published release"}; ${unchecked}`;
|
|
619
|
+
}
|
|
298
620
|
const parts = [];
|
|
299
|
-
if (clean.
|
|
300
|
-
parts.push(
|
|
621
|
+
if (clean.noAdvisoryFix) {
|
|
622
|
+
parts.push(`no advisory names a fixed version (they give only a last affected one); ${clean.target} is the first published release above the installed version that no known advisory affects`);
|
|
623
|
+
if (withheld)
|
|
624
|
+
parts.push(withheld);
|
|
625
|
+
}
|
|
626
|
+
else if (clean.target === null) {
|
|
627
|
+
if (withheld)
|
|
628
|
+
parts.push(withheld);
|
|
629
|
+
}
|
|
630
|
+
else if (clean.target !== clean.floor) {
|
|
631
|
+
if (clean.floorAffectedBy.length > 0) {
|
|
632
|
+
parts.push(`${clean.target} is the smallest version clear of every advisory a release fixes; ${clean.floor}, the highest per-advisory fix, is itself affected by ${clean.floorAffectedBy.join(", ")}`);
|
|
633
|
+
if (withheld)
|
|
634
|
+
parts.push(withheld);
|
|
635
|
+
}
|
|
636
|
+
else if (withheld) {
|
|
637
|
+
parts.push(withheld);
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
parts.push(`${clean.target} is the smallest version clear of every advisory a release fixes; ${clean.floor}, the highest per-advisory fix, ${clean.floorWithdrawn ? "is deprecated or yanked on the registry" : "is not a published release"}`);
|
|
641
|
+
}
|
|
301
642
|
}
|
|
302
643
|
if (clean.target && clean.stillAffectedBy.length > 0) {
|
|
303
|
-
parts.push(`${clean.stillAffectedBy
|
|
644
|
+
parts.push(`${clean.target} ${remainsText(clean.stillAffectedBy)}: ${clean.stillAffectedBy.length === 1 ? "it affects" : "they affect"} every newer release too`);
|
|
304
645
|
}
|
|
646
|
+
if (clean.target === null && clean.unreadable && clean.unreadable.length > 0)
|
|
647
|
+
parts.push(unreadableText(clean.unreadable));
|
|
648
|
+
if (clean.target === null && clean.landingNote)
|
|
649
|
+
parts.push(clean.landingNote);
|
|
650
|
+
if (clean.target === null && clean.introduces && clean.introduces.length > 0)
|
|
651
|
+
parts.push(introducesText(clean.introduces));
|
|
652
|
+
if (clean.registryUnread)
|
|
653
|
+
parts.push(REGISTRY_UNREAD_NOTE);
|
|
654
|
+
if (clean.targetNotice)
|
|
655
|
+
parts.push(clean.targetNotice);
|
|
305
656
|
return parts.length > 0 ? parts.join("; ") : null;
|
|
306
657
|
}
|
|
658
|
+
/**
|
|
659
|
+
* A finding's own fix as the registry still offers it (the app's `installable_fix`): the
|
|
660
|
+
* advisory's fixed version, or, when the registry withdrew it, the release to take instead
|
|
661
|
+
* (null: none past it is known), with the withdrawn ones and the plain-words note.
|
|
662
|
+
*/
|
|
663
|
+
export function rowFix(fixed, clean, ecosystem) {
|
|
664
|
+
const line = fixed && clean?.lineFixes?.[fixed];
|
|
665
|
+
if (!fixed || !line)
|
|
666
|
+
return { fixed, withheld: [], note: null };
|
|
667
|
+
const said = sayWithdrawn(line.withheld, clean.withheldWhy ?? {}, ecosystem);
|
|
668
|
+
const note = line.fix
|
|
669
|
+
? `${said}; ${line.fix} is the smallest release past it that fixes this advisory`
|
|
670
|
+
: `No installable fix for this advisory: ${said}, and no later release is known to fix it`;
|
|
671
|
+
return { fixed: line.fix, withheld: line.withheld, note };
|
|
672
|
+
}
|
|
307
673
|
/**
|
|
308
674
|
* The command that refreshes one transitive copy in place, for the tools it
|
|
309
675
|
* can be named for (4DA AD-055 ¶4); null for yarn 1 and bun, which have none
|
|
@@ -317,6 +683,9 @@ export function targetNote(clean) {
|
|
|
317
683
|
* pnpm 8.15 to 10.34 also moved a root project's transitive copy without it.
|
|
318
684
|
*/
|
|
319
685
|
export function refreshCommand(format, name, installed, target, workspaceRoot = false) {
|
|
686
|
+
const ecosystem = format === "cargo" ? "crates.io" : "npm";
|
|
687
|
+
if (!isShellSafeName(name, ecosystem) || !isShellSafeVersion(installed) || !isShellSafeVersion(target))
|
|
688
|
+
return null;
|
|
320
689
|
switch (format) {
|
|
321
690
|
case "package-lock":
|
|
322
691
|
return `npm update ${name}`;
|
|
@@ -331,8 +700,381 @@ export function refreshCommand(format, name, installed, target, workspaceRoot =
|
|
|
331
700
|
return `cargo update -p ${name}@${installed} --precise ${target}`;
|
|
332
701
|
}
|
|
333
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* Whether an override (npm `overrides`, pnpm `pnpm.overrides`, yarn `resolutions`) can reach the copy
|
|
705
|
+
* these blocking parents hold: never when one of them ships it bundled (rule vectors rv-066..rv-068).
|
|
706
|
+
*/
|
|
707
|
+
export function overrideReaches(blocking) {
|
|
708
|
+
return !blocking.some((b) => b.bundled === true);
|
|
709
|
+
}
|
|
710
|
+
/** The verified in-range refresh of a DECLARED npm-family dependency (DirectFix refresh). */
|
|
711
|
+
function npmDirectRefresh(manager, name) {
|
|
712
|
+
switch (manager) {
|
|
713
|
+
case "npm":
|
|
714
|
+
return `npm update ${name}`;
|
|
715
|
+
case "pnpm":
|
|
716
|
+
return `pnpm update ${name} --no-save`;
|
|
717
|
+
case "yarn1":
|
|
718
|
+
return `yarn upgrade ${name}`;
|
|
719
|
+
case "yarn-berry":
|
|
720
|
+
return `yarn up -R ${name}`;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** How far up from a project directory a workspace lockfile is looked for (the app's MAX_LOCKFILE_ASCENT). */
|
|
724
|
+
const MAX_LOCKFILE_ASCENT = 6;
|
|
725
|
+
/** The npm-family tool whose lockfile holds the project at `dir`, nearest directory first (yarn, pnpm, npm, as the app). */
|
|
726
|
+
function npmManager(dir) {
|
|
727
|
+
let here = path.resolve(dir);
|
|
728
|
+
for (let i = 0; i <= MAX_LOCKFILE_ASCENT; i++) {
|
|
729
|
+
if (fs.existsSync(path.join(here, "yarn.lock"))) {
|
|
730
|
+
let berry = false;
|
|
731
|
+
try {
|
|
732
|
+
berry = fs.readFileSync(path.join(here, "yarn.lock"), "utf-8").split("\n").slice(0, 12).some((l) => l.startsWith("__metadata:"));
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
berry = false;
|
|
736
|
+
}
|
|
737
|
+
return berry ? "yarn-berry" : "yarn1";
|
|
738
|
+
}
|
|
739
|
+
if (fs.existsSync(path.join(here, "pnpm-lock.yaml")))
|
|
740
|
+
return "pnpm";
|
|
741
|
+
if (fs.existsSync(path.join(here, "package-lock.json")))
|
|
742
|
+
return "npm";
|
|
743
|
+
const up = path.dirname(here);
|
|
744
|
+
if (up === here)
|
|
745
|
+
break;
|
|
746
|
+
here = up;
|
|
747
|
+
}
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
function readText(file) {
|
|
751
|
+
try {
|
|
752
|
+
return fs.readFileSync(file, "utf-8");
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/** The spec `name` is declared with, when exactly one of dependencies / devDependencies / optionalDependencies holds it. */
|
|
759
|
+
export function npmDeclared(packageJson, name) {
|
|
760
|
+
let pkg;
|
|
761
|
+
try {
|
|
762
|
+
pkg = JSON.parse(packageJson);
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
const found = ["dependencies", "devDependencies", "optionalDependencies"]
|
|
768
|
+
.map((s) => pkg?.[s]?.[name])
|
|
769
|
+
.filter((v) => typeof v === "string");
|
|
770
|
+
return found.length === 1 ? found[0] : null;
|
|
771
|
+
}
|
|
772
|
+
const PRERELEASE_BASE = /^\d+\.\d+\.\d+-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
|
|
773
|
+
const FULL_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
774
|
+
/** The operator a declared npm spec carries: "" for an exact pin, "^" or "~"; null for anything else. */
|
|
775
|
+
function npmOperator(declared) {
|
|
776
|
+
const d = declared.trim();
|
|
777
|
+
const digits = (s) => {
|
|
778
|
+
const parts = s.split(".");
|
|
779
|
+
return parts.length >= 1 && parts.length <= 3 && parts.every((p) => /^\d+$/.test(p));
|
|
780
|
+
};
|
|
781
|
+
// The 4DA lead (2026-10-19): a `^` or `~` on a PRERELEASE base (`^2.0.0-rc.4`, `~1.2.3-beta.1`)
|
|
782
|
+
// is bounded too: a full X.Y.Z with prerelease identifiers, no build metadata.
|
|
783
|
+
const base = (s) => digits(s) || PRERELEASE_BASE.test(s);
|
|
784
|
+
if (d.startsWith("^"))
|
|
785
|
+
return base(d.slice(1)) ? "^" : null;
|
|
786
|
+
if (d.startsWith("~"))
|
|
787
|
+
return base(d.slice(1)) ? "~" : null;
|
|
788
|
+
const exact = (d.startsWith("=") ? d.slice(1) : d).replace(/^v+/, "");
|
|
789
|
+
return FULL_SEMVER.test(exact) ? "" : null;
|
|
790
|
+
}
|
|
791
|
+
/** The command that moves a declared npm-family dependency, keeping its operator (quoted: `^` is cmd.exe's escape). */
|
|
792
|
+
export function npmDirectCommand(manager, declared, name, target) {
|
|
793
|
+
if (!isShellSafeName(name, "npm") || !isShellSafeVersion(target))
|
|
794
|
+
return null;
|
|
795
|
+
const op = npmOperator(declared);
|
|
796
|
+
if (op === null)
|
|
797
|
+
return null;
|
|
798
|
+
const spec = op === "" ? `${name}@${target}` : `"${name}@${op}${target}"`;
|
|
799
|
+
switch (manager) {
|
|
800
|
+
case "npm":
|
|
801
|
+
return op === "" ? `npm install ${spec} --save-exact` : `npm install ${spec}`;
|
|
802
|
+
case "pnpm":
|
|
803
|
+
return `pnpm up ${spec}`;
|
|
804
|
+
case "yarn1":
|
|
805
|
+
return `yarn upgrade ${spec}`;
|
|
806
|
+
case "yarn-berry":
|
|
807
|
+
return `yarn up ${spec}`;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const unquote = (s) => s.trim().replace(/^["']+|["']+$/g, "");
|
|
811
|
+
const crateKey = (name) => name.trim().toLowerCase().replace(/_/g, "-");
|
|
812
|
+
/** `[dependencies]`, `[dev-dependencies.foo]`, `[target.'cfg(unix)'.build-dependencies]`, ... */
|
|
813
|
+
function cargoTable(header) {
|
|
814
|
+
const inner = header.replace(/^\[+/, "").replace(/\]+$/, "").trim();
|
|
815
|
+
let target = null;
|
|
816
|
+
let rest = inner;
|
|
817
|
+
if (inner.startsWith("target.")) {
|
|
818
|
+
const after = inner.slice("target.".length);
|
|
819
|
+
if (after.startsWith("'") || after.startsWith('"')) {
|
|
820
|
+
const q = after.slice(1);
|
|
821
|
+
const close = q.search(/['"]/);
|
|
822
|
+
if (close < 0)
|
|
823
|
+
return null;
|
|
824
|
+
target = q.slice(0, close);
|
|
825
|
+
rest = q.slice(close + 1).replace(/^\.+/, "");
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
const dot = after.indexOf(".");
|
|
829
|
+
if (dot < 0)
|
|
830
|
+
return null;
|
|
831
|
+
target = after.slice(0, dot);
|
|
832
|
+
rest = after.slice(dot + 1);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const dot = rest.indexOf(".");
|
|
836
|
+
const section = dot < 0 ? rest : rest.slice(0, dot);
|
|
837
|
+
const key = dot < 0 ? null : unquote(rest.slice(dot + 1));
|
|
838
|
+
const kind = section === "dependencies" ? "normal" : section === "dev-dependencies" ? "dev" : section === "build-dependencies" ? "build" : null;
|
|
839
|
+
return kind ? { kind, target, key } : null;
|
|
840
|
+
}
|
|
841
|
+
/** Every declaration of `crate` in a Cargo.toml (by crate name, so a renamed dependency is found through `package = ...`). */
|
|
842
|
+
export function cargoDeclarations(manifest, crate) {
|
|
843
|
+
const want = crateKey(crate);
|
|
844
|
+
const found = [];
|
|
845
|
+
let table = null;
|
|
846
|
+
const set = (f, field, value) => {
|
|
847
|
+
if (field === "version")
|
|
848
|
+
f.req = unquote(value);
|
|
849
|
+
else if (field === "package")
|
|
850
|
+
f.pkg = unquote(value);
|
|
851
|
+
else if (field === "workspace")
|
|
852
|
+
f.workspace ||= value.trim() === "true";
|
|
853
|
+
else if (field === "git" || field === "path" || field === "registry")
|
|
854
|
+
f.otherSource = true;
|
|
855
|
+
};
|
|
856
|
+
for (const raw of manifest.split("\n")) {
|
|
857
|
+
const line = (raw.split("#")[0] ?? "").trim();
|
|
858
|
+
if (line.startsWith("[")) {
|
|
859
|
+
table = cargoTable(line);
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const eq = line.indexOf("=");
|
|
863
|
+
if (!table || eq < 0)
|
|
864
|
+
continue;
|
|
865
|
+
const k = unquote(line.slice(0, eq));
|
|
866
|
+
const v = line.slice(eq + 1);
|
|
867
|
+
let key;
|
|
868
|
+
let field;
|
|
869
|
+
if (table.key !== null) {
|
|
870
|
+
key = table.key;
|
|
871
|
+
field = k;
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
const dot = k.indexOf(".");
|
|
875
|
+
if (dot >= 0) {
|
|
876
|
+
key = unquote(k.slice(0, dot));
|
|
877
|
+
field = k.slice(dot + 1).trim();
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
key = k;
|
|
881
|
+
field = null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
let slot = found.find((d) => d.kind === table.kind && d.target === table.target && d.key === key);
|
|
885
|
+
if (!slot) {
|
|
886
|
+
slot = { kind: table.kind, target: table.target, key, f: { req: null, pkg: null, workspace: false, otherSource: false } };
|
|
887
|
+
found.push(slot);
|
|
888
|
+
}
|
|
889
|
+
if (field !== null)
|
|
890
|
+
set(slot.f, field, v);
|
|
891
|
+
else if (v.trimStart().startsWith("{")) {
|
|
892
|
+
const body = v.trim().replace(/^\{/, "").replace(/\}$/, "");
|
|
893
|
+
for (const kv of body.split(",")) {
|
|
894
|
+
const at = kv.indexOf("=");
|
|
895
|
+
if (at >= 0)
|
|
896
|
+
set(slot.f, kv.slice(0, at).trim(), kv.slice(at + 1).trim());
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
else
|
|
900
|
+
slot.f.req = unquote(v);
|
|
901
|
+
}
|
|
902
|
+
return found
|
|
903
|
+
.filter((d) => crateKey(d.f.pkg ?? d.key) === want)
|
|
904
|
+
.map((d) => ({ kind: d.kind, target: d.target, key: d.key, req: d.f.req, workspace: d.f.workspace, otherSource: d.f.otherSource }));
|
|
905
|
+
}
|
|
906
|
+
/** The fix for one Cargo declaration. */
|
|
907
|
+
function cargoDirect(decl, crate, target) {
|
|
908
|
+
if (decl.otherSource)
|
|
909
|
+
return null;
|
|
910
|
+
if (decl.workspace) {
|
|
911
|
+
return {
|
|
912
|
+
instruction: `bump ${crate} to ${target} in the workspace root's [workspace.dependencies] (it is inherited here; \`cargo add\` in this crate would replace the inheritance with a local pin)`,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
// Every value spliced below comes from the project's own Cargo.toml or lockfile (C-1).
|
|
916
|
+
const keyRenamed = crateKey(decl.key) !== crateKey(crate);
|
|
917
|
+
if (!isShellSafeName(crate, "crates.io") ||
|
|
918
|
+
!isShellSafeVersion(target) ||
|
|
919
|
+
(decl.target !== null && !isShellSafeCargoTarget(decl.target)) ||
|
|
920
|
+
(keyRenamed && !isShellSafeName(decl.key, "crates.io"))) {
|
|
921
|
+
return { instruction: `bump the ${isShellSafeName(crate, "crates.io") ? crate : "dependency"} requirement in Cargo.toml so it admits the fixed release (no command is named: the declaration holds characters that are unsafe in a command)` };
|
|
922
|
+
}
|
|
923
|
+
const r = decl.req?.trim() ?? "";
|
|
924
|
+
const op = r.startsWith("=") ? "=" : r.startsWith("~") ? "~" : r.startsWith("^") ? "^" : /^\d/.test(r) ? "" : null;
|
|
925
|
+
if (op === null)
|
|
926
|
+
return null;
|
|
927
|
+
let cmd = `cargo add ${op === "" ? `${crate}@${target}` : `"${crate}@${op}${target}"`}`;
|
|
928
|
+
if (decl.kind === "dev")
|
|
929
|
+
cmd += " --dev";
|
|
930
|
+
if (decl.kind === "build")
|
|
931
|
+
cmd += " --build";
|
|
932
|
+
// Double quotes: cmd.exe keeps single quotes as part of the argument.
|
|
933
|
+
if (decl.target)
|
|
934
|
+
cmd += ` --target "${decl.target}"`;
|
|
935
|
+
if (crateKey(decl.key) !== crateKey(crate))
|
|
936
|
+
cmd += ` --rename ${decl.key}`;
|
|
937
|
+
return { command: cmd };
|
|
938
|
+
}
|
|
939
|
+
/** `go get <module>@v<target>` when go.mod requires the module directly at `installed` and no `replace` points it elsewhere. */
|
|
940
|
+
export function goDirectCommand(goMod, module, installed, target) {
|
|
941
|
+
if (!isShellSafeName(module, "Go") || !isShellSafeVersion(installed) || !isShellSafeVersion(target))
|
|
942
|
+
return null;
|
|
943
|
+
const v = (s) => (s.startsWith("v") ? s : `v${s}`);
|
|
944
|
+
let inRequire = false;
|
|
945
|
+
let inReplace = false;
|
|
946
|
+
let requiredAt = null;
|
|
947
|
+
for (const raw of goMod.split("\n")) {
|
|
948
|
+
const line = raw.trim();
|
|
949
|
+
if (line === "require (")
|
|
950
|
+
inRequire = true;
|
|
951
|
+
else if (line === "replace (")
|
|
952
|
+
inReplace = true;
|
|
953
|
+
else if (line === ")")
|
|
954
|
+
inRequire = inReplace = false;
|
|
955
|
+
const replaced = inReplace ? line : line.startsWith("replace ") ? line.slice("replace ".length) : null;
|
|
956
|
+
if (replaced !== null && replaced.split(/\s+/)[0] === module)
|
|
957
|
+
return null;
|
|
958
|
+
const entry = inRequire ? line : line.startsWith("require ") ? line.slice("require ".length) : null;
|
|
959
|
+
if (entry !== null && !entry.includes("// indirect")) {
|
|
960
|
+
const parts = entry.split(/\s+/);
|
|
961
|
+
if (parts[0] === module)
|
|
962
|
+
requiredAt = parts[1] ?? null;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return requiredAt !== null && requiredAt === v(installed) ? `go get ${module}@${v(target)}` : null;
|
|
966
|
+
}
|
|
967
|
+
/** The edit of the requirements line that pins the package with `==` at `installed` (markers, extras kept). */
|
|
968
|
+
export function pypiDirectInstruction(requirements, file, name, installed, target) {
|
|
969
|
+
const lines = requirements.split("\n");
|
|
970
|
+
// Compiled output (pip-compile, and `uv pip compile`, whose header the old check missed, or any
|
|
971
|
+
// file with `# via` annotations) is regenerated from its input: a hand edit is reverted, and an
|
|
972
|
+
// indirect pin conflicts with its parent (audit 6.2.5 A-4). pypi-advice.ts words the re-compile.
|
|
973
|
+
if (compiledRequirements(requirements))
|
|
974
|
+
return null;
|
|
975
|
+
const norm = (s) => s.toLowerCase().replace(/[_.]/g, "-");
|
|
976
|
+
const want = norm(name);
|
|
977
|
+
for (const [i, raw] of lines.entries()) {
|
|
978
|
+
const line = raw.trim();
|
|
979
|
+
const at = line.indexOf("==");
|
|
980
|
+
if (at < 0)
|
|
981
|
+
continue;
|
|
982
|
+
const head = line.slice(0, at);
|
|
983
|
+
const rest = line.slice(at + 2);
|
|
984
|
+
if (norm((head.split("[")[0] ?? head).trim()) !== want)
|
|
985
|
+
continue;
|
|
986
|
+
const endAt = rest.search(/[;\\#\s]/);
|
|
987
|
+
const end = endAt < 0 ? rest.length : endAt;
|
|
988
|
+
const version = rest.slice(0, end);
|
|
989
|
+
const tail = rest.slice(end);
|
|
990
|
+
const continued = line.endsWith("\\") || line.includes("--hash") || (lines[i + 1]?.trimStart().startsWith("--hash") ?? false);
|
|
991
|
+
if (continued || version !== installed)
|
|
992
|
+
continue;
|
|
993
|
+
return `change \`${line}\` to \`${head}==${target}${tail}\` in ${file}`;
|
|
994
|
+
}
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* The fix for `name` at `installed`, declared in the project at `projectDir`, read from its own
|
|
999
|
+
* manifest; null when it cannot be named with certainty (see DirectFix).
|
|
1000
|
+
*/
|
|
1001
|
+
export function directFix(projectDir, ecosystem, name, installed, target) {
|
|
1002
|
+
const read = (file) => readText(path.join(projectDir, file));
|
|
1003
|
+
switch (ecosystem) {
|
|
1004
|
+
case "npm": {
|
|
1005
|
+
const manager = npmManager(projectDir);
|
|
1006
|
+
if (!manager)
|
|
1007
|
+
return null;
|
|
1008
|
+
// yarn's up / upgrade act on the whole workspace: from a member they rewrite siblings too.
|
|
1009
|
+
if ((manager === "yarn1" || manager === "yarn-berry") && !fs.existsSync(path.join(projectDir, "yarn.lock")))
|
|
1010
|
+
return null;
|
|
1011
|
+
const json = read("package.json");
|
|
1012
|
+
const declared = json === null ? null : npmDeclared(json, name);
|
|
1013
|
+
if (declared === null || npmRangeAdmits(declared, installed) !== true)
|
|
1014
|
+
return null;
|
|
1015
|
+
// In an npm workspace member too, run in the member's directory: on lockfiles npm wrote,
|
|
1016
|
+
// `npm update ms` there moved a member's own ^2.1.1 copy (hoisted and nested layouts, npm
|
|
1017
|
+
// 10.9.2, 2026-10-18). An earlier "it does not move" came from a hand-edited fixture lockfile
|
|
1018
|
+
// that carried 1.2.8's integrity under version 1.2.5.
|
|
1019
|
+
// Only a range bounded below the next major (`^`, `~`) is refreshed: a refresh installs the
|
|
1020
|
+
// NEWEST release in range, so a `>=` / `||` / `*` / x-range could jump a major or land on a
|
|
1021
|
+
// withdrawn or held release, and names nothing (fc-059, the app's npm_direct_fix).
|
|
1022
|
+
const op = npmOperator(declared);
|
|
1023
|
+
const bounded = op === "^" || op === "~";
|
|
1024
|
+
if (bounded && npmRangeAdmits(declared, target) === true)
|
|
1025
|
+
return isShellSafeName(name, "npm") ? { command: npmDirectRefresh(manager, name), refresh: true } : null;
|
|
1026
|
+
const command = npmDirectCommand(manager, declared, name, target);
|
|
1027
|
+
return command ? { command } : null;
|
|
1028
|
+
}
|
|
1029
|
+
case "crates.io": {
|
|
1030
|
+
const manifest = read("Cargo.toml");
|
|
1031
|
+
if (manifest === null)
|
|
1032
|
+
return null;
|
|
1033
|
+
const decls = cargoDeclarations(manifest, name);
|
|
1034
|
+
if (decls.length !== 1)
|
|
1035
|
+
return null;
|
|
1036
|
+
const [only] = decls;
|
|
1037
|
+
const admits = only.req !== null ? cargoReqAdmits(only.req, installed) === true : only.workspace;
|
|
1038
|
+
// A workspace-inherited, git, path or alternate-registry entry is never refreshed this way.
|
|
1039
|
+
if (admits && only.req !== null && !only.workspace && !only.otherSource && cargoReqAdmits(only.req, target) === true) {
|
|
1040
|
+
const command = refreshCommand("cargo", name, installed, target);
|
|
1041
|
+
return command ? { command, refresh: true } : null;
|
|
1042
|
+
}
|
|
1043
|
+
return admits ? cargoDirect(only, name, target) : null;
|
|
1044
|
+
}
|
|
1045
|
+
case "Go": {
|
|
1046
|
+
const goMod = read("go.mod");
|
|
1047
|
+
const command = goMod === null ? null : goDirectCommand(goMod, name, installed, target);
|
|
1048
|
+
return command ? { command } : null;
|
|
1049
|
+
}
|
|
1050
|
+
case "PyPI": {
|
|
1051
|
+
// A pin in a file included with `-r` is declared too, and edited there (audit 7g).
|
|
1052
|
+
const files = ["requirements.txt", "requirements-dev.txt", "requirements/base.txt"];
|
|
1053
|
+
for (let i = 0; i < files.length && i < 16; i++) {
|
|
1054
|
+
const body = read(files[i]);
|
|
1055
|
+
if (body === null)
|
|
1056
|
+
continue;
|
|
1057
|
+
const instruction = pypiDirectInstruction(body, files[i], name, installed, target);
|
|
1058
|
+
if (instruction)
|
|
1059
|
+
return { instruction };
|
|
1060
|
+
for (const { option, target: inc } of requirementOptions(body)) {
|
|
1061
|
+
const included = option === "r" ? confinedInclude(path.dirname(path.join(projectDir, files[i])), inc, projectDir) : null;
|
|
1062
|
+
const rel = included ? path.relative(projectDir, included).replace(/\\/g, "/") : null;
|
|
1063
|
+
if (rel && !files.includes(rel))
|
|
1064
|
+
files.push(rel);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
default:
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
334
1073
|
/** For a lockfile with no in-place refresh command (yarn 1, bun): the steps that move the copy. */
|
|
335
1074
|
export function manualRefreshSteps(format, name) {
|
|
1075
|
+
// The steps are read and followed; a name a shell would read specially is not echoed into them (C-1).
|
|
1076
|
+
if (!isShellSafeName(name, "npm"))
|
|
1077
|
+
return `refresh this package's ${format === "yarn" ? "yarn.lock" : "bun.lock"} entries by hand: its name contains characters that are unsafe in a command`;
|
|
336
1078
|
// Yarn 1 keys a block by every spec that resolved to it, and an alias block's
|
|
337
1079
|
// first key names the alias ("string-width-cjs@npm:string-width@^4.2.0"), so
|
|
338
1080
|
// name both forms. Deleting the entries and reinstalling is what moved the
|
|
@@ -371,7 +1113,8 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
|
|
|
371
1113
|
return null;
|
|
372
1114
|
const isCargo = parents.format === "cargo";
|
|
373
1115
|
const dialect = isCargo ? "cargo" : "npm";
|
|
374
|
-
|
|
1116
|
+
// A maintenance notice never makes a copy unclean (#994 review).
|
|
1117
|
+
const clean = (v) => !advisories || !advisories.some((a) => !a.maintenance && advisoryAffects(a, v, ecosystem));
|
|
375
1118
|
const releases = (published ?? []).filter((v) => !isPrereleaseVersion(v, ecosystem));
|
|
376
1119
|
const edgeReqs = await Promise.all(parents.edges.map((edge) => requirementsOf(sources, ecosystem, edge, name)));
|
|
377
1120
|
const reqs = [];
|
|
@@ -392,7 +1135,7 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
|
|
|
392
1135
|
if (rule.verdict === "unknown")
|
|
393
1136
|
return null;
|
|
394
1137
|
const blocking = [];
|
|
395
|
-
const block = (r) => blocking.push({ parent: r.edge.parent, parentVersion: r.edge.parentVersion, requirement: r.text ?? "" });
|
|
1138
|
+
const block = (r) => blocking.push({ parent: r.edge.parent, parentVersion: r.edge.parentVersion, requirement: r.text ?? "", ...(r.edge.bundled ? { bundled: true } : {}) });
|
|
396
1139
|
for (const r of reqs)
|
|
397
1140
|
if (requirementAdmits(r, target) === false)
|
|
398
1141
|
block(r);
|
|
@@ -429,6 +1172,8 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
|
|
|
429
1172
|
const prereqs = await cargoPrecisePrerequisites(sources, content, name, installed, target);
|
|
430
1173
|
if (prereqs === null)
|
|
431
1174
|
return null;
|
|
1175
|
+
if (prereqs.some((p) => !/^[A-Za-z0-9_-]+(?:@[0-9A-Za-z.+_-]+)?$/.test(p)))
|
|
1176
|
+
return null;
|
|
432
1177
|
if (prereqs.length > 0)
|
|
433
1178
|
command = `cargo update ${prereqs.map((p) => `-p ${p}`).join(" ")} && ${command}`;
|
|
434
1179
|
}
|