Skip to content
Merged
7 changes: 5 additions & 2 deletions src/output/html-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { renderMaintenanceFindingsHtml } from "./maintenance-findings-html.js";
import type { Finding } from "../types.js";
import type { OverrideFinding } from "../overrides/types.js";
import type { MaintenanceFinding } from "../maintenance/types.js";
import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js";
import { UNVERIFIED_PARENT_UPGRADE_NOTE, type SuggestedFixCommandPlan } from "../remediation/fix-commands.js";

export type SerializedFinding = ReturnType<typeof serializeHtmlFinding>;

Expand Down Expand Up @@ -698,6 +698,9 @@ export function renderFixPlan(plan: SuggestedFixCommandPlan | null): string {
const cooldownHtml = t.cooldownWarning
? ` <span class="fix-target-note">${escapeHtml(formatCooldownWarning(t.cooldownWarning))}</span>`
: "";
const unverifiedHtml = t.confidence === "unverified"
? ` <span class="fix-target-note">${escapeHtml(UNVERIFIED_PARENT_UPGRADE_NOTE)}</span>`
: "";
const coverageHtml = t.coverage === "partial"
? ` <span class="fix-target-coverage">Path-specific remediation. Run this command, then rescan; ${t.remainingPaths?.length ?? 0} other known ${pluralize(t.remainingPaths?.length ?? 0, "path")} may still need separate parent upgrades.</span>`
: "";
Expand All @@ -711,7 +714,7 @@ export function renderFixPlan(plan: SuggestedFixCommandPlan | null): string {
return ` <span class="chain-proof">${proofText}</span>`;
})()
: "";
return `<div class="fix-target-row">${versionHtml}${breakBadge}${statsHtml}${noteHtml}${cooldownHtml}${coverageHtml}${chainProofHtml}</div>`;
return `<div class="fix-target-row">${versionHtml}${breakBadge}${statsHtml}${noteHtml}${cooldownHtml}${unverifiedHtml}${coverageHtml}${chainProofHtml}</div>`;
}).join("\n");

const targetsHtml = section.targets.length > 0
Expand Down
13 changes: 11 additions & 2 deletions src/output/printers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Finding, ScanInput, SeverityLabel } from "../types.js";
import { chalk, stripAnsi } from "../utils/chalk.js";
import { buildSuggestedFixCommandPlan, type SuggestedFixTarget } from "../remediation/fix-commands.js";
import { buildSuggestedFixCommandPlan, UNVERIFIED_PARENT_UPGRADE_NOTE, type SuggestedFixTarget } from "../remediation/fix-commands.js";
import { isMajorVersionBump } from "../utils/version.js";
import { getPrimaryParent } from "../utils/finding.js";
import {
Expand Down Expand Up @@ -84,6 +84,12 @@ function printCooldownWarning(target: SuggestedFixTarget): void {
}
}

function printUnverifiedParentUpgradeWarning(target: SuggestedFixTarget): void {
if (target.confidence === "unverified") {
console.log(chalk.yellow(` ${UNVERIFIED_PARENT_UPGRADE_NOTE}`));
}
}

