@4da/mcp-server 6.0.1 → 6.1.0
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 +316 -0
- package/README.md +19 -13
- package/dist/db.d.ts +88 -7
- package/dist/db.js +248 -63
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +15 -9
- package/dist/embeddings.js +19 -7
- package/dist/gitignore.js +9 -3
- package/dist/index.js +54 -7
- package/dist/live/cache.d.ts +31 -2
- package/dist/live/cache.js +113 -1
- package/dist/live/changelog-classify.js +4 -1
- package/dist/live/changelog.d.ts +15 -0
- package/dist/live/changelog.js +67 -11
- package/dist/live/dev-scope.d.ts +2 -2
- package/dist/live/fix-paths.d.ts +118 -0
- package/dist/live/fix-paths.js +498 -0
- package/dist/live/go-registry.d.ts +5 -0
- package/dist/live/go-registry.js +1 -1
- package/dist/live/index.d.ts +30 -3
- package/dist/live/index.js +59 -6
- package/dist/live/lockfile-parents.d.ts +61 -0
- package/dist/live/lockfile-parents.js +360 -0
- package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
- package/dist/live/lockfile-parsers-pygo.js +178 -28
- package/dist/live/lockfile-parsers.d.ts +4 -1
- package/dist/live/lockfile-parsers.js +57 -24
- package/dist/live/lockfile-types.d.ts +12 -3
- package/dist/live/lockfile-types.js +2 -1
- package/dist/live/osv-ranges.d.ts +79 -0
- package/dist/live/osv-ranges.js +133 -0
- package/dist/live/osv-scanner.d.ts +1 -0
- package/dist/live/osv-scanner.js +85 -78
- package/dist/live/range-satisfies.d.ts +21 -0
- package/dist/live/range-satisfies.js +190 -0
- package/dist/live/rate-limiter.d.ts +6 -0
- package/dist/live/rate-limiter.js +5 -0
- package/dist/live/registry-health.js +2 -0
- package/dist/live/resolution.d.ts +11 -1
- package/dist/live/resolution.js +16 -6
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +10 -2
- package/dist/live/types.d.ts +17 -1
- package/dist/live/upgrade-sources.d.ts +2 -0
- package/dist/live/upgrade-sources.js +7 -2
- package/dist/memory-store.d.ts +19 -0
- package/dist/memory-store.js +8 -0
- package/dist/native-bindings.d.ts +7 -2
- package/dist/native-bindings.js +4 -0
- package/dist/project-tree.d.ts +50 -8
- package/dist/project-tree.js +121 -29
- package/dist/schema-registry.js +2 -2
- package/dist/setup.d.ts +76 -3
- package/dist/setup.js +313 -130
- package/dist/sqlite-driver.d.ts +134 -0
- package/dist/sqlite-driver.js +359 -0
- package/dist/tool-dispatch.d.ts +10 -1
- package/dist/tool-dispatch.js +37 -2
- package/dist/tools/agent-memory.js +10 -2
- package/dist/tools/app-plan.d.ts +11 -1
- package/dist/tools/app-plan.js +32 -6
- package/dist/tools/briefing-task-scope.d.ts +21 -2
- package/dist/tools/briefing-task-scope.js +97 -4
- package/dist/tools/decision-memory.js +9 -3
- package/dist/tools/dependency-check-signals.d.ts +7 -0
- package/dist/tools/dependency-check-signals.js +17 -2
- package/dist/tools/dependency-check.d.ts +1 -1
- package/dist/tools/dependency-check.js +1 -1
- package/dist/tools/dependency-health.d.ts +1 -1
- package/dist/tools/dependency-health.js +5 -0
- package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
- package/dist/tools/knowledge-gap-ranges.js +1 -1
- package/dist/tools/knowledge-gap-scope.d.ts +2 -2
- package/dist/tools/project-scope.d.ts +45 -0
- package/dist/tools/project-scope.js +119 -0
- package/dist/tools/record-feedback.js +1 -1
- package/dist/tools/upgrade-impact-literals.js +1 -1
- package/dist/tools/upgrade-impact-report.d.ts +10 -0
- package/dist/tools/upgrade-impact-report.js +32 -2
- package/dist/tools/upgrade-impact.js +1 -1
- package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +31 -3
- package/dist/tools/upgrade-planner.js +74 -19
- package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
- package/dist/tools/vulnerability-scan-concise.js +15 -3
- package/dist/tools/vulnerability-scan-format.d.ts +58 -1
- package/dist/tools/vulnerability-scan-format.js +107 -11
- package/dist/tools/vulnerability-scan.d.ts +104 -13
- package/dist/tools/vulnerability-scan.js +27 -3
- package/dist/tools/what-should-i-know.d.ts +22 -2
- package/dist/tools/what-should-i-know.js +121 -11
- package/package.json +10 -31
|
@@ -30,6 +30,8 @@ export interface UpgradePlannerParams {
|
|
|
30
30
|
max_recommendations?: number;
|
|
31
31
|
risk_threshold?: "all" | "low" | "medium" | "high" | "critical";
|
|
32
32
|
package?: string;
|
|
33
|
+
/** Project directory to plan for; "*" for every project. Default: the server's project, when it is one. */
|
|
34
|
+
project_path?: string;
|
|
33
35
|
}
|
|
34
36
|
interface UpgradeRecommendation {
|
|
35
37
|
package: string;
|
|
@@ -53,19 +55,37 @@ interface UpgradeRecommendation {
|
|
|
53
55
|
/** Whether this package is declared in a manifest (direct) or only present via the lockfile (transitive). */
|
|
54
56
|
scope: "direct" | "transitive";
|
|
55
57
|
/**
|
|
56
|
-
* Direct deps you can bump yourself;
|
|
57
|
-
*
|
|
58
|
+
* Direct deps you can bump yourself; `lockfile_refresh` = transitive, and
|
|
59
|
+
* every parent's declared requirement already admits a clean version, so
|
|
60
|
+
* `refreshCommands` fixes it; `waiting_on_upstream` = transitive, and a
|
|
61
|
+
* parent's requirement excludes every clean version (or could not be
|
|
62
|
+
* read); `reinstall` = the lockfile's version is fine
|
|
58
63
|
* but node_modules holds another one, so the fix is `installFix`;
|
|
59
64
|
* `no_fix_available` = vulnerable with no fixed release to move to;
|
|
60
65
|
* `not_built_on_this_host` = every advisory is against code this host never compiles.
|
|
61
66
|
*/
|
|
62
|
-
action: "upgrade_direct" | "waiting_on_upstream" | "reinstall" | "no_fix_available" | "not_built_on_this_host";
|
|
67
|
+
action: "upgrade_direct" | "lockfile_refresh" | "waiting_on_upstream" | "reinstall" | "no_fix_available" | "not_built_on_this_host";
|
|
68
|
+
/**
|
|
69
|
+
* For a vulnerable package: whether the target was checked against EVERY
|
|
70
|
+
* known advisory of the package (`all_advisories`), or only against the
|
|
71
|
+
* advisories of the installed version because the lookups failed or ran
|
|
72
|
+
* out of time (`installed_advisories_only`; the target may then be
|
|
73
|
+
* affected by an advisory the installed version predates).
|
|
74
|
+
*/
|
|
75
|
+
fixPathChecked?: "all_advisories" | "installed_advisories_only";
|
|
76
|
+
/** lockfile_refresh: the command to run, per lockfile directory. */
|
|
77
|
+
refreshCommands?: Array<{
|
|
78
|
+
dir: string;
|
|
79
|
+
command: string;
|
|
80
|
+
}>;
|
|
63
81
|
/** False when the dependency is gated behind a target spec not active on this host (label, never suppressed). */
|
|
64
82
|
platformActive: boolean;
|
|
65
83
|
/** npm: the version node_modules holds, when it differs from `currentVersion` (the lockfile's). */
|
|
66
84
|
installedVersion?: string;
|
|
67
85
|
/** npm: the command that reinstalls from the lockfile, when `installedVersion` is set. */
|
|
68
86
|
installFix?: string;
|
|
87
|
+
/** No lockfile: the manifest's declared requirement; `currentVersion` is its floor, not an install. */
|
|
88
|
+
declaredRange?: string;
|
|
69
89
|
}
|
|
70
90
|
interface UpgradePlanResult {
|
|
71
91
|
generatedAt: string;
|
|
@@ -76,6 +96,8 @@ interface UpgradePlanResult {
|
|
|
76
96
|
quickWins: number;
|
|
77
97
|
breakingChanges: number;
|
|
78
98
|
waitingOnUpstream: number;
|
|
99
|
+
/** Transitive steps a lockfile refresh fixes (every parent already admits a clean version). */
|
|
100
|
+
lockfileRefreshes: number;
|
|
79
101
|
/** False when no vulnerability scan has run this session — the ranking is then CVE-blind and says so. */
|
|
80
102
|
vulnerabilityDataAvailable: boolean;
|
|
81
103
|
provenance: {
|
|
@@ -84,6 +106,8 @@ interface UpgradePlanResult {
|
|
|
84
106
|
};
|
|
85
107
|
/** Why the 4DA app's own plan was not used (no database, no snapshot, other schema). */
|
|
86
108
|
appPlanUnavailable?: string;
|
|
109
|
+
/** The project the plan covers, or null when it is not narrowed to one. */
|
|
110
|
+
projectScope?: string | null;
|
|
87
111
|
}
|
|
88
112
|
export declare const upgradePlannerTool: {
|
|
89
113
|
name: string;
|
|
@@ -108,6 +132,10 @@ export declare const upgradePlannerTool: {
|
|
|
108
132
|
type: string;
|
|
109
133
|
description: string;
|
|
110
134
|
};
|
|
135
|
+
project_path: {
|
|
136
|
+
type: string;
|
|
137
|
+
description: string;
|
|
138
|
+
};
|
|
111
139
|
};
|
|
112
140
|
};
|
|
113
141
|
};
|
|
@@ -30,13 +30,16 @@ import { formatAppPlan, readAppPlan } from "./app-plan.js";
|
|
|
30
30
|
import { dirsLabel, driftFor } from "./install-drift-notes.js";
|
|
31
31
|
import { emptyAnswerNote } from "./package-presence.js";
|
|
32
32
|
import { relativeDir } from "./vulnerability-scan-format.js";
|
|
33
|
+
import { liveIntelFor, resolveProjectScope } from "./project-scope.js";
|
|
34
|
+
import { verifyFixPaths } from "./upgrade-planner-fixpaths.js";
|
|
35
|
+
import { discoveryNote } from "../project-tree.js";
|
|
33
36
|
/** How long a plan waits for its own OSV scan before answering CVE-blind (the scan keeps running). */
|
|
34
37
|
const PLANNER_SCAN_TIMEOUT_MS = 60_000;
|
|
35
38
|
const PROVENANCE_NOTE = "Point-in-time heuristic from local lockfiles plus live registry/OSV lookups. " +
|
|
36
39
|
"Not the 4DA desktop app's cross-project, version-confirmed plan (none was available).";
|
|
37
40
|
export const upgradePlannerTool = {
|
|
38
41
|
name: "upgrade_planner",
|
|
39
|
-
description: "Ranked dependency upgrade plan — call before upgrading, adding, or auditing any dependency to pick the safest order. When the 4DA desktop app has computed its plan, returns that plan's work order (provenance app_plan): per package the ecosystem, each installed version with its minimum clean target, upgrade type (patch/minor/major), the projects that hold it (direct/dev) and the mechanism (manifest_bump, lockfile_or_parent_update, mixed, no_fix); flagged stale past its freshness horizon. Otherwise falls back to a standalone plan from local lockfiles (provenance standalone_heuristic): the smallest version that fixes each vulnerability (direct and transitive), deprecation and version distance; it runs the OSV scan itself when none has run. `package` narrows either plan to one package. Privacy: the fallback may query public registries and OSV with package names and versions; it never sends source code.",
|
|
42
|
+
description: "Ranked dependency upgrade plan for one project — call before upgrading, adding, or auditing any dependency to pick the safest order. `project_path` picks the project (default: the one the server was started in; \"*\" for every project). When the 4DA desktop app has computed its plan, returns that plan's work order (provenance app_plan): per package the ecosystem, each installed version with its minimum clean target, upgrade type (patch/minor/major), the projects that hold it (direct/dev) and the mechanism (manifest_bump, lockfile_or_parent_update, mixed, no_fix); flagged stale past its freshness horizon. Otherwise falls back to a standalone plan from local lockfiles (provenance standalone_heuristic): the smallest version that fixes each vulnerability (direct and transitive), checked against every known advisory of the package, whether a lockfile refresh already reaches it (lockfile_refresh with the command) or a parent blocks it (waiting_on_upstream), deprecation and version distance; it runs the OSV scan itself when none has run. `package` narrows either plan to one package. Privacy: the fallback may query public registries and OSV with package names and versions; it never sends source code.",
|
|
40
43
|
inputSchema: {
|
|
41
44
|
type: "object",
|
|
42
45
|
properties: {
|
|
@@ -57,10 +60,25 @@ export const upgradePlannerTool = {
|
|
|
57
60
|
type: "string",
|
|
58
61
|
description: "Plan for this one package only (exact name, any ecosystem). Default: every dependency.",
|
|
59
62
|
},
|
|
63
|
+
project_path: {
|
|
64
|
+
type: "string",
|
|
65
|
+
description: "Project directory to plan for: the app's plan is narrowed to the steps (and installed lines) that touch it; the standalone plan resolves that project's lockfiles. \"*\" for every project the app tracks. Default: the project the server was started in, when that directory is a project.",
|
|
66
|
+
},
|
|
60
67
|
},
|
|
61
68
|
},
|
|
62
69
|
};
|
|
63
70
|
const RISK_LEVELS = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
71
|
+
/** critical > high > medium > low, then fixable now (direct) before transitive, then non-breaking first (quick wins). */
|
|
72
|
+
function planOrder(a, b) {
|
|
73
|
+
const riskDiff = RISK_LEVELS[b.risk] - RISK_LEVELS[a.risk];
|
|
74
|
+
if (riskDiff !== 0)
|
|
75
|
+
return riskDiff;
|
|
76
|
+
if (a.scope !== b.scope)
|
|
77
|
+
return a.scope === "direct" ? -1 : 1;
|
|
78
|
+
if (a.breaking !== b.breaking)
|
|
79
|
+
return a.breaking ? 1 : -1;
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
64
82
|
function severityToRisk(severities) {
|
|
65
83
|
if (severities.includes("critical"))
|
|
66
84
|
return "critical";
|
|
@@ -79,13 +97,23 @@ function higherRisk(a, b) {
|
|
|
79
97
|
return RISK_LEVELS[b] > RISK_LEVELS[a] ? b : a;
|
|
80
98
|
}
|
|
81
99
|
export async function executeUpgradePlanner(db, params, liveIntel) {
|
|
100
|
+
const scope = resolveProjectScope(params.project_path);
|
|
101
|
+
if (scope.kind === "error")
|
|
102
|
+
throw new Error(scope.error);
|
|
103
|
+
const scopePath = scope.kind === "project" ? scope.path : null;
|
|
82
104
|
// One brain: the app's plan wins whenever there is one, stale or not (a
|
|
83
105
|
// stale plan is returned flagged, never silently replaced by a heuristic).
|
|
106
|
+
// It covers every project the app tracks, so it is narrowed to this one.
|
|
84
107
|
const appPlan = readAppPlan(db);
|
|
85
108
|
if (appPlan.kind === "plan")
|
|
86
|
-
return formatAppPlan(appPlan, params);
|
|
87
|
-
|
|
88
|
-
|
|
109
|
+
return formatAppPlan(appPlan, params, scopePath);
|
|
110
|
+
// The live layer already covers the server's own project; only an explicit
|
|
111
|
+
// project_path elsewhere resolves another tree.
|
|
112
|
+
const scoped = scope.kind === "project" && scope.source === "argument"
|
|
113
|
+
? liveIntelFor(liveIntel, db, scope)
|
|
114
|
+
: liveIntel;
|
|
115
|
+
const standalone = await executeStandalonePlanner(params, scoped);
|
|
116
|
+
return { ...standalone, appPlanUnavailable: appPlan.reason, projectScope: scopePath ?? standalone.projectPath };
|
|
89
117
|
}
|
|
90
118
|
async function executeStandalonePlanner(params, liveIntel) {
|
|
91
119
|
if (!liveIntel || !liveIntel.isInitialized()) {
|
|
@@ -98,6 +126,7 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
98
126
|
quickWins: 0,
|
|
99
127
|
breakingChanges: 0,
|
|
100
128
|
waitingOnUpstream: 0,
|
|
129
|
+
lockfileRefreshes: 0,
|
|
101
130
|
vulnerabilityDataAvailable: false,
|
|
102
131
|
provenance: { mode: "standalone_heuristic", note: PROVENANCE_NOTE },
|
|
103
132
|
};
|
|
@@ -159,6 +188,8 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
159
188
|
// Build recommendations — direct deps (registry-informed)
|
|
160
189
|
const recommendations = [];
|
|
161
190
|
const directPackages = new Set();
|
|
191
|
+
// Per vulnerable step: the per-advisory fixes and where it is pinned, for fix-path verification.
|
|
192
|
+
const fixPathInputs = new Map();
|
|
162
193
|
for (const dep of registryData) {
|
|
163
194
|
directPackages.add(instanceKey(dep.ecosystem, dep.name, dep.currentVersion));
|
|
164
195
|
const reasons = [];
|
|
@@ -201,7 +232,15 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
201
232
|
// Check version distance
|
|
202
233
|
if (dep.versionsBehind) {
|
|
203
234
|
const d = dep.versionsBehind;
|
|
204
|
-
if (d.label === "major") {
|
|
235
|
+
if (d.label === "major" && d.major === 0) {
|
|
236
|
+
// A 0.x bump: breaking under caret rules, though the major number is unchanged.
|
|
237
|
+
const n = d.minor > 0 ? d.minor : d.patch;
|
|
238
|
+
const unit = d.minor > 0 ? "minor version" : "patch";
|
|
239
|
+
reasons.push(`${n} 0.x ${unit}${n !== 1 ? (unit === "patch" ? "es" : "s") : ""} behind (breaking: below 1.0 a ${unit === "patch" ? "0.0.x patch" : "minor"} bump is a major one)`);
|
|
240
|
+
if (risk === "low")
|
|
241
|
+
risk = "medium";
|
|
242
|
+
}
|
|
243
|
+
else if (d.label === "major") {
|
|
205
244
|
reasons.push(`${d.major} major version${d.major !== 1 ? "s" : ""} behind`);
|
|
206
245
|
if (risk === "low")
|
|
207
246
|
risk = "medium";
|
|
@@ -238,6 +277,9 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
238
277
|
// Skip if no upgrade needed (no vuln, not deprecated, not behind, no drift => no reasons)
|
|
239
278
|
if (reasons.length === 0)
|
|
240
279
|
continue;
|
|
280
|
+
if (dep.declaredRange) {
|
|
281
|
+
reasons.push(`No lockfile: ${dep.currentVersion} is the floor of the declared range "${dep.declaredRange}", not an installed version`);
|
|
282
|
+
}
|
|
241
283
|
// Never recommend a downgrade: when the current version is a prerelease
|
|
242
284
|
// AHEAD of the latest stable (rsa 0.10.0-rc.18 vs stable 0.9.10), the
|
|
243
285
|
// stable line is not an upgrade target.
|
|
@@ -260,7 +302,7 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
260
302
|
: targetVersion
|
|
261
303
|
? "upgrade_direct"
|
|
262
304
|
: "no_fix_available";
|
|
263
|
-
|
|
305
|
+
const rec = {
|
|
264
306
|
package: dep.name,
|
|
265
307
|
ecosystem: dep.ecosystem,
|
|
266
308
|
currentVersion: dep.currentVersion,
|
|
@@ -275,7 +317,12 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
275
317
|
action,
|
|
276
318
|
platformActive,
|
|
277
319
|
...(installedVersion ? { installedVersion, installFix } : {}),
|
|
278
|
-
|
|
320
|
+
...(dep.declaredRange ? { declaredRange: dep.declaredRange } : {}),
|
|
321
|
+
};
|
|
322
|
+
recommendations.push(rec);
|
|
323
|
+
if (fixable && !reinstallOnly && action === "upgrade_direct" && vulns) {
|
|
324
|
+
fixPathInputs.set(rec, { fixes: vulns.map((v) => v.fixedVersion).filter((f) => Boolean(f)), sourceDirs: [] });
|
|
325
|
+
}
|
|
279
326
|
}
|
|
280
327
|
// Transitive vulnerable packages — previously counted in scan totals but
|
|
281
328
|
// never recommended. Surfaced as waiting_on_upstream with honest labels.
|
|
@@ -312,7 +359,7 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
312
359
|
reasons.push("Affected code is gated behind a target not active on this platform");
|
|
313
360
|
}
|
|
314
361
|
const target = fixedVersions.length > 0 ? maxVersion(fixedVersions, first.ecosystem) : null;
|
|
315
|
-
|
|
362
|
+
const rec = {
|
|
316
363
|
package: first.package,
|
|
317
364
|
ecosystem: first.ecosystem,
|
|
318
365
|
currentVersion: first.currentVersion,
|
|
@@ -325,7 +372,11 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
325
372
|
scope: "transitive",
|
|
326
373
|
action: !platformActive ? "not_built_on_this_host" : target ? "waiting_on_upstream" : "no_fix_available",
|
|
327
374
|
platformActive,
|
|
328
|
-
}
|
|
375
|
+
};
|
|
376
|
+
recommendations.push(rec);
|
|
377
|
+
if (rec.action === "waiting_on_upstream") {
|
|
378
|
+
fixPathInputs.set(rec, { fixes: fixedVersions, sourceDirs: [...new Set(entries.flatMap((e) => e.sourceDirs ?? []))] });
|
|
379
|
+
}
|
|
329
380
|
}
|
|
330
381
|
}
|
|
331
382
|
// Defensive dedupe: identical (ecosystem, package, version, scope) rows can
|
|
@@ -345,22 +396,18 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
345
396
|
: deduped.filter((r) => RISK_LEVELS[r.risk] >= RISK_LEVELS[threshold]);
|
|
346
397
|
// Sort: critical > high > medium > low, then fixable-now (direct) before
|
|
347
398
|
// waiting-on-upstream (transitive), then non-breaking first (quick wins).
|
|
348
|
-
filtered.sort(
|
|
349
|
-
const riskDiff = RISK_LEVELS[b.risk] - RISK_LEVELS[a.risk];
|
|
350
|
-
if (riskDiff !== 0)
|
|
351
|
-
return riskDiff;
|
|
352
|
-
if (a.scope !== b.scope)
|
|
353
|
-
return a.scope === "direct" ? -1 : 1;
|
|
354
|
-
if (a.breaking !== b.breaking)
|
|
355
|
-
return a.breaking ? 1 : -1;
|
|
356
|
-
return 0;
|
|
357
|
-
});
|
|
399
|
+
filtered.sort(planOrder);
|
|
358
400
|
const maxRecs = params.max_recommendations ?? 20;
|
|
359
401
|
const limited = filtered.slice(0, maxRecs);
|
|
402
|
+
// Verify the fix path of every vulnerable step the plan returns: a target
|
|
403
|
+
// no advisory affects, and whether a lockfile refresh already reaches it.
|
|
404
|
+
const unverified = await verifyFixPaths(limited.filter((r) => fixPathInputs.has(r)).map((r) => ({ step: r, ...fixPathInputs.get(r) })), liveIntel);
|
|
405
|
+
limited.sort(planOrder);
|
|
360
406
|
const upgrades = limited.filter((r) => r.scope === "direct" && r.action === "upgrade_direct");
|
|
361
407
|
const quickWins = upgrades.filter((r) => !r.breaking).length;
|
|
362
408
|
const breakingChanges = upgrades.filter((r) => r.breaking).length;
|
|
363
409
|
const waitingOnUpstream = limited.filter((r) => r.action === "waiting_on_upstream").length;
|
|
410
|
+
const lockfileRefreshes = limited.filter((r) => r.action === "lockfile_refresh").length;
|
|
364
411
|
const reinstalls = limited.filter((r) => r.action === "reinstall").length;
|
|
365
412
|
const noFix = limited.filter((r) => r.action === "no_fix_available").length;
|
|
366
413
|
const inactive = limited.filter((r) => r.action === "not_built_on_this_host").length;
|
|
@@ -373,8 +420,12 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
373
420
|
parts.push(`${upgrades.length} upgradable directly (${quickWins} non-breaking, ${breakingChanges} crossing a major version)`);
|
|
374
421
|
if (reinstalls > 0)
|
|
375
422
|
parts.push(`${reinstalls} need only a reinstall (node_modules behind the lockfile)`);
|
|
423
|
+
if (lockfileRefreshes > 0)
|
|
424
|
+
parts.push(`${lockfileRefreshes} transitive, fixed by a lockfile refresh (every parent already admits a clean version)`);
|
|
376
425
|
if (waitingOnUpstream > 0)
|
|
377
426
|
parts.push(`${waitingOnUpstream} transitive, waiting on upstream`);
|
|
427
|
+
if (unverified > 0)
|
|
428
|
+
parts.push(`${unverified} fix target${unverified !== 1 ? "s" : ""} checked only against the installed version's advisories (offline, or OSV or the registry did not answer in time; see fixPathChecked)`);
|
|
378
429
|
if (noFix > 0)
|
|
379
430
|
parts.push(`${noFix} vulnerable with no fixed release yet`);
|
|
380
431
|
if (inactive > 0)
|
|
@@ -385,6 +436,9 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
385
436
|
if (maintenanceNotices.size > 0) {
|
|
386
437
|
parts.push(`${maintenanceNotices.size} unmaintained-package notice${maintenanceNotices.size !== 1 ? "s" : ""} not ranked (no fix exists; see vulnerability_scan maintenance_notices)`);
|
|
387
438
|
}
|
|
439
|
+
const partial = discoveryNote(liveIntel.getDiscovery());
|
|
440
|
+
if (partial)
|
|
441
|
+
parts.push(partial.replace(/[.]$/, ""));
|
|
388
442
|
const nothing = vulnerabilityDataAvailable ? "nothing to upgrade (no known vulnerability, deprecation, or newer release of a direct dependency)" : "no vulnerability data to plan from";
|
|
389
443
|
if (only && limited.length === 0)
|
|
390
444
|
parts.push(emptyAnswerNote(only, liveIntel, includeDev, nothing));
|
|
@@ -402,6 +456,7 @@ async function executeStandalonePlanner(params, liveIntel) {
|
|
|
402
456
|
quickWins,
|
|
403
457
|
breakingChanges,
|
|
404
458
|
waitingOnUpstream,
|
|
459
|
+
lockfileRefreshes,
|
|
405
460
|
vulnerabilityDataAvailable,
|
|
406
461
|
provenance: { mode: "standalone_heuristic", note: PROVENANCE_NOTE },
|
|
407
462
|
};
|
|
@@ -22,7 +22,9 @@ export interface PackageRow {
|
|
|
22
22
|
severity: SeverityTier;
|
|
23
23
|
advisories: number;
|
|
24
24
|
ids: string[];
|
|
25
|
+
/** The version to move to: checked against every advisory of the package when fix_checked says so. */
|
|
25
26
|
fixed_version: string | null;
|
|
27
|
+
fix_checked: "all_advisories" | "installed_advisories_only";
|
|
26
28
|
unfixed_advisories?: number;
|
|
27
29
|
is_direct: boolean | null;
|
|
28
30
|
is_dev?: true;
|
|
@@ -30,7 +32,7 @@ export interface PackageRow {
|
|
|
30
32
|
summary: string;
|
|
31
33
|
}
|
|
32
34
|
/** One row per package version: worst graded severity, the fix that clears every fixable advisory. */
|
|
33
|
-
export declare function groupByPackage(rows: FormattedVuln[]): PackageRow[];
|
|
35
|
+
export declare function groupByPackage(rows: FormattedVuln[], targets?: FullScan["fix_targets"]): PackageRow[];
|
|
34
36
|
export declare function conciseScanResult(full: FullScan): {
|
|
35
37
|
scan_time: string;
|
|
36
38
|
project_root: string;
|
|
@@ -38,6 +40,16 @@ export declare function conciseScanResult(full: FullScan): {
|
|
|
38
40
|
ecosystems_scanned: string[];
|
|
39
41
|
total_dependencies_scanned: number;
|
|
40
42
|
total_vulnerable_packages: number;
|
|
43
|
+
coverage?: {
|
|
44
|
+
complete: false;
|
|
45
|
+
note: string;
|
|
46
|
+
lockfiles_not_scanned: number;
|
|
47
|
+
unresolved_manifests?: {
|
|
48
|
+
manifest: string;
|
|
49
|
+
declared_ranges: number;
|
|
50
|
+
fix: string;
|
|
51
|
+
}[] | undefined;
|
|
52
|
+
} | undefined;
|
|
41
53
|
by_severity: import("../live/severity-scope.js").SeverityCounts;
|
|
42
54
|
vulnerable_packages: PackageRow[];
|
|
43
55
|
vulnerable_packages_note?: string | undefined;
|
|
@@ -19,7 +19,7 @@ const MAX_PACKAGES = 40;
|
|
|
19
19
|
const MAX_RECOMMENDATIONS = 25;
|
|
20
20
|
const IDS_PER_ROW = 3;
|
|
21
21
|
/** One row per package version: worst graded severity, the fix that clears every fixable advisory. */
|
|
22
|
-
export function groupByPackage(rows) {
|
|
22
|
+
export function groupByPackage(rows, targets = {}) {
|
|
23
23
|
const groups = new Map();
|
|
24
24
|
for (const v of rows) {
|
|
25
25
|
const key = `${v.ecosystem}\0${v.package}\0${v.current_version}`;
|
|
@@ -30,7 +30,8 @@ export function groupByPackage(rows) {
|
|
|
30
30
|
const worst = vulns.reduce((a, b) => (SEVERITY_RANK[b.severity] > SEVERITY_RANK[a.severity] ? b : a));
|
|
31
31
|
const fixes = vulns.map((v) => v.fixed_version).filter((f) => Boolean(f));
|
|
32
32
|
const unfixed = vulns.length - fixes.length;
|
|
33
|
-
const
|
|
33
|
+
const checked = targets[`${worst.package}@${worst.current_version}`];
|
|
34
|
+
const fixed = checked?.checked === "all_advisories" ? checked.target : fixes.length > 0 ? maxVersion(fixes, worst.ecosystem) : null;
|
|
34
35
|
// Unknown (null) only when no advisory row knows; any direct row makes it direct.
|
|
35
36
|
const direct = vulns.some((v) => v.is_direct === true) ? true : vulns.some((v) => v.is_direct === null) ? null : false;
|
|
36
37
|
out.push({
|
|
@@ -40,6 +41,7 @@ export function groupByPackage(rows) {
|
|
|
40
41
|
advisories: vulns.length,
|
|
41
42
|
ids: vulns.slice(0, IDS_PER_ROW).map((v) => v.vuln_id),
|
|
42
43
|
fixed_version: fixed,
|
|
44
|
+
fix_checked: checked?.checked ?? "installed_advisories_only",
|
|
43
45
|
...(unfixed > 0 ? { unfixed_advisories: unfixed } : {}),
|
|
44
46
|
is_direct: direct,
|
|
45
47
|
...(vulns.every((v) => v.is_dev) ? { is_dev: true } : {}),
|
|
@@ -57,7 +59,7 @@ export function groupByPackage(rows) {
|
|
|
57
59
|
export function conciseScanResult(full) {
|
|
58
60
|
const names = (rows) => [...new Set(rows.map((v) => `${v.package}@${v.current_version}`))];
|
|
59
61
|
const vulnerableDrift = full.install_drift.filter((row) => row.vulnerable_installed === true);
|
|
60
|
-
const packages = groupByPackage(full.vulnerabilities);
|
|
62
|
+
const packages = groupByPackage(full.vulnerabilities, full.fix_targets);
|
|
61
63
|
const listed = packages.slice(0, MAX_PACKAGES);
|
|
62
64
|
const recommendations = full.recommendations.slice(0, MAX_RECOMMENDATIONS);
|
|
63
65
|
const omittedPackages = packages.length - listed.length;
|
|
@@ -69,6 +71,16 @@ export function conciseScanResult(full) {
|
|
|
69
71
|
ecosystems_scanned: full.ecosystems_scanned,
|
|
70
72
|
total_dependencies_scanned: full.total_dependencies_scanned,
|
|
71
73
|
total_vulnerable_packages: full.total_vulnerable_packages,
|
|
74
|
+
...(full.coverage
|
|
75
|
+
? {
|
|
76
|
+
coverage: {
|
|
77
|
+
complete: false,
|
|
78
|
+
note: full.coverage.note,
|
|
79
|
+
lockfiles_not_scanned: full.coverage.lockfiles_not_scanned,
|
|
80
|
+
...("unresolved_manifests" in full.coverage ? { unresolved_manifests: full.coverage.unresolved_manifests } : {}),
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
: {}),
|
|
72
84
|
// Every listed advisory, before grouping and trimming.
|
|
73
85
|
by_severity: full.by_severity,
|
|
74
86
|
vulnerable_packages: listed,
|
|
@@ -4,13 +4,50 @@
|
|
|
4
4
|
* provenance. Split out of vulnerability-scan.ts, which keeps execution.
|
|
5
5
|
*/
|
|
6
6
|
import type { InstallDriftRecord, ResolutionProvenance, VulnerabilityScanResult } from "../live/types.js";
|
|
7
|
+
import { type TreeDiscovery } from "../project-tree.js";
|
|
8
|
+
import type { CleanTarget } from "../live/osv-ranges.js";
|
|
7
9
|
/** What the formatter needs beyond the scan itself. */
|
|
8
10
|
export interface ScanContext {
|
|
9
11
|
resolution: ResolutionProvenance | null;
|
|
10
12
|
reResolvedThisCall: boolean;
|
|
11
13
|
drift: InstallDriftRecord[];
|
|
12
14
|
includeDev: boolean;
|
|
15
|
+
/** What the project-tree walk left out; null when the set did not come from a walk. */
|
|
16
|
+
discovery?: TreeDiscovery | null;
|
|
17
|
+
/** Manifests with no lockfile, whose ranged dependencies were not scanned. */
|
|
18
|
+
unresolved?: Array<{
|
|
19
|
+
dir: string;
|
|
20
|
+
manifest: string;
|
|
21
|
+
ranges: number;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Upgrade targets checked against every advisory of the package, keyed by
|
|
25
|
+
* fixTargetKey(); absent when the check could not run (offline, budget).
|
|
26
|
+
*/
|
|
27
|
+
verifiedTargets?: Map<string, CleanTarget>;
|
|
13
28
|
}
|
|
29
|
+
/** The key a verified target is stored under: one installed copy of a package. */
|
|
30
|
+
export declare function fixTargetKey(ecosystem: string, name: string, version: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* What the scan did NOT cover, so a clean answer is never mistaken for a
|
|
33
|
+
* complete one: lockfiles past the project-tree bounds, and manifests with no
|
|
34
|
+
* lockfile (declared ranges are not installs, so they are not sent to OSV).
|
|
35
|
+
*/
|
|
36
|
+
export declare function scanCoverage(ctx: ScanContext, relative: (dir: string) => string, detailed: boolean): {
|
|
37
|
+
complete: boolean;
|
|
38
|
+
note: string;
|
|
39
|
+
lockfiles_not_scanned: number;
|
|
40
|
+
skipped_lockfiles?: {
|
|
41
|
+
path: string;
|
|
42
|
+
reason: "depth" | "project_limit";
|
|
43
|
+
}[] | undefined;
|
|
44
|
+
walk_truncated?: boolean | undefined;
|
|
45
|
+
unresolved_manifests?: {
|
|
46
|
+
manifest: string;
|
|
47
|
+
declared_ranges: number;
|
|
48
|
+
fix: string;
|
|
49
|
+
}[] | undefined;
|
|
50
|
+
} | null;
|
|
14
51
|
/**
|
|
15
52
|
* Collapse advisories that are the same finding under different identifiers.
|
|
16
53
|
*
|
|
@@ -36,6 +73,21 @@ export declare function relativeDir(dir: string, root: string): string;
|
|
|
36
73
|
export declare function formatScanResult(result: VulnerabilityScanResult, severityFilter: string | undefined, ctx: ScanContext): {
|
|
37
74
|
scan_time: string;
|
|
38
75
|
project: string;
|
|
76
|
+
coverage?: {
|
|
77
|
+
complete: boolean;
|
|
78
|
+
note: string;
|
|
79
|
+
lockfiles_not_scanned: number;
|
|
80
|
+
skipped_lockfiles?: {
|
|
81
|
+
path: string;
|
|
82
|
+
reason: "depth" | "project_limit";
|
|
83
|
+
}[] | undefined;
|
|
84
|
+
walk_truncated?: boolean | undefined;
|
|
85
|
+
unresolved_manifests?: {
|
|
86
|
+
manifest: string;
|
|
87
|
+
declared_ranges: number;
|
|
88
|
+
fix: string;
|
|
89
|
+
}[] | undefined;
|
|
90
|
+
} | undefined;
|
|
39
91
|
project_root: string;
|
|
40
92
|
workspaces_with_findings: string[];
|
|
41
93
|
host_platform: string;
|
|
@@ -113,6 +165,11 @@ export declare function formatScanResult(result: VulnerabilityScanResult, severi
|
|
|
113
165
|
}[];
|
|
114
166
|
install_drift: Record<string, unknown>[];
|
|
115
167
|
recommendations: string[];
|
|
168
|
+
fix_targets: Record<string, {
|
|
169
|
+
target: string | null;
|
|
170
|
+
checked: "all_advisories" | "installed_advisories_only";
|
|
171
|
+
note?: string;
|
|
172
|
+
}>;
|
|
116
173
|
clean_dependencies: number;
|
|
117
174
|
scan_duration_ms: number;
|
|
118
175
|
_meta: {
|
|
@@ -127,7 +184,7 @@ export declare function formatScanResult(result: VulnerabilityScanResult, severi
|
|
|
127
184
|
resolved_at: string | null;
|
|
128
185
|
lockfiles: {
|
|
129
186
|
path: string;
|
|
130
|
-
kind: "lockfile" | "manifest";
|
|
187
|
+
kind: "declared_ranges" | "lockfile" | "manifest";
|
|
131
188
|
mtime: string | null;
|
|
132
189
|
}[];
|
|
133
190
|
re_resolved_this_call: boolean;
|
|
@@ -9,6 +9,55 @@ import { 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
11
|
import { buildDriftView, groupDriftRows, lockfileAlsoAffected } from "./install-drift-notes.js";
|
|
12
|
+
import { discoveryNote } from "../project-tree.js";
|
|
13
|
+
import { targetNote } from "../live/fix-paths.js";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
/** The key a verified target is stored under: one installed copy of a package. */
|
|
16
|
+
export function fixTargetKey(ecosystem, name, version) {
|
|
17
|
+
return `${ecosystem}\0${name}\0${version}`;
|
|
18
|
+
}
|
|
19
|
+
/** The command that writes the missing lockfile for a manifest. */
|
|
20
|
+
function lockfileCommand(manifest) {
|
|
21
|
+
const base = path.basename(manifest);
|
|
22
|
+
return base === "Cargo.toml" ? "cargo generate-lockfile" : base === "package.json" ? "npm install --package-lock-only" : "your package manager's lock command";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* What the scan did NOT cover, so a clean answer is never mistaken for a
|
|
26
|
+
* complete one: lockfiles past the project-tree bounds, and manifests with no
|
|
27
|
+
* lockfile (declared ranges are not installs, so they are not sent to OSV).
|
|
28
|
+
*/
|
|
29
|
+
export function scanCoverage(ctx, relative, detailed) {
|
|
30
|
+
const discovery = ctx.discovery ?? null;
|
|
31
|
+
const unresolved = ctx.unresolved ?? [];
|
|
32
|
+
const notes = [];
|
|
33
|
+
const walk = discoveryNote(discovery);
|
|
34
|
+
if (walk)
|
|
35
|
+
notes.push(walk);
|
|
36
|
+
const ranged = unresolved.reduce((n, u) => n + u.ranges, 0);
|
|
37
|
+
if (ranged > 0) {
|
|
38
|
+
notes.push(`${ranged} dependenc${ranged === 1 ? "y" : "ies"} in ${unresolved.length} manifest${unresolved.length === 1 ? "" : "s"} with no lockfile (${unresolved.slice(0, 3).map((u) => relative(u.manifest)).join(", ")}${unresolved.length > 3 ? ", ..." : ""}) ${ranged === 1 ? "is a declared range" : "are declared ranges"}, not installed versions, and were not checked: run ${[...new Set(unresolved.map((u) => lockfileCommand(u.manifest)))].join(" / ")} there to scan them.`);
|
|
39
|
+
}
|
|
40
|
+
if (notes.length === 0)
|
|
41
|
+
return null;
|
|
42
|
+
return {
|
|
43
|
+
complete: false,
|
|
44
|
+
note: notes.join(" "),
|
|
45
|
+
lockfiles_not_scanned: discovery?.skippedCount ?? 0,
|
|
46
|
+
...(detailed && discovery && discovery.skipped.length > 0
|
|
47
|
+
? { skipped_lockfiles: discovery.skipped.map((s) => ({ path: s.path, reason: s.reason })) }
|
|
48
|
+
: {}),
|
|
49
|
+
...(discovery?.walkTruncated ? { walk_truncated: true } : {}),
|
|
50
|
+
...(unresolved.length > 0
|
|
51
|
+
? {
|
|
52
|
+
unresolved_manifests: unresolved.map((u) => ({
|
|
53
|
+
manifest: relative(u.manifest),
|
|
54
|
+
declared_ranges: u.ranges,
|
|
55
|
+
fix: `${lockfileCommand(u.manifest)} in ${relative(u.dir)}`,
|
|
56
|
+
})),
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
12
61
|
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.";
|
|
13
62
|
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.";
|
|
14
63
|
/**
|
|
@@ -112,9 +161,12 @@ export function formatScanResult(result, severityFilter, ctx) {
|
|
|
112
161
|
// "project" label hides the fact that a repo root can span several
|
|
113
162
|
// independently-versioned workspaces.
|
|
114
163
|
const scannedProjects = [...new Set(vulns.flatMap((v) => v.sourceDirs ?? []).map(relative))].sort();
|
|
164
|
+
const coverage = scanCoverage(ctx, relative, true);
|
|
115
165
|
return {
|
|
116
166
|
scan_time: result.scannedAt,
|
|
117
167
|
project: result.projectPath.split(/[/\\]/).pop() || result.projectPath,
|
|
168
|
+
// Present only when the scan is partial; read it before trusting a clean result.
|
|
169
|
+
...(coverage ? { coverage } : {}),
|
|
118
170
|
project_root: result.projectPath,
|
|
119
171
|
// Workspaces under `project_root` that contributed a finding. Advisories
|
|
120
172
|
// below are attributed per entry via `source_dirs`.
|
|
@@ -142,7 +194,12 @@ export function formatScanResult(result, severityFilter, ctx) {
|
|
|
142
194
|
// npm direct dependencies whose node_modules copy is not what the lockfile
|
|
143
195
|
// pins. The lockfile is what every other tool reads; this is what runs.
|
|
144
196
|
install_drift: drift.rows,
|
|
145
|
-
recommendations: buildRecommendations(active, result.vulnerabilities, relative),
|
|
197
|
+
recommendations: buildRecommendations(active, result.vulnerabilities, relative, ctx.verifiedTargets),
|
|
198
|
+
// Per vulnerable package version: the upgrade target checked against every
|
|
199
|
+
// advisory OSV has for the package. A row's own `fixed_version` fixes
|
|
200
|
+
// that advisory only, and the highest of them can be affected by another
|
|
201
|
+
// (openssl 0.10.79, GHSA-phqj-4mhp-q6mq).
|
|
202
|
+
fix_targets: fixTargets(active, ctx.verifiedTargets),
|
|
146
203
|
clean_dependencies: result.cleanCount,
|
|
147
204
|
scan_duration_ms: result.scanDurationMs,
|
|
148
205
|
_meta: {
|
|
@@ -171,7 +228,31 @@ export function formatScanResult(result, severityFilter, ctx) {
|
|
|
171
228
|
},
|
|
172
229
|
};
|
|
173
230
|
}
|
|
174
|
-
|
|
231
|
+
/** `{ "openssl@0.10.38": { target, checked } }` for every vulnerable package version. */
|
|
232
|
+
function fixTargets(active, verified) {
|
|
233
|
+
const out = {};
|
|
234
|
+
const byCopy = new Map();
|
|
235
|
+
for (const v of active) {
|
|
236
|
+
if (v.installDriftOf)
|
|
237
|
+
continue;
|
|
238
|
+
const key = fixTargetKey(v.ecosystem, v.package, v.currentVersion);
|
|
239
|
+
byCopy.set(key, [...(byCopy.get(key) ?? []), v]);
|
|
240
|
+
}
|
|
241
|
+
for (const [key, vulns] of byCopy) {
|
|
242
|
+
const label = `${vulns[0].package}@${vulns[0].currentVersion}`;
|
|
243
|
+
const clean = verified?.get(key);
|
|
244
|
+
if (clean) {
|
|
245
|
+
const note = targetNote(clean);
|
|
246
|
+
out[label] = { target: clean.target, checked: "all_advisories", ...(note ? { note } : {}) };
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
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" };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
function buildRecommendations(active, all, relative, verified) {
|
|
175
256
|
const lines = [];
|
|
176
257
|
const rankOf = (vulns) => Math.max(...vulns.map((v) => SEVERITY_RANK[presentedSeverity(v)]));
|
|
177
258
|
// Name the manifest directories that actually pin the vulnerable version.
|
|
@@ -182,32 +263,47 @@ function buildRecommendations(active, all, relative) {
|
|
|
182
263
|
return dirs.length > 0 ? ` in ${dirs.join(", ")}` : "";
|
|
183
264
|
};
|
|
184
265
|
// Recommendations are derived from platform-active, exploitable vulns only.
|
|
266
|
+
// One per installed VERSION, each advisory counted once: rsa 0.9.10 (in
|
|
267
|
+
// relay and src-tauri) and rsa 0.10.0-rc.18 shared RUSTSEC-2023-0071 and
|
|
268
|
+
// were merged into "rsa 0.9.10 in relay, src-tauri — 2 known
|
|
269
|
+
// vulnerabilities" (2026-10-07 eval): one advisory, two versions.
|
|
185
270
|
const byPackage = new Map();
|
|
186
271
|
for (const v of active) {
|
|
187
272
|
if (v.installDriftOf)
|
|
188
273
|
continue; // reinstall advice below, not an upgrade
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
274
|
+
const key = `${v.ecosystem}\0${v.package}\0${v.currentVersion}`;
|
|
275
|
+
const group = byPackage.get(key) ?? { unique: [], copies: [] };
|
|
276
|
+
// Every copy names a place; the same advisory on another copy is one finding.
|
|
277
|
+
group.copies.push(v);
|
|
278
|
+
if (!group.unique.some((seen) => seen.vulnId === v.vulnId))
|
|
279
|
+
group.unique.push(v);
|
|
280
|
+
byPackage.set(key, group);
|
|
194
281
|
}
|
|
195
|
-
for (const
|
|
282
|
+
for (const { unique: pkgVulns, copies } of byPackage.values()) {
|
|
283
|
+
const pkg = pkgVulns[0].package;
|
|
196
284
|
const highest = pkgVulns[0];
|
|
197
285
|
const breakdown = severityBreakdown(pkgVulns);
|
|
198
286
|
// Target the HIGHEST published fix so the upgrade resolves every fixable CVE,
|
|
199
287
|
// not just the first one. (The old code picked the first fix version, which
|
|
200
288
|
// could leave higher-version-gated CVEs unfixed while claiming to fix them.)
|
|
201
|
-
const
|
|
289
|
+
const highestFix = maxSemver(pkgVulns.map((v) => v.fixedVersion).filter((v) => Boolean(v)));
|
|
290
|
+
// Checked against every advisory of the package when that could run: the
|
|
291
|
+
// highest per-advisory fix can itself be affected by another advisory.
|
|
292
|
+
const clean = verified?.get(fixTargetKey(highest.ecosystem, pkg, highest.currentVersion));
|
|
293
|
+
const target = clean ? clean.target : highestFix;
|
|
202
294
|
const total = pkgVulns.length;
|
|
203
295
|
const fixable = pkgVulns.filter((v) => v.fixedVersion).length;
|
|
204
|
-
const where = whereOf(
|
|
296
|
+
const where = whereOf(copies);
|
|
205
297
|
let text;
|
|
206
298
|
if (target) {
|
|
207
299
|
const scope = fixable === total
|
|
208
300
|
? `${total} CVE${total > 1 ? "s" : ""}`
|
|
209
301
|
: `${fixable} of ${total} CVEs (${total - fixable} with no published fix)`;
|
|
210
|
-
|
|
302
|
+
const note = clean ? targetNote(clean) : null;
|
|
303
|
+
text = `Upgrade ${pkg} ${highest.currentVersion} → ${target}${where} — resolves ${scope} [${breakdown}]${note ? ` (${note})` : ""}`;
|
|
304
|
+
}
|
|
305
|
+
else if (clean && highestFix) {
|
|
306
|
+
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(", ")})`;
|
|
211
307
|
}
|
|
212
308
|
else {
|
|
213
309
|
text = `Review ${pkg} ${highest.currentVersion}${where} — ${total} known vulnerabilit${total > 1 ? "ies" : "y"} [${breakdown}], no fix version published`;
|