Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/remediation/npm-transitive-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ export async function resolveTransitiveRemediationViaRegistry(args: {
const fixHint = args.finding.validatedFirstFixedVersion ?? args.finding.firstFixedVersion;

if (directParentName !== immediateParentName) {
const packagesByName = new Map(args.packages.map(p => [p.name, p]));
return resolveWithinRangeForDeepChainViaRegistry({
immediateParentName,
vulnerableName,
installedVersion: args.finding.pkg.version,
fixHint,
viaPath,
packages: args.packages,
packagesByName,
workspaceMap: args.workspaceMap,
});
}
Expand Down Expand Up @@ -126,6 +128,7 @@ export async function resolveNpmTransitiveRemediation(args: {
// immediate parent's declared range for the vulnerable package already covers a safe version.
// If so, a lockfile refresh of the vulnerable package itself is sufficient.
if (directParentName !== immediateParentName) {
const packagesByName = new Map(args.packages.map(p => [p.name, p]));
return resolveWithinRangeForDeepChain({
graph: args.graph,
immediateParentName,
Expand All @@ -134,6 +137,7 @@ export async function resolveNpmTransitiveRemediation(args: {
fixHint,
viaPath,
packages: args.packages,
packagesByName,
offline: args.offline,
});
}
Expand Down Expand Up @@ -411,9 +415,10 @@ async function resolveWithinRangeForDeepChainViaRegistry(args: {
fixHint: string | null;
viaPath: string[];
packages: PackageRef[];
packagesByName: Map<string, PackageRef>;
workspaceMap?: Map<string, string[]> | null;
}): Promise<NpmTransitiveRemediation | null> {
const immediateParent = args.packages.find(pkg => pkg.name === args.immediateParentName);
const immediateParent = args.packagesByName.get(args.immediateParentName);
if (!immediateParent || !looksLikeVersion(immediateParent.version)) return null;

const safeCandidates = await findSafeChildCandidates(
Expand Down Expand Up @@ -462,9 +467,10 @@ async function resolveWithinRangeForDeepChain(args: {
fixHint: string | null;
viaPath: string[];
packages: PackageRef[];
packagesByName: Map<string, PackageRef>;
offline?: boolean;
}): Promise<NpmTransitiveRemediation | null> {
const immediateParent = args.packages.find(p => p.name === args.immediateParentName);
const immediateParent = args.packagesByName.get(args.immediateParentName);
if (!immediateParent || !looksLikeVersion(immediateParent.version)) return null;

const nodeIds = args.graph.nodeIdsFor(args.immediateParentName, immediateParent.version);
Expand Down
21 changes: 15 additions & 6 deletions src/remediation/parent-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ export async function resolveRecommendedParentUpgrade(
const viaPath = getBestPath(finding);
if (!viaPath || viaPath.length < 3) return null;

const packagesByName = new Map(packages.map(p => [p.name, p]));

const directParentContext = resolveDirectParentContext(
viaPath,
packages,
directDependencyNames,
packagesByName,
);
if (!directParentContext) return null;

Expand Down Expand Up @@ -68,13 +71,14 @@ function getBestPath(finding: Finding): string[] | null {
function findDirectDependency(
packages: PackageRef[],
name: string,
directDependencyNames?: ReadonlySet<string> | null,
directDependencyNames: ReadonlySet<string> | null | undefined,
packagesByName: Map<string, PackageRef>,
): PackageRef | null {
if (directDependencyNames?.has(name)) {
return packagesByName.get(name) ?? null;
}
for (const pkg of packages) {
if (pkg.name !== name) continue;
if (directDependencyNames?.has(name)) {
return pkg;
}
const paths = pkg.paths ?? [];
if (paths.some(path => path.at(-1) === name && path.length >= 2)) {
return pkg;
Expand All @@ -86,7 +90,8 @@ function findDirectDependency(
function resolveDirectParentContext(
viaPath: string[],
packages: PackageRef[],
directDependencyNames?: ReadonlySet<string> | null,
directDependencyNames: ReadonlySet<string> | null | undefined,
packagesByName: Map<string, PackageRef>,
): { directParentName: string; immediateParentName: string; directParent: PackageRef } | null {
const immediateParentName = viaPath[viaPath.length - 2];
const candidateSegments = viaPath.slice(1, -1);
Expand All @@ -98,7 +103,7 @@ function resolveDirectParentContext(
const directParentName = directParentCandidates[0];
if (!directParentName) return null;

const directParent = findDirectDependency(packages, directParentName, directDependencyNames);
const directParent = findDirectDependency(packages, directParentName, directDependencyNames, packagesByName);
if (!directParent) return null;

return { directParentName, immediateParentName, directParent };
Expand All @@ -109,6 +114,10 @@ function findPackageVersion(
name: string,
pathPrefix: string[],
): string | null {
// Cannot use a name-keyed Map here: when the same package is installed at
// multiple versions (e.g. lodash@3 and lodash@4 on different paths), a Map
// would only hold one entry. We need path-aware iteration across all installed
// versions to find the right one for this specific dependency chain.
for (const pkg of packages) {
if (pkg.name !== name) continue;
const paths = pkg.paths ?? [];
Expand Down
76 changes: 76 additions & 0 deletions tests/parent-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,4 +883,80 @@ describe("resolveRecommendedParentUpgrade", () => {
).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});

it("selects the correct version of a package installed at multiple versions based on dependency path", async () => {
// Regression test: when the same package (e.g. "mid") is installed at two
// different versions on different dependency paths, findPackageVersion must
// use path-aware iteration rather than a name-keyed Map. A Map only holds
// one entry (last write wins), so the version on the other path would be
// returned incorrectly or null.
//
// Graph: project -> app -> mid@2.0.0 -> lodash (vulnerable path)
// project -> app -> other -> mid@3.0.0 (different path, different version)
const resolveRecommendedParentUpgrade = await loadResolver();

mockPackumentsByPackage({
app: {
versions: {
"1.0.0": { dependencies: { mid: "^2.0.0" } },
"2.0.0": { dependencies: { mid: "^3.0.0" } },
},
},
});

// Two versions of "mid" installed at different paths.
const packages: PackageRef[] = [
{
name: "app",
version: "1.0.0",
ecosystem: "npm",
paths: [["project", "app"]],
},
{
name: "mid",
version: "2.0.0", // old version — the one on the vulnerable path
ecosystem: "npm",
paths: [["project", "app", "mid"]],
},
{
name: "mid",
version: "3.0.0", // new version — different path, must not shadow the above
ecosystem: "npm",
paths: [["project", "app", "other", "mid"]],
},
{
name: "lodash",
version: "4.17.20",
ecosystem: "npm",
paths: [["project", "app", "mid", "lodash"]],
},
];

const finding = createFinding({
dependencyPaths: [["project", "app", "mid", "lodash"]],
pkg: {
name: "lodash",
version: "4.17.20",
ecosystem: "npm",
paths: [["project", "app", "mid", "lodash"]],
},
});

// The resolver should use mid@2.0.0 (the version on the vulnerable path)
// when calling findUpgradeForImmediateIntermediate. If it mistakenly used
// mid@3.0.0 (from the Map's last-write-wins), the app@2.0.0 entry requires
// mid ^3.0.0 which satisfies 3.0.0, so it would not be selected. With the
// correct version (2.0.0), app@2.0.0 requires mid ^3.0.0 which no longer
// allows mid@2.0.0, triggering the best-effort upgrade recommendation.
const result = await resolveRecommendedParentUpgrade(finding, packages, new Set(["app"]));

expect(result).toMatchObject({
package: "app",
currentVersion: "1.0.0",
targetVersion: "2.0.0",
vulnerablePackage: "lodash",
confidence: "best-effort",
});
expect(result?.reason).toContain("no longer allows mid@2.0.0");
});
});