import type { Finding, ChainHop, ChainResolution, PackageRef } from "../types.js"; import type { SuggestedFixPackageManager } from "./fix-commands.js"; import { buildPackageInstallCommand } from "./fix-commands.js"; import { fetchPackument } from "./npm-registry.js"; import { compareVersions, looksLikeVersion, isPreReleaseVersion, satisfiesRange } from "../utils/version.js"; import type { LockParentGraph } from "./npm-transitive-resolution.js"; import { highestVersionSatisfyingRange } from "./npm-transitive-resolution.js"; export async function resolveChainFix( finding: Finding, packages: PackageRef[], packageManager: SuggestedFixPackageManager, options: { offline: boolean; workspaceMemberNames?: ReadonlySet; parentGraph?: LockParentGraph | null }, ): Promise { if (options.offline) return null; if (finding.relationship !== "transitive") return null; const fixedVersion = finding.validatedFirstFixedVersion ?? finding.firstFixedVersion; if (!fixedVersion) return null; for (const affectedPath of finding.dependencyPaths) { const directIndices = options.workspaceMemberNames?.has(affectedPath[1]) ? [1, 2] : [1]; for (const directIndex of directIndices) { if (affectedPath.length <= directIndex + 1) continue; const directDepName = affectedPath[directIndex]; const prefix = affectedPath.slice(0, directIndex + 1); let candidates = packages.filter(pkg => pkg.name === directDepName && pkg.paths?.some(path => path.length === prefix.length && path.every((part, i) => part === prefix[i]))); // Follow real lockfile edges backwards from the vulnerable version. Display // paths alone can be identical for several installed parent versions. if (options.parentGraph) { const graph = options.parentGraph; let nodes = [...graph.nodeIdsFor(finding.pkg.name, finding.pkg.version)]; for (const name of affectedPath.slice(directIndex, -1).reverse()) { nodes = [...new Set(nodes.flatMap(id => [...graph.parentsFor(id)]))] .filter(id => graph.getNode(id)?.name === name); } const versions = new Set(nodes.map(id => graph.getNode(id)?.version)); candidates = candidates.filter(pkg => versions.has(pkg.version)); } const versions = [...new Set(candidates.map(pkg => pkg.version))]; // No name-only fallback: missing or ambiguous path identity cannot safely // select a version floor or a declaring workspace. if (versions.length !== 1) continue; const directDepCurrentVersion = versions[0]; const packument = await fetchPackument(directDepName); if (!packument?.versions) continue; const candidateVersions = Object.keys(packument.versions) .filter(looksLikeVersion) .filter(v => !isPreReleaseVersion(v)) .filter(v => compareVersions(v, directDepCurrentVersion) > 0) .sort(compareVersions); for (const candidateVersion of candidateVersions) { const candidateDeps = packument.versions[candidateVersion]?.dependencies ?? {}; // hops = everything after the direct dep and before (and including) the vulnerable package // For path ["root", "express", "send", "mime-types"]: // directDep = path[1] = "express" // hops = path.slice(2) = ["send", "mime-types"] const hops = affectedPath.slice(directIndex + 1); const traced = await traceChain( candidateDeps, hops, finding.pkg.version, fixedVersion, ); if (!traced) continue; return { directDep: directDepName, directDepCurrentVersion, targetVersion: candidateVersion, chain: traced.chain, safeVersion: traced.safeVersion, command: buildPackageInstallCommand(packageManager, `${directDepName}@${candidateVersion}`), coveredPaths: 1, totalPaths: finding.dependencyPaths.length, }; } } } return null; } async function traceChain( deps: Record, remainingPath: string[], vulnerableInstalledVersion: string, fixedVersion: string, ): Promise<{ chain: ChainHop[]; safeVersion: string } | null> { if (remainingPath.length === 0) return null; const nextName = remainingPath[0]; const range = deps[nextName]; if (!range) return null; if (remainingPath.length === 1) { // Bottom of chain: nextName IS the vulnerable package. // The range from the parent must exclude the vulnerable installed version. if (satisfiesRange(vulnerableInstalledVersion, range)) { // Range still allows the vulnerable version - this candidate does not fix it. return null; } // Range excludes the vulnerable version - find the highest safe published version. const packument = await fetchPackument(nextName); if (!packument?.versions) return null; const candidates = Object.keys(packument.versions) .filter(looksLikeVersion) .filter(v => !isPreReleaseVersion(v)) .filter(v => compareVersions(v, fixedVersion) >= 0) .sort(compareVersions); const safeVersion = highestVersionSatisfyingRange(candidates, range); if (!safeVersion) return null; return { chain: [], safeVersion }; } // Intermediate hop: resolve the highest version of nextName within range, then recurse. const packument = await fetchPackument(nextName); if (!packument?.versions) return null; const candidates = Object.keys(packument.versions) .filter(looksLikeVersion) .filter(v => !isPreReleaseVersion(v)) .sort(compareVersions); const resolvedVersion = highestVersionSatisfyingRange(candidates, range); if (!resolvedVersion) return null; const nextDeps = packument.versions[resolvedVersion]?.dependencies ?? {}; const result = await traceChain(nextDeps, remainingPath.slice(1), vulnerableInstalledVersion, fixedVersion); if (!result) return null; return { chain: [{ name: nextName, version: resolvedVersion }, ...result.chain], safeVersion: result.safeVersion, }; }