diff --git a/src/cli/args.ts b/src/cli/args.ts index 705c6fd..37aad25 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -121,6 +121,22 @@ export function parseArgs(argv: string[]): { options.fix = true; continue; } + if (arg === "--create-pr") { + options.createPr = true; + continue; + } + if (arg === "--base") { + const val = argv[++i]; + if (!val) throw new Error("--base requires a branch name"); + options.prBase = val; + continue; + } + if (arg.startsWith("--base=")) { + const val = arg.slice("--base=".length); + if (!val) throw new Error("--base requires a branch name"); + options.prBase = val; + continue; + } if (arg === "--prod-only") { options.prodOnly = true; continue; diff --git a/src/cli/help.ts b/src/cli/help.ts index ae1b72d..1bf1717 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -39,6 +39,8 @@ export function printHelp(): void { " --cdx Write CycloneDX 1.4 SBOM to a timestamped .cdx.json file", " --no-open Don't auto-open the report in the browser", " --fix Apply validated direct dependency fixes and rescan", + " --create-pr After --fix, commit changes and open a GitHub pull request (requires gh)", + " --base Base branch for --create-pr (default: main)", " --osv-url Use a custom OSV-compatible advisory endpoint", " --ca-cert Path to a CA certificate file for corporate SSL proxies", " --debug Write verbose runtime/network diagnostics to a timestamped log file", diff --git a/src/cli/validate.ts b/src/cli/validate.ts index 46f456b..fff3da9 100644 --- a/src/cli/validate.ts +++ b/src/cli/validate.ts @@ -22,6 +22,18 @@ export function validateOptions(options: ParsedOptions): void { throw new Error("--fix cannot be used with --json"); } + if (options.createPr && !options.fix) { + throw new Error("--create-pr requires --fix"); + } + + if (options.createPr && options.json) { + throw new Error("--create-pr cannot be used with --json"); + } + + if (options.prBase && !options.createPr) { + throw new Error("--base can only be used with --create-pr"); + } + if (options.report && options.json) { throw new Error("--report cannot be used with --json"); } diff --git a/src/index.ts b/src/index.ts index 5308269..257edb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,10 @@ import { } from "./utils/fix-runner.js"; import { hasRootLockfile, findNestedLockfiles } from "./parsers/multi-package.js"; import { handleMultiFolderScan } from "./scan/multi-folder-scan.js"; +import { + createPullRequestForFixes, + findingsMeetFailOnThreshold, +} from "./utils/create-pr.js"; let parsedArgs: ReturnType | null = null; try { parsedArgs = parseArgs(process.argv.slice(2)); @@ -181,7 +185,7 @@ if (parsedArgs) { return; } - let advisorySourceLine: string; + let advisorySourceLine = ""; let advisoryDbFreshnessLine: string | null = null; let advisoryDbWarning: string | null = null; try { @@ -219,7 +223,9 @@ if (parsedArgs) { if (options.offline || options.offlineDb) { console.log(chalk.gray("Offline mode:") + " " + chalk.yellow("enabled") + " " + chalk.gray("(no external advisory calls will be made)")); } - console.log(`${chalk.gray("Advisory source:")} ${formatAdvisorySourceLine(advisorySourceLine)}`); + if (advisorySourceLine) { + console.log(`${chalk.gray("Advisory source:")} ${formatAdvisorySourceLine(advisorySourceLine)}`); + } if (advisoryDbFreshnessLine) { console.log(`${chalk.gray("Advisory DB freshness:")} ${advisoryDbFreshnessLine}`); } @@ -270,7 +276,8 @@ if (parsedArgs) { projectPath, debugLog, }); - const findingsBeforeFix = scanState.sorted.length; + const findingsBeforeFixList = scanState.sorted; + const findingsBeforeFix = findingsBeforeFixList.length; let fixResult: FixExecutionResult | null = null; if (options.fix) { @@ -310,6 +317,30 @@ if (parsedArgs) { findingsAfterFix: scanState.sorted.length, remainingBySeverity: countBySeverity(scanState.sorted), }); + + if (options.createPr && fixResult) { + if (fixResult.appliedFixCount === 0) { + logWarn("Skipping pull request creation: no direct fixes were applied.", options); + } else { + console.log(""); + console.log(chalk.bold.cyan("Creating pull request (--create-pr)")); + const prResult = await createPullRequestForFixes({ + projectPath, + baseBranch: options.prBase ?? "main", + fixResult, + findingsBeforeFix: findingsBeforeFixList, + findingsAfterFix: scanState.sorted, + }); + if (prResult.skipped) { + logWarn(prResult.skipReason ?? "Pull request was not created.", options); + } else if (prResult.prUrl) { + console.log(`${chalk.gray("Pull request:")} ${chalk.cyan(prResult.prUrl)}`); + console.log(`${chalk.gray("Branch:")} ${chalk.cyan(prResult.branchName)}`); + } else { + logWarn(`Branch ${prResult.branchName} was pushed, but no pull request URL was returned.`, options); + } + } + } } else { await writeOutputs(options, { sorted: scanState.sorted, diff --git a/src/types.ts b/src/types.ts index df49889..9003ce7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -161,6 +161,8 @@ export type ParsedOptions = { debug?: boolean; verbose?: boolean; fix?: boolean; + createPr?: boolean; + prBase?: string; prodOnly?: boolean; failOn: string; batchSize: string; diff --git a/src/utils/create-pr.ts b/src/utils/create-pr.ts new file mode 100644 index 0000000..9a70eca --- /dev/null +++ b/src/utils/create-pr.ts @@ -0,0 +1,290 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { Finding } from "../types.js"; +import { severityOrder } from "../constants.js"; +import { normalizeSeverity } from "../osv/severity.js"; +import { pluralize } from "./string.js"; +import type { FixExecutionResult } from "./fix-runner.js"; + +export type CreatePullRequestParams = { + projectPath: string; + baseBranch: string; + fixResult: FixExecutionResult; + findingsBeforeFix: Finding[]; + findingsAfterFix: Finding[]; +}; + +export type CreatePullRequestResult = { + branchName: string; + prUrl: string | null; + skipped: boolean; + skipReason?: string; +}; + +type CommandResult = { + stdout: string; + stderr: string; + status: number | null; + error: Error | null; +}; + +const DEPENDENCY_FILES_TO_STAGE = [ + "package.json", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lock", + "npm-shrinkwrap.json", +] as const; + +export function findingsMeetFailOnThreshold(findings: Finding[], failOn: string): boolean { + const failLevel = normalizeSeverity(failOn); + return findings.some(finding => severityOrder[finding.severity] >= severityOrder[failLevel]); +} + +export function defaultFixBranchName(date = new Date()): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `cve-lite/fix-${year}-${month}-${day}`; +} + +export function buildPullRequestTitle(appliedCount: number): string { + return `fix: resolve ${appliedCount} vulnerable ${pluralize(appliedCount, "dependency", "dependencies")}`; +} + +export function collectAdvisoryIdsForPackage(findings: Finding[], packageName: string): string[] { + const ids = new Set(); + for (const finding of findings) { + if (finding.pkg.name !== packageName) continue; + for (const vuln of finding.vulnerabilities) { + ids.add(vuln.id); + for (const alias of vuln.aliases ?? []) { + if (alias.startsWith("CVE-")) { + ids.add(alias); + } + } + } + } + return [...ids].sort(); +} + +export function buildPullRequestBody(params: { + fixResult: FixExecutionResult; + findingsBeforeFix: Finding[]; + findingsAfterFix: Finding[]; +}): string { + const lines: string[] = [ + "## Summary", + "", + "Automated security fixes applied by [CVE Lite CLI](https://github.com/OWASP/cve-lite-cli) using OSV-validated target versions.", + "", + "## Fixed packages", + "", + ]; + + for (const item of params.fixResult.applied) { + const advisoryIds = collectAdvisoryIdsForPackage(params.findingsBeforeFix, item.package); + const advisoryText = advisoryIds.length > 0 ? advisoryIds.join(", ") : "n/a"; + lines.push(`- **${item.package}**: \`${item.from}\` → \`${item.to}\``); + lines.push(` - Advisories: ${advisoryText}`); + } + + lines.push( + "", + "## Scan results", + "", + `- Findings before fix: **${params.findingsBeforeFix.length}**`, + `- Findings after fix: **${params.findingsAfterFix.length}**`, + "", + "## Notes", + "", + "- Only validated **direct** dependency upgrades are included in this PR.", + "- Transitive vulnerabilities may still require parent package upgrades or upstream releases.", + ); + + return lines.join("\n"); +} + +async function runCommand(command: string, args: string[], cwd: string): Promise { + return await new Promise(resolve => { + let stdout = ""; + let stderr = ""; + const child = spawn(command, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.on("data", chunk => { + stdout += String(chunk); + }); + child.stderr?.on("data", chunk => { + stderr += String(chunk); + }); + + child.on("error", error => { + resolve({ stdout, stderr, status: null, error }); + }); + child.on("close", code => { + resolve({ stdout, stderr, status: code, error: null }); + }); + }); +} + +async function assertGitRepository(projectPath: string): Promise { + const result = await runCommand("git", ["rev-parse", "--is-inside-work-tree"], projectPath); + if (result.error || result.status !== 0 || result.stdout.trim() !== "true") { + throw new Error("--create-pr requires a git repository. Run this command from your project root."); + } +} + +async function assertGhAvailable(): Promise { + const version = await runCommand("gh", ["--version"], process.cwd()); + if (version.error || version.status !== 0) { + throw new Error( + "--create-pr requires the GitHub CLI (gh). Install it from https://cli.github.com/ or set GITHUB_TOKEN and ensure gh is on PATH.", + ); + } + + const auth = await runCommand("gh", ["auth", "status"], process.cwd()); + if (auth.status !== 0 && !process.env.GITHUB_TOKEN) { + throw new Error( + "--create-pr requires GitHub authentication. Run `gh auth login` or set the GITHUB_TOKEN environment variable.", + ); + } +} + +async function hasAnyChanges(projectPath: string): Promise { + const status = await runCommand("git", ["status", "--porcelain"], projectPath); + if (status.error || status.status !== 0) { + throw new Error(`Failed to inspect git status: ${status.stderr.trim() || status.error?.message || "unknown error"}`); + } + return status.stdout.trim().length > 0; +} + +export async function stageDependencyFilesOnly(projectPath: string): Promise { + for (const filePath of DEPENDENCY_FILES_TO_STAGE) { + if (!fs.existsSync(path.join(projectPath, filePath))) { + continue; + } + const add = await runCommand("git", ["add", "--", filePath], projectPath); + if (add.error || add.status !== 0) { + throw new Error(`Failed to stage dependency file ${filePath}: ${add.stderr.trim() || add.error?.message || "unknown error"}`); + } + } +} + +export async function hasStagedDependencyChanges(projectPath: string): Promise { + const diff = await runCommand("git", ["diff", "--cached", "--name-only"], projectPath); + if (diff.error || diff.status !== 0) { + throw new Error(`Failed to inspect staged changes: ${diff.stderr.trim() || diff.error?.message || "unknown error"}`); + } + const stagedFiles = diff.stdout + .split("\n") + .map(line => line.trim()) + .filter(Boolean); + return stagedFiles.some(filePath => + DEPENDENCY_FILES_TO_STAGE.some(target => filePath === target || filePath.endsWith(`/${target}`)), + ); +} + +async function branchExists(projectPath: string, branchName: string): Promise { + const result = await runCommand("git", ["rev-parse", "--verify", branchName], projectPath); + return result.status === 0; +} + +export async function selectAvailableBranchName(projectPath: string, baseName: string): Promise { + if (!(await branchExists(projectPath, baseName))) { + return baseName; + } + for (let suffix = 2; suffix <= 99; suffix++) { + const candidate = `${baseName}-${suffix}`; + if (!(await branchExists(projectPath, candidate))) { + return candidate; + } + } + throw new Error(`Failed to allocate a unique branch name based on ${baseName}`); +} + +function extractPullRequestUrl(output: string): string | null { + const match = output.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/); + return match?.[0] ?? null; +} + +export async function createPullRequestForFixes( + params: CreatePullRequestParams, +): Promise { + const baseBranchName = defaultFixBranchName(); + + if (params.fixResult.appliedFixCount === 0) { + return { + branchName: baseBranchName, + prUrl: null, + skipped: true, + skipReason: "No direct dependency fixes were applied, so no pull request was created.", + }; + } + + await assertGitRepository(params.projectPath); + await assertGhAvailable(); + + if (!(await hasAnyChanges(params.projectPath))) { + return { + branchName: baseBranchName, + prUrl: null, + skipped: true, + skipReason: "No lockfile or manifest changes were detected after applying fixes.", + }; + } + + const title = buildPullRequestTitle(params.fixResult.appliedFixCount); + const body = buildPullRequestBody({ + fixResult: params.fixResult, + findingsBeforeFix: params.findingsBeforeFix, + findingsAfterFix: params.findingsAfterFix, + }); + + await stageDependencyFilesOnly(params.projectPath); + if (!(await hasStagedDependencyChanges(params.projectPath))) { + return { + branchName: baseBranchName, + prUrl: null, + skipped: true, + skipReason: "No dependency manifest or lockfile changes were detected after applying fixes.", + }; + } + + const branchName = await selectAvailableBranchName(params.projectPath, baseBranchName); + const checkout = await runCommand("git", ["checkout", "-b", branchName], params.projectPath); + if (checkout.error || checkout.status !== 0) { + const message = checkout.stderr.trim() || checkout.error?.message || "unknown error"; + throw new Error(`Failed to create branch ${branchName}: ${message}`); + } + + const commit = await runCommand("git", ["commit", "-m", title], params.projectPath); + if (commit.error || commit.status !== 0) { + throw new Error(`Failed to commit dependency changes: ${commit.stderr.trim() || commit.error?.message || "unknown error"}`); + } + + const push = await runCommand("git", ["push", "-u", "origin", branchName], params.projectPath); + if (push.error || push.status !== 0) { + throw new Error(`Failed to push branch ${branchName}: ${push.stderr.trim() || push.error?.message || "unknown error"}`); + } + + const create = await runCommand( + "gh", + ["pr", "create", "--base", params.baseBranch, "--head", branchName, "--title", title, "--body", body], + params.projectPath, + ); + if (create.error || create.status !== 0) { + throw new Error(`Failed to open pull request: ${create.stderr.trim() || create.error?.message || "unknown error"}`); + } + + const prUrl = extractPullRequestUrl(`${create.stdout}\n${create.stderr}`); + return { + branchName, + prUrl, + skipped: false, + }; +} diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index 98e3c4e..6f59eec 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -81,13 +81,12 @@ jest.unstable_mockModule("../src/advisory/osv-sync.js", () => ({ })); jest.unstable_mockModule("../src/output/formatters.js", () => ({ - formatAdvisorySourceLine: (value: string) => value, + formatAdvisorySourceLine: jest.fn((sourceLabel: string) => sourceLabel), logInfo: logInfoMock, logWarn: logWarnMock, printCacheSummary: printCacheSummaryMock, serializeFinding: serializeFindingMock, sortFindingsForOutput: sortFindingsForOutputMock, - formatAdvisorySourceLine: jest.fn(sourceLabel => sourceLabel), getRecommendedAction: jest.fn(() => "Upgrade to latest"), })); @@ -905,6 +904,25 @@ describe("CLI integration", () => { expect(result.stderr.join("\n")).toContain("--fix cannot be used with --json"); }); + it("fails fast when --create-pr is used without --fix", async () => { + parseArgsMock.mockReturnValue({ + command: "scan", + options: { + createPr: true, + failOn: "critical", + batchSize: "100", + searchDepth: "4", + minSeverity: "medium", + }, + projectArg: ".", + }); + + const result = await runIndexModule(); + + expect(result.exitCode).toBe(1); + expect(result.stderr.join("\n")).toContain("--create-pr requires --fix"); + }); + it("throws when --no-cache is used with --offline", async () => { parseArgsMock.mockReturnValue({ command: "scan", diff --git a/tests/create-pr.test.ts b/tests/create-pr.test.ts new file mode 100644 index 0000000..d176beb --- /dev/null +++ b/tests/create-pr.test.ts @@ -0,0 +1,122 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildPullRequestBody, + buildPullRequestTitle, + collectAdvisoryIdsForPackage, + defaultFixBranchName, + findingsMeetFailOnThreshold, + selectAvailableBranchName, + stageDependencyFilesOnly, +} from "../src/utils/create-pr.js"; +import type { Finding, OsvVuln } from "../src/types.js"; +import { validateOptions } from "../src/cli/validate.js"; +import { parseArgs } from "../src/cli/args.js"; + +function createFinding(overrides?: Partial): Finding { + const vuln: OsvVuln = { + id: "GHSA-abc", + aliases: ["CVE-2026-0001"], + summary: "Example", + }; + + return { + pkg: { name: "lodash", version: "4.17.20", ecosystem: "npm" }, + vulnerabilities: [vuln], + severity: "high", + cveAliases: ["CVE-2026-0001"], + dependencyPaths: [["project", "lodash"]], + relationship: "direct", + firstFixedVersion: "4.17.21", + ...overrides, + }; +} + +describe("create-pr helpers", () => { + it("builds a dated branch name", () => { + expect(defaultFixBranchName(new Date("2026-05-30T12:00:00Z"))).toBe("cve-lite/fix-2026-05-30"); + }); + + it("builds a pull request title for applied fixes", () => { + expect(buildPullRequestTitle(1)).toBe("fix: resolve 1 vulnerable dependency"); + expect(buildPullRequestTitle(3)).toBe("fix: resolve 3 vulnerable dependencies"); + }); + + it("collects OSV and CVE identifiers for a package", () => { + const findings = [createFinding()]; + expect(collectAdvisoryIdsForPackage(findings, "lodash")).toEqual(["CVE-2026-0001", "GHSA-abc"]); + }); + + it("builds a markdown body with fixes and scan counts", () => { + const body = buildPullRequestBody({ + fixResult: { + appliedFixCount: 1, + skippedCount: 0, + skippedTransitiveCount: 0, + skippedNoValidatedTargetCount: 0, + applied: [{ package: "lodash", from: "4.17.20", to: "4.17.21" }], + note: null, + }, + findingsBeforeFix: [createFinding(), createFinding({ pkg: { name: "express", version: "4.0.0", ecosystem: "npm" } })], + findingsAfterFix: [createFinding()], + }); + + expect(body).toContain("lodash"); + expect(body).toContain("4.17.20"); + expect(body).toContain("4.17.21"); + expect(body).toContain("CVE-2026-0001"); + expect(body).toContain("Findings before fix: **2**"); + expect(body).toContain("Findings after fix: **1**"); + expect(body).not.toContain("Closes #367"); + }); + + it("checks fail-on threshold against initial findings", () => { + expect(findingsMeetFailOnThreshold([createFinding({ severity: "high" })], "critical")).toBe(false); + expect(findingsMeetFailOnThreshold([createFinding({ severity: "critical" })], "critical")).toBe(true); + }); + + it("adds numeric suffix when branch already exists", async () => { + const branch = await selectAvailableBranchName(process.cwd(), "cve-lite/fix-2026-06-02"); + expect(branch).toMatch(/^cve-lite\/fix-2026-06-02(?:-\d+)?$/); + }); + + it("stages only existing dependency files without failing on missing lockfiles", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-create-pr-")); + try { + execSync("git init", { cwd: tmpDir, stdio: "ignore" }); + execSync('git config user.email "test@example.com"', { cwd: tmpDir, stdio: "ignore" }); + execSync('git config user.name "Test User"', { cwd: tmpDir, stdio: "ignore" }); + + fs.writeFileSync(path.join(tmpDir, "package.json"), '{"name":"test"}\n'); + fs.writeFileSync(path.join(tmpDir, "package-lock.json"), '{"lockfileVersion":3}\n'); + execSync("git add package.json package-lock.json", { cwd: tmpDir, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: tmpDir, stdio: "ignore" }); + + fs.writeFileSync(path.join(tmpDir, "package.json"), '{"name":"test","version":"1.0.1"}\n'); + + await expect(stageDependencyFilesOnly(tmpDir)).resolves.toBeUndefined(); + + const staged = execSync("git diff --cached --name-only", { cwd: tmpDir, encoding: "utf8" }); + expect(staged.trim().split("\n")).toEqual(["package.json"]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe("create-pr CLI options", () => { + it("parses --create-pr and --base", () => { + const result = parseArgs([".", "--fix", "--create-pr", "--base", "develop"]); + expect(result.options.fix).toBe(true); + expect(result.options.createPr).toBe(true); + expect(result.options.prBase).toBe("develop"); + }); + + it("requires --fix for --create-pr", () => { + expect(() => validateOptions({ failOn: "critical", batchSize: "100", searchDepth: "4", createPr: true })).toThrow( + "--create-pr requires --fix", + ); + }); +});