diff --git a/src/remediation/fix-commands.ts b/src/remediation/fix-commands.ts index cca4ce31..3c3d50c3 100644 --- a/src/remediation/fix-commands.ts +++ b/src/remediation/fix-commands.ts @@ -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, @@ -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; @@ -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([ + 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, diff --git a/tests/fix-commands-crosswire.test.ts b/tests/fix-commands-crosswire.test.ts new file mode 100644 index 00000000..8cd9e4d9 --- /dev/null +++ b/tests/fix-commands-crosswire.test.ts @@ -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), + } 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); + } + }); +});