Skip to content
Closed
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
72 changes: 72 additions & 0 deletions src/remediation/fix-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ export function buildSuggestedFixCommandPlan(
isUpgradeTarget(finding.pkg.version, directTarget) &&
validatedFieldOk
) {
if (isSuspectCrossWiredTarget(finding, directTarget, findings)) {
skippedByKey.set(`direct:${finding.pkg.name}@${finding.pkg.version}`, {
package: finding.pkg.name,
version: finding.pkg.version,
relationship: finding.relationship,
reason: `Withheld suspect fix target ${finding.pkg.name}@${directTarget}: it matches another package's version in this scan and jumps ${majorOf(finding.pkg.version) ?? "?"}→${majorOf(directTarget) ?? "?"} major versions. This looks cross-wired (see #1007) — inspect the advisory fixed-version hint before upgrading manually.`,
});
continue;
}
const pkgWorkspaces = workspaceMap.get(finding.pkg.name)?.filter(w => w !== ".") ?? [];
upsertTarget(targetsByPackage, {
package: finding.pkg.name,
Expand Down Expand Up @@ -675,6 +684,31 @@ function upsertTarget(
};

if (looksLikeVersion(existing.targetVersion) && looksLikeVersion(next.targetVersion)) {
const existingMajor = majorOf(existing.targetVersion);
const nextMajor = majorOf(next.targetVersion);
// Same-package findings should agree on the target major. A divergent major
// (e.g. nine findings say 1.19.0, one cross-wired finding says 4.0.6) must
// not hijack the merged target into a non-existent, breaking upgrade. Keep
// the conservative (lower-major) target so the emitted install command
// stays valid; the divergent finding is still visible via its own skipped
// entry when caught by isSuspectCrossWiredTarget.
if (existingMajor !== null && nextMajor !== null && existingMajor !== nextMajor) {
const winnerIsNext = nextMajor < existingMajor;
const winner = winnerIsNext ? next : existing;
const loser = winnerIsNext ? existing : next;
merged.targetVersion = winner.targetVersion;
merged.currentVersion = winner.currentVersion ?? merged.currentVersion;
if (severityOrder[loser.severity] > severityOrder[winner.severity]) {
merged.reason = loser.reason;
} else {
merged.reason = winner.reason;
}
merged.scannedVersions = winner.scannedVersions ?? merged.scannedVersions ?? null;
merged.knownVulnerableVersions = winner.knownVulnerableVersions ?? merged.knownVulnerableVersions ?? null;
merged.fixVersionPublishedAt = winner.fixVersionPublishedAt ?? merged.fixVersionPublishedAt ?? null;
targetsByPackage.set(next.package, merged);
return;
}
if (compareVersions(next.targetVersion, existing.targetVersion) > 0) {
merged.targetVersion = next.targetVersion;
merged.currentVersion = next.currentVersion ?? merged.currentVersion;
Expand Down Expand Up @@ -752,6 +786,44 @@ function isUpgradeTarget(currentVersion: string, targetVersion: string): boolean
return true;
}

export function majorOf(version: string): number | null {
const match = version.trim().match(/^v?(\d+)(?:\..*)?$/);
if (!match) return null;
const major = Number(match[1]);
return Number.isFinite(major) ? major : null;
}

/**
* Detect a fix target that looks cross-wired from another finding (#1007).
*
* Signal: the direct target exactly matches some *other* package's installed
* or hinted version in the same scan AND jumps 2+ major versions from the
* installed version (axios 1.16.1 → 4.0.6, where 4.0.6 belongs to form-data).
* Legitimate multi-major upgrades exist, but a legitimate upgrade never
* coincides exactly with an unrelated package's version in the same scan —
* that coincidence is the cross-wire fingerprint.
*/
export function isSuspectCrossWiredTarget(
finding: Finding,
directTarget: string,
allFindings: Finding[],
): boolean {
const installedMajor = majorOf(finding.pkg.version);
const targetMajor = majorOf(directTarget);
if (installedMajor === null || targetMajor === null) return false;
if (targetMajor - installedMajor < 2) return false;
for (const other of allFindings) {
if (other.pkg.name === finding.pkg.name) continue;
const foreignVersions = new Set<string>([
other.pkg.version,
...(other.firstFixedVersion ? [other.firstFixedVersion] : []),
...(other.validatedFirstFixedVersion ? [other.validatedFirstFixedVersion] : []),
]);
if (foreignVersions.has(directTarget)) return true;
}
return false;
}

function buildSections(
targets: SuggestedFixTarget[],
packageManager: SuggestedFixPackageManager,
Expand Down
141 changes: 141 additions & 0 deletions tests/fix-commands-crosswire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
buildSuggestedFixCommandPlan,
isSuspectCrossWiredTarget,
majorOf,
} from "../src/remediation/fix-commands.js";
import type { Finding, PackageRef } from "../src/types.js";

// Regression tests for #1007: suggestedFixCommands emitted axios@4.0.6 —
// a version that never existed for axios. 4.0.6 belongs to form-data, a
// different package in the same scan. The plan must withhold the suspect
// target instead of emitting an install command that fails outright.

function makeDirectFinding(
name: string,
version: string,
validatedFirstFixedVersion: string | null,
firstFixedVersion?: string,
): Finding {
const pkg: PackageRef = { name, version, ecosystem: "npm", paths: [["project", name]] };
return {
pkg,
vulnerabilities: [{ id: `OSV-${name}-001` }],
severity: "high",
cveAliases: [],
dependencyPaths: pkg.paths ?? [],
relationship: "direct",
firstFixedVersion: firstFixedVersion ?? validatedFirstFixedVersion ?? undefined,
validatedFirstFixedVersion,
...({} as Partial<Finding>),
} as Finding;
}

function npmScanInput(packages: PackageRef[]) {
return {
mode: "resolved-lockfile" as const,
source: "package-lock" as const,
filePath: null,
packages,
notes: [],
warnings: [],
skippedDependencies: [],
};
}

describe("majorOf", () => {
it("parses major versions including v-prefix", () => {
expect(majorOf("1.16.1")).toBe(1);
expect(majorOf("v4.0.6")).toBe(4);
expect(majorOf("4")).toBe(4);
expect(majorOf("not-a-version")).toBeNull();
});
});

describe("isSuspectCrossWiredTarget", () => {
it("flags axios@4.0.6 when form-data@4.0.6 is in the same scan", () => {
const axios = makeDirectFinding("axios", "1.16.1", "4.0.6", "1.19.0");
const formData: Finding = {
...makeDirectFinding("form-data", "4.0.5", "4.0.6"),
relationship: "transitive",
};
expect(isSuspectCrossWiredTarget(axios, "4.0.6", [axios, formData])).toBe(true);
});

it("does not flag a legitimate in-major upgrade", () => {
const axios = makeDirectFinding("axios", "1.16.1", "1.19.0");
const formData: Finding = {
...makeDirectFinding("form-data", "4.0.5", "4.0.6"),
relationship: "transitive",
};
expect(isSuspectCrossWiredTarget(axios, "1.19.0", [axios, formData])).toBe(false);
});

it("does not flag a multi-major upgrade with no foreign version match", () => {
const solo = makeDirectFinding("left-pad", "1.0.0", "4.0.0");
expect(isSuspectCrossWiredTarget(solo, "4.0.0", [solo])).toBe(false);
});
});

describe("buildSuggestedFixCommandPlan — #1007 cross-wire guard", () => {
it("withholds the cross-wired axios@4.0.6 target and explains why", () => {
const axiosPkg: PackageRef = { name: "axios", version: "1.16.1", ecosystem: "npm", paths: [["project", "axios"]] };
const jsYamlPkg: PackageRef = { name: "js-yaml", version: "4.1.1", ecosystem: "npm", paths: [["project", "js-yaml"]] };
const formDataPkg: PackageRef = {
name: "form-data",
version: "4.0.5",
ecosystem: "npm",
paths: [["project", "axios", "form-data"]],
};
const axios = makeDirectFinding("axios", "1.16.1", "4.0.6", "1.19.0");
const jsYaml = makeDirectFinding("js-yaml", "4.1.1", "4.3.1");
const formData: Finding = {
...makeDirectFinding("form-data", "4.0.5", "4.0.6"),
relationship: "transitive",
};

const plan = buildSuggestedFixCommandPlan(
[axios, jsYaml, formData],
npmScanInput([axiosPkg, jsYamlPkg, formDataPkg]),
{ offline: true },
);

expect(plan).not.toBeNull();
// The invalid axios@4.0.6 command must never be emitted.
expect(plan!.command).not.toContain("axios@4.0.6");
expect(plan!.targets.find((t) => t.package === "axios")?.targetVersion).not.toBe("4.0.6");
// The valid js-yaml fix survives.
expect(plan!.targets.find((t) => t.package === "js-yaml")?.targetVersion).toBe("4.3.1");
// The withheld target is visible in skipped with an actionable reason.
const skippedAxios = plan!.skipped.find((s) => s.package === "axios");
expect(skippedAxios?.reason).toMatch(/cross-wired/);
});

it("does not let one divergent-major finding hijack the merged same-package target", () => {
const axiosPkg: PackageRef = { name: "axios", version: "1.16.1", ecosystem: "npm", paths: [["project", "axios"]] };
const good = makeDirectFinding("axios", "1.16.1", "1.19.0");
const bad = makeDirectFinding("axios", "1.16.1", "4.0.6", "1.19.0");
const formData: Finding = {
...makeDirectFinding("form-data", "4.0.5", "4.0.6"),
relationship: "transitive",
};
const formDataPkg: PackageRef = {
name: "form-data",
version: "4.0.5",
ecosystem: "npm",
paths: [["project", "axios", "form-data"]],
};

const plan = buildSuggestedFixCommandPlan(
[good, bad, formData],
npmScanInput([axiosPkg, formDataPkg]),
{ offline: true },
);

// The bad finding is withheld; the merged axios target stays on the 1.x line.
expect(plan!.command).not.toContain("axios@4.0.6");
const axiosTargets = plan!.targets.filter((t) => t.package === "axios");
for (const target of axiosTargets) {
expect(majorOf(target.targetVersion)).toBe(1);
}
});
});