@4da/mcp-server 6.2.3 → 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 +520 -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 +289 -16
- package/dist/live/fix-paths.js +876 -93
- 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/range-satisfies.d.ts +4 -2
- package/dist/live/range-satisfies.js +7 -5
- package/dist/live/rate-limiter.d.ts +7 -0
- package/dist/live/rate-limiter.js +22 -1
- package/dist/live/refresh-rule.d.ts +36 -0
- package/dist/live/refresh-rule.js +49 -0
- 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 +51 -0
- package/dist/live/version-compare.js +152 -1
- 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 +164 -5
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +70 -7
- 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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* A release a tool OFFERS as an upgrade (upgrade_planner's target for a row with no advisory,
|
|
4
|
+
* dependency_health's latestStableVersion, what_should_i_know's "latest") is checked the way
|
|
5
|
+
* dependency_check decides `proceed`: against every advisory of the package with the same
|
|
6
|
+
* predicate (osv-ranges.ts advisoriesAffecting) and against the registry's withdrawals. A
|
|
7
|
+
* known-affected or withdrawn release is never offered as a safe upgrade, and the answer says why.
|
|
8
|
+
*
|
|
9
|
+
* Final release audit of 6.2.5 (2026-10-18): upgrade_planner sent crossenv 0.0.2-security, npm's
|
|
10
|
+
* placeholder for a removed malware package, to 1.0.1, which GHSA-c2m4-w5hm-vqjw (last_affected
|
|
11
|
+
* 1.0.1) lists as the malware itself. The same held for cross-env.js, mongose, babelcli, d3.js,
|
|
12
|
+
* jquery.js, ffmepg, nodesass and nodefabric: rows with no advisory on the installed version took
|
|
13
|
+
* the registry's latest stable release with no check at all.
|
|
14
|
+
*/
|
|
15
|
+
import { advisoriesAffecting, cleanReleases, isPrereleaseVersion, remainsText } from "./osv-ranges.js";
|
|
16
|
+
import { unreadableText } from "./fix-paths.js";
|
|
17
|
+
import { isSecurityHoldingRelease } from "./osv-scanner.js";
|
|
18
|
+
import { compareVersions } from "./version-compare.js";
|
|
19
|
+
/** Releases named in a list, "1.0.0 and 1.0.1" / "1.0.0, 1.0.1 and 1.0.2". */
|
|
20
|
+
function list(versions) {
|
|
21
|
+
return versions.length <= 1 ? versions.join("") : `${versions.slice(0, -1).join(", ")} and ${versions[versions.length - 1]}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Check one offered release. `installed` is the version the project holds; the replacement is
|
|
25
|
+
* looked for above it (a stable one unless nothing stable is newer).
|
|
26
|
+
*/
|
|
27
|
+
export async function vetOfferedRelease(sources, ecosystem, name, installed, offered) {
|
|
28
|
+
const [advisories, facts] = await Promise.all([
|
|
29
|
+
sources.packageAdvisories(ecosystem, name).catch(() => null),
|
|
30
|
+
sources.releaseFacts(ecosystem, name).catch(() => null),
|
|
31
|
+
]);
|
|
32
|
+
const base = { offered, checked: advisories !== null, affectedBy: [], withdrawn: null, safe: offered, holding: false, note: null };
|
|
33
|
+
if (!advisories) {
|
|
34
|
+
return { ...base, note: `${name} ${offered} was not checked against ${name}'s advisories (OSV.dev did not answer): run dependency_check on it before writing it` };
|
|
35
|
+
}
|
|
36
|
+
// The one clean-release predicate (#981): malware never skipped, unreadable ranges unknown, a
|
|
37
|
+
// genuinely unfixed advisory skipped and said.
|
|
38
|
+
const view = cleanReleases(advisories, ecosystem, [...(facts?.versions ?? []), offered]);
|
|
39
|
+
if (view.unreadable.length > 0) {
|
|
40
|
+
return { ...base, safe: null, note: `${name} ${offered} is not offered: ${unreadableText(view.unreadable)}` };
|
|
41
|
+
}
|
|
42
|
+
// An unfixed advisory is skipped only where the installed version has it too: an upgrade must
|
|
43
|
+
// never bring in one the project does not have (widget 1.0.0 -> 1.2.0).
|
|
44
|
+
const unavoidable = view.unfixed.filter((id) => installed !== null && advisoriesAffecting(advisories, installed, ecosystem).includes(id));
|
|
45
|
+
const affectedBy = advisoriesAffecting(advisories, offered, ecosystem).filter((id) => !unavoidable.includes(id));
|
|
46
|
+
const withdrawnWhy = facts?.withdrawn.includes(offered) ? (facts.withdrawnWhy[offered] ?? `${offered} is withdrawn on the registry`) : null;
|
|
47
|
+
if (affectedBy.length === 0 && !withdrawnWhy) {
|
|
48
|
+
const remains = view.remains(offered).filter((id) => unavoidable.includes(id));
|
|
49
|
+
return remains.length > 0 ? { ...base, note: `${name} ${offered} ${remainsText(remains)}` } : base;
|
|
50
|
+
}
|
|
51
|
+
const cmp = (a, b) => compareVersions(a, b, ecosystem);
|
|
52
|
+
const above = (facts?.versions ?? []).filter((v) => !installed || cmp(v, installed) > 0);
|
|
53
|
+
const stable = above.filter((v) => !isPrereleaseVersion(v, ecosystem));
|
|
54
|
+
const pool = (stable.length > 0 ? stable : above).sort(cmp);
|
|
55
|
+
const clean = (v) => advisoriesAffecting(advisories, v, ecosystem).every((id) => unavoidable.includes(id)) && view.unreadable.length === 0;
|
|
56
|
+
const safe = facts ? ([...pool].reverse().find(clean) ?? null) : null;
|
|
57
|
+
const holding = safe === null && installed !== null && isSecurityHoldingRelease(ecosystem, installed);
|
|
58
|
+
const why = affectedBy.length > 0
|
|
59
|
+
? advisories.some((a) => a.malware && affectedBy.includes(a.id))
|
|
60
|
+
? `is malware (${affectedBy.join(", ")})`
|
|
61
|
+
: `is affected by ${affectedBy.join(", ")}`
|
|
62
|
+
: withdrawnWhy.startsWith(`${offered} `) ? withdrawnWhy.slice(offered.length + 1) : `is withdrawn (${withdrawnWhy})`;
|
|
63
|
+
let note;
|
|
64
|
+
if (holding) {
|
|
65
|
+
const newer = pool.filter((v) => !clean(v));
|
|
66
|
+
const malware = advisories.some((a) => a.malware && newer.some((v) => advisoriesAffecting([a], v, ecosystem).length > 0));
|
|
67
|
+
const ids = [...new Set(newer.flatMap((v) => advisoriesAffecting(advisories, v, ecosystem)))];
|
|
68
|
+
note =
|
|
69
|
+
`${name} ${list(newer)} ${newer.length === 1 ? "is" : "are"} ${malware ? "malware" : "affected by a known advisory"} (${ids.join(", ")}); ` +
|
|
70
|
+
`${installed} is npm's empty security placeholder published in ${newer.length === 1 ? "its" : "their"} place, the safe state: do not upgrade ${name}, remove it from the project`;
|
|
71
|
+
}
|
|
72
|
+
else if (safe) {
|
|
73
|
+
note = `${name} ${offered} ${why}, so it is not offered: ${safe} is the newest release above ${installed ?? "the installed version"} that no advisory affects and the registry has not withdrawn`;
|
|
74
|
+
}
|
|
75
|
+
else if (!facts) {
|
|
76
|
+
note = `${name} ${offered} ${why}, so it is not offered; the registry could not be read to name a release that is not`;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
note = `${name} ${offered} ${why}, and no release above ${installed ?? "the installed version"} is free of known advisories and withdrawals: stay on ${installed ?? "the installed version"}`;
|
|
80
|
+
}
|
|
81
|
+
return { offered, checked: true, affectedBy, withdrawn: withdrawnWhy, safe, holding, note };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* vetOfferedRelease over many rows within a time budget; a row not checked in time is absent from
|
|
85
|
+
* the result, and the caller says so. Keyed by `key`.
|
|
86
|
+
*/
|
|
87
|
+
export async function vetOfferedReleases(sources, items, budgetMs, concurrency = 12) {
|
|
88
|
+
const out = new Map();
|
|
89
|
+
if (!sources || items.length === 0)
|
|
90
|
+
return out;
|
|
91
|
+
const deadline = Date.now() + budgetMs;
|
|
92
|
+
let next = 0;
|
|
93
|
+
const worker = async () => {
|
|
94
|
+
while (next < items.length && Date.now() < deadline) {
|
|
95
|
+
const item = items[next++];
|
|
96
|
+
let timer;
|
|
97
|
+
const late = new Promise((resolve) => {
|
|
98
|
+
timer = setTimeout(() => resolve(null), Math.max(0, deadline - Date.now()));
|
|
99
|
+
});
|
|
100
|
+
try {
|
|
101
|
+
const vet = await Promise.race([vetOfferedRelease(sources, item.ecosystem, item.name, item.installed, item.offered).catch(() => null), late]);
|
|
102
|
+
if (vet)
|
|
103
|
+
out.set(item.key, vet);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (timer !== undefined)
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/** Said for an offer that could not be checked in time (the budget ran out, or offline). */
|
|
115
|
+
export function uncheckedOfferNote(name, offered) {
|
|
116
|
+
return `${name} ${offered} was not checked against ${name}'s advisories in time: run dependency_check on it before writing it`;
|
|
117
|
+
}
|
|
@@ -31,6 +31,8 @@ export interface AdvisoryRanges {
|
|
|
31
31
|
* before 6.2.
|
|
32
32
|
*/
|
|
33
33
|
maintenance?: boolean;
|
|
34
|
+
/** A malicious-package record (malware.ts isMalware: a MAL- id or alias, or CWE-506). */
|
|
35
|
+
malware?: boolean;
|
|
34
36
|
}
|
|
35
37
|
/**
|
|
36
38
|
* Flatten the matching package's version ranges into spans. OSV sorts events
|
|
@@ -50,11 +52,69 @@ export declare function advisoryRanges(vuln: OsvVulnerability, packageName: stri
|
|
|
50
52
|
* reports as affected by PYSEC-2024-230 (4DA conformance corpus, 2026-10-12).
|
|
51
53
|
*/
|
|
52
54
|
export declare function advisoryAffects(advisory: AdvisoryRanges, version: string, ecosystem: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The advisories (maintenance notices left out) that affect `version`: the predicate dependency_check
|
|
57
|
+
* decides `proceed` by over a package's advisory list (checkRange), and the one every other tool
|
|
58
|
+
* runs on a release it offers (offered-release.ts). One predicate, so no tool offers a release
|
|
59
|
+
* dependency_check would refuse (final release audit of 6.2.5: upgrade_planner offered crossenv
|
|
60
|
+
* 1.0.1, which GHSA-c2m4-w5hm-vqjw lists as malware).
|
|
61
|
+
*/
|
|
62
|
+
export declare function advisoriesAffecting(advisories: readonly AdvisoryRanges[], version: string, ecosystem: string): string[];
|
|
63
|
+
/**
|
|
64
|
+
* Whether an advisory's ranges can decide anything for this package: at least one span or listed
|
|
65
|
+
* version, and every bound a version this ecosystem orders ("0" is OSV's since-the-beginning).
|
|
66
|
+
*/
|
|
67
|
+
export declare function readableAdvisory(a: AdvisoryRanges, ecosystem: string): boolean;
|
|
68
|
+
/** Every "clean release" decision, over one package's advisories (CleanReleases). */
|
|
69
|
+
export interface CleanReleases {
|
|
70
|
+
/** Advisories whose ranges cannot be read: the decision is UNKNOWN, no release is named, and the reason says so. */
|
|
71
|
+
unreadable: string[];
|
|
72
|
+
/** Malware advisories: any release one affects is never clean, never a target, never "newest". */
|
|
73
|
+
malware: string[];
|
|
74
|
+
/** Genuinely unfixed, non-malware advisories (they affect the highest release too): skipped when choosing. */
|
|
75
|
+
unfixed: string[];
|
|
76
|
+
/** No advisory that counts (every readable one but the unfixed) affects `version`. False for all when `unreadable` is not empty. */
|
|
77
|
+
clean: (version: string) => boolean;
|
|
78
|
+
/** The highest stable release no malicious advisory affects; null when none (or the decision is unknown). */
|
|
79
|
+
newest: string | null;
|
|
80
|
+
/** The unfixed advisories that still affect `version`: its text says "remains affected by <id> (no fix published)". */
|
|
81
|
+
remains: (version: string) => string[];
|
|
82
|
+
/** The malicious advisories that affect `version`: an installed copy they affect names no release at all. */
|
|
83
|
+
maliciousAt: (version: string) => string[];
|
|
84
|
+
/** The non-malicious advisories that affect `version` but not the installed version (a move would bring them in); empty without `installed`. */
|
|
85
|
+
introducedAt: (version: string) => string[];
|
|
86
|
+
/** `clean`, except that advisories the installed version does not have are ignored: whether only those block `version`. */
|
|
87
|
+
cleanButIntroduced: (version: string) => boolean;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* THE predicate for every "clean release" decision a tool makes, the rule of the 4DA #981
|
|
91
|
+
* re-review (2026-10-18): the newest release, a parent's or lever's own advisories, the child copy,
|
|
92
|
+
* parent standing, and the release a row offers. Underneath, the advisory-window function
|
|
93
|
+
* dependency_check decides `proceed` by (advisoryAffects, maintenance notices left out).
|
|
94
|
+
* (a) A malware advisory (MAL-, or a malicious-package record) is never skipped: a release it
|
|
95
|
+
* affects is never clean, never a target, never "newest"; the advice is to remove or replace.
|
|
96
|
+
* (b) An advisory with no ranges, or ranges that cannot be parsed, makes the decision unknown.
|
|
97
|
+
* (c) A genuinely unfixed, non-malware advisory (it also affects the highest release) may be
|
|
98
|
+
* skipped, and the chosen release says it remains affected.
|
|
99
|
+
* Skipping ANY advisory that affects the newest release, as before, was wrong for (a) and (b).
|
|
100
|
+
* `releases` are the candidates (installable; prereleases are left out of "highest").
|
|
101
|
+
*/
|
|
102
|
+
export declare function cleanReleases(advisories: readonly AdvisoryRanges[], ecosystem: string, releases: readonly string[], installed?: string | null): CleanReleases;
|
|
103
|
+
/** "remains affected by GHSA-x (no fix published)", or "" when nothing remains. */
|
|
104
|
+
export declare function remainsText(ids: string[]): string;
|
|
53
105
|
/** Whether a version is a prerelease in its ecosystem's own terms. */
|
|
54
106
|
export declare function isPrereleaseVersion(version: string, ecosystem: string): boolean;
|
|
55
107
|
export interface CleanTarget {
|
|
56
108
|
/** The smallest candidate at or above every per-advisory fix that no fixable advisory affects; null when none is clean. */
|
|
57
109
|
target: string | null;
|
|
110
|
+
/**
|
|
111
|
+
* True when no advisory named a fixed version (only `last_affected`) and
|
|
112
|
+
* `target` is the first published release above the installed version that
|
|
113
|
+
* no known advisory affects (fix-paths.ts cleanFixTarget).
|
|
114
|
+
*/
|
|
115
|
+
noAdvisoryFix?: boolean;
|
|
116
|
+
/** True when `floor` is published but deprecated or yanked, so it is never the target. */
|
|
117
|
+
floorWithdrawn?: boolean;
|
|
58
118
|
/** Where the candidates came from. */
|
|
59
119
|
basis: "registry" | "advisory_fixes";
|
|
60
120
|
/** The highest per-advisory fix. */
|
|
@@ -73,7 +133,28 @@ export interface CleanTarget {
|
|
|
73
133
|
* reported instead.
|
|
74
134
|
*/
|
|
75
135
|
unfixable: string[];
|
|
136
|
+
/**
|
|
137
|
+
* Releases the registry withdrew (yanked, deprecated: `excluded`) that the
|
|
138
|
+
* target would otherwise have been: each one at or above the floor and
|
|
139
|
+
* below the target (every one, when there is no target) that the advisories
|
|
140
|
+
* the target is judged against do not affect. Ascending. With no target, a
|
|
141
|
+
* non-empty list means a fix WAS published and then withdrawn: "no
|
|
142
|
+
* installable fix", not "no fix published" (4DA FX-H5 `WalkOutcome.withheld`).
|
|
143
|
+
*/
|
|
144
|
+
withheld: string[];
|
|
145
|
+
/** Advisories whose ranges cannot be read: no target is named, and the reason says so (#981 rule b). */
|
|
146
|
+
unreadable?: string[];
|
|
147
|
+
/** No target: every fixing release brings in these advisories, which the installed version does not have. */
|
|
148
|
+
introduces?: string[];
|
|
149
|
+
/**
|
|
150
|
+
* No target, and malware advisories (the malware predicate) affect releases a fix could land
|
|
151
|
+
* on: reported as malware ("<pkg> is flagged malicious (<id>): remove or replace <pkg>"), never
|
|
152
|
+
* as "brings in MAL-x" (#994 review). Sorted.
|
|
153
|
+
*/
|
|
154
|
+
maliciousLanding?: string[];
|
|
76
155
|
}
|
|
156
|
+
/** Whether `version` is one of `listed`, as a version (build metadata ignored), not only as a string. */
|
|
157
|
+
export declare function isListedVersion(listed: readonly string[], version: string, ecosystem: string): boolean;
|
|
77
158
|
/**
|
|
78
159
|
* The smallest version at or above the highest per-advisory fix that none
|
|
79
160
|
* of the package's fixable advisories affects.
|
|
@@ -83,10 +164,40 @@ export interface CleanTarget {
|
|
|
83
164
|
* name. An advisory that affects the newest candidate has no fixed release
|
|
84
165
|
* and does not disqualify a target (it is listed in `unfixable`). Null when
|
|
85
166
|
* `fixes` is empty.
|
|
167
|
+
*
|
|
168
|
+
* `excluded`: releases the registry withdrew (withdrawn-releases.ts). They
|
|
169
|
+
* stay in the pool so the result can say it passed one (`withheld`), but are
|
|
170
|
+
* never the target, exactly as a prerelease is not; an advisory whose every
|
|
171
|
+
* way out is withdrawn then has none, like an unfixed one (FX-H5).
|
|
86
172
|
*/
|
|
87
173
|
export declare function smallestCleanVersion(input: {
|
|
88
174
|
ecosystem: string;
|
|
89
175
|
fixes: string[];
|
|
90
176
|
advisories: AdvisoryRanges[];
|
|
91
177
|
published: string[] | null;
|
|
178
|
+
excluded?: readonly string[];
|
|
179
|
+
/**
|
|
180
|
+
* The installed version. An advisory no release fixes may be skipped only when the installed
|
|
181
|
+
* version already has it: a target never brings in an advisory, fixed or not, that the installed
|
|
182
|
+
* version does not have (the 4DA lead, 2026-10-18). Without it, as before (vectors that name none).
|
|
183
|
+
*/
|
|
184
|
+
installed?: string | null;
|
|
92
185
|
}): CleanTarget | null;
|
|
186
|
+
/**
|
|
187
|
+
* One advisory's own fix as a release the registry still offers (4DA
|
|
188
|
+
* `installable_fix`): `fix` itself, or, when the registry withdrew it, the
|
|
189
|
+
* lowest published release above it that is not withdrawn and that
|
|
190
|
+
* `advisories` (the ones that fix at `fix`) do not affect. `fix: null` with
|
|
191
|
+
* `withheld` set: the fix was published and withdrawn, and no installable
|
|
192
|
+
* release past it is known.
|
|
193
|
+
*/
|
|
194
|
+
export declare function installableFix(input: {
|
|
195
|
+
ecosystem: string;
|
|
196
|
+
fix: string;
|
|
197
|
+
advisories: AdvisoryRanges[];
|
|
198
|
+
published: string[] | null;
|
|
199
|
+
excluded: readonly string[];
|
|
200
|
+
}): {
|
|
201
|
+
fix: string | null;
|
|
202
|
+
withheld: string[];
|
|
203
|
+
};
|
package/dist/live/osv-ranges.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* 2026-10-10). The target is now checked against all of the package's
|
|
12
12
|
* advisories, not only the ones the installed version has.
|
|
13
13
|
*/
|
|
14
|
-
import { compareVersions, isComparable, parsePep440, samePackageName } from "./version-compare.js";
|
|
14
|
+
import { compareToLowerBound, compareVersions, isComparable, isComposerOrGemPrerelease, parsePep440, samePackageName } from "./version-compare.js";
|
|
15
15
|
import { parseSemverPrecedence } from "./semver-precedence.js";
|
|
16
|
-
import {
|
|
16
|
+
import { isMaintenanceRecord } from "./maintenance.js";
|
|
17
|
+
import { cweIdsOf, isMalware } from "./malware.js";
|
|
17
18
|
/**
|
|
18
19
|
* Flatten the matching package's version ranges into spans. OSV sorts events
|
|
19
20
|
* within a range; each `introduced` opens a span and the next `fixed` or
|
|
@@ -25,7 +26,7 @@ export function collectAffectedSpans(affected, packageName, ecosystem) {
|
|
|
25
26
|
for (const a of affected ?? []) {
|
|
26
27
|
// Names compare the registry's way: PEP 503 for PyPI (`Jinja2` is
|
|
27
28
|
// `jinja2`), `-`/`_` folding for crates.
|
|
28
|
-
if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
|
|
29
|
+
if (!a.package || a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
|
|
29
30
|
continue;
|
|
30
31
|
for (const range of a.ranges || []) {
|
|
31
32
|
if (range.type === "GIT")
|
|
@@ -60,7 +61,7 @@ export function collectAffectedSpans(affected, packageName, ecosystem) {
|
|
|
60
61
|
function listedVersions(affected, packageName, ecosystem) {
|
|
61
62
|
const out = [];
|
|
62
63
|
for (const a of affected ?? []) {
|
|
63
|
-
if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
|
|
64
|
+
if (!a.package || a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
|
|
64
65
|
continue;
|
|
65
66
|
out.push(...(a.versions ?? []));
|
|
66
67
|
}
|
|
@@ -70,12 +71,14 @@ function listedVersions(affected, packageName, ecosystem) {
|
|
|
70
71
|
export function advisoryRanges(vuln, packageName, ecosystem) {
|
|
71
72
|
if (vuln.withdrawn)
|
|
72
73
|
return null;
|
|
73
|
-
|
|
74
|
+
// THE malware predicate (malware.ts): a MAL- id or alias, or CWE-506; never summary wording.
|
|
75
|
+
const malware = isMalware(vuln.id, vuln.aliases ?? [], cweIdsOf(vuln), vuln.summary ?? "");
|
|
74
76
|
return {
|
|
75
77
|
id: vuln.id,
|
|
76
78
|
spans: collectAffectedSpans(vuln.affected, packageName, ecosystem),
|
|
77
79
|
versions: listedVersions(vuln.affected, packageName, ecosystem),
|
|
78
|
-
maintenance:
|
|
80
|
+
maintenance: isMaintenanceRecord(vuln, packageName, ecosystem),
|
|
81
|
+
...(malware ? { malware: true } : {}),
|
|
79
82
|
};
|
|
80
83
|
}
|
|
81
84
|
/**
|
|
@@ -93,18 +96,99 @@ export function advisoryAffects(advisory, version, ecosystem) {
|
|
|
93
96
|
if (isComparable(version, ecosystem) && advisory.versions.some((v) => v !== "0" && isComparable(v, ecosystem) && cmp(v, version) === 0)) {
|
|
94
97
|
return true;
|
|
95
98
|
}
|
|
96
|
-
return advisory.spans.some((s) =>
|
|
99
|
+
return advisory.spans.some((s) => compareToLowerBound(version, s.introduced, ecosystem) >= 0 &&
|
|
97
100
|
(s.fixed !== null ? cmp(version, s.fixed) < 0 : s.lastAffected !== null ? cmp(version, s.lastAffected) <= 0 : true));
|
|
98
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The advisories (maintenance notices left out) that affect `version`: the predicate dependency_check
|
|
104
|
+
* decides `proceed` by over a package's advisory list (checkRange), and the one every other tool
|
|
105
|
+
* runs on a release it offers (offered-release.ts). One predicate, so no tool offers a release
|
|
106
|
+
* dependency_check would refuse (final release audit of 6.2.5: upgrade_planner offered crossenv
|
|
107
|
+
* 1.0.1, which GHSA-c2m4-w5hm-vqjw lists as malware).
|
|
108
|
+
*/
|
|
109
|
+
export function advisoriesAffecting(advisories, version, ecosystem) {
|
|
110
|
+
return [...new Set(advisories.filter((a) => !a.maintenance && advisoryAffects(a, version, ecosystem)).map((a) => a.id))];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Whether an advisory's ranges can decide anything for this package: at least one span or listed
|
|
114
|
+
* version, and every bound a version this ecosystem orders ("0" is OSV's since-the-beginning).
|
|
115
|
+
*/
|
|
116
|
+
export function readableAdvisory(a, ecosystem) {
|
|
117
|
+
if (a.spans.length === 0 && a.versions.length === 0)
|
|
118
|
+
return false;
|
|
119
|
+
const ok = (v) => v === null || v === "0" || isComparable(v, ecosystem);
|
|
120
|
+
return a.spans.every((s) => ok(s.introduced) && ok(s.fixed) && ok(s.lastAffected));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* THE predicate for every "clean release" decision a tool makes, the rule of the 4DA #981
|
|
124
|
+
* re-review (2026-10-18): the newest release, a parent's or lever's own advisories, the child copy,
|
|
125
|
+
* parent standing, and the release a row offers. Underneath, the advisory-window function
|
|
126
|
+
* dependency_check decides `proceed` by (advisoryAffects, maintenance notices left out).
|
|
127
|
+
* (a) A malware advisory (MAL-, or a malicious-package record) is never skipped: a release it
|
|
128
|
+
* affects is never clean, never a target, never "newest"; the advice is to remove or replace.
|
|
129
|
+
* (b) An advisory with no ranges, or ranges that cannot be parsed, makes the decision unknown.
|
|
130
|
+
* (c) A genuinely unfixed, non-malware advisory (it also affects the highest release) may be
|
|
131
|
+
* skipped, and the chosen release says it remains affected.
|
|
132
|
+
* Skipping ANY advisory that affects the newest release, as before, was wrong for (a) and (b).
|
|
133
|
+
* `releases` are the candidates (installable; prereleases are left out of "highest").
|
|
134
|
+
*/
|
|
135
|
+
export function cleanReleases(advisories, ecosystem, releases, installed) {
|
|
136
|
+
const real = advisories.filter((a) => !a.maintenance);
|
|
137
|
+
const unreadable = [...new Set(real.filter((a) => !readableAdvisory(a, ecosystem)).map((a) => a.id))];
|
|
138
|
+
const readable = real.filter((a) => readableAdvisory(a, ecosystem));
|
|
139
|
+
const malware = [...new Set(readable.filter((a) => a.malware).map((a) => a.id))];
|
|
140
|
+
const cmp = (a, b) => compareVersions(a, b, ecosystem);
|
|
141
|
+
const stable = releases.filter((v) => isComparable(v, ecosystem) && !isPrereleaseVersion(v, ecosystem)).sort(cmp);
|
|
142
|
+
// #981 round 3: newest = the highest stable, non-withdrawn release no MALICIOUS advisory affects;
|
|
143
|
+
// usable(v) = no malicious advisory affects v, and every other advisory affecting v also affects
|
|
144
|
+
// newest (no fix is published for it), so an unfixed advisory never blocks a move that fixes the rest.
|
|
145
|
+
const malicious = readable.filter((a) => a.malware);
|
|
146
|
+
const benign = readable.filter((a) => !a.malware);
|
|
147
|
+
const notMalicious = (v) => !malicious.some((a) => advisoryAffects(a, v, ecosystem));
|
|
148
|
+
const newest = unreadable.length > 0 ? null : ([...stable].reverse().find(notMalicious) ?? null);
|
|
149
|
+
// Skipped only when the installed version already has it: a move never brings an advisory in.
|
|
150
|
+
const unfixedAdv = newest ? benign.filter((a) => advisoryAffects(a, newest, ecosystem) && (!installed || advisoryAffects(a, installed, ecosystem))) : [];
|
|
151
|
+
const clean = (v) => unreadable.length === 0 && notMalicious(v) && benign.every((a) => !advisoryAffects(a, v, ecosystem) || unfixedAdv.includes(a));
|
|
152
|
+
return {
|
|
153
|
+
unreadable,
|
|
154
|
+
malware,
|
|
155
|
+
unfixed: [...new Set(unfixedAdv.map((a) => a.id))],
|
|
156
|
+
clean,
|
|
157
|
+
newest,
|
|
158
|
+
remains: (v) => [...new Set(unfixedAdv.filter((a) => advisoryAffects(a, v, ecosystem)).map((a) => a.id))],
|
|
159
|
+
maliciousAt: (v) => [...new Set(malicious.filter((a) => advisoryAffects(a, v, ecosystem)).map((a) => a.id))],
|
|
160
|
+
introducedAt: (v) => installed ? [...new Set(benign.filter((a) => advisoryAffects(a, v, ecosystem) && !advisoryAffects(a, installed, ecosystem)).map((a) => a.id))] : [],
|
|
161
|
+
cleanButIntroduced: (v) => !!installed &&
|
|
162
|
+
unreadable.length === 0 &&
|
|
163
|
+
notMalicious(v) &&
|
|
164
|
+
benign.every((a) => !advisoryAffects(a, v, ecosystem) || unfixedAdv.includes(a) || !advisoryAffects(a, installed, ecosystem)),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** "remains affected by GHSA-x (no fix published)", or "" when nothing remains. */
|
|
168
|
+
export function remainsText(ids) {
|
|
169
|
+
return ids.length === 0 ? "" : `remains affected by ${ids.join(", ")} (no fix published)`;
|
|
170
|
+
}
|
|
99
171
|
/** Whether a version is a prerelease in its ecosystem's own terms. */
|
|
100
172
|
export function isPrereleaseVersion(version, ecosystem) {
|
|
101
173
|
if (ecosystem === "PyPI") {
|
|
102
174
|
const p = parsePep440(version);
|
|
103
175
|
return p ? p.pre !== null || p.dev !== null : false;
|
|
104
176
|
}
|
|
177
|
+
// Gem prereleases are letter segments (`4.2.0.beta3`), not `-` suffixes.
|
|
178
|
+
const own = isComposerOrGemPrerelease(version, ecosystem);
|
|
179
|
+
if (own !== null)
|
|
180
|
+
return own;
|
|
105
181
|
const p = parseSemverPrecedence(version.replace(/^v(?=\d)/, ""));
|
|
106
182
|
return p ? p.prerelease.length > 0 : false;
|
|
107
183
|
}
|
|
184
|
+
/** Whether `version` is one of `listed`, as a version (build metadata ignored), not only as a string. */
|
|
185
|
+
export function isListedVersion(listed, version, ecosystem) {
|
|
186
|
+
if (listed.includes(version))
|
|
187
|
+
return true;
|
|
188
|
+
if (!isComparable(version, ecosystem))
|
|
189
|
+
return false;
|
|
190
|
+
return listed.some((l) => isComparable(l, ecosystem) && compareVersions(l, version, ecosystem) === 0);
|
|
191
|
+
}
|
|
108
192
|
/**
|
|
109
193
|
* The smallest version at or above the highest per-advisory fix that none
|
|
110
194
|
* of the package's fixable advisories affects.
|
|
@@ -114,8 +198,16 @@ export function isPrereleaseVersion(version, ecosystem) {
|
|
|
114
198
|
* name. An advisory that affects the newest candidate has no fixed release
|
|
115
199
|
* and does not disqualify a target (it is listed in `unfixable`). Null when
|
|
116
200
|
* `fixes` is empty.
|
|
201
|
+
*
|
|
202
|
+
* `excluded`: releases the registry withdrew (withdrawn-releases.ts). They
|
|
203
|
+
* stay in the pool so the result can say it passed one (`withheld`), but are
|
|
204
|
+
* never the target, exactly as a prerelease is not; an advisory whose every
|
|
205
|
+
* way out is withdrawn then has none, like an unfixed one (FX-H5).
|
|
117
206
|
*/
|
|
118
207
|
export function smallestCleanVersion(input) {
|
|
208
|
+
// #994 review (both engines): a maintenance notice (unmaintained / deprecated package) never
|
|
209
|
+
// blocks a target, never makes a release unclean, and never "introduces": it is not judged here.
|
|
210
|
+
input = { ...input, advisories: input.advisories.filter((a) => !a.maintenance) };
|
|
119
211
|
const { ecosystem } = input;
|
|
120
212
|
const fixes = input.fixes.filter((f) => isComparable(f, ecosystem));
|
|
121
213
|
if (fixes.length === 0)
|
|
@@ -123,24 +215,91 @@ export function smallestCleanVersion(input) {
|
|
|
123
215
|
const cmp = (a, b) => compareVersions(a, b, ecosystem);
|
|
124
216
|
const floor = fixes.reduce((max, v) => (cmp(v, max) > 0 ? v : max), fixes[0]);
|
|
125
217
|
const allowPre = isPrereleaseVersion(floor, ecosystem);
|
|
218
|
+
const excluded = input.excluded ?? [];
|
|
219
|
+
const withdrawn = (v) => excluded.length > 0 && isListedVersion(excluded, v, ecosystem);
|
|
126
220
|
const fromRegistry = input.published !== null && input.published.length > 0;
|
|
127
221
|
const pool = fromRegistry
|
|
128
222
|
? input.published
|
|
129
223
|
: [floor, ...input.advisories.flatMap((a) => a.spans.map((s) => s.fixed).filter((f) => f !== null))];
|
|
130
|
-
const
|
|
224
|
+
const releases = [...new Set(pool)]
|
|
131
225
|
.filter((v) => isComparable(v, ecosystem) && cmp(v, floor) >= 0 && (allowPre || !isPrereleaseVersion(v, ecosystem)))
|
|
132
226
|
.sort(cmp);
|
|
227
|
+
const candidates = releases.filter((v) => !withdrawn(v));
|
|
133
228
|
const basis = fromRegistry ? "registry" : "advisory_fixes";
|
|
134
229
|
const newest = candidates[candidates.length - 1];
|
|
135
|
-
|
|
230
|
+
// The #981 rule (cleanReleases): an advisory whose ranges cannot be read leaves the decision
|
|
231
|
+
// unknown, no release named; a malware advisory is never "unfixable", so a release it affects is
|
|
232
|
+
// never the target.
|
|
233
|
+
const unreadable = [...new Set(input.advisories.filter((a) => !a.maintenance && !readableAdvisory(a, ecosystem)).map((a) => a.id))];
|
|
234
|
+
if (unreadable.length > 0) {
|
|
235
|
+
return { target: null, basis, floor, floorAffectedBy: unreadable, stillAffectedBy: unreadable, unfixable: [], withheld: [], unreadable };
|
|
236
|
+
}
|
|
237
|
+
const has = (a) => !input.installed || advisoryAffects(a, input.installed, ecosystem);
|
|
238
|
+
const unfixable = newest ? input.advisories.filter((a) => !a.malware && advisoryAffects(a, newest, ecosystem) && has(a)) : [];
|
|
239
|
+
// Unfixed, and the installed version does not have it: every fixing release would bring it in.
|
|
240
|
+
const introduced = newest ? input.advisories.filter((a) => !a.malware && advisoryAffects(a, newest, ecosystem) && !has(a)) : [];
|
|
136
241
|
const fixable = input.advisories.filter((a) => !unfixable.includes(a));
|
|
137
242
|
const unfixableIds = [...new Set(unfixable.map((a) => a.id))];
|
|
138
243
|
const affecting = (v) => [...new Set(input.advisories.filter((a) => advisoryAffects(a, v, ecosystem)).map((a) => a.id))];
|
|
244
|
+
const clear = (judged) => (v) => !judged.some((a) => advisoryAffects(a, v, ecosystem));
|
|
245
|
+
// The withdrawn releases that would have been the target: clean against `judged`, below `target`.
|
|
246
|
+
const passed = (judged, target) => releases.filter((v) => withdrawn(v) && (target === null || cmp(v, target) < 0) && clear(judged)(v));
|
|
139
247
|
// Clean against everything if any candidate is; otherwise against every advisory a release fixes.
|
|
140
|
-
for (const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
248
|
+
for (const judged of [input.advisories, fixable]) {
|
|
249
|
+
// A prerelease fix event (docker/distribution v2.8.2-beta.1) lets prereleases in, but a clean
|
|
250
|
+
// stable release still comes first: v2.8.2+incompatible, not the beta (audit 6.2.5 A-7).
|
|
251
|
+
const candidate = (allowPre ? candidates.find((v) => !isPrereleaseVersion(v, ecosystem) && clear(judged)(v)) : undefined) ?? candidates.find(clear(judged));
|
|
252
|
+
if (candidate) {
|
|
253
|
+
return {
|
|
254
|
+
target: candidate,
|
|
255
|
+
basis,
|
|
256
|
+
floor,
|
|
257
|
+
floorAffectedBy: affecting(floor),
|
|
258
|
+
stillAffectedBy: affecting(candidate),
|
|
259
|
+
unfixable: unfixableIds,
|
|
260
|
+
withheld: passed(judged, candidate),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
144
263
|
}
|
|
145
|
-
|
|
264
|
+
// #994 review: the ids are the UNION, over every candidate blocked only by advisories the
|
|
265
|
+
// installed version lacks, of those advisories (sorted), not the smallest such release's alone.
|
|
266
|
+
const lacks = (a) => !a.malware && !has(a);
|
|
267
|
+
const blockedOnlyByNew = candidates.filter((v) => input.advisories.every((a) => !advisoryAffects(a, v, ecosystem) || unfixable.includes(a) || lacks(a)) &&
|
|
268
|
+
input.advisories.some((a) => lacks(a) && advisoryAffects(a, v, ecosystem)));
|
|
269
|
+
const unionIds = new Set(blockedOnlyByNew.flatMap((v) => input.advisories.filter((a) => lacks(a) && advisoryAffects(a, v, ecosystem)).map((a) => a.id)));
|
|
270
|
+
const introduces = [...(unionIds.size > 0 ? unionIds : new Set(introduced.map((a) => a.id)))].sort();
|
|
271
|
+
const maliciousLanding = [...new Set(input.advisories.filter((a) => a.malware && candidates.some((v) => advisoryAffects(a, v, ecosystem))).map((a) => a.id))].sort();
|
|
272
|
+
return {
|
|
273
|
+
...(introduces.length > 0 ? { introduces } : {}),
|
|
274
|
+
...(maliciousLanding.length > 0 ? { maliciousLanding } : {}),
|
|
275
|
+
target: null,
|
|
276
|
+
basis,
|
|
277
|
+
floor,
|
|
278
|
+
floorAffectedBy: affecting(floor),
|
|
279
|
+
stillAffectedBy: affecting(floor),
|
|
280
|
+
unfixable: unfixableIds,
|
|
281
|
+
withheld: passed(fixable, null),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* One advisory's own fix as a release the registry still offers (4DA
|
|
286
|
+
* `installable_fix`): `fix` itself, or, when the registry withdrew it, the
|
|
287
|
+
* lowest published release above it that is not withdrawn and that
|
|
288
|
+
* `advisories` (the ones that fix at `fix`) do not affect. `fix: null` with
|
|
289
|
+
* `withheld` set: the fix was published and withdrawn, and no installable
|
|
290
|
+
* release past it is known.
|
|
291
|
+
*/
|
|
292
|
+
export function installableFix(input) {
|
|
293
|
+
const { ecosystem, fix } = input;
|
|
294
|
+
if (!isListedVersion(input.excluded, fix, ecosystem))
|
|
295
|
+
return { fix, withheld: [] };
|
|
296
|
+
const cmp = (a, b) => compareVersions(a, b, ecosystem);
|
|
297
|
+
const allowPre = isPrereleaseVersion(fix, ecosystem);
|
|
298
|
+
const above = [...new Set(input.published ?? [])]
|
|
299
|
+
.filter((v) => isComparable(v, ecosystem) && cmp(v, fix) > 0 && (allowPre || !isPrereleaseVersion(v, ecosystem)))
|
|
300
|
+
.filter((v) => !input.advisories.some((a) => !a.maintenance && advisoryAffects(a, v, ecosystem)))
|
|
301
|
+
.sort(cmp);
|
|
302
|
+
const next = above.find((v) => !isListedVersion(input.excluded, v, ecosystem)) ?? null;
|
|
303
|
+
const skipped = above.filter((v) => isListedVersion(input.excluded, v, ecosystem) && (next === null || cmp(v, next) < 0));
|
|
304
|
+
return { fix: next, withheld: [fix, ...skipped.filter((v) => v !== fix)] };
|
|
146
305
|
}
|