function printChainProofLines(targets: SuggestedFixTarget[]): void {
for (const target of targets) {
if (!target.chainProof || !target.chainSafeVersion) continue;
Expand Down Expand Up @@ -144,7 +150,10 @@ export function printSuggestedFixCommands(
printParentUpgradeTargetsTable(section.targets, sharedParentUpgradeTableWidths);
}

for (const target of section.targets) printCooldownWarning(target);
for (const target of section.targets) {
printCooldownWarning(target);
printUnverifiedParentUpgradeWarning(target);
}
console.log(renderCommandCallout(section.command, section.targets));
printChainProofLines(section.targets);
}
Expand Down
31 changes: 30 additions & 1 deletion src/remediation/fix-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import { buildPnpmWorkspaceMap } from "../parsers/pnpm-lock.js";
import { buildYarnWorkspaceMap } from "../parsers/yarn-lock.js";
import { buildBunWorkspaceMap } from "../parsers/bun-lock.js";
import { readConfiguredCooldown, cooldownWarningFor, type CooldownWarning } from "./cooldown.js";
import { isVerifiedUpgrade } from "./parent-upgrade.js";

// Rendered next to a parent-upgrade fix target whose recommendedParentUpgrade
// could not be verified end-to-end (parent-upgrade.ts confidence: "unverified").
// Shared across terminal and HTML output so the wording stays in sync.
export const UNVERIFIED_PARENT_UPGRADE_NOTE =
"⚠ unverified - rescan after upgrading to confirm this resolves the finding";

export type SuggestedFixPackageManager = "npm" | "pnpm" | "yarn" | "bun";

Expand All @@ -25,6 +32,10 @@ export type SuggestedFixTarget = {
severity: SeverityLabel;
adjusted: boolean;
adjustmentNote?: string | null;
// Set from RecommendedParentUpgrade.confidence when this target was built from
// finding.recommendedParentUpgrade. Undefined for direct fixes, within-range
// parent updates, and validated chain upgrades, which don't carry this distinction.
confidence?: "verified" | "unverified";
reason: string;
command?: string;
workspaces?: string[];
Expand Down Expand Up @@ -330,6 +341,7 @@ export function buildSuggestedFixCommandPlan(
chainProof: chainForParentUpgrade?.chain,
chainSafeVersion: chainForParentUpgrade?.safeVersion,
chainVulnerablePackage: chainForParentUpgrade ? finding.pkg.name : undefined,
confidence: finding.recommendedParentUpgrade.confidence,
});
continue;
}
Expand Down Expand Up @@ -460,7 +472,19 @@ export function buildSuggestedFixCommandPlan(
totalFindingCount: findings.length,
};

plan.coveredFindingCount = findings.filter(f => findSuggestedCommandForFinding(plan, f) !== null).length;
// A finding only counts as covered when it has a matching fix target AND that
// target isn't an unverified parent upgrade - an unverified recommendation may
// not actually resolve the finding, so it must not inflate the "fix all" claim.
plan.coveredFindingCount = findings.filter(f => {
const target = findFixTargetForFinding(plan, f);
if (!target) return false;
// Coverage is a per-finding property, not a per-target one: two unrelated
// findings can merge into the same target (same package upgrade), so a
// finding's own recommendedParentUpgrade confidence - not the blended
// target confidence - decides whether it is genuinely covered.
if (f.recommendedParentUpgrade) return isVerifiedUpgrade(f.recommendedParentUpgrade.confidence);
return true;
}).length;

if (options?.subfolder) {
const prefix = `cd ${options.subfolder} && `;
Expand Down Expand Up @@ -613,6 +637,11 @@ function upsertTarget(
chainProof: existing.chainProof ?? next.chainProof,
chainSafeVersion: existing.chainSafeVersion ?? next.chainSafeVersion,
chainVulnerablePackage: existing.chainVulnerablePackage ?? next.chainVulnerablePackage,
// Conservative: if either contributing finding's recommendation is unverified,
// keep the merged target unverified rather than overstate coverage.
confidence: existing.confidence === "unverified" || next.confidence === "unverified"
? "unverified"
: existing.confidence ?? next.confidence,
};

if (looksLikeVersion(existing.targetVersion) && looksLikeVersion(next.targetVersion)) {
Expand Down
164 changes: 140 additions & 24 deletions src/remediation/parent-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { Finding, PackageRef, RecommendedParentUpgrade } from "../types.js"
import { compareVersions, isPreReleaseVersion, looksLikeVersion } from "../utils/version.js";
import { fetchPackument } from "./npm-registry.js";

export function isVerifiedUpgrade(confidence: RecommendedParentUpgrade["confidence"]): boolean {
return confidence === "verified";
}

export async function resolveRecommendedParentUpgrade(
finding: Finding,
packages: PackageRef[],
Expand Down Expand Up @@ -42,9 +46,9 @@ export async function resolveRecommendedParentUpgrade(
});
}

// Best-effort fallback for deeper chains.
// Deeper chains: verify along the path before recommending.
// Use the immediate child of the direct parent (not the immediate parent of the vulnerable
// package) that's what the direct parent actually lists in its own dependencies.
// package) - that's what the direct parent actually lists in its own dependencies.
const directParentIdx = viaPath.indexOf(directParentName);
if (directParentIdx < 0) return null;
const immediateChildName = viaPath[directParentIdx + 1] ?? immediateParentName;
Expand All @@ -58,6 +62,8 @@ export async function resolveRecommendedParentUpgrade(
viaPath.slice(0, directParentIdx + 2),
) ?? "",
vulnerableName,
vulnerableFixedVersion: finding.validatedFirstFixedVersion ?? finding.firstFixedVersion,
pathAfterParent: viaPath.slice(directParentIdx + 1),
viaPath,
});
}
Expand Down Expand Up @@ -136,6 +142,65 @@ function startsWithPath(path: string[], prefix: string[]): boolean {
return true;
}

