import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { createSpinner } from "../output/spinner.js"; import { chalk } from "./chalk.js"; import type { ParsedOptions } from "../types.js"; import type { SeverityLabel } from "../types.js"; import { pluralize } from "./string.js"; import { isBreakingUpgrade } from "./version.js"; import { buildTargetCommand } from "../remediation/fix-commands.js"; import { formatParentUpdateChildNote } from "../remediation/parent-update-child-note.js"; import type { SuggestedFixCommandPlan, SuggestedFixTarget } from "../remediation/fix-commands.js"; import type { DebugLogger } from "../output/debug.js"; import { redactDeep, redactPath } from "./debug-redaction.js"; const STDERR_LIMIT = 8 * 1024; function fixErrorSummary(stderr: string, cwd: string): string { const withoutControls = stderr .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ""); const withoutSecrets = withoutControls .replace(/\b(authorization\s*[:=]\s*)[^\r\n]+/gi, "$1") .replace(/\b(Bearer\s+)\S+/gi, "$1") .replace(/(["'](?:[a-z_][a-z\d_]*_token|_authToken|_auth|authToken|accessToken|refreshToken|apiKey|password|passwd|token|secret)["']\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}\]]+)/gi, "$1") .replace(/\b((?:[a-z_][a-z\d_]*_token|_authToken|_auth|authToken|accessToken|refreshToken|apiKey|password|passwd|token|secret)\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/gi, "$1"); const label = "stderr: "; const summary = withoutSecrets.split("\n").map(line => { const npmPath = /^(npm ERR!\s+path\s+)(.+)$/i.exec(line); if (npmPath && path.isAbsolute(npmPath[2])) { return `${npmPath[1]}${redactPath(npmPath[2], cwd)}`; } const pathError = /^(.+?):\s+(.+)$/.exec(line); if (pathError && path.isAbsolute(pathError[1])) { const detail = String(redactDeep(label + pathError[2], cwd)).slice(label.length); return `${redactPath(pathError[1], cwd)}: ${detail}`; } // The label prevents redactDeep from treating the full error as one path. return String(redactDeep(label + line, cwd)).slice(label.length); }).join("\n").trim(); const bytes = Buffer.from(summary, "utf8"); if (bytes.length <= STDERR_LIMIT) return summary; let start = bytes.length - STDERR_LIMIT; while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++; return bytes.subarray(start).toString("utf8"); } export async function runInstallCommand( command: string, args: string[], cwd: string, debugLog?: DebugLogger, ): Promise<{ status: number | null; error: Error | null; stderr?: string }> { const commandLine = [command, ...args].join(" "); debugLog?.("Fix command", { command: commandLine, cwd }); return await new Promise(resolve => { let stderr = Buffer.alloc(0); let truncated = false; const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"], }); child.stdout.on("data", () => {}); child.stderr.on("data", (chunk: Buffer) => { const data = Buffer.concat([stderr, chunk]); truncated ||= data.length > STDERR_LIMIT; stderr = data.subarray(Math.max(0, data.length - STDERR_LIMIT)); }); child.on("error", error => { debugLog?.("Fix command finished", { command: commandLine, exitCode: null, error: error.message }); resolve({ status: null, error }); }); child.on("close", code => { debugLog?.("Fix command finished", { command: commandLine, exitCode: code }); if (code === 0) { resolve({ status: code, error: null }); return; } let output = stderr.toString("utf8"); if (truncated) { const firstLineEnd = output.indexOf("\n"); if (firstLineEnd >= 0 && output.slice(firstLineEnd + 1).trim()) { output = output.slice(firstLineEnd + 1); } } resolve({ status: code, error: null, stderr: fixErrorSummary(output, cwd) }); }); }); } export function commandLabelForPackageManager(packageManager: SuggestedFixCommandPlan["packageManager"]): string { if (packageManager === "npm") return "npm install"; if (packageManager === "pnpm") return "pnpm add"; if (packageManager === "bun") return "bun add"; return "yarn add"; } export type UnappliedBreakdown = { // Parent upgrades (parent-upgrade): need review before applying. parentUpgradeCount: number; // Subset of parentUpgradeCount that cross a major version (breaking). breakingUpgradeCount: number; // Findings with no available fix (skip records). noFixCount: number; }; export type FixExecutionResult = { appliedFixCount: number; // Subset of appliedFixCount that are within-range transitive refreshes // (parent-update), as opposed to direct upgrades. appliedWithinRangeRefreshCount: number; applied: Array<{ package: string; from: string; to: string; childPackage?: string; childTargetVersion?: string; }>; note: string | null; } & UnappliedBreakdown; // Categorize the fixes that --fix does NOT auto-apply. Direct upgrades and // within-range refreshes (parent-update) are auto-applied, so only parent // upgrades (breaking ones especially) and no-fix findings remain here. export function categorizeUnappliedTargets(plan: SuggestedFixCommandPlan): UnappliedBreakdown { let parentUpgradeCount = 0; let breakingUpgradeCount = 0; for (const target of plan.targets) { if (target.kind === "parent-upgrade") { parentUpgradeCount += 1; if (target.currentVersion && isBreakingUpgrade(target.currentVersion, target.targetVersion)) { breakingUpgradeCount += 1; } } } return { parentUpgradeCount, breakingUpgradeCount, noFixCount: plan.skipped.length, }; } const EMPTY_UNAPPLIED: UnappliedBreakdown = { parentUpgradeCount: 0, breakingUpgradeCount: 0, noFixCount: 0, }; // Build the command(s) to apply a target. Within-range refresh (parent-update) // targets carry an explicit, already-workspace-scoped command; direct targets // are installed by version. A multi-member workspace command is `&&`-joined and // is split into one spawn per member. function buildApplyCommands( packageManager: SuggestedFixCommandPlan["packageManager"], target: SuggestedFixTarget, ): string[][] { // Use the same workspace-scoped command the report/plan shows, so --fix applies // fixes to the declaring member (npm install -w, pnpm --filter ...) rather than // flat at the root. A multi-member command is `&&`-joined; split per spawn. return buildTargetCommand(target, packageManager) .split("&&") .map(part => part.trim()) .filter(Boolean) .map(part => part.split(/\s+/)); } export type FixResultJson = { appliedFixCount: number; appliedWithinRangeRefreshCount: number; findingsBeforeFix: number; findingsAfterFix: number; applied: Array<{ package: string; from: string; to: string; advisories: string[]; childPackage?: string; childTargetVersion?: string; }>; // Structured breakdown of fixes that were not auto-applied. Additive - existing // consumers reading appliedFixCount / findingsBeforeFix / findingsAfterFix / applied // are unaffected. notAutoApplied: UnappliedBreakdown; }; export function writeFixResultJson(json: FixResultJson, projectPath: string): void { const outPath = path.join(projectPath, "cve-lite-fix-result.json"); fs.writeFileSync(outPath, JSON.stringify(json, null, 2), "utf8"); } export async function applyFixesIfRequested(params: { plan: SuggestedFixCommandPlan | null; projectPath: string; totalFindings: number; options: ParsedOptions; debugLog?: DebugLogger; runCommand?: typeof runInstallCommand; }): Promise { const runCommand = params.runCommand ?? runInstallCommand; console.log(""); console.log(chalk.bold.cyan("Applying fixes (--fix)")); if (!params.plan) { return { appliedFixCount: 0, appliedWithinRangeRefreshCount: 0, ...EMPTY_UNAPPLIED, applied: [], note: "No package-manager-native fix command is available for this project.", }; } // Auto-apply the safe fixes: direct upgrades and within-range transitive // refreshes (parent-update). Parent upgrades (breaking or otherwise) still // need review and are left for the user. const applyTargets = params.plan.targets.filter( target => target.kind === "direct" || target.kind === "parent-update", ); const unapplied = categorizeUnappliedTargets(params.plan); if (applyTargets.length === 0) { return { appliedFixCount: 0, appliedWithinRangeRefreshCount: 0, ...unapplied, applied: [], note: "No fixes were auto-applied. `--fix` auto-applies safe direct upgrades and within-range refreshes; the remaining fixes are parent upgrades that need review. See the Suggested Fix Plan above.", }; } const commandLabel = commandLabelForPackageManager(params.plan.packageManager); const total = applyTargets.length; const spinner = createSpinner( `Applying safe package fixes with ${commandLabel}... 0/${total}`, params.options, ); const fixHint = `\nHint: Check that \`${params.plan.packageManager}\` is available and you have write access to node_modules.\nIf running in CI, ensure the install step has already run before cve-lite --fix.`; // parent-update is keyed per child, so two children behind one parent produce // two targets. npm update names the child, so those commands differ. Two // parents that refresh the same child can still share `npm update `. // Spawn each command once; the displayed plan already dedupes the same way. const seenCommands = new Set(); for (let i = 0; i < applyTargets.length; i++) { const target = applyTargets[i]; for (const commandParts of buildApplyCommands(params.plan.packageManager, target)) { const commandLine = commandParts.join(" "); if (seenCommands.has(commandLine)) continue; seenCommands.add(commandLine); spinner.update(`Applying fix ${i + 1}/${total}: ${commandLine}`); const run = await runCommand( commandParts[0], commandParts.slice(1), params.projectPath, params.debugLog, ); if (run.error) { spinner.fail("Failed to apply fixes"); throw new Error(`Failed to apply fixes: ${fixErrorSummary(run.error.message, params.projectPath)}${fixHint}`); } if ((run.status ?? 1) !== 0) { spinner.fail(`Fix command failed (${commandLabel})`); const detail = run.stderr ? `\n${fixErrorSummary(run.stderr, params.projectPath)}` : ""; throw new Error(`Fix command exited with status ${run.status ?? 1}${detail}${fixHint}`); } } } spinner.succeed(`Applied ${applyTargets.length} package ${pluralize(applyTargets.length, "fix", "fixes")} with ${commandLabel}`); return { appliedFixCount: applyTargets.length, appliedWithinRangeRefreshCount: applyTargets.filter(target => target.kind === "parent-update").length, ...unapplied, applied: applyTargets.map(target => ({ package: target.package, from: target.currentVersion ?? "unknown", to: target.targetVersion, ...(target.childPackage ? { childPackage: target.childPackage } : {}), ...(target.childTargetVersion ? { childTargetVersion: target.childTargetVersion } : {}), })), note: null, }; } export function printFixModeSummary(params: { fixResult: FixExecutionResult | null; findingsBeforeFix: number; findingsAfterFix: number; remainingBySeverity: Record; }): void { const result = params.fixResult; if (!result) return; if (result.note) { console.log(chalk.gray(result.note)); } if (result.applied.length > 0) { console.log(""); console.log(chalk.bold.cyan("Applied fixes")); for (const item of result.applied) { const childNote = formatParentUpdateChildNote(item); const line = `- ${chalk.white(item.package)}: ${chalk.gray(item.from)} ${chalk.cyan("->")} ${chalk.green(item.to)}`; console.log(childNote ? `${line} (${childNote})` : line); } } console.log(""); console.log(chalk.bold.cyan("Fix summary")); const refreshNote = result.appliedWithinRangeRefreshCount > 0 ? ` (incl. ${result.appliedWithinRangeRefreshCount} within-range ${pluralize(result.appliedWithinRangeRefreshCount, "refresh", "refreshes")})` : ""; console.log(`- Applied fixes: ${chalk.green(String(result.appliedFixCount))}${chalk.gray(refreshNote)}`); const notAutoApplied = result.parentUpgradeCount + result.noFixCount; console.log(`- Not auto-applied: ${chalk.yellow(String(notAutoApplied))}`); if (result.parentUpgradeCount > 0) { const breakingNote = result.breakingUpgradeCount > 0 ? `, ${result.breakingUpgradeCount} breaking` : ""; console.log(` - Parent upgrades (review + test): ${chalk.yellow(String(result.parentUpgradeCount))}${chalk.gray(breakingNote)}`); } if (result.noFixCount > 0) { console.log(` - No fix available: ${chalk.yellow(String(result.noFixCount))}`); } console.log(`- Findings before fix: ${chalk.white(String(params.findingsBeforeFix))}`); console.log(`- Remaining findings after fix: ${chalk.white(String(params.findingsAfterFix))}`); console.log( `- Remaining severity mix: critical ${chalk.redBright(String(params.remainingBySeverity.critical))}, high ${chalk.red(String(params.remainingBySeverity.high))}, medium ${chalk.yellow(String(params.remainingBySeverity.medium))}, low ${chalk.blueBright(String(params.remainingBySeverity.low))}, unknown ${chalk.magenta(String(params.remainingBySeverity.unknown))}`, ); }