@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
@@ -5,13 +5,18 @@
5
5
  * provenance. Split out of vulnerability-scan.ts, which keeps execution.
6
6
  */
7
7
  import { hostTarget } from "../live/platform.js";
8
- import { maxSemver } from "../live/semver-utils.js";
8
+ import { computeSemverDistance, maxSemver } from "../live/semver-utils.js";
9
9
  import { isMaintenanceNotice } from "../live/maintenance.js";
10
10
  import { countBySeverity, presentedSeverity, scopeAdjustmentReason, SEVERITY_RANK, } from "../live/severity-scope.js";
11
+ import { vulnerabilityCoverage } from "../live/scan-coverage.js";
11
12
  import { buildDriftView, groupDriftRows, lockfileAlsoAffected } from "./install-drift-notes.js";
12
- import { discoveryNote } from "../project-tree.js";
13
- import { targetNote } from "../live/fix-paths.js";
13
+ import { discoveryNote, exclusionNotes, lockfileProblemNotes, splitLockfileProblems } from "../project-tree.js";
14
+ import { rowFix, targetCheckedAll, targetNote, withheldText } from "../live/fix-paths.js";
15
+ import { pypiScanNote } from "../live/pypi-advice.js";
16
+ import { inEcosystemForm, maxVersion } from "../live/version-compare.js";
17
+ import * as fs from "node:fs";
14
18
  import * as path from "node:path";
19
+ import { compiledRequirements } from "../live/lockfile-parsers-pygo.js";
15
20
  /** The key a verified target is stored under: one installed copy of a package. */
