@4da/mcp-server 6.2.3 → 6.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +520 -0
- package/README.md +22 -6
- package/dist/db.d.ts +38 -0
- package/dist/db.js +78 -23
- package/dist/doctor.js +6 -3
- package/dist/embeddings.d.ts +1 -1
- package/dist/embeddings.js +16 -14
- package/dist/index.js +73 -103
- package/dist/live/advisory-groups.d.ts +68 -0
- package/dist/live/advisory-groups.js +131 -0
- package/dist/live/cache.js +11 -3
- package/dist/live/cargo-platform.d.ts +6 -0
- package/dist/live/cargo-platform.js +8 -2
- package/dist/live/changelog.js +38 -6
- package/dist/live/dependency-set.d.ts +9 -1
- package/dist/live/dependency-set.js +10 -2
- package/dist/live/fix-paths.d.ts +289 -16
- package/dist/live/fix-paths.js +876 -93
- package/dist/live/github-releases.js +3 -0
- package/dist/live/held-dependencies.d.ts +93 -0
- package/dist/live/held-dependencies.js +212 -0
- package/dist/live/held-targets.d.ts +30 -0
- package/dist/live/held-targets.js +107 -0
- package/dist/live/http-utils.js +13 -3
- package/dist/live/index.d.ts +7 -4
- package/dist/live/index.js +24 -19
- package/dist/live/install-state.js +6 -2
- package/dist/live/js-lockfile-readers.d.ts +8 -1
- package/dist/live/js-lockfile-readers.js +43 -6
- package/dist/live/lockfile-parents.d.ts +15 -0
- package/dist/live/lockfile-parents.js +85 -10
- package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
- package/dist/live/lockfile-parsers-pygo.js +230 -12
- package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
- package/dist/live/lockfile-parsers-rbphp.js +123 -0
- package/dist/live/lockfile-parsers.d.ts +6 -0
- package/dist/live/lockfile-parsers.js +116 -4
- package/dist/live/lockfile-types.d.ts +28 -1
- package/dist/live/maintenance.d.ts +20 -4
- package/dist/live/maintenance.js +31 -2
- package/dist/live/malware.d.ts +20 -0
- package/dist/live/malware.js +31 -0
- package/dist/live/npm-packument.d.ts +4 -1
- package/dist/live/npm-packument.js +5 -2
- package/dist/live/offered-release.d.ts +53 -0
- package/dist/live/offered-release.js +117 -0
- package/dist/live/osv-ranges.d.ts +111 -0
- package/dist/live/osv-ranges.js +173 -14
- package/dist/live/osv-scanner.d.ts +87 -5
- package/dist/live/osv-scanner.js +310 -59
- package/dist/live/pinned-advisories.d.ts +2 -2
- package/dist/live/pinned-advisories.js +3 -3
- package/dist/live/pypi-advice.d.ts +36 -0
- package/dist/live/pypi-advice.js +158 -0
- package/dist/live/range-satisfies.d.ts +4 -2
- package/dist/live/range-satisfies.js +7 -5
- package/dist/live/rate-limiter.d.ts +7 -0
- package/dist/live/rate-limiter.js +22 -1
- package/dist/live/refresh-rule.d.ts +36 -0
- package/dist/live/refresh-rule.js +49 -0
- package/dist/live/release-metadata.d.ts +5 -0
- package/dist/live/resolution.d.ts +3 -0
- package/dist/live/resolution.js +6 -3
- package/dist/live/scan-coverage.d.ts +26 -0
- package/dist/live/scan-coverage.js +70 -0
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +26 -3
- package/dist/live/severity-scope.d.ts +26 -1
- package/dist/live/severity-scope.js +32 -0
- package/dist/live/shell-safe.d.ts +19 -0
- package/dist/live/shell-safe.js +36 -0
- package/dist/live/types.d.ts +134 -8
- package/dist/live/version-compare.d.ts +51 -0
- package/dist/live/version-compare.js +152 -1
- package/dist/live/withdrawn-releases.d.ts +50 -0
- package/dist/live/withdrawn-releases.js +125 -0
- package/dist/project-manifests.d.ts +7 -0
- package/dist/project-manifests.js +45 -5
- package/dist/project-scanner.js +6 -4
- package/dist/project-tree.d.ts +56 -0
- package/dist/project-tree.js +174 -5
- package/dist/schema-registry.d.ts +47 -4
- package/dist/schema-registry.js +76 -9
- package/dist/server-instructions.js +4 -2
- package/dist/setup.d.ts +8 -0
- package/dist/setup.js +21 -2
- package/dist/tool-dispatch.js +3 -3
- package/dist/tools/agent-memory.d.ts +5 -0
- package/dist/tools/agent-memory.js +9 -0
- package/dist/tools/briefing-task-scope.js +5 -2
- package/dist/tools/decision-memory.js +2 -0
- package/dist/tools/dependency-check-held.d.ts +44 -0
- package/dist/tools/dependency-check-held.js +98 -0
- package/dist/tools/dependency-check-signals.d.ts +19 -2
- package/dist/tools/dependency-check-signals.js +84 -13
- package/dist/tools/dependency-check-versions.d.ts +37 -4
- package/dist/tools/dependency-check-versions.js +48 -13
- package/dist/tools/dependency-check.d.ts +11 -1
- package/dist/tools/dependency-check.js +81 -29
- package/dist/tools/dependency-health.js +209 -45
- package/dist/tools/get-context.d.ts +3 -0
- package/dist/tools/get-context.js +4 -0
- package/dist/tools/project-scope.d.ts +8 -0
- package/dist/tools/project-scope.js +21 -0
- package/dist/tools/upgrade-impact-features.d.ts +37 -0
- package/dist/tools/upgrade-impact-features.js +176 -0
- package/dist/tools/upgrade-impact-literals.js +26 -4
- package/dist/tools/upgrade-impact-report.d.ts +5 -1
- package/dist/tools/upgrade-impact-report.js +26 -5
- package/dist/tools/upgrade-impact.js +10 -3
- package/dist/tools/upgrade-planner-fixpaths.d.ts +164 -5
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +70 -7
- package/dist/tools/upgrade-planner.js +237 -61
- package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
- package/dist/tools/vulnerability-scan-concise.js +37 -3
- package/dist/tools/vulnerability-scan-format.d.ts +126 -24
- package/dist/tools/vulnerability-scan-format.js +193 -26
- package/dist/tools/vulnerability-scan.d.ts +224 -63
- package/dist/tools/vulnerability-scan.js +50 -15
- package/dist/tools/what-should-i-know.d.ts +6 -1
- package/dist/tools/what-should-i-know.js +72 -15
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
184
|
-
advisory_by_severity:
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
text = `
|
|
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.
|
|
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
|
+
}
|