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
43 changes: 30 additions & 13 deletions src/remediation/fix-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function buildSuggestedFixCommandPlan(
return a.pkg.name.localeCompare(b.pkg.name);
});

const targetsByPackage = new Map<string, SuggestedFixTarget>();
const targetsByKey = new Map<string, SuggestedFixTarget>();
const skippedByKey = new Map<string, SuggestedFixSkip>();

const orderedFindings = [
Expand Down Expand Up @@ -176,7 +176,7 @@ export function buildSuggestedFixCommandPlan(
validatedFieldOk
) {
const pkgWorkspaces = workspaceMap.get(finding.pkg.name)?.filter(w => w !== ".") ?? [];
upsertTarget(targetsByPackage, {
upsertTarget(targetsByKey, finding, {
package: finding.pkg.name,
currentVersion: finding.pkg.version,
targetVersion: directTarget,
Expand Down Expand Up @@ -244,7 +244,7 @@ export function buildSuggestedFixCommandPlan(
finding.relationship === "transitive" &&
finding.recommendedNpmTransitiveRemediation?.kind === "update-parent-within-range"
) {
upsertTarget(targetsByPackage, {
upsertTarget(targetsByKey, finding, {
package: finding.recommendedNpmTransitiveRemediation.package,
currentVersion: finding.recommendedNpmTransitiveRemediation.currentVersion,
targetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion,
Expand Down Expand Up @@ -281,7 +281,7 @@ export function buildSuggestedFixCommandPlan(
? finding.chainResolution
: null;
const npmParentUpgradeWorkspaces = workspaceMap.get(finding.recommendedNpmTransitiveRemediation.package)?.filter(w => w !== ".") ?? [];
upsertTarget(targetsByPackage, {
upsertTarget(targetsByKey, finding, {
package: finding.recommendedNpmTransitiveRemediation.package,
currentVersion: finding.recommendedNpmTransitiveRemediation.currentVersion,
targetVersion: finding.recommendedNpmTransitiveRemediation.targetVersion,
Expand Down Expand Up @@ -322,7 +322,7 @@ export function buildSuggestedFixCommandPlan(
? finding.chainResolution
: null;
const parentUpgradeWorkspaces = workspaceMap.get(finding.recommendedParentUpgrade.package)?.filter(w => w !== ".") ?? [];
upsertTarget(targetsByPackage, {
upsertTarget(targetsByKey, finding, {
package: finding.recommendedParentUpgrade.package,
currentVersion: finding.recommendedParentUpgrade.currentVersion,
targetVersion: finding.recommendedParentUpgrade.targetVersion,
Expand Down Expand Up @@ -369,7 +369,7 @@ export function buildSuggestedFixCommandPlan(
: packageManager === "yarn"
? `yarn upgrade ${finding.pkg.name}`
: `bun update ${finding.pkg.name}`;
upsertTarget(targetsByPackage, {
upsertTarget(targetsByKey, finding, {
package: finding.pkg.name,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on targetKey says targetVersion is the child's version rather than <package>'s, but that is not true on this branch. Here package is finding.pkg.name, so the target package IS the child and the old merge was sound.

The key degenerates to parent-update:form-data>form-data and splits it anyway, which changes the emitted command on pnpm, Yarn and Bun:

before after
pnpm pnpm add form-data@4.0.6 pnpm update --recursive --no-save form-data && pnpm add form-data@4.0.6
yarn yarn add form-data@4.0.6 yarn upgrade form-data && yarn add form-data@4.0.6
bun bun add form-data@4.0.6 bun update form-data && bun add form-data@4.0.6

Might well be an improvement, but it is undisclosed and untested on three of the four package managers we support. Worth a test and a corrected comment.

currentVersion: finding.pkg.version,
targetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion,
Expand All @@ -393,7 +393,7 @@ export function buildSuggestedFixCommandPlan(
const isDev = devLookup.get(`${finding.chainResolution.directDep}@${finding.chainResolution.directDepCurrentVersion}`) ?? false;
const coverage = calculatePathCoverage(finding.dependencyPaths, [finding.chainResolution.directDep]);
const chainWorkspaces = workspaceMap.get(finding.chainResolution.directDep)?.filter(w => w !== ".") ?? [];
upsertTarget(targetsByPackage, {
upsertTarget(targetsByKey, finding, {
package: finding.chainResolution.directDep,
currentVersion: finding.chainResolution.directDepCurrentVersion,
targetVersion: finding.chainResolution.targetVersion,
Expand Down Expand Up @@ -436,7 +436,7 @@ export function buildSuggestedFixCommandPlan(
});
}

const targets = [...targetsByPackage.values()].sort((a, b) => {
const targets = [...targetsByKey.values()].sort((a, b) => {
const sevDelta = severityOrder[b.severity] - severityOrder[a.severity];
if (sevDelta !== 0) return sevDelta;

Expand Down Expand Up @@ -634,13 +634,30 @@ function packageManagerSourceLabel(scanInput: ScanInput): string {
return scanInput.source;
}

// Install targets (direct / parent-upgrade) all mean "install <package>@<targetVersion>",
// so findings that land on the same package merge into one target and the higher
// version wins. A parent-update target means something else: "refresh <vulnerable
// child> within <package>'s declared range", and its targetVersion is the CHILD's
// version, not <package>'s. Merging it with an install target for the same package
// compares versions of two different packages, and the child's version can end up
// pinned onto the parent (`npm install axios@4.0.6`, where 4.0.6 is form-data's
// version - #1007). Keep parent-update targets in their own keyspace, per child, so
// they only ever merge with a refresh of the same child through the same package.
function targetKey(finding: Finding, target: SuggestedFixTarget): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keyspace split is the right call and I agree with the diagnosis.

What it does not fix is that targetVersion on a parent-update target is still the CHILD's version while package is the parent, and every renderer treats those two fields as belonging together.

So on the reporter's own repro this branch still produces { "package": "axios", "targetVersion": "4.0.6" } in the JSON, an axios | 1.16.1 | 4.0.6 row under a column headed "Recommended target", and a breaking badge in the HTML report because isBreakingUpgrade compares axios 1.16.1 against form-data's 4.0.6 and sees a major jump. That is the second half of what @alamb-hex reported.

Smallest fix I can see is to carry the child separately, something like refreshedChild: { name, targetVersion }, and stop putting a foreign package's version in targetVersion. If that is too invasive, suppressing the version column and the breaking badge when kind === "parent-update" would at least stop the output being wrong.

Second thing on the same change: keying per child means N findings behind one parent now produce N targets that all carry package: "axios" and the same command. On a scan touching one package I get "2 command groups ready across 3 packages", two axios rows in the table where the first reads as a downgrade, npm update axios spawned twice by --fix, and two cve.fix.applied audit records carrying other packages' versions. Worth deduping on the emitted command before render and apply.

return target.kind === "parent-update"
? `parent-update:${target.package}>${finding.pkg.name}`
: `install:${target.package}`;
}

function upsertTarget(
targetsByPackage: Map<string, SuggestedFixTarget>,
targetsByKey: Map<string, SuggestedFixTarget>,
finding: Finding,
next: SuggestedFixTarget,
): void {
const existing = targetsByPackage.get(next.package);
const key = targetKey(finding, next);
const existing = targetsByKey.get(key);
if (!existing) {
targetsByPackage.set(next.package, next);
targetsByKey.set(key, next);
return;
}

Expand Down Expand Up @@ -687,7 +704,7 @@ function upsertTarget(
merged.knownVulnerableVersions = next.knownVulnerableVersions ?? merged.knownVulnerableVersions ?? null;
merged.fixVersionPublishedAt = next.fixVersionPublishedAt ?? merged.fixVersionPublishedAt ?? null;
}
targetsByPackage.set(next.package, merged);
targetsByKey.set(key, merged);
return;
}

Expand All @@ -701,7 +718,7 @@ function upsertTarget(
merged.fixVersionPublishedAt = next.fixVersionPublishedAt ?? merged.fixVersionPublishedAt ?? null;
}

targetsByPackage.set(next.package, merged);
targetsByKey.set(key, merged);
}

function buildParentUpgradeReason(
Expand Down
147 changes: 147 additions & 0 deletions tests/remediation/fix-commands-parent-update-merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
buildSuggestedFixCommandPlan,
findFixTargetForFinding,
findSuggestedCommandForFinding,
} from "../../src/remediation/fix-commands.js";
import type { Finding, ScanInput } from "../../src/types.js";

// Regression coverage for #1007: a transitive finding whose remediation is a
// within-range refresh (parent-update) carries the CHILD's target version under
// the PARENT's package name. Keyed by package name alone, that target merged
// with the direct fix for the parent and the child's version won the "higher
// version" comparison, producing `npm install axios@4.0.6` - a version of axios
// that does not exist (4.0.6 is form-data's).

function scanInput(): ScanInput {
return {
mode: "resolved-lockfile",
source: "package-lock",
filePath: "/tmp/package-lock.json",
packages: [
{ name: "axios", version: "1.16.1", ecosystem: "npm" },
{ name: "js-yaml", version: "4.1.1", ecosystem: "npm" },
{ name: "form-data", version: "4.0.5", ecosystem: "npm" },
{ name: "follow-redirects", version: "1.15.0", ecosystem: "npm" },
],
notes: [],
warnings: [],
skippedDependencies: [],
};
}

function directAxios(): Finding {
return {
pkg: { name: "axios", version: "1.16.1", ecosystem: "npm" },
vulnerabilities: [{ id: "GHSA-axios-0001" }],
severity: "high",
cveAliases: [],
dependencyPaths: [["project", "axios"]],
relationship: "direct",
firstFixedVersion: "1.19.0",
validatedFirstFixedVersion: "1.19.0",
};
}

function directJsYaml(): Finding {
return {
pkg: { name: "js-yaml", version: "4.1.1", ecosystem: "npm" },
vulnerabilities: [{ id: "GHSA-js-yaml-0001" }],
severity: "high",
cveAliases: [],
dependencyPaths: [["project", "js-yaml"]],
relationship: "direct",
firstFixedVersion: "4.3.1",
validatedFirstFixedVersion: "4.3.1",
};
}

function withinRangeChildOfAxios(name: string, installed: string, target: string): Finding {
return {
pkg: { name, version: installed, ecosystem: "npm" },
vulnerabilities: [{ id: `GHSA-${name}-0001` }],
severity: "high",
cveAliases: [],
dependencyPaths: [["project", "axios", name]],
relationship: "transitive",
firstFixedVersion: target,
validatedFirstFixedVersion: target,
recommendedNpmTransitiveRemediation: {
kind: "update-parent-within-range",
package: "axios",
currentVersion: "1.16.1",
targetChildVersion: target,
viaPath: ["project", "axios", name],
reason: `axios@1.16.1 already allows ${name}@${target} within the current dependency range`,
},
};
}

describe("buildSuggestedFixCommandPlan - parent-update targets do not merge with install targets (#1007)", () => {
it("keeps the direct fix version for the parent when a child is refreshed within the parent's range", () => {
const axios = directAxios();
const jsYaml = directJsYaml();
const formData = withinRangeChildOfAxios("form-data", "4.0.5", "4.0.6");

const plan = buildSuggestedFixCommandPlan([axios, jsYaml, formData], scanInput());
expect(plan).not.toBeNull();

const axiosInstall = plan!.targets.find(t => t.package === "axios" && t.kind === "direct");
expect(axiosInstall).toMatchObject({ currentVersion: "1.16.1", targetVersion: "1.19.0" });

// The refresh is still there, as its own target, still describing form-data's version.
const formDataRefresh = plan!.targets.find(t => t.kind === "parent-update");
expect(formDataRefresh).toMatchObject({
package: "axios",
targetVersion: "4.0.6",
command: "npm update axios",
});

// The emitted command never pins axios to form-data's version.
expect(plan!.command).toContain("npm install axios@1.19.0 js-yaml@4.3.1");
expect(plan!.command).not.toContain("axios@4.0.6");
for (const section of plan!.sections) {
expect(section.command).not.toContain("axios@4.0.6");
}

// Every finding still resolves to its own target and its own command.
expect(findFixTargetForFinding(plan!, axios)?.targetVersion).toBe("1.19.0");
expect(findFixTargetForFinding(plan!, jsYaml)?.targetVersion).toBe("4.3.1");
expect(findFixTargetForFinding(plan!, formData)?.targetVersion).toBe("4.0.6");
expect(findSuggestedCommandForFinding(plan!, axios)).toBe("npm install axios@1.19.0");
expect(findSuggestedCommandForFinding(plan!, formData)).toBe("npm update axios");
expect(plan!.coveredFindingCount).toBe(3);
});

it("does not merge two within-range refreshes of different children through the same parent", () => {
const formData = withinRangeChildOfAxios("form-data", "4.0.5", "4.0.6");
const followRedirects = withinRangeChildOfAxios("follow-redirects", "1.15.0", "1.15.6");

const plan = buildSuggestedFixCommandPlan([formData, followRedirects], scanInput());
expect(plan).not.toBeNull();

// Two distinct child versions cannot be compared against each other, so
// each refresh keeps its own target rather than one child's version
// overwriting the other's.
const refreshes = plan!.targets.filter(t => t.kind === "parent-update");
expect(refreshes.map(t => t.targetVersion).sort()).toEqual(["1.15.6", "4.0.6"]);
expect(refreshes.every(t => t.package === "axios")).toBe(true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two lines together assert that targets labelled axios carry form-data's and follow-redirects' versions, which is the exact pairing #1007 was filed about.

As written this locks the bug in, so it should change alongside the fix rather than encoding the current behaviour.


// The same refresh command is only emitted once.
expect(plan!.command).toBe("npm update axios");

expect(findFixTargetForFinding(plan!, formData)?.targetVersion).toBe("4.0.6");
expect(findFixTargetForFinding(plan!, followRedirects)?.targetVersion).toBe("1.15.6");
expect(plan!.coveredFindingCount).toBe(2);
});

it("still merges repeated install targets for the same package to the highest version (no regression)", () => {
const lower: Finding = { ...directAxios(), firstFixedVersion: "1.17.0", validatedFirstFixedVersion: "1.17.0", vulnerabilities: [{ id: "GHSA-axios-0002" }] };
const higher = directAxios();

const plan = buildSuggestedFixCommandPlan([lower, higher], scanInput());
expect(plan).not.toBeNull();
expect(plan!.targets).toHaveLength(1);
expect(plan!.targets[0]).toMatchObject({ package: "axios", kind: "direct", targetVersion: "1.19.0" });
expect(plan!.command).toBe("npm install axios@1.19.0");
});
});