@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +520 -0
  2. package/README.md +22 -6
  3. package/dist/db.d.ts +38 -0
  4. package/dist/db.js +78 -23
  5. package/dist/doctor.js +6 -3
  6. package/dist/embeddings.d.ts +1 -1
  7. package/dist/embeddings.js +16 -14
  8. package/dist/index.js +73 -103
  9. package/dist/live/advisory-groups.d.ts +68 -0
  10. package/dist/live/advisory-groups.js +131 -0
  11. package/dist/live/cache.js +11 -3
  12. package/dist/live/cargo-platform.d.ts +6 -0
  13. package/dist/live/cargo-platform.js +8 -2
  14. package/dist/live/changelog.js +38 -6
  15. package/dist/live/dependency-set.d.ts +9 -1
  16. package/dist/live/dependency-set.js +10 -2
  17. package/dist/live/fix-paths.d.ts +289 -16
  18. package/dist/live/fix-paths.js +876 -93
  19. package/dist/live/github-releases.js +3 -0
  20. package/dist/live/held-dependencies.d.ts +93 -0
  21. package/dist/live/held-dependencies.js +212 -0
  22. package/dist/live/held-targets.d.ts +30 -0
  23. package/dist/live/held-targets.js +107 -0
  24. package/dist/live/http-utils.js +13 -3
  25. package/dist/live/index.d.ts +7 -4
  26. package/dist/live/index.js +24 -19
  27. package/dist/live/install-state.js +6 -2
  28. package/dist/live/js-lockfile-readers.d.ts +8 -1
  29. package/dist/live/js-lockfile-readers.js +43 -6
  30. package/dist/live/lockfile-parents.d.ts +15 -0
  31. package/dist/live/lockfile-parents.js +85 -10
  32. package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
  33. package/dist/live/lockfile-parsers-pygo.js +230 -12
  34. package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
  35. package/dist/live/lockfile-parsers-rbphp.js +123 -0
  36. package/dist/live/lockfile-parsers.d.ts +6 -0
  37. package/dist/live/lockfile-parsers.js +116 -4
  38. package/dist/live/lockfile-types.d.ts +28 -1
  39. package/dist/live/maintenance.d.ts +20 -4
  40. package/dist/live/maintenance.js +31 -2
  41. package/dist/live/malware.d.ts +20 -0
  42. package/dist/live/malware.js +31 -0
  43. package/dist/live/npm-packument.d.ts +4 -1
  44. package/dist/live/npm-packument.js +5 -2
  45. package/dist/live/offered-release.d.ts +53 -0
  46. package/dist/live/offered-release.js +117 -0
  47. package/dist/live/osv-ranges.d.ts +111 -0
  48. package/dist/live/osv-ranges.js +173 -14
  49. package/dist/live/osv-scanner.d.ts +87 -5
  50. package/dist/live/osv-scanner.js +310 -59
  51. package/dist/live/pinned-advisories.d.ts +2 -2
  52. package/dist/live/pinned-advisories.js +3 -3
  53. package/dist/live/pypi-advice.d.ts +36 -0
  54. package/dist/live/pypi-advice.js +158 -0
  55. package/dist/live/range-satisfies.d.ts +4 -2
  56. package/dist/live/range-satisfies.js +7 -5
  57. package/dist/live/rate-limiter.d.ts +7 -0
  58. package/dist/live/rate-limiter.js +22 -1
  59. package/dist/live/refresh-rule.d.ts +36 -0
  60. package/dist/live/refresh-rule.js +49 -0
  61. package/dist/live/release-metadata.d.ts +5 -0
  62. package/dist/live/resolution.d.ts +3 -0
  63. package/dist/live/resolution.js +6 -3
  64. package/dist/live/scan-coverage.d.ts +26 -0
  65. package/dist/live/scan-coverage.js +70 -0
  66. package/dist/live/semver-utils.d.ts +8 -0
  67. package/dist/live/semver-utils.js +26 -3
  68. package/dist/live/severity-scope.d.ts +26 -1
  69. package/dist/live/severity-scope.js +32 -0
  70. package/dist/live/shell-safe.d.ts +19 -0
  71. package/dist/live/shell-safe.js +36 -0
  72. package/dist/live/types.d.ts +134 -8
  73. package/dist/live/version-compare.d.ts +51 -0
  74. package/dist/live/version-compare.js +152 -1
  75. package/dist/live/withdrawn-releases.d.ts +50 -0
  76. package/dist/live/withdrawn-releases.js +125 -0
  77. package/dist/project-manifests.d.ts +7 -0
  78. package/dist/project-manifests.js +45 -5
  79. package/dist/project-scanner.js +6 -4
  80. package/dist/project-tree.d.ts +56 -0
  81. package/dist/project-tree.js +174 -5
  82. package/dist/schema-registry.d.ts +47 -4
  83. package/dist/schema-registry.js +76 -9
  84. package/dist/server-instructions.js +4 -2
  85. package/dist/setup.d.ts +8 -0
  86. package/dist/setup.js +21 -2
  87. package/dist/tool-dispatch.js +3 -3
  88. package/dist/tools/agent-memory.d.ts +5 -0
  89. package/dist/tools/agent-memory.js +9 -0
  90. package/dist/tools/briefing-task-scope.js +5 -2
  91. package/dist/tools/decision-memory.js +2 -0
  92. package/dist/tools/dependency-check-held.d.ts +44 -0
  93. package/dist/tools/dependency-check-held.js +98 -0
  94. package/dist/tools/dependency-check-signals.d.ts +19 -2
  95. package/dist/tools/dependency-check-signals.js +84 -13
  96. package/dist/tools/dependency-check-versions.d.ts +37 -4
  97. package/dist/tools/dependency-check-versions.js +48 -13
  98. package/dist/tools/dependency-check.d.ts +11 -1
  99. package/dist/tools/dependency-check.js +81 -29
  100. package/dist/tools/dependency-health.js +209 -45
  101. package/dist/tools/get-context.d.ts +3 -0
  102. package/dist/tools/get-context.js +4 -0
  103. package/dist/tools/project-scope.d.ts +8 -0
  104. package/dist/tools/project-scope.js +21 -0
  105. package/dist/tools/upgrade-impact-features.d.ts +37 -0
  106. package/dist/tools/upgrade-impact-features.js +176 -0
  107. package/dist/tools/upgrade-impact-literals.js +26 -4
  108. package/dist/tools/upgrade-impact-report.d.ts +5 -1
  109. package/dist/tools/upgrade-impact-report.js +26 -5
  110. package/dist/tools/upgrade-impact.js +10 -3
  111. package/dist/tools/upgrade-planner-fixpaths.d.ts +164 -5
  112. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  113. package/dist/tools/upgrade-planner.d.ts +70 -7
  114. package/dist/tools/upgrade-planner.js +237 -61
  115. package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
  116. package/dist/tools/vulnerability-scan-concise.js +37 -3
  117. package/dist/tools/vulnerability-scan-format.d.ts +126 -24
  118. package/dist/tools/vulnerability-scan-format.js +193 -26
  119. package/dist/tools/vulnerability-scan.d.ts +224 -63
  120. package/dist/tools/vulnerability-scan.js +50 -15
  121. package/dist/tools/what-should-i-know.d.ts +6 -1
  122. package/dist/tools/what-should-i-know.js +72 -15
  123. 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
+ };
@@ -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 { isMaintenanceNotice } from "./maintenance.js";
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
- const informational = vuln.database_specific?.informational;
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: isMaintenanceNotice({ summary: vuln.summary ?? "" }) || informational === "unmaintained" || informational === "notice",
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) => cmp(version, s.introduced) >= 0 &&
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 candidates = [...new Set(pool)]
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
- const unfixable = newest ? input.advisories.filter((a) => advisoryAffects(a, newest, ecosystem)) : [];
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 pool of [input.advisories, fixable]) {
141
- const candidate = candidates.find((v) => !pool.some((a) => advisoryAffects(a, v, ecosystem)));
142
- if (candidate)
143
- return { target: candidate, basis, floor, floorAffectedBy: affecting(floor), stillAffectedBy: affecting(candidate), unfixable: unfixableIds };
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
- return { target: null, basis, floor, floorAffectedBy: affecting(floor), stillAffectedBy: affecting(floor), unfixable: unfixableIds };
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
  }