@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
@@ -10,6 +10,8 @@
10
10
  * verdict fall through to `proceed`.
11
11
  */
12
12
  import { compareIn, isPrereleaseIn, versionParts } from "./dependency-check-versions.js";
13
+ import { excludedReleases } from "../live/withdrawn-releases.js";
14
+ import { heldSeverity, heldText } from "../live/held-dependencies.js";
13
15
  /** Verdict precedence, strongest first. */
14
16
  const PRECEDENCE = ["avoid", "review", "unknown", "wait", "proceed"];
15
17
  export const WAIT_DAYS = 3;
@@ -57,40 +59,82 @@ export function introducedDependencies(to, base) {
57
59
  const before = new Set(base.dependencies);
58
60
  return to.dependencies.filter((d) => !before.has(d)).sort();
59
61
  }
60
- export function advisoriesSignal(item, facts) {
62
+ /**
63
+ * The advisories signal, with what `to` holds (npm, audit 6.2.5 D-4): a dependency it exact-pins
64
+ * or narrowly ranges that an advisory affects is installed with it, so it counts as one of `to`'s
65
+ * own, graded the same way (critical/high: avoid, else review). A held check that could not run
66
+ * never lets the signal say proceed.
67
+ */
68
+ export function advisoriesSignal(item, facts, held) {
69
+ const own = ownAdvisoriesSignal(item, facts);
70
+ if (!held || (held.status === "checked" && held.affected.length === 0))
71
+ return own;
72
+ if (held.status === "unchecked") {
73
+ const note = `Whether the dependencies ${item.to} exact-pins or narrowly ranges have known advisories was not checked (${held.reason}).`;
74
+ return {
75
+ ...own,
76
+ value: own.value === null ? { held_dependencies_unchecked: held.reason } : { ...own.value, held_dependencies_unchecked: held.reason },
77
+ effect: own.effect === "avoid" || own.effect === "review" ? own.effect : "unknown",
78
+ evidence: `${own.evidence} ${note}`,
79
+ };
80
+ }
81
+ const severity = heldSeverity(held.affected);
82
+ const heldEffect = severity === "critical" || severity === "high" ? "avoid" : "review";
83
+ const effect = own.effect === "avoid" || heldEffect === "avoid" ? "avoid" : "review";
84
+ const affected = held.affected.map((d) => ({ package: d.name, spec: d.spec, installs: d.version, advisories: d.ids, severity: d.severity }));
85
+ const heldNote = `${heldText(item.to, held.affected)}: installing ${item.to} installs ${held.affected.length === 1 ? "it" : "them"}.`;
86
+ return {
87
+ id: "advisories",
88
+ value: own.value === null ? { held_dependencies_affected: affected } : { ...own.value, held_dependencies_affected: affected },
89
+ effect,
90
+ // "No known vulnerability affects X." is about X's own advisories; say what it holds instead.
91
+ evidence: own.effect === "proceed"
92
+ ? `${own.evidence.replace(`No known vulnerability affects ${item.to}.`, `No advisory of ${item.package} itself affects ${item.to}, but ${heldNote}`)}`
93
+ : `${own.evidence} ${heldNote}`,
94
+ };
95
+ }
96
+ function ownAdvisoriesSignal(item, facts) {
61
97
  if (!facts.available) {
62
98
  return { id: "advisories", value: null, evidence: `OSV.dev could not be reached: ${facts.detail}`, effect: "unknown" };
63
99
  }
64
100
  const fromIds = new Set(facts.from.map((a) => a.id));
65
101
  const toIds = new Set(facts.to.map((a) => a.id));
66
102
  const fixed = facts.from.filter((a) => !toIds.has(a.id) && !a.maintenance);
67
- const value = { affecting_to: facts.to.map((a) => a.id), fixed_by_this_change: fixed.map((a) => a.id) };
103
+ // An unmaintained-package notice names no exploitable vulnerability: it is
104
+ // reported in its own field and never moves the verdict (audit L3, the
105
+ // same split as vulnerability_scan's `maintenance_notices`).
106
+ const notices = facts.to.filter((a) => a.maintenance);
107
+ const value = {
108
+ affecting_to: facts.to.filter((a) => !a.maintenance).map((a) => a.id),
109
+ fixed_by_this_change: fixed.map((a) => a.id),
110
+ ...(notices.length > 0 ? { maintenance_notices: notices.map((a) => a.id) } : {}),
111
+ };
112
+ const noticeNote = notices.length > 0
113
+ ? ` Unmaintained-package notice${notices.length > 1 ? "s" : ""} ${notices.map((a) => a.id).join(", ")} ${notices.length > 1 ? "apply" : "applies"} to ${item.to}${notices.every((a) => fromIds.has(a.id)) ? ` (already on ${item.from})` : ""}: no exploitable vulnerability, listed in maintenance_notices.`
114
+ : "";
68
115
  const fixedNote = fixed.length > 0 ? ` This change fixes ${list(fixed)} affecting ${item.from}.` : "";
69
116
  const serious = facts.to.filter((a) => !a.maintenance && (a.severity === "critical" || a.severity === "high"));
70
117
  if (serious.length > 0) {
71
- return { id: "advisories", value, effect: "avoid", evidence: `${item.to} is affected by ${list(serious)}.${fixedNote}` };
118
+ return { id: "advisories", value, effect: "avoid", evidence: `${item.to} is affected by ${list(serious)}.${fixedNote}${noticeNote}` };
72
119
  }
73
120
  const other = facts.to.filter((a) => !a.maintenance);
74
- const newNotices = facts.to.filter((a) => a.maintenance && !fromIds.has(a.id));
75
- if (other.length > 0 || newNotices.length > 0) {
121
+ if (other.length > 0) {
76
122
  return {
77
123
  id: "advisories",
78
124
  value,
79
125
  effect: "review",
80
- evidence: `${item.to} is affected by ${list([...other, ...newNotices])}.${fixedNote}`,
126
+ evidence: `${item.to} is affected by ${list(other)}.${fixedNote}${noticeNote}`,
81
127
  };
82
128
  }
83
- // Only maintenance notices that `from` already carries can remain here.
84
- const carried = facts.to.length > 0 ? ` (maintenance notice${facts.to.length > 1 ? "s" : ""} ${facts.to.map((a) => a.id).join(", ")} already apply to ${item.from})` : "";
85
129
  return {
86
130
  id: "advisories",
87
131
  value,
88
132
  effect: "proceed",
89
- evidence: `No known advisory affects ${item.to}${carried}.${fixedNote}`,
133
+ evidence: `No known vulnerability affects ${item.to}.${fixedNote}${noticeNote}`,
90
134
  };
91
135
  }
92
136
  function fmt(a) {
93
- return `${a.id} (${a.maintenance ? "unmaintained notice" : a.severity})`;
137
+ return `${a.id} (${a.maintenance ? "unmaintained notice" : a.severityFrom ? `${a.severity}, per ${a.severityFrom}` : a.severity})`;
94
138
  }
95
139
  /** At most three named in prose; the full id list is in the signal's `value`. */
96
140
  function list(advisories) {
@@ -241,12 +285,32 @@ export function newDependenciesSignal(item, to, base, introduced, created) {
241
285
  evidence: `${to.version} adds ${list} since ${base.record.version}.`,
242
286
  };
243
287
  }
244
- export function withdrawnSignal(item, to) {
288
+ /**
289
+ * Yanked, unpublished, retracted: avoid. npm deprecated: avoid when the 4DA rule withdraws it (a
290
+ * later, non-deprecated stable release exists on its caret line, lodash 4.18.0 "Bad release"),
291
+ * and when the notice is this release's alone ("Critical bug in 1.0.3"); review when the rule
292
+ * keeps it and the same notice is on other releases (glob 10.5.0, one of 160 "Old versions of
293
+ * glob are not supported"): a "please upgrade" notice, not a withdrawn release, and possibly
294
+ * the only fix on its line (FX-H5, withdrawn-releases.ts). Without the index: every deprecation avoids.
295
+ */
296
+ export function withdrawnSignal(item, to, index) {
245
297
  if (to.withdrawn) {
246
298
  return { id: "yanked_or_deprecated", value: { [to.withdrawn]: true }, effect: "avoid", evidence: `${item.to} was ${to.withdrawn} from the registry.` };
247
299
  }
248
300
  if (to.deprecated) {
249
- return { id: "yanked_or_deprecated", value: { deprecated: to.deprecated }, effect: "avoid", evidence: `${item.to} is deprecated: "${to.deprecated}".` };
301
+ const value = { deprecated: to.deprecated };
302
+ // The same notice on other releases too: a "please upgrade" notice about the line or the package.
303
+ const shared = index ? Object.values(index.releases).filter((r) => r.version !== to.version && r.deprecated === to.deprecated).length : 0;
304
+ if (index && item.ecosystem === "npm" && shared > 0 && !excludedReleases(index).has(to.version)) {
305
+ return {
306
+ id: "yanked_or_deprecated",
307
+ value: { ...value, withdrawn: false },
308
+ effect: "review",
309
+ evidence: `${item.to} is deprecated: "${to.deprecated}". The same notice is on ${shared} other release${shared === 1 ? "" : "s"} and no later release on its line is free of one, ` +
310
+ `so it is a notice about the line, not a withdrawn release; tell the user it is deprecated.`,
311
+ };
312
+ }
313
+ return { id: "yanked_or_deprecated", value, effect: "avoid", evidence: `${item.to} is deprecated: "${to.deprecated}".` };
250
314
  }
251
315
  return { id: "yanked_or_deprecated", value: null, effect: "proceed", evidence: `${item.to} is neither yanked nor deprecated.` };
252
316
  }
@@ -301,7 +365,14 @@ export function decide(signals) {
301
365
  const fixes = signals.find((s) => s.id === "release_age" && /security fix/.test(s.evidence));
302
366
  return { verdict, reason: fixes ? fixes.evidence : "No advisory, age, trust, script or new-dependency concern found." };
303
367
  }
304
- return { verdict, reason: hits.map((s) => `${s.id}: ${s.evidence}`).join(" | ") };
368
+ // A stronger verdict must not hide what could not be checked: crossenv `latest` (the malware
369
+ // 1.0.1) read `review` for "major" alone while its advisory lookup had failed (audit 6.2.5 D-10).
370
+ const unchecked = verdict === "unknown" ? [] : signals.filter((s) => s.effect === "unknown");
371
+ const reason = hits.map((s) => `${s.id}: ${s.evidence}`).join(" | ");
372
+ return {
373
+ verdict,
374
+ reason: unchecked.length > 0 ? `${reason} | NOT CHECKED: ${unchecked.map((s) => `${s.id}: ${s.evidence}`).join(" | ")}` : reason,
375
+ };
305
376
  }
306
377
  return { verdict: "unknown", reason: "No signal could be evaluated." };
307
378
  }
@@ -76,13 +76,38 @@ export interface RangeCheck {
76
76
  newest_safe_outside_range?: string | null;
77
77
  /** When `from` is given: the smallest safe release above it (the minimal fixing upgrade). */
78
78
  smallest_safe_upgrade?: string | null;
79
+ /**
80
+ * Releases in range that no advisory affects but the registry withdrew (yanked; npm
81
+ * deprecated with a later clean release on its line), below the smallest safe pick (all of
82
+ * them when none is safe), and why in plain words (FX-H5).
83
+ */
84
+ withheld?: string[];
85
+ withheld_note?: string;
86
+ /**
87
+ * npm (audit 6.2.5 D-4): releases left out of the safe ones because a dependency they exact-pin
88
+ * or narrowly range is affected, or because that could not be checked; and why, in words.
89
+ */
90
+ held_excluded?: number;
91
+ held_note?: string;
79
92
  }
93
+ /**
94
+ * The releases in range that the package's own advisories leave safe, ascending: what checkRange
95
+ * would call safe before held dependencies are judged. dependency_check judges the held
96
+ * dependencies of the smallest and newest of these (audit 6.2.5 D-4).
97
+ */
98
+ export declare function ownSafeInRange(input: {
99
+ ecosystem: CheckEcosystem;
100
+ index: PackageReleaseIndex;
101
+ range: string;
102
+ advisories: AdvisoryRanges[];
103
+ }): string[];
80
104
  /**
81
105
  * The releases a constraint admits, judged against every advisory OSV.dev
82
- * lists for the package. Withdrawn (yanked / unpublished / retracted) and
83
- * deprecated releases are not candidates; neither are prereleases, unless
84
- * the constraint admits nothing else. Maintenance notices do not make a
85
- * release unsafe.
106
+ * lists for the package. Withdrawn releases (yanked / unpublished /
107
+ * retracted, and npm deprecations the 4DA rule withdraws: withdrawn-releases.ts)
108
+ * are not candidates; neither are prereleases, unless the constraint admits
109
+ * nothing else. A whole-line npm deprecation notice (glob 10.x, tar 6.x)
110
+ * withdraws nothing. Maintenance notices do not make a release unsafe.
86
111
  */
87
112
  export declare function checkRange(input: {
88
113
  ecosystem: CheckEcosystem;
@@ -91,4 +116,12 @@ export declare function checkRange(input: {
91
116
  basis: RangeCheck["basis"];
92
117
  advisories: AdvisoryRanges[];
93
118
  from?: string;
119
+ /**
120
+ * npm: a release whose held dependencies (exact pins, narrow ranges) are affected or could not
121
+ * be judged is not safe either (audit 6.2.5 D-4), and `heldNote` says why when any is left out.
122
+ */
123
+ held?: {
124
+ unsafe: (version: string) => boolean;
125
+ note: string;
126
+ };
94
127
  }): RangeCheck;
@@ -15,10 +15,11 @@
15
15
  * "v1" for a Go major. Pure functions, no I/O.
16
16
  */
17
17
  import { cargoReqAdmits, npmRangeAdmits } from "../live/range-satisfies.js";
18
- import { advisoryAffects, isPrereleaseVersion } from "../live/osv-ranges.js";
18
+ import { advisoriesAffecting, isPrereleaseVersion } from "../live/osv-ranges.js";
19
19
  import { compareVersions, parsePep440 } from "../live/version-compare.js";
20
20
  import { parseSemverPrecedence } from "../live/semver-precedence.js";
21
21
  import { GO_VERSION, goVersion } from "../live/go-releases.js";
22
+ import { excludedReleases, withheldNote } from "../live/withdrawn-releases.js";
22
23
  const FULL_SEMVER = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
23
24
  /** Read a `to` value; null when it is neither a version nor a constraint this ecosystem understands. */
24
25
  export function parseTarget(ecosystem, raw) {
@@ -174,22 +175,39 @@ export function lineOf(ecosystem, version) {
174
175
  return `v${p.major}`;
175
176
  return p.major === 0 ? `0.${p.minor}.x` : `${p.major}.x`;
176
177
  }
178
+ /** The installable releases a constraint admits, ascending; prereleases only when it admits nothing else. */
179
+ function rangeCandidates(ecosystem, installable, range) {
180
+ const admitted = installable.filter((v) => rangeAdmits(ecosystem, range, v) === true);
181
+ const stable = admitted.filter((v) => !isPrereleaseIn(ecosystem, v));
182
+ return { candidates: (stable.length > 0 ? stable : admitted).sort((a, b) => compareIn(ecosystem, a, b)), stable };
183
+ }
184
+ /**
185
+ * The releases in range that the package's own advisories leave safe, ascending: what checkRange
186
+ * would call safe before held dependencies are judged. dependency_check judges the held
187
+ * dependencies of the smallest and newest of these (audit 6.2.5 D-4).
188
+ */
189
+ export function ownSafeInRange(input) {
190
+ const excluded = excludedReleases(input.index);
191
+ const installable = Object.values(input.index.releases).filter((r) => !excluded.has(r.version)).map((r) => r.version);
192
+ return rangeCandidates(input.ecosystem, installable, input.range).candidates.filter((v) => advisoriesAffecting(input.advisories, v, input.ecosystem).length === 0);
193
+ }
177
194
  /**
178
195
  * The releases a constraint admits, judged against every advisory OSV.dev
179
- * lists for the package. Withdrawn (yanked / unpublished / retracted) and
180
- * deprecated releases are not candidates; neither are prereleases, unless
181
- * the constraint admits nothing else. Maintenance notices do not make a
182
- * release unsafe.
196
+ * lists for the package. Withdrawn releases (yanked / unpublished /
197
+ * retracted, and npm deprecations the 4DA rule withdraws: withdrawn-releases.ts)
198
+ * are not candidates; neither are prereleases, unless the constraint admits
199
+ * nothing else. A whole-line npm deprecation notice (glob 10.x, tar 6.x)
200
+ * withdraws nothing. Maintenance notices do not make a release unsafe.
183
201
  */
184
202
  export function checkRange(input) {
185
203
  const { ecosystem, index, range, basis } = input;
186
- const installable = Object.values(index.releases).filter((r) => !r.withdrawn && !r.deprecated).map((r) => r.version);
187
- const admitted = installable.filter((v) => rangeAdmits(ecosystem, range, v) === true);
188
- const stable = admitted.filter((v) => !isPrereleaseIn(ecosystem, v));
189
- const candidates = (stable.length > 0 ? stable : admitted).sort((a, b) => compareIn(ecosystem, a, b));
190
- const real = input.advisories.filter((a) => !a.maintenance);
191
- const affecting = (v) => real.filter((a) => advisoryAffects(a, v, ecosystem)).map((a) => a.id);
192
- const safe = candidates.filter((v) => affecting(v).length === 0);
204
+ const excluded = excludedReleases(index);
205
+ const installable = Object.values(index.releases).filter((r) => !excluded.has(r.version)).map((r) => r.version);
206
+ const { candidates, stable } = rangeCandidates(ecosystem, installable, range);
207
+ // The same predicate every tool runs on a release it offers (osv-ranges.ts advisoriesAffecting).
208
+ const affecting = (v) => advisoriesAffecting(input.advisories, v, ecosystem);
209
+ const ownSafe = candidates.filter((v) => affecting(v).length === 0);
210
+ const safe = input.held ? ownSafe.filter((v) => !input.held.unsafe(v)) : ownSafe;
193
211
  const out = {
194
212
  range,
195
213
  basis,
@@ -202,12 +220,29 @@ export function checkRange(input) {
202
220
  if (candidates.length > 0 && safe.length === 0) {
203
221
  out.affecting_all = [...new Set(candidates.flatMap(affecting))].filter((id) => candidates.every((v) => affecting(v).includes(id))).slice(0, 10);
204
222
  const outside = installable
205
- .filter((v) => !isPrereleaseIn(ecosystem, v) && affecting(v).length === 0)
223
+ .filter((v) => !isPrereleaseIn(ecosystem, v) && affecting(v).length === 0 && !input.held?.unsafe(v))
206
224
  .sort((a, b) => compareIn(ecosystem, a, b));
207
225
  out.newest_safe_outside_range = outside[outside.length - 1] ?? null;
208
226
  }
209
227
  if (input.from) {
210
228
  out.smallest_safe_upgrade = safe.find((v) => compareIn(ecosystem, v, input.from) > 0) ?? null;
211
229
  }
230
+ if (input.held && safe.length < ownSafe.length) {
231
+ out.held_excluded = ownSafe.length - safe.length;
232
+ out.held_note = input.held.note;
233
+ }
234
+ // The withdrawn releases that would otherwise have been the smallest safe pick (FX-H5).
235
+ const pick = input.from ? out.smallest_safe_upgrade : out.smallest_safe_in_range;
236
+ const withheld = [...excluded.keys()]
237
+ .filter((v) => rangeAdmits(ecosystem, range, v) === true &&
238
+ (stable.length === 0 || !isPrereleaseIn(ecosystem, v)) &&
239
+ (!input.from || compareIn(ecosystem, v, input.from) > 0) &&
240
+ (!pick || compareIn(ecosystem, v, pick) < 0) &&
241
+ affecting(v).length === 0)
242
+ .sort((a, b) => compareIn(ecosystem, a, b));
243
+ if (withheld.length > 0) {
244
+ out.withheld = withheld;
245
+ out.withheld_note = withheldNote(withheld, pick ?? null, Object.fromEntries(excluded), ecosystem);
246
+ }
212
247
  return out;
213
248
  }
@@ -37,7 +37,8 @@
37
37
  */
38
38
  import type { FourDADatabase } from "../db.js";
39
39
  import type { LiveIntelligence } from "../live/index.js";
40
- import { type Signal, type Verdict } from "./dependency-check-signals.js";
40
+ import type { VulnerabilityEntry } from "../live/types.js";
41
+ import { type AdvisoryRef, type Signal, type Verdict } from "./dependency-check-signals.js";
41
42
  import { type RangeCheck } from "./dependency-check-versions.js";
42
43
  export interface DependencyCheckParams {
43
44
  items?: unknown;
@@ -92,6 +93,8 @@ interface ItemResult {
92
93
  reason: string;
93
94
  signals: Signal[];
94
95
  range_check?: RangeCheck;
96
+ /** Unmaintained-package notices on `to`: reported, never part of the verdict (audit L3). */
97
+ maintenance_notices?: string[];
95
98
  }
96
99
  export declare function executeDependencyCheck(_db: FourDADatabase | null, params: DependencyCheckParams, liveIntel: LiveIntelligence | null, opts?: {
97
100
  now?: Date;
@@ -111,4 +114,11 @@ export declare function executeDependencyCheck(_db: FourDADatabase | null, param
111
114
  };
112
115
  };
113
116
  }>;
117
+ /**
118
+ * One reference per vulnerability: OSV lists GHSA and RUSTSEC/PYSEC records
119
+ * for the same issue, each naming the other in `aliases`. They were counted
120
+ * twice ("6 advisories" for tokio 1.18.0's three, audit A9). The kept record
121
+ * of an alias group is its most severe, so merging never softens a verdict.
122
+ */
123
+ export declare function refs(entries: VulnerabilityEntry[] | undefined): AdvisoryRef[];
114
124
  export {};
@@ -36,10 +36,12 @@
36
36
  * `.info` is read from it. OSV receives name + version, as vulnerability_scan
37
37
  * already does, and the package name for its advisory list.
38
38
  */
39
+ import { groupAdvisories } from "../live/advisory-groups.js";
39
40
  import { isMaintenanceNotice } from "../live/maintenance.js";
40
41
  import { goPathMajor, goVersionMajor } from "../live/upgrade-go.js";
41
42
  import { advisoriesSignal, decide, installScriptSignal, introducedDependencies, newDependenciesSignal, publishTrustSignal, releaseAgeSignal, selectBaseline, upgradeTypeSignal, withdrawnSignal, WAIT_DAYS, } from "./dependency-check-signals.js";
42
- import { checkRange, compareIn, isPrereleaseIn, lineOf, parseTarget, rangeAdmits } from "./dependency-check-versions.js";
43
+ import { checkRange, compareIn, isPrereleaseIn, lineOf, ownSafeInRange, parseTarget, rangeAdmits } from "./dependency-check-versions.js";
44
+ import { HeldJudge, heldRangeCandidates } from "./dependency-check-held.js";
43
45
  const MAX_ITEMS = 25;
44
46
  /** First-publish lookups for newly introduced dependencies, per call (each is a registry read). */
45
47
  const MAX_NEW_DEP_LOOKUPS = 40;
@@ -57,7 +59,7 @@ export const dependencyCheckTool = {
57
59
  "Is this dependency version safe to write? Call BEFORE you write any version into a manifest or lockfile: adding, pinning, upgrading, bumping or downgrading a package, or choosing a version (npm, crates.io, PyPI, Go). Pass the exact version you intend to write, or the constraint you were given (\"7.x\", \"^1.20\", \"~=3.1\", \"v1\") to get the smallest and newest safe release in it. Write only a version that came back `proceed`. Your memory of which release is safe is out of date; this reads the registry and OSV.dev now.",
58
60
  "",
59
61
  "Per item: `verdict` (proceed | wait | review | avoid | unknown), a one-line `reason`, `signals` (each with `id`, `value`, `evidence`, `effect`), and `range_check` when `to` is a constraint or an exact `to` has an advisory: `smallest_safe_in_range`, `newest_safe_in_range`, `safe_versions`, `smallest_safe_upgrade` (above `from`). For a constraint the verdict is for the version it picks: the smallest safe upgrade above `from`, else the newest safe release.",
60
- "- advisories: OSV advisories affecting `to` (high/critical -> avoid, other -> review) and those this change fixes on `from`.",
62
+ "- advisories: OSV advisories affecting `to` (high/critical -> avoid, other -> review) and those this change fixes on `from`. npm: a dependency `to` exact-pins, or narrowly ranges with no clean release in reach, is installed with it, so its advisories count the same (`held_dependencies_affected`), and such releases are never among the safe ones.",
61
63
  "- release_age: under 3 days -> wait, unless `to` fixes an advisory affecting `from`, or every release on its line past the hold has an advisory and no safe one is past it (then proceed; tell the user its age).",
62
64
  "- yanked_or_deprecated: yanked, unpublished, retracted (Go) or deprecated -> avoid.",
63
65
  "- publish_trust (npm, crates.io): trusted publisher / provenance dropped -> review. install_script_added (npm): new install script -> review. new_dependencies (npm, crates.io): a new runtime dependency under 30 days old -> review. Not applicable elsewhere, and said so.",
@@ -109,7 +111,7 @@ export async function executeDependencyCheck(_db, params, liveIntel, opts = {})
109
111
  const indexes = await fetchIndexes(requested.map(provisional), sources, new Set(requested.filter(needsFullList).map(lookupKey)));
110
112
  // Constraints are judged against the package's whole advisory list.
111
113
  const advisoryLists = await packageAdvisoryLists(fixSources, requested.filter((r) => r.target.kind === "range" || r.target.kind === "exact_or_series"));
112
- const items = requested.map((r) => resolveTarget(r, indexes.get(lookupKey(r)), advisoryLists.get(lookupKey(r)), now));
114
+ let items = requested.map((r) => resolveTarget(r, indexes.get(lookupKey(r)), advisoryLists.get(lookupKey(r)), now));
113
115
  // Go dates come from per-version .info: read them for versions chosen from the list
114
116
  // (a constraint, "latest", or `v2.0.0` listed as `v2.0.0+incompatible`).
115
117
  const goRedo = items.filter((i) => {
@@ -121,34 +123,72 @@ export async function executeDependencyCheck(_db, params, liveIntel, opts = {})
121
123
  indexes.set(key, lookup);
122
124
  await settleGoPicks(goRedo, indexes, sources.Go, now);
123
125
  }
126
+ // npm: what a release installs at a fixed version comes with it (audit 6.2.5 D-4: express 4.20.0
127
+ // exact-pins an affected qs). Judge `to` and, for a constraint, the smallest and newest safe
128
+ // releases in it, then pick again among the releases that hold nothing affected.
129
+ const held = new HeldJudge(fixSources, (deps) => liveIntel.queryAdvisories(deps));
130
+ const isNpm = (i) => i.ecosystem === "npm" && !i.unresolved;
131
+ await held.judge(items.filter(isNpm).map((item) => {
132
+ const lookup = indexes.get(lookupKey(item));
133
+ const advisories = advisoryLists.get(lookupKey(item));
134
+ const ownSafe = item.rangeCheck && lookup?.status === "ok" && advisories
135
+ ? ownSafeInRange({ ecosystem: "npm", index: lookup.data, range: item.rangeCheck.range, advisories })
136
+ : [];
137
+ return { pkg: item.package, versions: [item.to, ...heldRangeCandidates(ownSafe, (v) => !item.from || compareIn("npm", v, item.from) > 0)] };
138
+ }));
139
+ items = items.map((item, i) => {
140
+ if (!isNpm(item) || item.rangeCheck?.basis !== "requested")
141
+ return item;
142
+ const filter = held.rangeFilter(item.package);
143
+ const r = requested[i];
144
+ return filter ? resolveTarget(r, indexes.get(lookupKey(r)), advisoryLists.get(lookupKey(r)), now, filter) : item;
145
+ });
146
+ // The pick may have moved to a release judged only through its range: judge it too (normally a no-op).
147
+ await held.judge(items.filter(isNpm).map((item) => ({ pkg: item.package, versions: [item.to] })));
148
+ const heldAt = (item) => (item.ecosystem === "npm" ? held.at(item.package, item.to) : undefined);
124
149
  const osvDeps = items
125
150
  .filter((i) => !i.unresolved)
126
151
  .flatMap((item) => [item.to, item.from].filter((v) => Boolean(v)).map((version) => osvDep(item, version)));
127
152
  const scan = await liveIntel.queryAdvisories(osvDeps);
153
+ // Unrated (Go) records carry their GHSA alias's grade from the scanner, as in every tool (severityFrom).
128
154
  const byInstance = new Map();
129
155
  for (const v of scan.vulnerabilities) {
130
156
  const key = instanceKey(v.ecosystem, v.package, v.currentVersion);
131
157
  byInstance.set(key, [...(byInstance.get(key) ?? []), v]);
132
158
  }
159
+ // A pre-install check needs a current answer: an expired cached one (OSV failed) can predate
160
+ // the advisory that matters, and read as `proceed` (audit 6.2.5 B-2).
133
161
  const advisoriesFor = (item) => scan.offline
134
162
  ? { available: false, detail: "lookup failed or was rate limited" }
135
- : {
136
- available: true,
137
- to: refs(byInstance.get(instanceKey(item.ecosystem, item.package, item.to))),
138
- from: item.from ? refs(byInstance.get(instanceKey(item.ecosystem, item.package, item.from))) : [],
139
- };
140
- // An exact `to` an advisory affects: the safe releases on its own line.
141
- const affectedExact = items.filter((i) => !i.unresolved && !i.rangeCheck && advisoriesFor(i).available && hasRealAdvisory(advisoriesFor(i)));
163
+ : (scan.staleAnswers ?? 0) > 0
164
+ ? { available: false, detail: `no current answer, only an expired cached one (${scan.staleReason ?? "the fresh query failed"})` }
165
+ : {
166
+ available: true,
167
+ to: refs(byInstance.get(instanceKey(item.ecosystem, item.package, item.to))),
168
+ from: item.from ? refs(byInstance.get(instanceKey(item.ecosystem, item.package, item.from))) : [],
169
+ };
170
+ // An exact `to` an advisory affects (its own, or one it holds): the safe releases on its own line.
171
+ const affectedExact = items.filter((i) => !i.unresolved &&
172
+ !i.rangeCheck &&
173
+ ((advisoriesFor(i).available && hasRealAdvisory(advisoriesFor(i))) || (i.ecosystem === "npm" && held.affected(i.package, i.to))));
142
174
  const moreLists = await packageAdvisoryLists(fixSources, affectedExact.filter((i) => !advisoryLists.has(lookupKey(i))));
143
175
  for (const [k, v] of moreLists)
144
176
  advisoryLists.set(k, v);
145
- for (const item of affectedExact) {
177
+ const lines = affectedExact.flatMap((item) => {
146
178
  const lookup = indexes.get(lookupKey(item));
147
179
  const advisories = advisoryLists.get(lookupKey(item));
148
180
  const line = lineOf(item.ecosystem, item.to);
149
- if (lookup.status !== "ok" || !advisories || !line)
150
- continue;
151
- item.rangeCheck = checkRange({ ecosystem: item.ecosystem, index: lookup.data, range: line, basis: "same_line", advisories, from: item.from });
181
+ return lookup.status === "ok" && advisories && line ? [{ item, index: lookup.data, advisories, line }] : [];
182
+ });
183
+ await held.judge(lines
184
+ .filter((l) => l.item.ecosystem === "npm")
185
+ .map((l) => ({
186
+ pkg: l.item.package,
187
+ versions: heldRangeCandidates(ownSafeInRange({ ecosystem: "npm", index: l.index, range: l.line, advisories: l.advisories }), (v) => !l.item.from || compareIn("npm", v, l.item.from) > 0),
188
+ })));
189
+ for (const { item, index, advisories, line } of lines) {
190
+ const filter = item.ecosystem === "npm" ? held.rangeFilter(item.package) : undefined;
191
+ item.rangeCheck = checkRange({ ecosystem: item.ecosystem, index, range: line, basis: "same_line", advisories, from: item.from, ...(filter ? { held: filter } : {}) });
152
192
  }
153
193
  const holdWaivers = await findHoldWaivers(items, indexes, advisoryLists, fixSources, sources.Go, advisoriesFor, now);
154
194
  // Phase 2: first-publish dates of newly introduced dependencies.
@@ -168,12 +208,16 @@ export async function executeDependencyCheck(_db, params, liveIntel, opts = {})
168
208
  });
169
209
  const results = plans.map((p) => {
170
210
  const extra = p.item.rangeCheck ? { range_check: p.item.rangeCheck } : {};
211
+ const noticesOf = (s) => {
212
+ const ids = s.value?.maintenance_notices;
213
+ return ids && ids.length > 0 ? { maintenance_notices: ids } : {};
214
+ };
171
215
  if (p.blocked) {
172
216
  // No release metadata: only the advisory lookup can still speak, and
173
217
  // only a negative finding is worth more than "unknown".
174
218
  if (p.item.unresolved)
175
219
  return { ...head(p.item), verdict: "unknown", reason: p.blocked, signals: [], ...extra };
176
- const advisories = advisoriesSignal(p.item, advisoriesFor(p.item));
220
+ const advisories = advisoriesSignal(p.item, advisoriesFor(p.item), heldAt(p.item));
177
221
  const negative = advisories.effect === "avoid" || advisories.effect === "review";
178
222
  return {
179
223
  ...head(p.item),
@@ -181,12 +225,13 @@ export async function executeDependencyCheck(_db, params, liveIntel, opts = {})
181
225
  reason: withAlternatives(negative ? `advisories: ${advisories.evidence} | ${p.blocked}` : p.blocked, p.item, negative),
182
226
  signals: [advisories],
183
227
  ...extra,
228
+ ...noticesOf(advisories),
184
229
  };
185
230
  }
186
231
  const { item, index } = p;
187
232
  const to = index.releases[item.to];
188
233
  const base = selectBaseline(index, item);
189
- const advisories = advisoriesSignal(item, advisoriesFor(item));
234
+ const advisories = advisoriesSignal(item, advisoriesFor(item), heldAt(item));
190
235
  const createdForItem = new Map();
191
236
  for (const name of p.introduced ?? []) {
192
237
  const fact = created.get(`${item.ecosystem}\0${name}`);
@@ -199,12 +244,12 @@ export async function executeDependencyCheck(_db, params, liveIntel, opts = {})
199
244
  publishTrustSignal(item, to, base),
200
245
  installScriptSignal(item, to, item.from ? index.releases[item.from] ?? null : null),
201
246
  newDependenciesSignal(item, to, base, p.introduced, createdForItem),
202
- withdrawnSignal(item, to),
247
+ withdrawnSignal(item, to, index),
203
248
  upgradeTypeSignal(item),
204
249
  ];
205
250
  const decided = decide(signals);
206
251
  const unsafe = decided.verdict === "avoid" || decided.verdict === "review";
207
- return { ...head(item), verdict: decided.verdict, reason: withAlternatives(decided.reason, item, unsafe), signals, ...extra };
252
+ return { ...head(item), verdict: decided.verdict, reason: withAlternatives(decided.reason, item, unsafe), signals, ...extra, ...noticesOf(advisories) };
208
253
  });
209
254
  return finish(checkedAt, results, { offline: scan.offline, osvCached: scan.cached });
210
255
  }
@@ -218,10 +263,15 @@ function withAlternatives(reason, item, unsafe) {
218
263
  const ids = rc.affecting_all ?? [];
219
264
  const all = ids.length ? `: ${ids.slice(0, 3).join(", ")}${ids.length > 3 ? ` and ${ids.length - 3} more` : ""} affect every one` : "";
220
265
  const outside = rc.newest_safe_outside_range ? ` Newest safe release outside it: ${rc.newest_safe_outside_range}.` : "";
221
- return `${reason} | No release ${where} is free of known advisories${all}.${outside}`;
266
+ const withheld = rc.withheld_note ? ` ${rc.withheld_note}.` : "";
267
+ return `${reason} | No release ${where} is free of known advisories${all}.${withheld}${outside}`;
222
268
  }
223
269
  const upgrade = rc.smallest_safe_upgrade && rc.smallest_safe_upgrade !== rc.smallest_safe_in_range ? `, smallest above ${item.from} ${rc.smallest_safe_upgrade}` : "";
224
- const safe = `Safe ${where}: smallest ${rc.smallest_safe_in_range}${upgrade}, newest ${rc.newest_safe_in_range}.`;
270
+ // FX-H5: a withdrawn release the pick steps over is named, in the registry's words.
271
+ const withheld = rc.withheld_note ? ` ${rc.withheld_note}.` : "";
272
+ // audit 6.2.5 D-4: releases passed over for what they hold are counted, never dropped silently.
273
+ const heldOut = rc.held_excluded ? ` ${rc.held_excluded} release${rc.held_excluded === 1 ? "" : "s"} clear of ${item.package}'s own advisories left out: ${rc.held_note}.` : "";
274
+ const safe = `Safe ${where}: smallest ${rc.smallest_safe_in_range}${upgrade}, newest ${rc.newest_safe_in_range}.${withheld}${heldOut}`;
225
275
  if (rc.basis === "same_line" && !unsafe)
226
276
  return reason;
227
277
  return `${reason} | ${safe}`;
@@ -242,7 +292,7 @@ function needsFullList(r) {
242
292
  * the proxy lists it), "latest" (newest stable, not withdrawn), or the
243
293
  * version a constraint recommends.
244
294
  */
245
- function resolveTarget(r, lookup, advisories, now) {
295
+ function resolveTarget(r, lookup, advisories, now, held) {
246
296
  const base = provisional(r);
247
297
  if (lookup.status !== "ok")
248
298
  return base;
@@ -275,7 +325,7 @@ function resolveTarget(r, lookup, advisories, now) {
275
325
  // Without the advisory list no release can be called safe; judge the newest one on its own advisories.
276
326
  return { ...base, to: newestOf(r.ecosystem, admitted), requested: r.raw };
277
327
  }
278
- const rangeCheck = checkRange({ ecosystem: r.ecosystem, index, range, basis: "requested", advisories, from: r.from });
328
+ const rangeCheck = checkRange({ ecosystem: r.ecosystem, index, range, basis: "requested", advisories, from: r.from, ...(held ? { held } : {}) });
279
329
  const safe = rangeCheck.safe_versions;
280
330
  const settled = (v) => {
281
331
  const at = index.releases[v]?.publishedAt;
@@ -490,13 +540,15 @@ function osvDep(item, version) {
490
540
  sourceDirs: [],
491
541
  };
492
542
  }
493
- function refs(entries) {
494
- const seen = new Set();
495
- return (entries ?? []).flatMap((v) => {
496
- if (seen.has(v.vulnId))
497
- return [];
498
- seen.add(v.vulnId);
499
- return [{ id: v.vulnId, severity: v.severity, summary: v.summary, maintenance: isMaintenanceNotice(v) }];
543
+ /**
544
+ * One reference per vulnerability: OSV lists GHSA and RUSTSEC/PYSEC records
545
+ * for the same issue, each naming the other in `aliases`. They were counted
546
+ * twice ("6 advisories" for tokio 1.18.0's three, audit A9). The kept record
547
+ * of an alias group is its most severe, so merging never softens a verdict.
548
+ */
549
+ export function refs(entries) {
550
+ return groupAdvisories(entries).map(({ best: v }) => {
551
+ return { id: v.vulnId, severity: v.severity, summary: v.summary, maintenance: isMaintenanceNotice(v), ...(v.severityFrom ? { severityFrom: v.severityFrom } : {}) };
500
552
  });
501
553
  }
502
554
  const instanceKey = (eco, name, version) => `${eco}\0${eco === "PyPI" ? normalizePypi(name) : name}\0${version}`;