diff --git a/src/parsers/yarn-lock.ts b/src/parsers/yarn-lock.ts index a458f88d2..cfcc8d853 100644 --- a/src/parsers/yarn-lock.ts +++ b/src/parsers/yarn-lock.ts @@ -1,9 +1,11 @@ import fs from "node:fs"; import path from "node:path"; +import { parse as parseYaml } from "yaml"; import { parse as parseYarnLock } from "yarn-lockfile"; import type { PackageRef } from "../types.js"; import { looksLikeVersion, normalizeRawVersion } from "../utils/version.js"; import { upsertPackage, MAX_PATHS_PER_PACKAGE } from "./utils.js"; +import { resolveWorkspacePackageJsonPaths } from "../utils/package-json.js"; const MAX_PATH_DEPTH = 10; @@ -89,7 +91,10 @@ function readRootPackageJson(filePath: string): any | null { export function buildYarnWorkspaceMap(filePath: string): Map { const map = new Map(); - for (const { name: wsName, pkg: wsPkg } of readWorkspacePackages(filePath)) { + const members = readWorkspacePackages(filePath); + const root = readRootPackageJson(filePath); + const manifests = root && members.length > 0 ? [{ name: ".", pkg: root }, ...members] : members; + for (const { name: wsName, pkg: wsPkg } of manifests) { for (const depSection of ["dependencies", "optionalDependencies", "devDependencies"]) { const deps = wsPkg[depSection]; if (!deps || typeof deps !== "object") continue; @@ -119,35 +124,15 @@ function readWorkspacePackages(filePath: string, rootPkg?: any): { name: string; if (!patterns.length) return []; const result: { name: string; pkg: any }[] = []; - for (const pattern of patterns) { - const parts = pattern.split("/"); - const lastPart = parts[parts.length - 1]; - const baseParts = parts.slice(0, -1); - - let wsDirs: string[]; - if (lastPart === "*") { - const baseDir = path.join(dir, ...baseParts); - try { - wsDirs = fs.readdirSync(baseDir, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => [...baseParts, d.name].join("/")); - } catch { - continue; - } - } else { - wsDirs = [pattern]; - } - - for (const wsDir of wsDirs) { - let wsPkg: any; - try { - wsPkg = JSON.parse(fs.readFileSync(path.join(dir, wsDir, "package.json"), "utf8")); - } catch { - continue; - } - if (!wsPkg.name) continue; - result.push({ name: wsPkg.name, pkg: wsPkg }); + for (const packageJsonPath of resolveWorkspacePackageJsonPaths(dir, patterns)) { + let wsPkg: any; + try { + wsPkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + } catch { + continue; } + if (!wsPkg?.name) continue; + result.push({ name: wsPkg.name, pkg: wsPkg }); } return result; @@ -157,6 +142,96 @@ export function isYarnBerry(content: string): boolean { return content.startsWith('__metadata:') || content.includes('\n__metadata:'); } +export type YarnBerryInfo = { + isBerry: boolean; + supportsRecursiveUp: boolean | "ambiguous"; + ambiguityReason?: "yarnPath" | "metadata-v4" | "missing-metadata"; +}; + +function readPackageManagerYarnMajor(filePath: string): number | null { + const root = readRootPackageJson(filePath); + if (typeof root?.packageManager === "string") { + const match = root.packageManager.match(/^yarn@(\d+)\.(\d+)(?:\.(\d+))?/); + if (match) return parseInt(match[1], 10); + } + return null; +} + +function readActiveYarnPath(projectDir: string): string | null { + try { + const rcPath = path.join(projectDir, ".yarnrc.yml"); + if (!fs.existsSync(rcPath)) return null; + const content = fs.readFileSync(rcPath, "utf8"); + const parsed = parseYaml(content); + if (!parsed || typeof parsed !== "object") return null; + if (parsed.ignorePath === true || parsed.ignorePath === "true") return null; + if (typeof parsed.yarnPath === "string" && parsed.yarnPath.trim()) { + return parsed.yarnPath.trim(); + } + } catch { + // best-effort + } + return null; +} + +function berryInfoFromMajor(major: number): YarnBerryInfo | null { + if (major >= 3) return { isBerry: true, supportsRecursiveUp: true }; + if (major === 2) return { isBerry: true, supportsRecursiveUp: false }; + return null; +} + +export function detectYarnBerryInfo( + filePath: string | null | undefined, + lockContent: string, +): YarnBerryInfo { + if (!isYarnBerry(lockContent)) { + return { isBerry: false, supportsRecursiveUp: false }; + } + + if (filePath) { + const projectDir = path.dirname(filePath); + + // 1. Check for active yarnPath in .yarnrc.yml + const activeYarnPath = readActiveYarnPath(projectDir); + if (activeYarnPath) { + const match = path.basename(activeYarnPath).match(/yarn-(\d+)\.(\d+)(?:\.(\d+))?/); + if (match) { + const info = berryInfoFromMajor(parseInt(match[1], 10)); + if (info) return info; + } + return { isBerry: true, supportsRecursiveUp: "ambiguous", ambiguityReason: "yarnPath" }; + } + + // 2. When no yarnPath is configured (or ignorePath: true), check packageManager + const pmMajor = readPackageManagerYarnMajor(filePath); + if (pmMajor !== null) { + const info = berryInfoFromMajor(pmMajor); + if (info) return info; + } + } + + // 3. Fallback to lockfile metadata version: + // __metadata: + // version: 8 + const metaMatch = lockContent.match(/__metadata:[\s\S]*?version:\s*["']?(\d+)["']?/); + if (metaMatch) { + const lockVersion = parseInt(metaMatch[1], 10); + // Version 5 and higher support recursive upgrades (yarn up -R) + if (lockVersion >= 5) { + return { isBerry: true, supportsRecursiveUp: true }; + } + // Version 4 is ambiguous: shared by Yarn 2.4.3 and Yarn 3.0.0 + if (lockVersion === 4) { + return { isBerry: true, supportsRecursiveUp: "ambiguous", ambiguityReason: "metadata-v4" }; + } + // Versions < 4 are early Berry v2 + return { isBerry: true, supportsRecursiveUp: false }; + } + + // Without metadata version or exact version configuration, capability is ambiguous + return { isBerry: true, supportsRecursiveUp: "ambiguous", ambiguityReason: "missing-metadata" }; +} + function pathsEqual(left: string[], right: string[]): boolean { if (left.length !== right.length) return false; return left.every((value, index) => value === right[index]); @@ -177,7 +252,7 @@ function collectYarnPaths( map: Map, taggedPathsByKey: Map, ): void { - const queue = seeds.map(seed => ({ key: seed.key, path: seed.path })); + const queue = seeds.map(seed => ({ key: seed.key, path: seed.path, depth: 0 })); const visitedStates = new Set(); let head = 0; @@ -187,11 +262,18 @@ function collectYarnPaths( if (!ref) continue; const nextPath = [...current.path, ref.name]; + // Preserve reachability before display-path de-duplication: registry and + // workspace parents can produce exactly the same printable path. + const pkg = map.get(ref.key); + if (pkg && current.depth > 0) { + const paths = pkg.yarnTransitivePaths ??= []; + if (paths.length < MAX_PATHS_PER_PACKAGE && !paths.some(p => pathsEqual(p, nextPath))) paths.push(nextPath); + } const stateKey = `${ref.key}>${nextPath.join(">")}`; if (visitedStates.has(stateKey)) continue; visitedStates.add(stateKey); - // Only record paths for packages that exist in the lockfile map, matching prior behavior. + // Only record paths for packages that exist in the lockfile map. if (map.has(ref.key)) addTaggedPath(taggedPathsByKey, ref.key, nextPath); if (nextPath.length >= MAX_PATH_DEPTH) continue; @@ -201,7 +283,7 @@ function collectYarnPaths( } for (const child of graph.get(ref.key) ?? []) { - queue.push({ key: child, path: nextPath }); + queue.push({ key: child, path: nextPath, depth: current.depth + 1 }); } } } @@ -228,6 +310,16 @@ function resolveDepSeeds( selectorToKey.get(`${depName}@${depRange}`) ?? selectorToKey.get(`${depName}@npm:${depRange}`); if (resolved) { + if (pathPrefix.length === 2) { + const direct = map.get(resolved); + if (direct) direct.workspaceDirect = true; + } + const direct = map.get(resolved); + if (direct) { + const workspaces = direct.yarnDirectWorkspaces ??= []; + const workspace = pathPrefix[1] ?? "."; + if (!workspaces.includes(workspace)) workspaces.push(workspace); + } seeds.push({ key: resolved, path: pathPrefix, dev }); continue; } @@ -237,6 +329,11 @@ function resolveDepSeeds( const name = String(depName); const key = `${name}@${fallbackVersion}`; upsertPackage(map, { name, version: fallbackVersion, ecosystem: "npm", paths: [] }); + const direct = map.get(key)!; + if (pathPrefix.length === 2) direct.workspaceDirect = true; + const workspaces = direct.yarnDirectWorkspaces ??= []; + const workspace = pathPrefix[1] ?? "."; + if (!workspaces.includes(workspace)) workspaces.push(workspace); addTaggedPath(taggedPathsByKey, key, [...pathPrefix, name]); // Unresolved fallback prod deps are not graph nodes, so record their key as // production-reachable directly (mirrors the resolved production roots). diff --git a/src/remediation/fix-commands.ts b/src/remediation/fix-commands.ts index ff22fc6c7..1142269b8 100644 --- a/src/remediation/fix-commands.ts +++ b/src/remediation/fix-commands.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { safeReadText } from "../utils/file.js"; import type { Finding, ScanInput, SeverityLabel, ChainHop } from "../types.js"; import { severityOrder } from "../constants.js"; import { compareVersions, looksLikeVersion } from "../utils/version.js"; @@ -8,7 +9,7 @@ import { calculatePathCoverage, formatDependencyPath } from "../utils/path-cover import { pluralize } from "../utils/string.js"; import { buildNpmWorkspaceMap } from "../parsers/package-lock.js"; import { buildPnpmWorkspaceMap } from "../parsers/pnpm-lock.js"; -import { buildYarnWorkspaceMap } from "../parsers/yarn-lock.js"; +import { buildYarnWorkspaceMap, detectYarnBerryInfo, type YarnBerryInfo } from "../parsers/yarn-lock.js"; import { buildBunWorkspaceMap } from "../parsers/bun-lock.js"; import { readConfiguredCooldown, cooldownWarningFor, type CooldownWarning } from "./cooldown.js"; import { isVerifiedUpgrade } from "./parent-upgrade.js"; @@ -53,6 +54,7 @@ export type SuggestedFixTarget = { reason: string; command?: string; workspaces?: string[]; + yarnBerry?: boolean; fixVersionPublishedAt?: string | null; cooldownWarning?: CooldownWarning | null; coverage?: "complete" | "partial"; @@ -106,6 +108,9 @@ export function buildSuggestedFixCommandPlan( const offline = !!options?.offline; let workspaceMap: Map = new Map(); + let isBerry = false; + let yarnBerryRecursiveUp: boolean | "ambiguous" = true; + let yarnBerryAmbiguityReason: YarnBerryInfo["ambiguityReason"]; if (scanInput.filePath) { try { if (packageManager === "npm" && isNpmLockSource(scanInput.source)) { @@ -113,6 +118,11 @@ export function buildSuggestedFixCommandPlan( } else if (packageManager === "pnpm" && scanInput.source === "pnpm-lock") { workspaceMap = buildPnpmWorkspaceMap(scanInput.filePath); } else if (packageManager === "yarn" && scanInput.source === "yarn-lock") { + const lockContent = safeReadText(scanInput.filePath) ?? ""; + const berryInfo = detectYarnBerryInfo(scanInput.filePath, lockContent); + isBerry = berryInfo.isBerry; + yarnBerryRecursiveUp = berryInfo.supportsRecursiveUp; + yarnBerryAmbiguityReason = berryInfo.ambiguityReason; workspaceMap = buildYarnWorkspaceMap(scanInput.filePath); } else if (packageManager === "bun" && scanInput.source === "bun-lock") { workspaceMap = buildBunWorkspaceMap(scanInput.filePath); @@ -122,6 +132,16 @@ export function buildSuggestedFixCommandPlan( } } + // Use the same resolved declaration selection for direct and parent targets. + // Yarn's root is a real target, but only for the version being upgraded. + function workspacesFor(name: string, version: string): string[] { + const resolved = packageManager === "yarn" + ? scanInput.packages.find(pkg => packageNameKey(pkg) === name && pkg.version === version) + : undefined; + const workspaces = resolved?.yarnDirectWorkspaces ?? workspaceMap.get(name) ?? []; + return workspaces.filter(workspace => packageManager === "yarn" || workspace !== "."); + } + const devLookup = new Map(); for (const pkg of scanInput.packages) { devLookup.set(`${pkg.name}@${pkg.version}`, pkg.dev ?? false); @@ -204,7 +224,7 @@ export function buildSuggestedFixCommandPlan( isUpgradeTarget(finding.pkg.version, directTarget) && validatedFieldOk ) { - const pkgWorkspaces = workspaceMap.get(packageNameKey(finding.pkg))?.filter(w => w !== ".") ?? []; + const pkgWorkspaces = workspacesFor(packageNameKey(finding.pkg), finding.pkg.version); upsertTarget(targetsByKey, finding, { package: finding.pkg.name, ...(finding.pkg.registryName ? { registryName: finding.pkg.registryName } : {}), @@ -261,6 +281,14 @@ export function buildSuggestedFixCommandPlan( continue; } + if (finding.relationship === "transitive" && finding.pkg.yarnDirectWorkspaces?.length + && finding.pkg.yarnTransitivePaths?.length) { + skippedByKey.set(`direct:${packageInstanceKey(finding.pkg)}`, { + package: finding.pkg.name, version: finding.pkg.version, relationship: "direct", + reason: `Also declared directly in ${finding.pkg.yarnDirectWorkspaces.join(", ")}. Update these declarations separately; parent remediation covers only the transitive paths. Rescan afterward.`, + }); + } + if (finding.relationship === "unknown") { skippedByKey.set(`unknown:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, @@ -363,7 +391,7 @@ export function buildSuggestedFixCommandPlan( const chainForParentUpgrade = finding.chainResolution?.directDep === finding.recommendedParentUpgrade.package ? finding.chainResolution : null; - const parentUpgradeWorkspaces = workspaceMap.get(finding.recommendedParentUpgrade.package)?.filter(w => w !== ".") ?? []; + const parentUpgradeWorkspaces = workspacesFor(finding.recommendedParentUpgrade.package, finding.recommendedParentUpgrade.currentVersion); upsertTarget(targetsByKey, finding, { package: finding.recommendedParentUpgrade.package, currentVersion: finding.recommendedParentUpgrade.currentVersion, @@ -406,20 +434,39 @@ export function buildSuggestedFixCommandPlan( finding.relationship === "transitive" && finding.recommendedNpmTransitiveRemediation?.kind === "update-parent-within-range" ) { - const lockfileRefreshCommand = packageManager === "pnpm" - ? buildPnpmLockfileRefreshCommand(finding.pkg.name, finding.recommendedNpmTransitiveRemediation.workspaces) - : packageManager === "yarn" - ? buildYarnUpgradeCommand(finding.pkg.name, finding.recommendedNpmTransitiveRemediation.workspaces) - : buildBunUpdateCommand(finding.pkg.name); - upsertTarget(targetsByKey, finding, parentUpdateTarget(finding, urgent, { - package: finding.pkg.name, - currentVersion: finding.pkg.version, - childTargetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion, - reason: `${finding.pkg.name}@${finding.pkg.version} can be refreshed to ${finding.recommendedNpmTransitiveRemediation.targetChildVersion}+; no parent upgrade needed.`, - command: lockfileRefreshCommand, - workspaces: finding.recommendedNpmTransitiveRemediation?.workspaces, - })); - continue; + if (packageManager === "yarn" && isBerry && (yarnBerryRecursiveUp === false || yarnBerryRecursiveUp === "ambiguous")) { + if (!finding.chainResolution) { + const reason = yarnBerryRecursiveUp === false + ? `Yarn Berry prior to 3.0 does not support recursive lockfile refresh (yarn up -R) for transitive dependencies; run \`yarn set version stable\`, then \`yarn up -R ${finding.pkg.name}\` or update the parent dependency.` + : yarnBerryAmbiguityReason === "yarnPath" + ? "Yarn Berry version cannot be determined reliably from yarnPath in .yarnrc.yml; check the active version with `yarn --version` or update the parent dependency." + : yarnBerryAmbiguityReason === "metadata-v4" + ? "Yarn Berry version cannot be determined reliably (lockfile metadata version 4 is shared by Yarn 2 and 3); specify packageManager in package.json or yarnPath in .yarnrc.yml to enable automatic refresh." + : "Yarn Berry version cannot be determined reliably (lockfile metadata version is missing); specify packageManager in package.json or yarnPath in .yarnrc.yml to enable automatic refresh."; + skippedByKey.set(`${finding.relationship}:${packageInstanceKey(finding.pkg)}`, { + package: finding.pkg.name, + version: finding.pkg.version, + relationship: finding.relationship, + reason, + }); + continue; + } + } else { + const lockfileRefreshCommand = packageManager === "pnpm" + ? buildPnpmLockfileRefreshCommand(finding.pkg.name, finding.recommendedNpmTransitiveRemediation.workspaces) + : packageManager === "yarn" + ? buildYarnUpgradeCommand(finding.pkg.name, finding.recommendedNpmTransitiveRemediation.workspaces, isBerry) + : buildBunUpdateCommand(finding.pkg.name); + upsertTarget(targetsByKey, finding, parentUpdateTarget(finding, urgent, { + package: finding.pkg.name, + currentVersion: finding.pkg.version, + childTargetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion, + reason: `${finding.pkg.name}@${finding.pkg.version} can be refreshed to ${finding.recommendedNpmTransitiveRemediation.targetChildVersion}+; no parent upgrade needed.`, + command: lockfileRefreshCommand, + workspaces: finding.recommendedNpmTransitiveRemediation?.workspaces, + })); + continue; + } } const primaryParent = getPrimaryParent(finding); @@ -427,7 +474,7 @@ export function buildSuggestedFixCommandPlan( if (finding.chainResolution) { 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 !== ".") ?? []; + const chainWorkspaces = workspacesFor(finding.chainResolution.directDep, finding.chainResolution.directDepCurrentVersion); upsertTarget(targetsByKey, finding, { package: finding.chainResolution.directDep, currentVersion: finding.chainResolution.directDepCurrentVersion, @@ -490,6 +537,7 @@ export function buildSuggestedFixCommandPlan( // Parent-update commands are rebuilt from the (possibly merged) workspace list // so the printed plan matches what --fix will spawn. for (const target of targets) { + if (packageManager === "yarn") target.yarnBerry = isBerry; if (target.kind === "parent-update") { const updatePkg = target.childPackage ?? target.package; if (packageManager === "npm") { @@ -497,7 +545,7 @@ export function buildSuggestedFixCommandPlan( } else if (packageManager === "pnpm") { target.command = buildPnpmLockfileRefreshCommand(updatePkg, target.workspaces); } else if (packageManager === "yarn") { - target.command = buildYarnUpgradeCommand(updatePkg, target.workspaces); + target.command = buildYarnUpgradeCommand(updatePkg, target.workspaces, target.yarnBerry); } else if (packageManager === "bun") { target.command = buildBunUpdateCommand(updatePkg); } @@ -585,9 +633,16 @@ function buildPnpmLockfileRefreshCommand(pkg: string, workspaces?: string[]): st return `pnpm --filter ${shellQuote("./" + workspace)} update --no-save ${pkgQ}`; } -function buildYarnUpgradeCommand(pkg: string, workspaces?: string[]): string { +function buildYarnUpgradeCommand( + pkg: string, + workspaces?: string[], + isBerry = false, +): string { const pkgQ = shellQuote(pkg); - const nonRoot = (workspaces ?? []).filter(w => w !== "."); + if (isBerry) { + return `yarn up -R ${pkgQ}`; + } + const nonRoot = [...new Set((workspaces ?? []).filter(w => w !== "."))]; if (nonRoot.length === 0) return `yarn upgrade ${pkgQ}`; return nonRoot.map(ws => `yarn workspace ${shellQuote(ws)} upgrade ${pkgQ}`).join(" && "); } @@ -623,17 +678,19 @@ export function findFixTargetForFinding( const target = plan.targets.find(item => { if (finding.recommendedNpmTransitiveRemediation?.kind === "update-parent-within-range") { const rem = finding.recommendedNpmTransitiveRemediation; - if (item.kind !== "parent-update") return false; - if (item.package !== rem.package && item.package !== finding.pkg.name) return false; - // Parent-named parent-update: match only on child identity. Falling - // through to targetVersion compares the parent's version to the child's - // destination and can attach the wrong sibling. - if (item.childPackage) { - return item.childPackage === finding.pkg.name - && item.childTargetVersion === rem.targetChildVersion; + if (item.kind === "parent-update") { + if (item.package !== rem.package && item.package !== finding.pkg.name) return false; + // Parent-named parent-update: match only on child identity. Falling + // through to targetVersion compares the parent's version to the child's + // destination and can attach the wrong sibling. + if (item.childPackage) { + return item.childPackage === finding.pkg.name + && item.childTargetVersion === rem.targetChildVersion; + } + // Self-refresh / lockfile refresh: destination stays on targetVersion. + return item.targetVersion === rem.targetChildVersion; } - // Self-refresh / lockfile refresh: destination stays on targetVersion. - return item.targetVersion === rem.targetChildVersion; + if (!finding.chainResolution) return false; } if (finding.recommendedParentUpgrade) { @@ -1105,7 +1162,9 @@ function buildWorkspaceInstallCommands( commands.push(`yarn add${flag} ${pkgArgs}`); } else { for (const ws of workspaces) { - commands.push(`yarn workspace ${shellQuote(ws)} add${flag} ${pkgArgs}`); + commands.push(ws === "." + ? `yarn add${targets[0]?.yarnBerry ? "" : " -W"}${flag} ${pkgArgs}` + : `yarn workspace ${shellQuote(ws)} add${flag} ${pkgArgs}`); } } } else if (packageManager === "bun") { diff --git a/src/remediation/transitive-chain-resolver.ts b/src/remediation/transitive-chain-resolver.ts index 36d409bf4..1458ca0f8 100644 --- a/src/remediation/transitive-chain-resolver.ts +++ b/src/remediation/transitive-chain-resolver.ts @@ -1,15 +1,16 @@ -import type { Finding, ChainHop, ChainResolution } from "../types.js"; +import type { Finding, ChainHop, ChainResolution, PackageRef } from "../types.js"; import type { SuggestedFixPackageManager } from "./fix-commands.js"; import { commandPrefix } 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, - installedVersions: Map, + packages: PackageRef[], packageManager: SuggestedFixPackageManager, - options: { offline: boolean }, + options: { offline: boolean; workspaceMemberNames?: ReadonlySet; parentGraph?: LockParentGraph | null }, ): Promise { if (options.offline) return null; if (finding.relationship !== "transitive") return null; @@ -17,56 +18,70 @@ export async function resolveChainFix( const fixedVersion = finding.validatedFirstFixedVersion ?? finding.firstFixedVersion; if (!fixedVersion) return null; - const uniqueDirectDeps = [...new Set( - finding.dependencyPaths - .filter(path => path.length >= 3) - .map(path => path[1]), - )]; - - for (const directDepName of uniqueDirectDeps) { - const directDepCurrentVersion = installedVersions.get(directDepName); - if (!directDepCurrentVersion) continue; - - 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); - - const pathsForDep = finding.dependencyPaths.filter(p => p[1] === directDepName); - - 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 = pathsForDep[0].slice(2); - - 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: `${commandPrefix(packageManager)} ${directDepName}@${candidateVersion}`, - coveredPaths: 1, - totalPaths: finding.dependencyPaths.length, - }; + 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: `${commandPrefix(packageManager)} ${directDepName}@${candidateVersion}`, + coveredPaths: 1, + totalPaths: finding.dependencyPaths.length, + }; + } } } - return null; } diff --git a/src/scanner.ts b/src/scanner.ts index 578dc2ede..505fd9b1b 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -482,11 +482,15 @@ export async function scanPackages( const severity = maxSeverity(vulnerabilities); const cveAliases = extractCveAliases(vulnerabilities); - const dependencyPaths = result.pkg.paths ?? []; - const relationship = classifyRelationship( + // Mixed Yarn reachability must retain parent remediation; direct declarations + // are carried separately on the resolved package for the fix-plan warning. + const mixedYarn = !!result.pkg.yarnDirectWorkspaces?.length && !!result.pkg.yarnTransitivePaths?.length; + const dependencyPaths = mixedYarn ? result.pkg.yarnTransitivePaths! : result.pkg.paths ?? []; + const relationship = mixedYarn ? "transitive" : classifyRelationship( dependencyPaths, result.pkg.name, context?.directDependencyNames, + result.pkg.workspaceDirect === true, ); const selectedFix = selectFixedVersion( vulnerabilities, @@ -811,7 +815,6 @@ export async function scanPackages( // Chain fix resolution: pre-compute validated chain for transitive findings const chainPm = packageManagerForSource(context?.scanSource); if (chainPm) { - const installedVersions = new Map(packages.map(p => [p.name, p.version])); await Promise.all( findings .filter(f => f.relationship === "transitive" && f.dependencyPaths?.length && !aliasRemediationFindings.has(f)) @@ -819,9 +822,9 @@ export async function scanPackages( try { finding.chainResolution = await resolveChainFix( finding, - installedVersions, + packages, chainPm, - { offline }, + { offline, workspaceMemberNames, parentGraph: lockParentGraph }, ); } catch (error) { chainResolutionFailureCount += 1; @@ -1138,15 +1141,20 @@ function classifyRelationship( paths: string[][], packageName?: string, directDependencyNames?: ReadonlySet | null, + workspaceDirect = false, ): "direct" | "transitive" | "unknown" { if (packageName && directDependencyNames?.has(packageName)) { // The package name is declared as a direct dependency. But when multiple versions // of the same package are installed (one direct, one transitive), the name alone // is not enough - we need to verify this specific installed version is the direct - // one. A direct install always has at least one path of length 2 (["project", name]). - // If all paths are longer, this is a different (transitive) version of the package. + // one. A direct install has a path ["project", name], or + // ["project", workspaceName, name] with parser-confirmed workspace identity. + // A registry parent can share a workspace name, so the display path alone + // cannot prove that this resolved version is a workspace dependency. + // Longer paths describe a different (transitive) installed version. if (paths.length === 0) return "direct"; // no path data - trust the name - const hasDirectPath = paths.some(p => p.length <= 2); + const hasDirectPath = paths.some(p => p.length <= 2 + || (p.length === 3 && workspaceDirect)); return hasDirectPath ? "direct" : "transitive"; } if (paths.length === 0) return "unknown"; diff --git a/src/types.ts b/src/types.ts index 7b0befbdb..d961e6242 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,12 @@ export type PackageRef = { ecosystem: string; dev?: boolean; paths?: string[][]; + /** This resolved version is declared by a Yarn workspace member, independent of display paths. */ + workspaceDirect?: boolean; + /** Yarn declarations resolving to this exact package version (root is "."). */ + yarnDirectWorkspaces?: string[]; + /** Paths reached through a registry dependency rather than a declaration seed. */ + yarnTransitivePaths?: string[][]; resolvedUrl?: string; /** * SPDX license expression as declared by the package, when the lockfile diff --git a/src/utils/fix-runner.ts b/src/utils/fix-runner.ts index 7dbcc27c5..5852b7af5 100644 --- a/src/utils/fix-runner.ts +++ b/src/utils/fix-runner.ts @@ -187,7 +187,7 @@ function buildInstallApplyArgv( packageManager: SuggestedFixCommandPlan["packageManager"], target: SuggestedFixTarget, ): string[][] { - const workspaces = (target.workspaces ?? []).filter(w => w !== "."); + const workspaces = (target.workspaces ?? []).filter(w => packageManager === "yarn" || w !== "."); const pkgArg = installPackageSpec(target); const isDev = Boolean(target.isDev); @@ -220,7 +220,7 @@ function buildInstallApplyArgv( return [["yarn", ...args]]; } return workspaces.map(ws => { - const args = ["workspace", ws, "add"]; + const args = ws === "." ? ["add", ...(target.yarnBerry ? [] : ["-W"])] : ["workspace", ws, "add"]; if (isDev) args.push("-D"); args.push(pkgArg); return ["yarn", ...args]; @@ -270,6 +270,10 @@ function buildParentUpdateApplyArgv( } if (packageManager === "yarn") { + const isBerry = Boolean(target.yarnBerry); + if (isBerry) { + return [["yarn", "up", "-R", updatePkg]]; + } if (nonRoot.length === 0) { return [["yarn", "upgrade", updatePkg]]; } diff --git a/src/utils/package-json.ts b/src/utils/package-json.ts index 71b020b00..2ebd891c1 100644 --- a/src/utils/package-json.ts +++ b/src/utils/package-json.ts @@ -110,7 +110,8 @@ function readWorkspacePatterns(manifest: Record, projectPath: s return []; } -function resolveWorkspacePackageJsonPaths(projectPath: string, patterns: string[]): string[] { +/** Expands workspace patterns to unique existing member manifests. */ +export function resolveWorkspacePackageJsonPaths(projectPath: string, patterns: string[]): string[] { const matches = new Set(); for (const pattern of patterns) { diff --git a/tests/fix-runner-apply.test.ts b/tests/fix-runner-apply.test.ts index b7d621f74..92fd246ec 100644 --- a/tests/fix-runner-apply.test.ts +++ b/tests/fix-runner-apply.test.ts @@ -23,7 +23,10 @@ function target(overrides: Partial): SuggestedFixTarget { }; } -function plan(targets: SuggestedFixTarget[]): SuggestedFixCommandPlan { +function plan( + targets: SuggestedFixTarget[], + overrides?: Partial, +): SuggestedFixCommandPlan { return { packageManager: "npm", sourceLabel: "package-lock.json", @@ -33,6 +36,7 @@ function plan(targets: SuggestedFixTarget[]): SuggestedFixCommandPlan { skipped: [], coveredFindingCount: 0, totalFindingCount: 0, + ...overrides, }; } @@ -442,4 +446,156 @@ describe("applyFixesIfRequested - within-range refresh auto-apply", () => { expect(argvs.map(a => a[0])).toEqual(["npm"]); }); + describe("Yarn variant --fix execution", () => { + it("executes yarn up -R for a root within-range refresh on Yarn Berry (v3.1+)", async () => { + const { calls, argvs, run } = recordingRunner(); + await applyFixesIfRequested({ + plan: plan( + [ + target({ + kind: "parent-update", + package: "follow-redirects", + currentVersion: "1.14.0", + targetVersion: "1.16.0", + yarnBerry: true, + command: "yarn up -R follow-redirects", + }), + ], + { packageManager: "yarn" }, + ), + projectPath: "/tmp/proj", + totalFindings: 1, + options, + runCommand: run, + }); + + expect(calls).toEqual(["yarn up -R follow-redirects"]); + expect(argvs).toEqual([["yarn", "up", "-R", "follow-redirects"]]); + }); + + it("executes project-level yarn up -R exactly once for within-range refresh on Yarn Berry 3.0+ across multiple workspaces", async () => { + const { calls, argvs, run } = recordingRunner(); + await applyFixesIfRequested({ + plan: plan( + [ + target({ + kind: "parent-update", + package: "follow-redirects", + currentVersion: "1.14.0", + targetVersion: "1.16.0", + workspaces: ["packages/client", "packages/admin"], + yarnBerry: true, + command: "yarn up -R follow-redirects", + }), + ], + { packageManager: "yarn" }, + ), + projectPath: "/tmp/proj", + totalFindings: 1, + options, + runCommand: run, + }); + + expect(calls).toEqual(["yarn up -R follow-redirects"]); + expect(argvs).toEqual([["yarn", "up", "-R", "follow-redirects"]]); + }); + + it("preserves yarn upgrade for a root within-range refresh on Yarn Classic", async () => { + const { calls, argvs, run } = recordingRunner(); + await applyFixesIfRequested({ + plan: plan( + [ + target({ + kind: "parent-update", + package: "follow-redirects", + currentVersion: "1.14.0", + targetVersion: "1.16.0", + yarnBerry: false, + command: "yarn upgrade follow-redirects", + }), + ], + { packageManager: "yarn" }, + ), + projectPath: "/tmp/proj", + totalFindings: 1, + options, + runCommand: run, + }); + + expect(calls).toEqual(["yarn upgrade follow-redirects"]); + expect(argvs).toEqual([["yarn", "upgrade", "follow-redirects"]]); + }); + + it("preserves yarn workspace upgrade for a workspace within-range refresh on Yarn Classic", async () => { + const { calls, argvs, run } = recordingRunner(); + await applyFixesIfRequested({ + plan: plan( + [ + target({ + kind: "parent-update", + package: "follow-redirects", + currentVersion: "1.14.0", + targetVersion: "1.16.0", + workspaces: ["packages/client"], + yarnBerry: false, + command: "yarn workspace packages/client upgrade follow-redirects", + }), + ], + { packageManager: "yarn" }, + ), + projectPath: "/tmp/proj", + totalFindings: 1, + options, + runCommand: run, + }); + + expect(calls).toEqual(["yarn workspace packages/client upgrade follow-redirects"]); + expect(argvs).toEqual([["yarn", "workspace", "packages/client", "upgrade", "follow-redirects"]]); + }); + + it("executes yarn add for direct dependencies on Yarn Berry", async () => { + const { calls, run } = recordingRunner(); + await applyFixesIfRequested({ + plan: plan( + [ + target({ + kind: "direct", + package: "axios", + currentVersion: "0.21.1", + targetVersion: "0.33.0", + workspaces: ["packages/api"], + yarnBerry: true, + }), + ], + { packageManager: "yarn" }, + ), + projectPath: "/tmp/proj", + totalFindings: 1, + options, + runCommand: run, + }); + + expect(calls).toEqual(["yarn workspace packages/api add axios@0.33.0"]); + }); + }); +}); + +it.each([false, true])("applies Yarn fixes to root and member with matching dependency flags: %s", async (isDev) => { + const { argvs, run } = recordingRunner(); + const yarnPlan = plan([target({ package: "lodash", workspaces: [".", "web"], isDev })]); + yarnPlan.packageManager = "yarn"; + await applyFixesIfRequested({ plan: yarnPlan, projectPath: "/tmp/proj", totalFindings: 1, options, runCommand: run }); + const flag = isDev ? ["-D"] : []; + expect(argvs).toEqual([ + ["yarn", "add", "-W", ...flag, "lodash@1.0.1"], + ["yarn", "workspace", "web", "add", ...flag, "lodash@1.0.1"], + ]); +}); + +it("omits the Classic-only root flag when applying a Berry target", async () => { + const { argvs, run } = recordingRunner(); + const yarnPlan = plan([target({ package: "lodash", workspaces: [".", "web"], yarnBerry: true })]); + yarnPlan.packageManager = "yarn"; + await applyFixesIfRequested({ plan: yarnPlan, projectPath: "/tmp/proj", totalFindings: 1, options, runCommand: run }); + expect(argvs).toEqual([["yarn", "add", "lodash@1.0.1"], ["yarn", "workspace", "web", "add", "lodash@1.0.1"]]); }); diff --git a/tests/fixture-scan.test.ts b/tests/fixture-scan.test.ts index d5510e456..818637e22 100644 --- a/tests/fixture-scan.test.ts +++ b/tests/fixture-scan.test.ts @@ -121,6 +121,44 @@ describe("fixture remediation scans", () => { }, ); + itWithFixture( + "yarn-berry", + "suggests yarn up -R for yarn-berry within-range transitive instead of yarn upgrade", + () => { + const scanInput = loadFixture("yarn-berry"); + const finding = findingFor(scanInput, "follow-redirects", { + relationship: "transitive", + firstFixedVersion: "1.15.0", + dependencyPaths: [["project", "axios", "follow-redirects"]], + recommendedNpmTransitiveRemediation: { + kind: "update-parent-within-range", + package: "axios", + currentVersion: "0.21.1", + targetChildVersion: "1.15.0", + viaPath: ["project", "axios", "follow-redirects"], + reason: "follow-redirects can be refreshed within axios's declared range.", + }, + }); + + const plan = buildSuggestedFixCommandPlan([finding], scanInput); + + expect(plan?.packageManager).toBe("yarn"); + expect(plan?.command).toBe("yarn up -R follow-redirects"); + expect(plan?.targets).toEqual([ + expect.objectContaining({ + package: "follow-redirects", + kind: "parent-update", + yarnBerry: true, + }), + ]); + expect(plan?.targets).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ package: "axios", kind: "parent-upgrade" }), + ]), + ); + }, + ); + describe("exact-pinned-intermediate", () => { let originalFetch: typeof fetch; const fetchMock = jest.fn(); diff --git a/tests/parsers/yarn-lock.test.ts b/tests/parsers/yarn-lock.test.ts index 1009310ea..24ac9d5ce 100644 --- a/tests/parsers/yarn-lock.test.ts +++ b/tests/parsers/yarn-lock.test.ts @@ -7,7 +7,10 @@ import { loadNpmLockGraph } from "../../src/parsers/npm-lock-graph.js"; import { loadFromPackageJson } from "../../src/parsers/package-json.js"; import { loadFromPackageLock } from "../../src/parsers/package-lock.js"; import { loadFromPnpmLock } from "../../src/parsers/pnpm-lock.js"; -import { loadFromYarnLock } from "../../src/parsers/yarn-lock.js"; +import { + loadFromYarnLock, + detectYarnBerryInfo, +} from "../../src/parsers/yarn-lock.js"; import { removeDir } from "../test-utils.js"; function createTempProjectDir(): string { @@ -688,4 +691,211 @@ follow-redirects@^1.10.0: } }); + describe("Yarn Berry detection & configuration precedence", () => { + it("gives active yarnPath precedence over packageManager", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "yarn.lock"); + fs.writeFileSync(lockPath, "__metadata:\n version: 4\n cacheKey: 10c0\n", "utf8"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ packageManager: "yarn@3.8.0" }), + "utf8", + ); + // Active yarnPath is Yarn 2, conflicting with packageManager Yarn 3 + fs.writeFileSync( + path.join(projectDir, ".yarnrc.yml"), + 'yarnPath: ".yarn/releases/yarn-2.4.3.cjs"\n', + "utf8", + ); + + try { + const info = detectYarnBerryInfo(lockPath, fs.readFileSync(lockPath, "utf8")); + expect(info.isBerry).toBe(true); + expect(info.supportsRecursiveUp).toBe(false); + } finally { + removeDir(projectDir); + } + }); + + it("respects ignorePath: true and falls back to packageManager version", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "yarn.lock"); + fs.writeFileSync(lockPath, "__metadata:\n version: 4\n cacheKey: 10c0\n", "utf8"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ packageManager: "yarn@3.8.0" }), + "utf8", + ); + fs.writeFileSync( + path.join(projectDir, ".yarnrc.yml"), + 'yarnPath: ".yarn/releases/yarn-2.4.3.cjs"\nignorePath: true\n', + "utf8", + ); + + try { + const info = detectYarnBerryInfo(lockPath, fs.readFileSync(lockPath, "utf8")); + expect(info.isBerry).toBe(true); + expect(info.supportsRecursiveUp).toBe(true); + } finally { + removeDir(projectDir); + } + }); + + it("preserves ambiguous state when active yarnPath version cannot be determined", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "yarn.lock"); + // Even if lockfile has metadata 5 and packageManager has 3.8.0, active yarnPath with unknown version must stay ambiguous + fs.writeFileSync(lockPath, "__metadata:\n version: 5\n cacheKey: 10c0\n", "utf8"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ packageManager: "yarn@3.8.0" }), + "utf8", + ); + fs.writeFileSync( + path.join(projectDir, ".yarnrc.yml"), + 'yarnPath: ".yarn/releases/custom-yarn-bundle.cjs"\n', + "utf8", + ); + + try { + const info = detectYarnBerryInfo(lockPath, fs.readFileSync(lockPath, "utf8")); + expect(info.isBerry).toBe(true); + expect(info.supportsRecursiveUp).toBe("ambiguous"); + expect(info.ambiguityReason).toBe("yarnPath"); + } finally { + removeDir(projectDir); + } + }); + + it("uses packageManager when yarnPath is absent", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "yarn.lock"); + fs.writeFileSync(lockPath, "__metadata:\n version: 4\n cacheKey: 10c0\n", "utf8"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ packageManager: "yarn@3.8.0" }), + "utf8", + ); + + try { + const info = detectYarnBerryInfo(lockPath, fs.readFileSync(lockPath, "utf8")); + expect(info.isBerry).toBe(true); + expect(info.supportsRecursiveUp).toBe(true); + } finally { + removeDir(projectDir); + } + }); + + it("correctly handles comments, quoted values, and inline comments in .yarnrc.yml", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "yarn.lock"); + fs.writeFileSync(lockPath, "__metadata:\n version: 4\n cacheKey: 10c0\n", "utf8"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ packageManager: "yarn@2.4.3" }), + "utf8", + ); + fs.writeFileSync( + path.join(projectDir, ".yarnrc.yml"), + '# Global settings\nyarnPath: \'.yarn/releases/yarn-3.8.0.cjs\' # active berry release\n# ignorePath: true\n', + "utf8", + ); + + try { + const info = detectYarnBerryInfo(lockPath, fs.readFileSync(lockPath, "utf8")); + expect(info.isBerry).toBe(true); + expect(info.supportsRecursiveUp).toBe(true); + } finally { + removeDir(projectDir); + } + }); + + it("respects string quoted ignorePath: \"true\" in .yarnrc.yml", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "yarn.lock"); + fs.writeFileSync(lockPath, "__metadata:\n version: 4\n cacheKey: 10c0\n", "utf8"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ packageManager: "yarn@3.8.0" }), + "utf8", + ); + fs.writeFileSync( + path.join(projectDir, ".yarnrc.yml"), + 'yarnPath: ".yarn/releases/yarn-2.4.3.cjs"\nignorePath: "true"\n', + "utf8", + ); + + try { + const info = detectYarnBerryInfo(lockPath, fs.readFileSync(lockPath, "utf8")); + expect(info.isBerry).toBe(true); + expect(info.supportsRecursiveUp).toBe(true); + } finally { + removeDir(projectDir); + } + }); + + it("does not probe unsupported .yarnrc.yaml", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "yarn.lock"); + fs.writeFileSync(lockPath, "__metadata:\n version: 4\n cacheKey: 10c0\n", "utf8"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ packageManager: "yarn@3.8.0" }), + "utf8", + ); + // Unsupported .yarnrc.yaml should be ignored + fs.writeFileSync( + path.join(projectDir, ".yarnrc.yaml"), + 'yarnPath: ".yarn/releases/yarn-2.4.3.cjs"\n', + "utf8", + ); + + try { + const info = detectYarnBerryInfo(lockPath, fs.readFileSync(lockPath, "utf8")); + expect(info.isBerry).toBe(true); + // packageManager 3.8.0 wins because .yarnrc.yaml was not probed + expect(info.supportsRecursiveUp).toBe(true); + } finally { + removeDir(projectDir); + } + }); + + it("does not infer installed version from engines.yarn range", () => { + const projectDir = createTempProjectDir(); + const lockPath = path.join(projectDir, "yarn.lock"); + // Lockfile metadata 4 is ambiguous + fs.writeFileSync(lockPath, "__metadata:\n version: 4\n cacheKey: 10c0\n", "utf8"); + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify({ engines: { yarn: ">=3.0.0" } }), + "utf8", + ); + + try { + const info = detectYarnBerryInfo(lockPath, fs.readFileSync(lockPath, "utf8")); + expect(info.isBerry).toBe(true); + // Must stay ambiguous, not inferred as true from engines range + expect(info.supportsRecursiveUp).toBe("ambiguous"); + expect(info.ambiguityReason).toBe("metadata-v4"); + } finally { + removeDir(projectDir); + } + }); + + it("reports missing-metadata ambiguity when lockfile lacks metadata version", () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-meta-missing-parser-")); + const lockPath = path.join(projectDir, "yarn.lock"); + fs.writeFileSync(lockPath, "__metadata:\n cacheKey: 10c0\n", "utf8"); + + try { + const info = detectYarnBerryInfo(lockPath, fs.readFileSync(lockPath, "utf8")); + expect(info.isBerry).toBe(true); + expect(info.supportsRecursiveUp).toBe("ambiguous"); + expect(info.ambiguityReason).toBe("missing-metadata"); + } finally { + removeDir(projectDir); + } + }); + }); + }); diff --git a/tests/parsers/yarn-workspace-globs.test.ts b/tests/parsers/yarn-workspace-globs.test.ts new file mode 100644 index 000000000..2fa7e92af --- /dev/null +++ b/tests/parsers/yarn-workspace-globs.test.ts @@ -0,0 +1,168 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { loadPackages } from "../../src/parsers/index.js"; +import { buildYarnWorkspaceMap } from "../../src/parsers/yarn-lock.js"; +import { buildSuggestedFixCommandPlan } from "../../src/remediation/fix-commands.js"; +import type { Finding } from "../../src/types.js"; +import { removeDir } from "../test-utils.js"; + +const patterns = ["packages/*/apps/*", "packages/**", "packages/team/apps/w*", "packages/team/apps/web/"]; + +describe.each(["classic", "berry"])("Yarn %s workspace globs", (format) => { + it.each(patterns)("scopes fixes and dependency paths for %s", (pattern) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-yarn-glob-")); + try { + const member = path.join(dir, "packages/team/apps/web"); + fs.mkdirSync(member, { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ + name: "root", private: true, workspaces: [pattern], + })); + fs.writeFileSync(path.join(member, "package.json"), JSON.stringify({ + name: "web", dependencies: { lodash: "4.17.20" }, + })); + const lockPath = path.join(dir, "yarn.lock"); + fs.writeFileSync(lockPath, format === "classic" + ? 'lodash@4.17.20:\n version "4.17.20"\n' + : '__metadata:\n version: 8\n\n"lodash@npm:4.17.20":\n version: 4.17.20\n resolution: "lodash@npm:4.17.20"\n'); + + expect(buildYarnWorkspaceMap(lockPath).get("lodash")).toEqual(["web"]); + const input = loadPackages(dir, false, 4); + const pkg = input.packages.find(p => p.name === "lodash")!; + expect(pkg.paths).toEqual([["project", "web", "lodash"]]); + const finding: Finding = { + pkg, vulnerabilities: [{ id: "GHSA-test" }], severity: "high", cveAliases: [], + relationship: "direct", dependencyPaths: pkg.paths ?? [], firstFixedVersion: "4.17.21", + }; + const plan = buildSuggestedFixCommandPlan([finding], input); + expect(plan?.command).toBe("yarn workspace web add lodash@4.17.21"); + } finally { + removeDir(dir); + } + }); +}); + +it.each([false, true])("fixes root and member declarations together (dev: %s)", (dev) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-yarn-root-")); + try { + fs.mkdirSync(path.join(dir, "packages/web"), { recursive: true }); + const deps = { [dev ? "devDependencies" : "dependencies"]: { lodash: "4.17.20" } }; + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ private: true, workspaces: ["packages/*"], ...deps })); + fs.writeFileSync(path.join(dir, "packages/web/package.json"), JSON.stringify({ name: "web", ...deps })); + fs.writeFileSync(path.join(dir, "yarn.lock"), 'lodash@4.17.20:\n version "4.17.20"\n'); + const input = loadPackages(dir, false, 4); + const pkg = input.packages[0]!; + const finding: Finding = { + pkg, vulnerabilities: [{ id: "GHSA-test" }], severity: "high", cveAliases: [], + relationship: "direct", dependencyPaths: pkg.paths ?? [], firstFixedVersion: "4.17.21", + }; + const plan = buildSuggestedFixCommandPlan([finding], input); + const flag = dev ? " -D" : ""; + expect(plan?.command).toBe(`yarn add -W${flag} lodash@4.17.21 && yarn workspace web add${flag} lodash@4.17.21`); + expect(plan?.targets[0]?.workspaces).toEqual([".", "web"]); + } finally { removeDir(dir); } +}); + +it("skips a null manifest and still finds valid members", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-yarn-null-")); + try { + for (const name of ["bad", "web"]) fs.mkdirSync(path.join(dir, "packages", name), { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ private: true, workspaces: ["packages/*"] })); + fs.writeFileSync(path.join(dir, "packages/bad/package.json"), "null"); + fs.writeFileSync(path.join(dir, "packages/web/package.json"), JSON.stringify({ name: "web", dependencies: { lodash: "4.17.20" } })); + fs.writeFileSync(path.join(dir, "yarn.lock"), 'lodash@4.17.20:\n version "4.17.20"\n'); + expect(buildYarnWorkspaceMap(path.join(dir, "yarn.lock")).get("lodash")).toEqual(["web"]); + expect(loadPackages(dir, false, 4).packages[0]?.workspaceDirect).toBe(true); + } finally { removeDir(dir); } +}); + +it.each(["classic", "berry"].flatMap(format => [false, true].map(shared => ({ format, shared }))))("distinguishes registry/workspace paths in $format (shared version: $shared)", ({ format, shared }) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-yarn-identity-")); + try { + fs.mkdirSync(path.join(dir, "packages/web"), { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ private: true, workspaces: ["packages/*"], dependencies: { web: "1.0.0" } })); + fs.writeFileSync(path.join(dir, "packages/web/package.json"), JSON.stringify({ name: "web", dependencies: { diff: shared ? "1.0.0" : "2.0.0" } })); + fs.writeFileSync(path.join(dir, "yarn.lock"), format === "classic" + ? 'web@1.0.0:\n version "1.0.0"\n dependencies:\n diff "1.0.0"\n\ndiff@1.0.0:\n version "1.0.0"\n\ndiff@2.0.0:\n version "2.0.0"\n' + : '__metadata:\n version: 8\n\n"web@npm:1.0.0":\n version: 1.0.0\n resolution: "web@npm:1.0.0"\n dependencies:\n diff: 1.0.0\n\n"diff@npm:1.0.0":\n version: 1.0.0\n resolution: "diff@npm:1.0.0"\n\n"diff@npm:2.0.0":\n version: 2.0.0\n resolution: "diff@npm:2.0.0"\n'); + const packages = loadPackages(dir, false, 4).packages; + const nested = packages.find(p => p.name === "diff" && p.version === "1.0.0"); + const direct = packages.find(p => p.name === "diff" && p.version === "2.0.0"); + if (shared) { + expect(nested?.workspaceDirect).toBe(true); + expect(nested?.yarnDirectWorkspaces).toEqual(["web"]); + expect(nested?.yarnTransitivePaths).toEqual([["project", "web", "diff"]]); + return; + } + // Display paths collide, but only the workspace's resolved version is direct. + expect(nested?.paths).toEqual([["project", "web", "diff"]]); + expect(direct?.paths).toEqual(nested?.paths); + expect(nested?.workspaceDirect).not.toBe(true); + expect(direct?.workspaceDirect).toBe(true); + } finally { removeDir(dir); } +}); + +it.each(["classic", "berry"])("selects only declarations resolving to the vulnerable version in %s", (format) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-yarn-versions-")); + try { + fs.mkdirSync(path.join(dir, "packages/web"), { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ private: true, workspaces: ["packages/*"], dependencies: { lodash: "4.18.1" } })); + fs.writeFileSync(path.join(dir, "packages/web/package.json"), JSON.stringify({ name: "web", dependencies: { lodash: "4.17.20" } })); + fs.writeFileSync(path.join(dir, "yarn.lock"), format === "classic" + ? 'lodash@4.18.1:\n version "4.18.1"\n\nlodash@4.17.20:\n version "4.17.20"\n' + : '__metadata:\n version: 8\n\n"lodash@npm:4.18.1":\n version: 4.18.1\n resolution: "lodash@npm:4.18.1"\n\n"lodash@npm:4.17.20":\n version: 4.17.20\n resolution: "lodash@npm:4.17.20"\n'); + const input = loadPackages(dir, false, 4); + const pkg = input.packages.find(p => p.version === "4.17.20")!; + const finding: Finding = { pkg, vulnerabilities: [{ id: "GHSA-test" }], severity: "high", cveAliases: [], relationship: "direct", dependencyPaths: pkg.paths ?? [], firstFixedVersion: "4.17.21" }; + expect(buildSuggestedFixCommandPlan([finding], input)?.command).toBe("yarn workspace web add lodash@4.17.21"); + expect(pkg.yarnDirectWorkspaces).toEqual(["web"]); + // The root is separately attributed to the newer resolved version. + const rootPkg = input.packages.find(p => p.version === "4.18.1")!; + expect(rootPkg.yarnDirectWorkspaces).toEqual(["."]); + const rootFinding = { ...finding, pkg: rootPkg, firstFixedVersion: "4.19.0" }; + expect(buildSuggestedFixCommandPlan([rootFinding], input)?.command).toBe(`yarn add${format === "classic" ? " -W" : ""} lodash@4.19.0`); + } finally { removeDir(dir); } +}); + +it("retains parent remediation and reports the direct declaration on mixed findings", () => { + const finding: Finding = { + pkg: { name: "diff", version: "1.0.0", ecosystem: "npm", yarnDirectWorkspaces: ["web"], yarnTransitivePaths: [["project", "parent", "diff"]] }, + vulnerabilities: [{ id: "GHSA-test" }], severity: "high", cveAliases: [], relationship: "transitive", + dependencyPaths: [["project", "parent", "diff"]], firstFixedVersion: "2.0.0", + recommendedParentUpgrade: { package: "parent", currentVersion: "1.0.0", targetVersion: "2.0.0", viaPath: ["project", "parent", "diff"], vulnerablePackage: "diff", confidence: "verified", reason: "parent@2 resolves diff@2" }, + }; + const plan = buildSuggestedFixCommandPlan([finding], { mode: "resolved-lockfile", source: "yarn-lock", filePath: null, packages: [finding.pkg], notes: [], warnings: [], skippedDependencies: [] }); + expect(plan?.targets).toEqual(expect.arrayContaining([expect.objectContaining({ package: "parent", kind: "parent-upgrade" })])); + expect(plan?.skipped).toEqual(expect.arrayContaining([expect.objectContaining({ relationship: "direct", reason: expect.stringContaining("Also declared directly in web") })])); +}); + +it.each(["classic", "berry"].flatMap(format => [false, true].flatMap(chain => [false, true].map(newerRoot => ({ format, chain, newerRoot }))))) +("scopes $format parent upgrades (chain: $chain, newer root: $newerRoot)", ({ format, chain, newerRoot }) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-yarn-parent-")); + try { + fs.mkdirSync(path.join(dir, "packages/web"), { recursive: true }); + const rootVersion = newerRoot ? "3.0.0" : "1.0.0"; + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ private: true, workspaces: ["packages/*"], dependencies: { parent: rootVersion } })); + fs.writeFileSync(path.join(dir, "packages/web/package.json"), JSON.stringify({ name: "web", dependencies: { parent: "1.0.0" } })); + const versions = newerRoot ? ["1.0.0", "3.0.0"] : ["1.0.0"]; + const lock = versions.map(v => format === "classic" + ? `parent@${v}:\n version "${v}"\n` + : `"parent@npm:${v}":\n version: ${v}\n resolution: "parent@npm:${v}"\n`).join("\n"); + fs.writeFileSync(path.join(dir, "yarn.lock"), (format === "berry" ? "__metadata:\n version: 8\n\n" : "") + lock); + const input = loadPackages(dir, false, 4); + const finding: Finding = { + pkg: { name: "diff", version: "1.0.0", ecosystem: "npm" }, vulnerabilities: [{ id: "GHSA-test" }], + severity: "high", cveAliases: [], relationship: "transitive", dependencyPaths: [["project", "parent", "diff"]], firstFixedVersion: "2.0.0", + ...(chain ? { + chainResolution: { directDep: "parent", directDepCurrentVersion: "1.0.0", targetVersion: "2.0.0", chain: [], safeVersion: "2.0.0", command: "yarn add parent@2.0.0", coveredPaths: 1, totalPaths: 1 }, + } : { + recommendedParentUpgrade: { package: "parent", currentVersion: "1.0.0", targetVersion: "2.0.0", viaPath: ["project", "parent", "diff"], vulnerablePackage: "diff", confidence: "verified", reason: "parent@2 resolves diff@2" }, + }), + }; + const plan = buildSuggestedFixCommandPlan([finding], input); + const memberCommand = "yarn workspace web add parent@2.0.0"; + const rootCommand = `yarn add${format === "classic" ? " -W" : ""} parent@2.0.0`; + expect(plan?.targets[0]?.workspaces).toEqual(newerRoot ? ["web"] : [".", "web"]); + expect(plan?.command).toBe(newerRoot ? memberCommand : `${rootCommand} && ${memberCommand}`); + } finally { removeDir(dir); } +}); diff --git a/tests/remediation/fix-commands-workspace-scoping.test.ts b/tests/remediation/fix-commands-workspace-scoping.test.ts index 2aa48fc17..c52afc968 100644 --- a/tests/remediation/fix-commands-workspace-scoping.test.ts +++ b/tests/remediation/fix-commands-workspace-scoping.test.ts @@ -86,6 +86,622 @@ describe("buildSuggestedFixCommandPlan - pnpm workspace command scoping", () => }); }); +function yarnScanInput(filePath: string): ScanInput { + return { + mode: "resolved-lockfile", + source: "yarn-lock", + filePath, + packages: [], + notes: [], + warnings: [], + skippedDependencies: [], + }; +} + +function writeYarnLock(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-ws-")); + const file = path.join(dir, "yarn.lock"); + fs.writeFileSync(file, content); + return file; +} + +function yarnWithinRangeRefreshFinding(workspaces?: string[]): Finding { + return { + pkg: { name: "js-cookie", version: "3.0.1", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-qjx8-664m-686j" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "@aws-amplify/core", "js-cookie"]], + relationship: "transitive", + firstFixedVersion: "3.0.5", + recommendedNpmTransitiveRemediation: { + kind: "update-parent-within-range", + package: "js-cookie", + currentVersion: "3.0.1", + targetChildVersion: "3.0.5", + viaPath: ["project", "@aws-amplify/core", "js-cookie"], + reason: "@aws-amplify/core@6.16.1 already allows js-cookie@3.0.5 within the current dependency range", + workspaces, + }, + }; +} + +describe("buildSuggestedFixCommandPlan - yarn lockfile refresh scoping", () => { + it("keeps yarn upgrade unchanged on Yarn Classic (v1) without workspaces", () => { + const lock = writeYarnLock("# yarn lockfile v1\n"); + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + const target = findFixTargetForFinding(plan!, finding); + expect(target?.command).toBe("yarn upgrade js-cookie"); + expect(plan?.command).toBe("yarn upgrade js-cookie"); + }); + + it("keeps yarn upgrade unchanged on Yarn Classic (v1) even with workspace members", () => { + const lock = writeYarnLock("# yarn lockfile v1\n"); + const finding = yarnWithinRangeRefreshFinding(["client"]); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + const target = findFixTargetForFinding(plan!, finding); + expect(target?.command).toBe("yarn workspace client upgrade js-cookie"); + expect(plan?.command).toBe("yarn workspace client upgrade js-cookie"); + }); + + it("emits yarn up -R on Yarn Berry (v3.1+) when there is no workspace", () => { + const lock = writeYarnLock("__metadata:\n version: 8\n cacheKey: 10c0\n"); + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + const target = findFixTargetForFinding(plan!, finding); + expect(target?.command).toBe("yarn up -R js-cookie"); + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("emits yarn up -R on Yarn Berry (v3.1+) when the package is at the workspace root", () => { + const lock = writeYarnLock("__metadata:\n version: 8\n cacheKey: 10c0\n"); + const finding = yarnWithinRangeRefreshFinding(["."]); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + const target = findFixTargetForFinding(plan!, finding); + expect(target?.command).toBe("yarn up -R js-cookie"); + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("emits project-level yarn up -R on Yarn Berry (v3.0+) even when the package is scoped to a workspace member", () => { + const lock = writeYarnLock("__metadata:\n version: 8\n cacheKey: 10c0\n"); + const finding = yarnWithinRangeRefreshFinding(["client"]); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + const target = findFixTargetForFinding(plan!, finding); + expect(target?.command).toBe("yarn up -R js-cookie"); + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("emits project-level yarn up -R exactly once on Yarn Berry (v3.0+) across multiple workspaces", () => { + const lock = writeYarnLock("__metadata:\n version: 8\n cacheKey: 10c0\n"); + const finding = yarnWithinRangeRefreshFinding(["client", "admin"]); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + const target = findFixTargetForFinding(plan!, finding); + expect(target?.command).toBe("yarn up -R js-cookie"); + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("detects Berry before building workspace map even if workspace map construction fails", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-berry-ws-err-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 8\n cacheKey: 10c0\n"); + // Invalid package.json with workspaces that causes workspace map parsing issues + const rootPkg = path.join(dir, "package.json"); + fs.writeFileSync(rootPkg, JSON.stringify({ workspaces: ["packages/*"] })); + const badPkgDir = path.join(dir, "packages", "bad"); + fs.mkdirSync(badPkgDir, { recursive: true }); + fs.writeFileSync(path.join(badPkgDir, "package.json"), "NOT_JSON"); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.packageManager).toBe("yarn"); + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("recognizes lockfile metadata version 5 as supporting recursive upgrade (Yarn 3.0+)", () => { + const lock = writeYarnLock("__metadata:\n version: 5\n cacheKey: 10c0\n"); + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.packageManager).toBe("yarn"); + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("treats metadata version 4 as ambiguous and skips transitive refresh with explanation", () => { + const lock = writeYarnLock("__metadata:\n version: 4\n cacheKey: 10c0\n"); + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.packageManager).toBe("yarn"); + expect(plan?.command).toBeNull(); + expect(plan?.targets).toHaveLength(0); + expect(plan?.skipped).toEqual([ + expect.objectContaining({ + package: "js-cookie", + relationship: "transitive", + reason: expect.stringContaining("lockfile metadata version 4 is shared by Yarn 2 and 3"), + }), + ]); + }); + + it("never mistakes engines.yarn ranges for installed versions and keeps metadata 4 ambiguous", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-engines-range-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ engines: { yarn: ">=2" } })); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBeNull(); + expect(plan?.skipped[0]?.reason).toContain("lockfile metadata version 4 is shared by Yarn 2 and 3"); + }); + + it("restricts .yarnrc.yml version detection strictly to the yarnPath value, ignoring comments and plugins", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-yarnrc-path-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + fs.writeFileSync( + path.join(dir, ".yarnrc.yml"), + [ + "# yarnPath: .yarn/releases/yarn-2.4.3.cjs", + "plugins:", + " - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools-2.4.3.cjs", + 'yarnPath: ".yarn/releases/yarn-3.0.0.cjs"', + ].join("\n"), + ); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("detects older Berry from yarnPath in .yarnrc.yml and skips transitive refresh with 3.0+ note", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-yarnrc-v2-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + fs.writeFileSync( + path.join(dir, ".yarnrc.yml"), + [ + "# yarn-3.0.0 mention in comment", + 'yarnPath: ".yarn/releases/yarn-2.4.3.cjs"', + ].join("\n"), + ); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBeNull(); + expect(plan?.skipped[0]?.reason).toContain("Yarn Berry prior to 3.0 does not support recursive lockfile refresh"); + expect(plan?.skipped[0]?.reason).toContain("yarn set version stable"); + expect(plan?.skipped[0]?.reason).toContain("yarn up -R js-cookie"); + }); + + it("supports recursive upgrade when packageManager specifies Yarn 3.0 explicitly", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-v30-pkg-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ packageManager: "yarn@3.0.0" })); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("skips transitive within-range refresh on older Berry (< 3.0, metadata version < 4) with an explanatory note", () => { + const lock = writeYarnLock("__metadata:\n version: 3\n cacheKey: 10c0\n"); + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.packageManager).toBe("yarn"); + expect(plan?.command).toBeNull(); + expect(plan?.targets).toHaveLength(0); + expect(plan?.skipped).toEqual([ + expect.objectContaining({ + package: "js-cookie", + relationship: "transitive", + reason: expect.stringContaining("Yarn Berry prior to 3.0 does not support recursive lockfile refresh"), + }), + ]); + expect(plan?.skipped[0]?.reason).toContain("yarn set version stable"); + expect(plan?.skipped[0]?.reason).toContain("yarn up -R js-cookie"); + }); + + it("skips transitive within-range refresh when packageManager specifies Yarn Berry 2.x", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-v2-pkg-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 5\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ packageManager: "yarn@2.4.3" })); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBeNull(); + expect(plan?.skipped[0]?.reason).toContain("Yarn Berry prior to 3.0 does not support recursive lockfile refresh"); + expect(plan?.skipped[0]?.reason).toContain("yarn set version stable"); + expect(plan?.skipped[0]?.reason).toContain("yarn up -R js-cookie"); + }); + + it("still allows direct package upgrades on older Yarn Berry releases", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-v2-direct-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 3\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ packageManager: "yarn@2.4.3" })); + + const directFinding: Finding = { + pkg: { name: "lodash", version: "4.17.20", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-lodash" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "lodash"]], + relationship: "direct", + firstFixedVersion: "4.17.21", + }; + + const plan = buildSuggestedFixCommandPlan([directFinding], yarnScanInput(lock)); + + expect(plan?.command).toBe("yarn add lodash@4.17.21"); + }); + + it("extracts Yarn version from the basename of yarnPath, ignoring versioned parent directories", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-basename-v3-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + // Parent directory has 2.4.3, but basename is yarn-3.1.0.cjs + fs.writeFileSync( + path.join(dir, ".yarnrc.yml"), + 'yarnPath: "/opt/yarn-2.4.3/releases/yarn-3.1.0.cjs"\n', + ); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("extracts older Berry version from basename even if parent directory has a higher version", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-basename-v2-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + // Parent directory has 3.1.0, but basename is yarn-2.4.3.cjs + fs.writeFileSync( + path.join(dir, ".yarnrc.yml"), + 'yarnPath: "/opt/yarn-3.1.0/releases/yarn-2.4.3.cjs"\n', + ); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBeNull(); + expect(plan?.skipped[0]?.reason).toContain("Yarn Berry prior to 3.0 does not support recursive lockfile refresh"); + expect(plan?.skipped[0]?.reason).toContain("yarn set version stable"); + expect(plan?.skipped[0]?.reason).toContain("yarn up -R js-cookie"); + }); + + it("identifies yarnPath uncertainty when active yarnPath version cannot be determined even with lockfile v8 and packageManager", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-no-version-basename-")); + const lock = path.join(dir, "yarn.lock"); + // Even with metadata version 8 and packageManager yarn@3.8.0, active yarnPath with unknown version must stay ambiguous + // and identify yarnPath uncertainty rather than reporting metadata version 4 or recommending packageManager. + fs.writeFileSync(lock, "__metadata:\n version: 8\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ packageManager: "yarn@3.8.0" })); + fs.writeFileSync( + path.join(dir, ".yarnrc.yml"), + 'yarnPath: ".yarn/releases/custom-yarn.cjs"\n', + ); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBeNull(); + expect(plan?.skipped[0]?.reason).toContain("from yarnPath in .yarnrc.yml"); + expect(plan?.skipped[0]?.reason).toContain("check the active version with `yarn --version`"); + expect(plan?.skipped[0]?.reason).not.toContain("metadata version 4"); + expect(plan?.skipped[0]?.reason).not.toContain("packageManager"); + }); + + it("reports missing-metadata ambiguity when lockfile has no metadata version", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-meta-missing-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "my-app" })); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBeNull(); + expect(plan?.skipped[0]?.reason).toContain("lockfile metadata version is missing"); + expect(plan?.skipped[0]?.reason).not.toContain("metadata version 4"); + expect(plan?.skipped[0]?.reason).toContain("specify packageManager in package.json or yarnPath in .yarnrc.yml"); + }); + + it("reports metadata-v4 ambiguity when lockfile is version 4 without active yarnPath", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-meta4-ambig-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "my-app" })); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBeNull(); + expect(plan?.skipped[0]?.reason).toContain("lockfile metadata version 4 is shared by Yarn 2 and 3"); + expect(plan?.skipped[0]?.reason).toContain("specify packageManager in package.json or yarnPath in .yarnrc.yml"); + }); + + it("gives active yarnPath precedence over packageManager when versions conflict", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-precedence-conflict-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + // packageManager says 3.8.0, but active yarnPath is 2.4.3 + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ packageManager: "yarn@3.8.0" })); + fs.writeFileSync( + path.join(dir, ".yarnrc.yml"), + 'yarnPath: ".yarn/releases/yarn-2.4.3.cjs"\n', + ); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + expect(plan?.command).toBeNull(); + expect(plan?.skipped[0]?.reason).toContain("Yarn Berry prior to 3.0 does not support recursive lockfile refresh"); + expect(plan?.skipped[0]?.reason).toContain("yarn set version stable"); + expect(plan?.skipped[0]?.reason).toContain("yarn up -R js-cookie"); + }); + + it("respects ignorePath: true in .yarnrc.yml so packageManager determines active version", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-ignore-path-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ packageManager: "yarn@3.8.0" })); + fs.writeFileSync( + path.join(dir, ".yarnrc.yml"), + 'yarnPath: ".yarn/releases/yarn-2.4.3.cjs"\nignorePath: true\n', + ); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + // yarnPath ignored due to ignorePath: true, packageManager yarn@3.8.0 wins + expect(plan?.command).toBe("yarn up -R js-cookie"); + }); + + it("ignores nested or indented yarnPath settings and falls back to metadata", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-nested-path-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + fs.writeFileSync( + path.join(dir, ".yarnrc.yml"), + [ + "custom:", + ' yarnPath: ".yarn/releases/yarn-3.0.0.cjs"', + "plugins:", + " - path: .yarn/plugins/@yarnpkg/plugin-3.0.0.cjs", + ].join("\n"), + ); + + const finding = yarnWithinRangeRefreshFinding(); + const plan = buildSuggestedFixCommandPlan([finding], yarnScanInput(lock)); + + // Top-level yarnPath is absent; falls back to metadata version 4 (ambiguous) + expect(plan?.command).toBeNull(); + expect(plan?.skipped[0]?.reason).toContain("lockfile metadata version 4 is shared by Yarn 2 and 3"); + }); + + it("proceeds to parent upgrade via chain resolution when Berry recursive up is ambiguous", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-chain-res-ambiguous-")); + const lock = path.join(dir, "yarn.lock"); + // Metadata version 4, no packageManager field + fs.writeFileSync(lock, "__metadata:\n version: 4\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "my-app" })); + + const findingWithChain: Finding = { + pkg: { name: "follow-redirects", version: "1.14.0", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-follow-redirects" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "axios", "follow-redirects"]], + relationship: "transitive", + firstFixedVersion: "1.14.8", + recommendedNpmTransitiveRemediation: { + kind: "update-parent-within-range", + package: "axios", + currentVersion: "0.21.1", + targetChildVersion: "1.14.8", + viaPath: ["project", "axios", "follow-redirects"], + reason: "axios permits follow-redirects 1.14.8", + }, + chainResolution: { + directDep: "axios", + directDepCurrentVersion: "0.21.1", + targetVersion: "0.21.4", + safeVersion: "1.14.8", + chain: [ + { name: "axios", version: "0.21.4" }, + { name: "follow-redirects", version: "1.14.8" }, + ], + command: "yarn add axios@0.21.4", + coveredPaths: 1, + totalPaths: 1, + }, + }; + + const plan = buildSuggestedFixCommandPlan([findingWithChain], yarnScanInput(lock)); + + expect(plan?.command).toBe("yarn add axios@0.21.4"); + expect(plan?.targets).toHaveLength(1); + expect(plan?.targets[0]?.package).toBe("axios"); + expect(plan?.targets[0]?.targetVersion).toBe("0.21.4"); + expect(plan?.targets[0]?.kind).toBe("parent-upgrade"); + expect(plan?.skipped).toHaveLength(0); + + // Finding without chain resolution remains skipped + const findingWithoutChain: Finding = { + ...findingWithChain, + chainResolution: undefined, + }; + const skippedPlan = buildSuggestedFixCommandPlan([findingWithoutChain], yarnScanInput(lock)); + expect(skippedPlan?.command).toBeNull(); + expect(skippedPlan?.targets).toHaveLength(0); + expect(skippedPlan?.skipped).toHaveLength(1); + expect(skippedPlan?.skipped[0]?.package).toBe("follow-redirects"); + expect(skippedPlan?.skipped[0]?.reason).toContain("lockfile metadata version 4 is shared by Yarn 2 and 3"); + }); + + it("proceeds to parent upgrade via chain resolution when Berry recursive up is false", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn-chain-res-false-")); + const lock = path.join(dir, "yarn.lock"); + fs.writeFileSync(lock, "__metadata:\n version: 3\n cacheKey: 10c0\n"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ packageManager: "yarn@2.4.3" })); + + const findingWithChain: Finding = { + pkg: { name: "follow-redirects", version: "1.14.0", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-follow-redirects" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "axios", "follow-redirects"]], + relationship: "transitive", + firstFixedVersion: "1.14.8", + recommendedNpmTransitiveRemediation: { + kind: "update-parent-within-range", + package: "axios", + currentVersion: "0.21.1", + targetChildVersion: "1.14.8", + viaPath: ["project", "axios", "follow-redirects"], + reason: "axios permits follow-redirects 1.14.8", + }, + chainResolution: { + directDep: "axios", + directDepCurrentVersion: "0.21.1", + targetVersion: "0.21.4", + safeVersion: "1.14.8", + chain: [ + { name: "axios", version: "0.21.4" }, + { name: "follow-redirects", version: "1.14.8" }, + ], + command: "yarn add axios@0.21.4", + coveredPaths: 1, + totalPaths: 1, + }, + }; + + const plan = buildSuggestedFixCommandPlan([findingWithChain], yarnScanInput(lock)); + + expect(plan?.command).toBe("yarn add axios@0.21.4"); + expect(plan?.targets[0]?.package).toBe("axios"); + expect(plan?.skipped).toHaveLength(0); + + const findingWithoutChain: Finding = { + ...findingWithChain, + chainResolution: undefined, + }; + const skippedPlan = buildSuggestedFixCommandPlan([findingWithoutChain], yarnScanInput(lock)); + expect(skippedPlan?.command).toBeNull(); + expect(skippedPlan?.targets).toHaveLength(0); + expect(skippedPlan?.skipped[0]?.reason).toContain("Yarn Berry prior to 3.0 does not support recursive lockfile refresh"); + expect(skippedPlan?.skipped[0]?.reason).toContain("yarn set version stable"); + expect(skippedPlan?.skipped[0]?.reason).toContain("yarn up -R follow-redirects"); + }); + + it("accounts for all 3 of 3 findings on a Yarn 2 copy of examples/yarn-berry", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "yarn2-berry-all-3-")); + const lock = path.join(dir, "yarn.lock"); + fs.copyFileSync(path.resolve("examples/yarn-berry/yarn.lock"), lock); + const pkgJson = JSON.parse(fs.readFileSync(path.resolve("examples/yarn-berry/package.json"), "utf8")); + pkgJson.packageManager = "yarn@2.4.3"; + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkgJson, null, 2), "utf8"); + + // Findings matching examples/yarn-berry: + // 1. axios direct finding + const axiosFinding: Finding = { + pkg: { name: "axios", version: "0.21.1", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-cph5-m8f7-6c5x" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "axios"]], + relationship: "direct", + firstFixedVersion: "0.21.2", + validatedFirstFixedVersion: "0.21.4", + }; + + // 2. lodash direct finding + const lodashFinding: Finding = { + pkg: { name: "lodash", version: "4.17.20", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-35jh-r3h4-6jhm" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "lodash"]], + relationship: "direct", + firstFixedVersion: "4.17.21", + validatedFirstFixedVersion: "4.17.21", + }; + + // 3. follow-redirects transitive finding (child of axios) + const followRedirectsFinding: Finding = { + pkg: { name: "follow-redirects", version: "1.14.0", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-74fj-2j2h-c42q" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "axios", "follow-redirects"]], + relationship: "transitive", + firstFixedVersion: "1.14.8", + recommendedNpmTransitiveRemediation: { + kind: "update-parent-within-range", + package: "axios", + currentVersion: "0.21.1", + targetChildVersion: "1.14.8", + viaPath: ["project", "axios", "follow-redirects"], + reason: "axios permits follow-redirects 1.14.8", + }, + chainResolution: { + directDep: "axios", + directDepCurrentVersion: "0.21.1", + targetVersion: "0.21.4", + safeVersion: "1.14.8", + chain: [ + { name: "axios", version: "0.21.4" }, + { name: "follow-redirects", version: "1.14.8" }, + ], + command: "yarn add axios@0.21.4", + coveredPaths: 1, + totalPaths: 1, + }, + }; + + const findings = [axiosFinding, lodashFinding, followRedirectsFinding]; + const plan = buildSuggestedFixCommandPlan(findings, yarnScanInput(lock)); + + expect(plan).not.toBeNull(); + // Must account for all 3 of 3 findings without empty skipped list hiding a missing finding + expect(plan?.totalFindingCount).toBe(3); + expect(plan?.coveredFindingCount).toBe(3); + expect(plan?.skipped).toHaveLength(0); + + // Each finding must have a matching fix target + expect(findFixTargetForFinding(plan!, axiosFinding)).toEqual( + expect.objectContaining({ package: "axios", targetVersion: "0.21.4", kind: "direct" }), + ); + expect(findFixTargetForFinding(plan!, lodashFinding)).toEqual( + expect.objectContaining({ package: "lodash", targetVersion: "4.17.21", kind: "direct" }), + ); + expect(findFixTargetForFinding(plan!, followRedirectsFinding)).toEqual( + expect.objectContaining({ package: "axios", targetVersion: "0.21.4", kind: "direct" }), + ); + + // Command suggested for follow-redirects routes to the parent upgrade + expect(findSuggestedCommandForFinding(plan!, followRedirectsFinding)).toBe("yarn add axios@0.21.4"); + }); +}); + // A transitive finding whose fix is a breaking parent upgrade (install), where // the parent package is a direct dependency of a workspace member. function pnpmParentUpgradeFinding(parent: string, currentVersion: string, targetVersion: string): Finding { diff --git a/tests/scanner-cache.test.ts b/tests/scanner-cache.test.ts index 9b0480f2b..309574028 100644 --- a/tests/scanner-cache.test.ts +++ b/tests/scanner-cache.test.ts @@ -455,6 +455,129 @@ describe("scanPackages cache behavior", () => { } }); + it.each([false, true])("classifies workspace paths without treating a nested version as direct: %s", async (transitive) => { + const cacheDir = createTempCacheDir(); + const pkg = createPackage("diff", "4.0.2"); + const member = path.join(cacheDir, "packages/team/apps/web"); + fs.mkdirSync(member, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, "package.json"), JSON.stringify({ workspaces: ["packages/*/apps/*"] })); + fs.writeFileSync(path.join(member, "package.json"), JSON.stringify({ name: "web", dependencies: { diff: "4.0.2" } })); + // Both display paths are identical; only the resolved workspace identity differs. + pkg.paths = [["project", "web", "diff"]]; + pkg.workspaceDirect = !transitive; + const detail: OsvVuln = { + id: "OSV-DIRECT", + affected: [{ ranges: [{ events: [{ fixed: "4.0.4" }] }] }], + }; + + queryBatchMock.mockResolvedValue([ + { + package: pkg.name, + version: pkg.version, + vulnerabilities: [{ id: "OSV-DIRECT" }], + }, + ]); + getVulnMock.mockResolvedValue(detail); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ versions: { "4.0.2": {}, "4.0.4": {} } }), + }); + + try { + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir), { + directDependencyNames: new Set(["diff", "typescript"]), + scanSource: "yarn-lock", + scanFilePath: path.join(cacheDir, "yarn.lock"), + }); + + expect(findings).toHaveLength(1); + expect(findings[0]?.relationship).toBe(transitive ? "transitive" : "direct"); + } finally { + removeDir(cacheDir); + } + }); + + it("preserves parent remediation for a version also declared by a workspace", async () => { + const cacheDir = createTempCacheDir(); + const pkg = createPackage("diff", "4.0.2"); + pkg.workspaceDirect = true; + pkg.yarnDirectWorkspaces = ["web"]; + pkg.paths = [["project", "web", "diff"], ["project", "registry-parent", "diff"]]; + pkg.yarnTransitivePaths = [["project", "registry-parent", "diff"]]; + const detail: OsvVuln = { + id: "OSV-DIRECT", + affected: [{ ranges: [{ events: [{ fixed: "4.0.4" }] }] }], + }; + + queryBatchMock.mockResolvedValue([ + { + package: pkg.name, + version: pkg.version, + vulnerabilities: [{ id: "OSV-DIRECT" }], + }, + ]); + getVulnMock.mockResolvedValue(detail); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ versions: { "4.0.2": {}, "4.0.4": {} } }), + }); + + try { + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir), { + directDependencyNames: new Set(["diff", "typescript"]), + }); + + expect(findings).toHaveLength(1); + expect(findings[0]?.relationship).toBe("transitive"); + expect(findings[0]?.dependencyPaths).toEqual([["project", "registry-parent", "diff"]]); + expect(findings[0]?.pkg.yarnDirectWorkspaces).toEqual(["web"]); + } finally { + removeDir(cacheDir); + } + }); + + it.each([false, true])("classifies workspace paths without treating a nested version as direct: %s", async (transitive) => { + const cacheDir = createTempCacheDir(); + const pkg = createPackage("diff", "4.0.2"); + const member = path.join(cacheDir, "packages/team/apps/web"); + fs.mkdirSync(member, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, "package.json"), JSON.stringify({ workspaces: ["packages/*/apps/*"] })); + fs.writeFileSync(path.join(member, "package.json"), JSON.stringify({ name: "web", dependencies: { diff: "4.0.2" } })); + // Both display paths are identical; only the resolved workspace identity differs. + pkg.paths = [["project", "web", "diff"]]; + pkg.workspaceDirect = !transitive; + const detail: OsvVuln = { + id: "OSV-DIRECT", + affected: [{ ranges: [{ events: [{ fixed: "4.0.4" }] }] }], + }; + + queryBatchMock.mockResolvedValue([ + { + package: pkg.name, + version: pkg.version, + vulnerabilities: [{ id: "OSV-DIRECT" }], + }, + ]); + getVulnMock.mockResolvedValue(detail); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ versions: { "4.0.2": {}, "4.0.4": {} } }), + }); + + try { + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir), { + directDependencyNames: new Set(["diff", "typescript"]), + scanSource: "yarn-lock", + scanFilePath: path.join(cacheDir, "yarn.lock"), + }); + + expect(findings).toHaveLength(1); + expect(findings[0]?.relationship).toBe(transitive ? "transitive" : "direct"); + } finally { + removeDir(cacheDir); + } + }); + it("keeps published fixed-version hints unconfirmed when advisory ranges are incomplete", async () => { const cacheDir = createTempCacheDir(); const pkg = createPackage("tar", "2.0.0"); diff --git a/tests/transitive-chain-resolver.test.ts b/tests/transitive-chain-resolver.test.ts index e736f07e2..e7a4a94ca 100644 --- a/tests/transitive-chain-resolver.test.ts +++ b/tests/transitive-chain-resolver.test.ts @@ -1,5 +1,6 @@ import { jest } from "@jest/globals"; -import type { Finding } from "../src/types.js"; +import type { Finding, PackageRef } from "../src/types.js"; +import { buildSuggestedFixCommandPlan } from "../src/remediation/fix-commands.js"; import { resolveChainFix } from "../src/remediation/transitive-chain-resolver.js"; import { clearPackumentCache } from "../src/remediation/npm-registry.js"; @@ -20,6 +21,10 @@ function makeFinding(overrides: Partial = {}): Finding { } as Finding; } +function installed(entries: Array<[string, string]> = []): PackageRef[] { + return entries.map(([name, version]) => ({ name, version, ecosystem: "npm", paths: [["root", name]] })); +} + beforeEach(() => { fetchMock.mockReset(); clearPackumentCache(); @@ -29,7 +34,7 @@ describe("resolveChainFix", () => { it("returns null when offline", async () => { const result = await resolveChainFix( makeFinding(), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "npm", { offline: true }, ); @@ -40,7 +45,7 @@ describe("resolveChainFix", () => { it("returns null when finding is not transitive", async () => { const result = await resolveChainFix( makeFinding({ relationship: "direct" }), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "npm", { offline: false }, ); @@ -50,7 +55,7 @@ describe("resolveChainFix", () => { it("returns null when direct dep is not in installedVersions", async () => { const result = await resolveChainFix( makeFinding(), - new Map(), + installed(), "npm", { offline: false }, ); @@ -61,7 +66,7 @@ describe("resolveChainFix", () => { fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); const result = await resolveChainFix( makeFinding(), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "npm", { offline: false }, ); @@ -102,7 +107,7 @@ describe("resolveChainFix", () => { makeFinding({ dependencyPaths: [["root", "express", "mime-types"]], }), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "npm", { offline: false }, ); @@ -159,7 +164,7 @@ describe("resolveChainFix", () => { const result = await resolveChainFix( makeFinding(), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "npm", { offline: false }, ); @@ -213,7 +218,7 @@ describe("resolveChainFix", () => { const result = await resolveChainFix( makeFinding(), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "npm", { offline: false }, ); @@ -250,7 +255,7 @@ describe("resolveChainFix", () => { const result = await resolveChainFix( makeFinding({ dependencyPaths: [["root", "express", "mime-types"]] }), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "pnpm", { offline: false }, ); @@ -296,7 +301,7 @@ describe("resolveChainFix", () => { firstFixedVersion: "2.0.0", validatedFirstFixedVersion: "2.0.0", }), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "npm", { offline: false }, ); @@ -344,7 +349,7 @@ describe("resolveChainFix", () => { firstFixedVersion: "2.0.0", validatedFirstFixedVersion: "2.0.0", }), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "npm", { offline: false }, ); @@ -403,7 +408,7 @@ describe("resolveChainFix", () => { firstFixedVersion: "2.0.0", validatedFirstFixedVersion: "2.0.0", }), - new Map([["express", "4.17.0"]]), + installed([["express", "4.17.0"]]), "npm", { offline: false }, ); @@ -416,3 +421,51 @@ describe("resolveChainFix", () => { expect(result!.command).toBe("npm install express@4.19.0"); }); }); + +it.each([false, true].flatMap(member => [false, true].map(reverse => ({ member, reverse })))) +("uses the affected parent's version and workspace (member: $member, reverse: $reverse)", async ({ member, reverse }) => { + fetchMock.mockImplementation(async (url: unknown) => ({ + ok: true, + json: async () => ({ versions: String(url).includes("/parent") ? { + "1.0.0": { dependencies: { diff: "1.0.0" } }, + "2.0.0": { dependencies: { diff: "2.0.0" } }, + "3.0.0": { dependencies: { diff: "2.0.0" } }, + } : { "1.0.0": {}, "2.0.0": {} } }), + })); + const prefix = member ? ["project", "web", "parent"] : ["project", "parent"]; + const packages: PackageRef[] = [ + { name: "parent", version: "1.0.0", ecosystem: "npm", paths: [prefix], yarnDirectWorkspaces: [member ? "web" : "."] }, + { name: "parent", version: "3.0.0", ecosystem: "npm", paths: [member ? ["project", "parent"] : ["project", "web", "parent"]], yarnDirectWorkspaces: [member ? "." : "web"] }, + ]; + if (reverse) packages.reverse(); + const finding = makeFinding({ pkg: { name: "diff", version: "1.0.0", ecosystem: "npm" }, dependencyPaths: [[...prefix, "diff"]] }); + const result = await resolveChainFix(finding, packages, "yarn", { offline: false, workspaceMemberNames: new Set(["web"]) }); + expect(result?.directDepCurrentVersion).toBe("1.0.0"); + expect(result?.targetVersion).toBe("2.0.0"); + finding.chainResolution = result; + const plan = buildSuggestedFixCommandPlan([finding], { source: "yarn-lock", mode: "resolved-lockfile", filePath: null, packages, notes: [], warnings: [], skippedDependencies: [] }); + expect(plan?.command).toBe(member ? "yarn workspace web add parent@2.0.0" : "yarn add -W parent@2.0.0"); +}); + +it("does not guess when two parent versions have the same display path", async () => { + const finding = makeFinding({ dependencyPaths: [["root", "express", "mime-types"]] }); + expect(await resolveChainFix(finding, installed([["express", "4.17.0"], ["express", "4.20.0"]]), "npm", { offline: false })).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +it("uses graph edges to disambiguate parent versions sharing a display path", async () => { + fetchMock.mockImplementation(async (url: unknown) => ({ ok: true, json: async () => ({ versions: String(url).includes("/express") + ? { "4.17.0": {}, "4.19.0": { dependencies: { "mime-types": "2.0.0" } }, "4.20.0": {} } + : { "2.0.0": {} } }) })); + const finding = makeFinding({ dependencyPaths: [["root", "express", "mime-types"]] }); + const result = await resolveChainFix(finding, installed([["express", "4.17.0"], ["express", "4.20.0"]]), "npm", { + offline: false, + parentGraph: { + nodeIdsFor: () => ["vulnerable"], + parentsFor: id => id === "vulnerable" ? ["affected-parent"] : [], + getNode: id => id === "affected-parent" ? { name: "express", version: "4.17.0" } : { name: "mime-types", version: "1.0.0" }, + }, + }); + expect(result?.directDepCurrentVersion).toBe("4.17.0"); + expect(result?.targetVersion).toBe("4.19.0"); +});