type DepManifest = {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};

/**
* Highest published, non-prerelease version of `name` that satisfies `range`,
* or null if none do (or the packument cannot be fetched).
*/
export async function resolveHighestSatisfying(name: string, range: string): Promise<string | null> {
const packument = await fetchPackument(name);
const versions = Object.keys(packument?.versions ?? {})
.filter(looksLikeVersion)
.filter(v => !isPreReleaseVersion(v))
.filter(version => versionSatisfiesRange(version, range))
.sort(compareVersions);

return versions.at(-1) ?? null;
}

/**
* Walk `pathNames` (in order, ending with `vulnerableName`) starting from
* `startManifest`, resolving each declared range to its highest published
* version to find what the vulnerable package would actually resolve to.
*
* - A dep dropped mid-path means the vulnerable package is no longer pulled
* via this path: `{ terminalVersion: null, resolvable: true }`.
* - A range that cannot be resolved to any published version means the walk
* cannot be verified: `{ terminalVersion: null, resolvable: false }`.
*/
export async function resolvePathTerminalVersion(
startManifest: DepManifest,
pathNames: string[],
vulnerableName: string,
): Promise<{ terminalVersion: string | null; resolvable: boolean }> {
let currentManifest: DepManifest = startManifest;

for (const nextName of pathNames) {
const range = currentManifest.dependencies?.[nextName] ?? currentManifest.optionalDependencies?.[nextName];
if (range === undefined) {
return { terminalVersion: null, resolvable: true };
}

const resolved = await resolveHighestSatisfying(nextName, range);
if (resolved === null) {
return { terminalVersion: null, resolvable: false };
}

if (nextName === vulnerableName) {
return { terminalVersion: resolved, resolvable: true };
}

const nextPackument = await fetchPackument(nextName);
currentManifest = nextPackument?.versions?.[resolved] ?? {};
}

return { terminalVersion: null, resolvable: true };
}

type ExactDirectChildArgs = {
directParentName: string;
directParentVersion: string;
Expand Down Expand Up @@ -178,7 +243,7 @@ async function findUpgradeForExactDirectChild(
targetVersion: version,
viaPath: args.viaPath,
vulnerablePackage: args.vulnerableName,
confidence: "exact-direct-child",
confidence: "verified",
reason: `${args.directParentName}@${version} no longer allows ${args.vulnerableName}@${args.vulnerableInstalledVersion}${args.vulnerableFixedVersion ? ` and allows ${args.vulnerableFixedVersion}+` : ""}`,
};
}
Expand All @@ -192,9 +257,16 @@ type ImmediateIntermediateArgs = {
immediateChildName: string;
immediateChildInstalledVersion: string;
vulnerableName: string;
vulnerableFixedVersion: string | null;
pathAfterParent: string[];
viaPath: string[];
};

// Bound how many candidate parent versions we resolve the full path for, to keep
// network cost reasonable. Packuments are process-cached, so shared path prefixes
// across candidates are cheap, but the terminal walk still fetches per link.
const MAX_CANDIDATE_PARENT_VERSIONS = 25;