16
21
  export function fixTargetKey(ecosystem, name, version) {
17
22
  return `${ecosystem}\0${name}\0${version}`;
@@ -21,6 +26,16 @@ function lockfileCommand(manifest) {
21
26
  const base = path.basename(manifest);
22
27
  return base === "Cargo.toml" ? "cargo generate-lockfile" : base === "package.json" ? "npm install --package-lock-only" : "your package manager's lock command";
23
28
  }
29
+ /** A requirements file that pins only what it lists: not compiled (no `# via`, no compile header) and not hashed. */
30
+ function isFlatRequirements(file) {
31
+ try {
32
+ const content = fs.readFileSync(file, "utf-8");
33
+ return compiledRequirements(content) === null && !/--hash[= ]/.test(content);
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
24
39
  /**
25
40
  * What the scan did NOT cover, so a clean answer is never mistaken for a
26
41
  * complete one: lockfiles past the project-tree bounds, and manifests with no
@@ -30,6 +45,11 @@ export function scanCoverage(ctx, relative, detailed) {
30
45
  const discovery = ctx.discovery ?? null;
31
46
  const unresolved = ctx.unresolved ?? [];
32
47
  const notes = [];
48
+ const problems = ctx.problems ?? [];
49
+ // UNREADABLE, PARTIAL (entries skipped, audit L5) and REFUSED includes (M4), worded as in every tool.
50
+ notes.push(...lockfileProblemNotes(problems, relative));
51
+ const { unreadable, partial, refused } = splitLockfileProblems(problems);
52
+ notes.push(...exclusionNotes(discovery));
33
53
  const walk = discoveryNote(discovery);
34
54
  if (walk)
35
55
  notes.push(walk);
@@ -47,6 +67,11 @@ export function scanCoverage(ctx, relative, detailed) {
47
67
  ? { skipped_lockfiles: discovery.skipped.map((s) => ({ path: s.path, reason: s.reason })) }
48
68
  : {}),
49
69
  ...(discovery?.walkTruncated ? { walk_truncated: true } : {}),
70
+ ...(unreadable.length > 0 ? { unreadable_lockfiles: unreadable.map((p) => ({ path: relative(p.file), error: p.error })) } : {}),
71
+ ...(partial.length > 0 ? { partial_lockfiles: partial.map((p) => ({ path: relative(p.file), skipped_entries: p.skipped ?? 0, reason: p.error })) } : {}),
72
+ ...(refused.length > 0 ? { refused_includes: refused.map((p) => ({ path: relative(p.file), reason: p.error })) } : {}),
73
+ ...(discovery?.notCoveredCount ? { not_covered: discovery.notCovered ?? [], not_covered_count: discovery.notCoveredCount } : {}),
74
+ ...(discovery?.fixtures ? { not_scanned: { test_fixture_lockfiles: discovery.fixtures.count, dirs: discovery.fixtures.dirs } } : {}),
50
75
  ...(unresolved.length > 0
51
76
  ? {
52
77
  unresolved_manifests: unresolved.map((u) => ({
@@ -58,6 +83,23 @@ export function scanCoverage(ctx, relative, detailed) {
58
83
  : {}),
59
84
  };
60
85
  }
86
+ /**
87
+ * One `coverage` block for everything the scan did not cover: lockfiles past
88
+ * the walk's bounds, manifests with no lockfile, and dependencies OSV.dev gave
89
+ * no answer for. The unchecked count leads the note.
90
+ */
91
+ export function mergeCoverage(walk, osv) {
92
+ if (!osv)
93
+ return walk;
94
+ return {
95
+ ...(walk ?? { lockfiles_not_scanned: 0 }),
96
+ complete: false,
97
+ note: walk ? `${osv.note} ${walk.note}` : osv.note,
98
+ unchecked_dependencies: osv.unchecked_dependencies,
99
+ checked_dependencies: osv.checked_dependencies,
100
+ osv_status: osv.reason,
101
+ };
102
+ }
61
103
  const SEVERITY_RULE = "`severity` is the grade every 4DA surface presents: a transitive-only dependency caps critical at high, and a dev-only dependency drops one level (critical to high, high to medium, medium to low). Unknown dev scope gets no discount. `advisory_severity` is the advisory's own tier (counted in `advisory_by_severity`); `by_severity` counts the presented grade.";
62
104
  const RESOLUTION_NOTE = "Dependency versions were read from these files at resolved_at. Every call re-checks them (stat only) and re-resolves when one changes. `cached` and `osv_cached` describe the OSV advisory lookup, not dependency resolution.";
63
105
  /**
@@ -67,7 +109,11 @@ const RESOLUTION_NOTE = "Dependency versions were read from these files at resol
67
109
  * other in `aliases`. Reported separately they double the count and read as two
68
110
  * problems: `quinn-proto` showed as "2 high" for a single memory-exhaustion
69
111
  * bug. Keeps the entry with the most usable detail (a CVSS score, then a fix
70
- * version) and folds the rest into its alias list.
112
+ * version) and folds the rest into its alias list. The kept entry carries the
113
+ * group's MOST severe grade, naming the record it came from (`severityFrom`),
114
+ * as every other tool does (advisory-groups.ts gradeAliasGroups): keeping the
115
+ * richest record's own grade showed CVE-2026-4277 as low here and critical in
116
+ * dependency_check (final release audit of 6.2.5).
71
117
  */
72
118
  export function collapseAliases(vulns) {
73
119
  const kept = [];
@@ -88,8 +134,23 @@ export function collapseAliases(vulns) {
88
134
  }
89
135
  const incumbent = kept[hitIndex];
90
136
  const winner = richness(v) > richness(incumbent) ? v : incumbent;
137
+ const graver = SEVERITY_RANK[v.severity] > SEVERITY_RANK[incumbent.severity] ? v : incumbent;
138
+ const severityFrom = graver.severity === winner.severity ? winner.severityFrom : (graver.severityFrom ?? graver.vulnId);
139
+ // One advisory is fixed only when every record of it is: the group's fix is the highest any
140
+ // record names. Keeping the richer record's fix offered netmask 1.1.0 for GHSA-4c7m while its
141
+ // alias GHSA-pch5 is fixed only in 2.0.1 (audit 6.2.5 F-1).
142
+ const groupFixes = [incumbent.fixedVersion, v.fixedVersion].filter((f) => Boolean(f));
143
+ const fixedVersion = groupFixes.length > 0 ? maxVersion(groupFixes, v.ecosystem) ?? winner.fixedVersion : winner.fixedVersion;
91
144
  const merged = {
92
145
  ...winner,
146
+ fixedVersion,
147
+ // A notice only when every record of the advisory is one, as the planner's groups decide
148
+ // (advisory-groups.ts): GHSA-hhw4-xg65-fp2x reads "unsound and unmaintained" and states no
149
+ // class, its alias RUSTSEC-2025-0068 states `unsound`; kept as the GHSA, serde_yml
150
+ // 0.0.12 was listed only as a notice (audit 6.2.5 F-7).
151
+ maintenance: isMaintenanceNotice(incumbent) && isMaintenanceNotice(v),
152
+ severity: graver.severity,
153
+ ...(severityFrom ? { severityFrom } : {}),
93
154
  aliases: [
94
155
  ...new Set([...incumbent.aliases, ...ids, incumbent.vulnId].filter((id) => id !== winner.vulnId)),
95
156
  ],
@@ -156,13 +217,42 @@ export function formatScanResult(result, severityFilter, ctx) {
156
217
  const maintenance = onHost.filter((v) => isMaintenanceNotice(v));
157
218
  const drift = buildDriftView(result, ctx.drift, ctx.includeDev, relative);
158
219
  const host = hostTarget();
159
- const activePackages = new Set(active.map((v) => v.package)).size;
220
+ // Keyed by ecosystem and name, as dependency_health counts them (an npm and a PyPI package of one name are two).
221
+ const activePackages = new Set(active.map((v) => `${v.ecosystem}\0${v.package}`)).size;
160
222
  // Every distinct manifest directory the scan actually covered. A single
161
223
  // "project" label hides the fact that a repo root can span several
162
224
  // independently-versioned workspaces.
163
225
  const scannedProjects = [...new Set(vulns.flatMap((v) => v.sourceDirs ?? []).map(relative))].sort();
164
- const coverage = scanCoverage(ctx, relative, true);
226
+ // Dependencies OSV gave no answer for. Their totals are not totals, so they
227
+ // are null, and the answer leads with what was not checked (6.2.4 reported
228
+ // `total_vulnerable_packages: 0` for 10,568 unchecked dependencies).
229
+ const osvGap = vulnerabilityCoverage(result, { enabled: true });
230
+ const nothingReadable = result.totalScanned === 0 && (ctx.discovery?.notCoveredCount ?? 0) > 0;
231
+ const merged = mergeCoverage(scanCoverage(ctx, relative, true), osvGap);
232
+ // The Go standard library and toolchain are not dependencies in go.mod/go.sum, so their
233
+ // advisories (GO-* records for package "stdlib"/"toolchain") are not checked; the app reports
234
+ // them from the go directive. Said, never implied clean (audit 6.2.5 F-5).
235
+ const goNote = result.ecosystemsScanned.includes("Go")
236
+ ? "Go: the standard library and toolchain (the go directive) are not checked; run govulncheck for those."
237
+ : null;
238
+ // A plain requirements file pins only what it lists: the packages those pins pull in are not
239
+ // pinned anywhere and were not checked. The scan said nothing, while osv-scanner (resolving the
240
+ // tree) found virtualenv 21.9.1 under black (audit 6.2.5 A-5). A compiled file (uv / pip-compile,
241
+ // `# via`) or a hashed one pins the whole tree.
242
+ const flat = (ctx.resolution?.sources ?? []).filter((src) => src.kind === "manifest" && /requirements[w.-]*.txt$/i.test(src.path) && isFlatRequirements(src.path));
243
+ const pyNote = flat.length > 0
244
+ ? `PyPI: ${flat.slice(0, 3).map((src) => relative(src.path)).join(", ")}${flat.length > 3 ? ", ..." : ""} pin${flat.length === 1 ? "s" : ""} only the packages listed; what they depend on is not pinned there and was not checked. Compile it (uv pip compile / pip-compile) or add a lockfile to scan the whole tree.`
245
+ : null;
246
+ const extra = [goNote, pyNote].filter((n) => n !== null).join(" ");
247
+ const coverage = extra
248
+ ? merged
249
+ ? { ...merged, note: `${merged.note} ${extra}` }
250
+ : { complete: false, note: extra, lockfiles_not_scanned: 0 }
251
+ : merged;
252
+ const bySeverity = countBySeverity(active, presentedSeverity);
253
+ const advisoryBySeverity = countBySeverity(active, (v) => v.severity);
165
254
  return {
255
+ ...(osvGap ? { summary: osvGap.note } : {}),
166
256
  scan_time: result.scannedAt,
167
257
  project: result.projectPath.split(/[/\\]/).pop() || result.projectPath,
168
258
  // Present only when the scan is partial; read it before trusting a clean result.
@@ -174,23 +264,39 @@ export function formatScanResult(result, severityFilter, ctx) {
174
264
  host_platform: `${host.os}/${host.arch}`,
175
265
  ecosystems_scanned: result.ecosystemsScanned,
176
266
  total_dependencies_scanned: result.totalScanned,
177
- total_vulnerable_packages: activePackages,
267
+ // The population the counts range over, worded as dependency_health and upgrade_planner word it.
268
+ ...(ctx.devOnlyCopies !== undefined && ctx.devOnlyCopies > 0
269
+ ? { dev_scope: ctx.includeDev ? "dev-only copies included" : `${ctx.devOnlyCopies} dev-only cop${ctx.devOnlyCopies === 1 ? "y" : "ies"} left out (include_dev: false)` }
270
+ : {}),
271
+ // Null when any dependency went unchecked: see `coverage` and `found_in_checked_dependencies`.
272
+ // Null too when nothing was scanned beside manifests this server does not read: a Maven-only
273
+ // project read "0 vulnerable" next to NOT COVERED (audit 6.2.5 A-6).
274
+ total_vulnerable_packages: osvGap || nothingReadable ? null : activePackages,
178
275
  platform_inactive_packages: result.platformInactiveVulnerable,
179
276
  // Counted over the listed findings: the same rows `vulnerabilities` shows,
180
277
  // one per vulnerability (aliases collapsed). Counting every raw OSV row
181
278
  // (alias duplicates, maintenance notices, other-platform advisories) gave
182
279
  // "medium 3, low 1, unknown 9" beside three listed rows (2026-10-02 eval).
183
- by_severity: countBySeverity(active, presentedSeverity),
184
- advisory_by_severity: countBySeverity(active, (v) => v.severity),
185
- vulnerabilities: active.map((v) => formatVuln(v, root, drift)),
280
+ by_severity: osvGap || nothingReadable ? null : bySeverity,
281
+ advisory_by_severity: osvGap || nothingReadable ? null : advisoryBySeverity,
282
+ ...(osvGap
283
+ ? {
284
+ found_in_checked_dependencies: {
285
+ note: `Only ${osvGap.checked_dependencies} of ${osvGap.total_dependencies} dependencies were checked; these counts are a lower bound.`,
286
+ vulnerable_packages: activePackages,
287
+ by_severity: bySeverity,
288
+ },
289
+ }
290
+ : {}),
291
+ vulnerabilities: active.map((v) => formatVuln(v, root, drift, ctx.verifiedTargets)),
186
292
  // Advisories suppressed from the main list because the affected crate is not
187
293
  // built on this host (e.g. cfg(not(windows)) deps on Windows). Surfaced for
188
294
  // cross-platform builds, clearly labeled with the gating target.
189
- platform_inactive_vulnerabilities: inactive.map((v) => formatVuln(v, root, drift)),
295
+ platform_inactive_vulnerabilities: inactive.map((v) => formatVuln(v, root, drift, ctx.verifiedTargets)),
190
296
  // Unmaintained-dependency notices: real information to plan around, but
191
297
  // nothing exploitable and nothing to upgrade to. Separated so they cannot
192
298
  // outnumber the advisories that need action today.
193
- maintenance_notices: maintenance.map((v) => formatVuln(v, root, drift)),
299
+ maintenance_notices: maintenance.map((v) => formatVuln(v, root, drift, ctx.verifiedTargets)),
194
300
  // npm direct dependencies whose node_modules copy is not what the lockfile
195
301
  // pins. The lockfile is what every other tool reads; this is what runs.
196
302
  install_drift: drift.rows,
@@ -200,7 +306,8 @@ export function formatScanResult(result, severityFilter, ctx) {
200
306
  // that advisory only, and the highest of them can be affected by another
201
307
  // (openssl 0.10.79, GHSA-phqj-4mhp-q6mq).
202
308
  fix_targets: fixTargets(active, ctx.verifiedTargets),
203
- clean_dependencies: result.cleanCount,
309
+ // Null when any dependency went unchecked: an unanswered dependency is not clean.
310
+ clean_dependencies: osvGap ? null : result.cleanCount,
204
311
  scan_duration_ms: result.scanDurationMs,
205
312
  _meta: {
206
313
  source: "osv.dev",
@@ -208,7 +315,9 @@ export function formatScanResult(result, severityFilter, ctx) {
208
315
  cached: result.cached,
209
316
  osv_cached: result.cached,
210
317
  offline: result.offline,
211
- privacy: "Only package names and versions were sent (public manifest data)",
318
+ ...(result.staleAnswers ? { stale_osv_answers: result.staleAnswers } : {}),
319
+ ...(result.aliasSeverity ? { alias_severity: aliasSeverityMeta(result.aliasSeverity) } : {}),
320
+ privacy: "Only package names and versions were sent (private names included); no source code or file paths",
212
321
  // State what actually happened. This previously asserted that advisories
213
322
  // WERE platform-filtered while reporting zero inactive packages and
214
323
  // listing nine Linux-only crates on a Windows host — a claim of filtering
@@ -242,12 +351,19 @@ function fixTargets(active, verified) {
242
351
  const label = `${vulns[0].package}@${vulns[0].currentVersion}`;
243
352
  const clean = verified?.get(key);
244
353
  if (clean) {
245
- const note = targetNote(clean);
246
- out[label] = { target: clean.target, checked: "all_advisories", ...(note ? { note } : {}) };
354
+ const note = targetNote(clean, vulns[0].ecosystem);
355
+ // Registry facts without the advisory list (H5): a better target, still not checked against every advisory.
356
+ out[label] = {
357
+ target: inEcosystemForm(vulns[0].ecosystem, clean.target),
358
+ checked: targetCheckedAll(clean) ? "all_advisories" : "installed_advisories_only",
359
+ ...(note ? { note } : {}),
360
+ // Releases the registry withdrew (yanked, deprecated) that the target steps over, or, with no target, the only fixes there were.
361
+ ...(clean.withheld.length > 0 ? { withheld: clean.withheld, withheld_note: withheldText(clean, vulns[0].ecosystem) } : {}),
362
+ };
247
363
  }
248
364
  else {
249
365
  const fixes = vulns.map((v) => v.fixedVersion).filter((f) => Boolean(f));
250
- out[label] = { target: fixes.length > 0 ? maxSemver(fixes) : null, checked: "installed_advisories_only" };
366
+ out[label] = { target: inEcosystemForm(vulns[0].ecosystem, fixes.length > 0 ? maxVersion(fixes, vulns[0].ecosystem) : null), checked: "installed_advisories_only" };
251
367
  }
252
368
  }
253
369
  return out;
@@ -290,17 +406,42 @@ function buildRecommendations(active, all, relative, verified) {
290
406
  // Checked against every advisory of the package when that could run: the
291
407
  // highest per-advisory fix can itself be affected by another advisory.
292
408
  const clean = verified?.get(fixTargetKey(highest.ecosystem, pkg, highest.currentVersion));
293
- const target = clean ? clean.target : highestFix;
409
+ const target = inEcosystemForm(highest.ecosystem, clean ? clean.target : highestFix);
410
+ // Said in the line itself: an agent runs the command it reads, and a major jump breaks builds.
411
+ // vitest 0.25.3 -> 4.1.11 read as a plain upgrade (audit 6.2.5 A-2 target check).
412
+ const major = target && computeSemverDistance(highest.currentVersion, target)?.label === "major" ? " (a major version change: check upgrade_impact)" : "";
294
413
  const total = pkgVulns.length;
295
414
  const fixable = pkgVulns.filter((v) => v.fixedVersion).length;
296
415
  const where = whereOf(copies);
297
416
  let text;
298
- if (target) {
299
- const scope = fixable === total
300
- ? `${total} CVE${total > 1 ? "s" : ""}`
301
- : `${fixable} of ${total} CVEs (${total - fixable} with no published fix)`;
302
- const note = clean ? targetNote(clean) : null;
303
- text = `Upgrade ${pkg} ${highest.currentVersion} → ${target}${where} — resolves ${scope} [${breakdown}]${note ? ` (${note})` : ""}`;
417
+ if (target && clean?.noAdvisoryFix) {
418
+ text = `Upgrade ${pkg} ${highest.currentVersion} → ${target}${major}${where} — resolves ${total} CVE${total > 1 ? "s" : ""} [${breakdown}] (${targetNote(clean, highest.ecosystem)})`;
419
+ }
420
+ else if (!target && clean && clean.withheld.length > 0) {
421
+ // Every release that fixes it was withdrawn by the registry: "no installable fix", never "no fix published" (FX-H5).
422
+ text = `Review ${pkg} ${highest.currentVersion}${where} — ${total} known vulnerabilit${total > 1 ? "ies" : "y"} [${breakdown}]; ${withheldText(clean, highest.ecosystem)}`;
423
+ }
424
+ else if (target) {
425
+ // An advisory with no fixed version but only a last affected one is resolved by a target
426
+ // above it once the target is checked against every advisory (M2: pyjwt PYSEC-2025-183,
427
+ // last_affected 2.10.1, is past at 2.15.0; it read "12 of 13 CVEs (1 with no published fix)").
428
+ const unfixed = pkgVulns.filter((v) => !v.fixedVersion);
429
+ const checkedAll = clean !== undefined && targetCheckedAll(clean) && inEcosystemForm(highest.ecosystem, clean.target) === target;
430
+ const past = checkedAll ? unfixed.filter((v) => ![v.vulnId, ...(v.aliases ?? [])].some((id) => clean.stillAffectedBy.includes(id))) : [];
431
+ const resolved = fixable + past.length;
432
+ const pastNote = past.length > 0
433
+ ? `${past.map((v) => v.vulnId).join(", ")} name${past.length === 1 ? "s" : ""} no fixed version; ${target} is past ${past.length === 1 ? "its" : "their"} last affected release`
434
+ : null;
435
+ const scope = resolved === total
436
+ ? `${total} CVE${total > 1 ? "s" : ""}${pastNote ? ` (${pastNote})` : ""}`
437
+ : `${resolved} of ${total} CVEs (${total - resolved} with no published fix${pastNote ? `; ${pastNote}` : ""})`;
438
+ const note = clean ? targetNote(clean, highest.ecosystem) : null;
439
+ text = clean
440
+ ? `Upgrade ${pkg} ${highest.currentVersion} → ${target}${major}${where} — resolves ${scope} [${breakdown}]${note ? ` (${note})` : ""}`
441
+ : // The check of the target against every advisory of the package did not finish (time
442
+ // budget, registry or OSV slow): the advisories' own fix can itself be affected
443
+ // (netmask 1.1.0, audit 6.2.5 F-1). Never "resolves" on that.
444
+ `Upgrade ${pkg} ${highest.currentVersion}${where} [${breakdown}]: its advisories name ${target}${major} as the fix, but ${target} was NOT CHECKED against every advisory of ${pkg} (the check did not finish); confirm it with dependency_check before pinning it`;
304
445
  }
305
446
  else if (clean && highestFix) {
306
447
  text = `Review ${pkg} ${highest.currentVersion}${where} — ${total} known vulnerabilit${total > 1 ? "ies" : "y"} [${breakdown}]; no published version clears every advisory (${clean.floor} is still affected by ${clean.stillAffectedBy.join(", ")})`;
@@ -308,6 +449,12 @@ function buildRecommendations(active, all, relative, verified) {
308
449
  else {
309
450
  text = `Review ${pkg} ${highest.currentVersion}${where} — ${total} known vulnerabilit${total > 1 ? "ies" : "y"} [${breakdown}], no fix version published`;
310
451
  }
452
+ if (highest.ecosystem === "PyPI" && target) {
453
+ // A compiled or fully pinned requirements file is not moved by one `==` edit (audit 6.2.5 A-4).
454
+ const notes = [...new Set(copies.flatMap((v) => v.sourceDirs ?? []))].map((d) => pypiScanNote(d, pkg, highest.currentVersion)).filter((n) => n !== null);
455
+ if (notes.length > 0)
456
+ text += ` — ${[...new Set(notes)].join("; ")}`;
457
+ }
311
458
  lines.push({ rank: rankOf(pkgVulns), text });
312
459
  }
313
460
  // Installed copies the lockfile does not pin. The lockfile already says what
@@ -341,9 +488,11 @@ function severityBreakdown(vulns) {
341
488
  .map((s) => `${counts[s]} ${s}`)
342
489
  .join(", ");
343
490
  }
344
- function formatVuln(v, root, drift) {
491
+ function formatVuln(v, root, drift, verified) {
345
492
  const presented = presentedSeverity(v);
346
493
  const reason = scopeAdjustmentReason(v);
494
+ // The advisory's own fix as the registry still offers it: never a yanked or deprecated release (FX-H5).
495
+ const fix = rowFix(v.fixedVersion, verified?.get(fixTargetKey(v.ecosystem, v.package, v.currentVersion)), v.ecosystem);
347
496
  return {
348
497
  package: v.package,
349
498
  current_version: v.currentVersion,
@@ -359,14 +508,32 @@ function formatVuln(v, root, drift) {
359
508
  aliases: v.aliases,
360
509
  severity: presented,
361
510
  advisory_severity: v.severity,
511
+ // A malicious-package record (MAL- id or alias, CWE-506): the row says so (audit 6.2.5 D-3).
512
+ ...(v.malware ? { malware: true } : {}),
513
+ // The record rates nothing; its grade is its GHSA alias's (osv-scanner rateFromAliases).
514
+ ...(v.severityFrom ? { severity_from: v.severityFrom } : {}),
362
515
  ...(reason
363
516
  ? { severity_note: `Graded ${presented}: the advisory rates it ${v.severity}, and this is a ${reason}.` }
364
517
  : {}),
365
518
  cvss_score: v.cvssScore,
366
519
  summary: v.summary,
367
- fixed_version: v.fixedVersion,
520
+ fixed_version: inEcosystemForm(v.ecosystem, fix.fixed),
521
+ ...(fix.withheld.length > 0 ? { withheld: fix.withheld, withheld_note: fix.note } : {}),
368
522
  published: v.published,
369
523
  references: v.references,
370
524
  ...drift.annotate(v),
371
525
  };
372
526
  }
527
+ /** `_meta.alias_severity`: how many records were graded from a GHSA alias, and which stay unknown, with why. */
528
+ export function aliasSeverityMeta(report) {
529
+ return {
530
+ rated_from_alias: report.rated,
531
+ unresolved: report.unresolved,
532
+ ungraded: report.ungraded,
533
+ ...(report.reason
534
+ ? {
535
+ note: `${report.ungraded.length > 0 ? `${report.ungraded.length} advisor${report.ungraded.length === 1 ? "y names a GHSA alias but is" : "ies name a GHSA alias but are"} still graded unknown (ungraded). ` : ""}${report.unresolved.length > 0 ? `Alias records not read (unresolved): ${report.reason}; they never lower a grade. ` : ""}Unknown is not clean.`,
536
+ }
537
+ : {}),
538
+ };
539
+ }