async function findUpgradeForImmediateIntermediate(
args: ImmediateIntermediateArgs,
): Promise<RecommendedParentUpgrade | null> {
Expand All @@ -207,34 +279,78 @@ async function findUpgradeForImmediateIntermediate(
.filter(looksLikeVersion)
.filter(v => !isPreReleaseVersion(v))
.filter(version => compareVersions(version, args.directParentVersion) > 0)
.sort(compareVersions);
.sort(compareVersions)
.slice(0, MAX_CANDIDATE_PARENT_VERSIONS);

// Three outcomes:
// 1. A candidate whose subtree resolves the vulnerable package to a fixed
// version (or drops it entirely) -> recommend it, verified.
// 2. Every candidate is resolvable but none reaches a fixed version -> null
// (honest: no parent upgrade currently resolves this).
// 3. Some candidate could not be verified (a range/packument we could not
// resolve) -> fall back to the old "no longer allows the installed child"
// heuristic, but label it unverified.
let sawUnverifiable = false;
let heuristicFallback: RecommendedParentUpgrade | null = null;

for (const version of versions) {
const manifest = packument?.versions?.[version];
const depRange =
manifest?.dependencies?.[args.immediateChildName] ??
manifest?.optionalDependencies?.[args.immediateChildName];

if (!depRange) continue;

const stillAllowsInstalledChild = versionSatisfiesRange(
args.immediateChildInstalledVersion,
depRange,
);
if (!manifest) continue;

const walk = await resolvePathTerminalVersion(manifest, args.pathAfterParent, args.vulnerableName);

if (walk.resolvable) {
const isFixed =
walk.terminalVersion === null ||
(args.vulnerableFixedVersion != null &&
compareVersions(walk.terminalVersion, args.vulnerableFixedVersion) >= 0);
if (isFixed) {
// "verified" means verified against declared dependency ranges along this
// path (each link resolved to its highest published satisfying version),
// not an install-level guarantee - npm dedup/hoisting can still land a
// lower version. This is the honest upper bound we can compute without
// reimplementing npm's resolver.
const reason = walk.terminalVersion === null
? `${args.directParentName}@${version} no longer pulls ${args.vulnerableName} on this path`
: `${args.directParentName}@${version} resolves ${args.vulnerableName} to ${walk.terminalVersion}${args.vulnerableFixedVersion ? `, at or above the fix ${args.vulnerableFixedVersion}` : ""}`;
return {
package: args.directParentName,
currentVersion: args.directParentVersion,
targetVersion: version,
viaPath: args.viaPath,
vulnerablePackage: args.vulnerableName,
confidence: "verified",
reason,
};
}
continue;
}

if (!stillAllowsInstalledChild) {
return {
package: args.directParentName,
currentVersion: args.directParentVersion,
targetVersion: version,
viaPath: args.viaPath,
vulnerablePackage: args.vulnerableName,
confidence: "best-effort",
reason: `${args.directParentName}@${version} no longer allows ${args.immediateChildName}@${args.immediateChildInstalledVersion} in the current path`,
};
// Could not verify this candidate's subtree. Remember the first candidate that
// also satisfies the old heuristic (parent no longer allows the installed
// immediate child) as an explicitly-unverified fallback.
sawUnverifiable = true;
if (!heuristicFallback) {
const depRange =
manifest.dependencies?.[args.immediateChildName] ??
manifest.optionalDependencies?.[args.immediateChildName];
if (depRange && !versionSatisfiesRange(args.immediateChildInstalledVersion, depRange)) {
heuristicFallback = {
package: args.directParentName,
currentVersion: args.directParentVersion,
targetVersion: version,
viaPath: args.viaPath,
vulnerablePackage: args.vulnerableName,
confidence: "unverified",
reason: `${args.directParentName}@${version} may resolve ${args.vulnerableName}, but this could not be verified; rescan after upgrading to confirm`,
};
}
}
}

if (sawUnverifiable && heuristicFallback) {
return heuristicFallback;
}
return null;
}

Expand Down
2 changes: 1 addition & 1 deletion src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ export async function scanPackages(
targetVersion: finding.recommendedNpmTransitiveRemediation.targetVersion ?? "",
viaPath: finding.recommendedNpmTransitiveRemediation.viaPath,
vulnerablePackage: finding.pkg.name,
confidence: "exact-direct-child",
confidence: "verified",
reason: finding.recommendedNpmTransitiveRemediation.reason,
};
continue;
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export type RecommendedParentUpgrade = {
targetVersion: string;
viaPath: string[];
vulnerablePackage: string;
confidence: "exact-direct-child" | "best-effort";
confidence: "verified" | "unverified";
reason: string;
};

Expand Down
4 changes: 2 additions & 2 deletions tests/fixture-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ describe("fixture remediation scans", () => {
currentVersion: "4.22.1",
targetVersion: "4.22.2",
vulnerablePackage: "qs",
confidence: "best-effort",
confidence: "unverified",
});

finding.recommendedParentUpgrade = parentUpgrade ?? undefined;
Expand Down Expand Up @@ -256,7 +256,7 @@ describe("fixture remediation scans", () => {
targetVersion: "15.2.1",
viaPath: ["project", "lint-staged", "string-width"],
vulnerablePackage: "string-width",
confidence: "exact-direct-child",
confidence: "verified",
reason: "Upgrade lint-staged to pick up a safe string-width release.",
},
});
Expand Down
Loading