diff --git a/CHANGELOG.md b/CHANGELOG.md index 962d957..b7ab2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Rename `audit-fix` command to `fix-vulnerabilities` +- `fix-vulnerabilities` also runs `dotnet package update --vulnerable` for NuGet repositories + ## [5.1.0] - 2026-08-06 ### Added diff --git a/src/bin.ts b/src/bin.ts index 3a51195..396e866 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -14,7 +14,7 @@ import startProductRelease from './startProductRelease.js' import promptForReleaseName from './promptForReleaseName.js' import getRepositoryPlugin from './repositories-plugins/plugins-factory.js' import startUpdateDependents from './startUpdateDependents.js' -import startAuditFix from './startAuditFix.js' +import startFixVulnerabilities from './startFixVulnerabilities.js' import waitForNpmPackageVersion from './waitForNpmPackageVersion.js' import { readPackageUp } from 'read-package-up' @@ -35,12 +35,14 @@ ${chalk.bold('Examples')} oneblink-release product oneblink-release product --name="Inappropriate Release Name" -${chalk.bold.blue('oneblink-release audit-fix [--force] [--ticket]')} +${chalk.bold.blue('oneblink-release fix-vulnerabilities [--force] [--ticket]')} ${chalk.grey( - `Run "npm audit fix --package-lock-only" across each Product repository. Where -package-lock.json changes, create a shared branch, commit, push, and create pull -requests when GITHUB_OAUTH_TOKEN is set (otherwise print create-PR URLs).`, + `Run "npm audit fix --package-lock-only" for NPM repositories and "dotnet +package update --vulnerable" for NuGet repositories across each Product +repository. Where dependency files change, create a shared branch, commit, +push, and create pull requests when GITHUB_OAUTH_TOKEN is set (otherwise print +create-PR URLs).`, )} --force ......... Skip the ticket prompt. Requires --ticket. @@ -51,9 +53,9 @@ requests when GITHUB_OAUTH_TOKEN is set (otherwise print create-PR URLs).`, ${chalk.bold('Examples')} - oneblink-release audit-fix - oneblink-release audit-fix --ticket ON-4323 - oneblink-release audit-fix --force --ticket ON-4323 + oneblink-release fix-vulnerabilities + oneblink-release fix-vulnerabilities --ticket ON-4323 + oneblink-release fix-vulnerabilities --force --ticket ON-4323 ${chalk.bold.blue( 'oneblink-release repository [next-version] [--no-git] [--name] [--no-name] [--cwd path] [--update-dependents] [--force] [--force-update-dependency] [--force-publish-intermediate-dependency] [--ticket]', @@ -236,8 +238,8 @@ async function run(): Promise { }) break } - case 'audit-fix': { - await startAuditFix({ + case 'fix-vulnerabilities': { + await startFixVulnerabilities({ ticket: cli.flags.ticket, force: cli.flags.force, }) diff --git a/src/repositories-plugins/NpmPlugin.ts b/src/repositories-plugins/NpmPlugin.ts index bb54289..69ff68e 100644 --- a/src/repositories-plugins/NpmPlugin.ts +++ b/src/repositories-plugins/NpmPlugin.ts @@ -1,8 +1,17 @@ +import fs from 'fs/promises' +import path from 'path' import { readPackageUp } from 'read-package-up' import { main as packageDiffSummary } from '../package-diff-summary/index.js' import { SemVer } from 'semver' import executeCommand from '../executeCommand.js' -import { RepositoryPlugin } from './RepositoryPlugin.js' +import { + hasFileChanges, + runCommandAllowingRemainingVulnerabilities, +} from './fix-vulnerabilities-helpers.js' +import { + FixVulnerabilitiesResult, + RepositoryPlugin, +} from './RepositoryPlugin.js' export default class NpmPlugin implements RepositoryPlugin { displayType = 'NPM' @@ -59,4 +68,49 @@ export default class NpmPlugin implements RepositoryPlugin { throw error } } + + async fixVulnerabilities({ + ticket, + repositoryName, + }: { + ticket: string + repositoryName: string + }): Promise { + const packageLockPath = path.join(this.cwd, 'package-lock.json') + try { + await fs.stat(packageLockPath) + } catch { + console.log( + `Skipping "${repositoryName}" as it does not contain a package-lock.json file.`, + ) + return + } + + await runCommandAllowingRemainingVulnerabilities({ + command: 'npm', + args: ['audit', 'fix', '--package-lock-only', '--audit-level=none'], + cwd: this.cwd, + hasChanges: () => hasFileChanges(this.cwd, 'package-lock.json'), + }) + + if (!(await hasFileChanges(this.cwd, 'package-lock.json'))) { + console.log( + `Skipping "${repositoryName}" as npm audit fix did not change package-lock.json.`, + ) + return + } + + const filesToStage = ['package-lock.json'] + + // npm audit fix can also update package.json when dependency ranges change + if (await hasFileChanges(this.cwd, 'package.json')) { + filesToStage.push('package.json') + } + + return { + filesToStage, + commitMessage: `${ticket} # npm audit fix`, + pullRequestBody: 'Automated `npm audit fix`.', + } + } } diff --git a/src/repositories-plugins/NugetPlugin.ts b/src/repositories-plugins/NugetPlugin.ts index 0fd9777..96cbf89 100644 --- a/src/repositories-plugins/NugetPlugin.ts +++ b/src/repositories-plugins/NugetPlugin.ts @@ -2,7 +2,14 @@ import { readFile, writeFile } from 'fs/promises' import path from 'path' import { SemVer } from 'semver' import getPreRelease from '../getPreRelease.js' -import { RepositoryPlugin } from './RepositoryPlugin.js' +import { + getChangedFilesMatching, + runCommandAllowingRemainingVulnerabilities, +} from './fix-vulnerabilities-helpers.js' +import { + FixVulnerabilitiesResult, + RepositoryPlugin, +} from './RepositoryPlugin.js' export default class NugetPlugin implements RepositoryPlugin { isDeploymentRequired = false @@ -49,4 +56,40 @@ export default class NugetPlugin implements RepositoryPlugin { ) await writeFile(projectFile, newFileContents, 'utf-8') } + + async fixVulnerabilities({ + ticket, + repositoryName, + }: { + ticket: string + repositoryName: string + }): Promise { + await runCommandAllowingRemainingVulnerabilities({ + command: 'dotnet', + args: [ + 'package', + 'update', + '--vulnerable', + '--project', + this.relativeProjectFile, + ], + cwd: this.cwd, + hasChanges: async () => + (await getChangedFilesMatching(this.cwd, '*.csproj')).length > 0, + }) + + const filesToStage = await getChangedFilesMatching(this.cwd, '*.csproj') + if (!filesToStage.length) { + console.log( + `Skipping "${repositoryName}" as dotnet package update --vulnerable did not change csproj files.`, + ) + return + } + + return { + filesToStage, + commitMessage: `${ticket} # dotnet package update --vulnerable`, + pullRequestBody: 'Automated `dotnet package update --vulnerable`.', + } + } } diff --git a/src/repositories-plugins/RepositoryPlugin.ts b/src/repositories-plugins/RepositoryPlugin.ts index 4f8460c..6067ce8 100644 --- a/src/repositories-plugins/RepositoryPlugin.ts +++ b/src/repositories-plugins/RepositoryPlugin.ts @@ -1,5 +1,11 @@ import { SemVer } from 'semver' +export type FixVulnerabilitiesResult = { + filesToStage: string[] + commitMessage: string + pullRequestBody: string +} + export interface RepositoryPlugin { isDeploymentRequired: boolean supportsDependencyUpdates: boolean @@ -20,4 +26,9 @@ export interface RepositoryPlugin { | { result: 'ENTRIES'; entries: string | undefined } | { result: 'WARNING'; message: string } > + + fixVulnerabilities?: (options: { + ticket: string + repositoryName: string + }) => Promise } diff --git a/src/repositories-plugins/fix-vulnerabilities-helpers.ts b/src/repositories-plugins/fix-vulnerabilities-helpers.ts new file mode 100644 index 0000000..d670bc5 --- /dev/null +++ b/src/repositories-plugins/fix-vulnerabilities-helpers.ts @@ -0,0 +1,82 @@ +import { execa } from 'execa' +import wrapWithLoading from '../wrapWithLoading.js' + +export async function runCommandAllowingRemainingVulnerabilities({ + command, + args, + cwd, + hasChanges, +}: { + command: string + args: string[] + cwd: string + hasChanges: () => Promise +}) { + const log = `"${command} ${args.join(' ')}"` + return await wrapWithLoading( + { + startText: `Running ${log}`, + failText: `Failed to run ${log}`, + }, + async (spinner) => { + // Both npm audit fix and dotnet package update --vulnerable can exit + // non-zero when vulnerabilities remain after applying available fixes + const result = await execa(command, args, { + cwd, + reject: false, + }) + + if (result.exitCode !== 0 && !(await hasChanges())) { + spinner.fail(`Failed to run ${log}`) + throw new Error( + result.stderr || + result.stdout || + `${command} ${args.join(' ')} failed with exit code ${result.exitCode}`, + ) + } + + spinner.succeed(`Ran ${log}`) + return result + }, + ) +} + +export async function hasFileChanges( + cwd: string, + fileName: string, +): Promise { + const { stdout } = await execa( + 'git', + ['status', '--porcelain', '--', fileName], + { + cwd, + }, + ) + return Boolean(stdout.trim()) +} + +export async function getChangedFilesMatching( + cwd: string, + ...pathspecs: string[] +): Promise { + const { stdout } = await execa( + 'git', + ['status', '--porcelain', '--', ...pathspecs], + { + cwd, + }, + ) + + return stdout + .split('\n') + .filter(Boolean) + .map((line) => { + // Porcelain status is always 2 characters followed by a space + const pathPart = line.slice(3) + const renameSeparator = ' -> ' + const renameIndex = pathPart.indexOf(renameSeparator) + return renameIndex === -1 + ? pathPart.trim() + : pathPart.slice(renameIndex + renameSeparator.length).trim() + }) +} diff --git a/src/startAuditFix.ts b/src/startAuditFix.ts deleted file mode 100644 index 946bc32..0000000 --- a/src/startAuditFix.ts +++ /dev/null @@ -1,230 +0,0 @@ -import fs from 'fs/promises' -import path from 'path' -import boxen from 'boxen' -import chalk from 'chalk' -import { execa } from 'execa' -import enumerateProductRepositories from './enumerateProductRepositories.js' -import executeCommand from './executeCommand.js' -import wrapWithLoading from './wrapWithLoading.js' -import createOrLinkPullRequest, { - createPullRequestOctokit, -} from './createOrLinkPullRequest.js' -import resolveTicket from './resolveTicket.js' - -export default async function startAuditFix({ - ticket: ticketFlag, - force = false, -}: { - ticket?: string - force?: boolean -}) { - const ticket = await resolveTicket({ ticketFlag, force }) - const octokit = createPullRequestOctokit() - - console.log( - `Beginning npm audit fix across product repositories using branch "${ticket}"`, - ) - - const pullRequestUrls: string[] = [] - const fixedRepositories: string[] = [] - let createdAnyPullRequest = false - let completedSuccessfully = false - - try { - await enumerateProductRepositories( - async ({ productRepository, repositoryWorkingDirectory }) => { - const { repositoryName, type } = productRepository - - if (type === 'NUGET') { - console.log( - `Skipping "${repositoryName}" as NuGet repositories do not support npm audit fix.`, - ) - return - } - - const packageLockPath = path.join( - repositoryWorkingDirectory, - 'package-lock.json', - ) - if (!(await fileExists(packageLockPath))) { - console.log( - `Skipping "${repositoryName}" as it does not contain a package-lock.json file.`, - ) - return - } - - await runNpmAuditFix(repositoryWorkingDirectory) - - const packageLockChanged = await hasPackageLockChanges( - repositoryWorkingDirectory, - ) - if (!packageLockChanged) { - console.log( - `Skipping "${repositoryName}" as npm audit fix did not change package-lock.json.`, - ) - return - } - - const commitMessage = `${ticket} # npm audit fix` - - await executeCommand( - 'git', - ['checkout', '-b', ticket], - repositoryWorkingDirectory, - ) - await executeCommand( - 'git', - ['add', 'package-lock.json'], - repositoryWorkingDirectory, - ) - - // npm audit fix can also update package.json when dependency ranges change - const packageJsonChanged = await hasFileChanges( - repositoryWorkingDirectory, - 'package.json', - ) - if (packageJsonChanged) { - await executeCommand( - 'git', - ['add', 'package.json'], - repositoryWorkingDirectory, - ) - } - - await executeCommand( - 'git', - ['commit', '--message', commitMessage], - repositoryWorkingDirectory, - ) - await executeCommand( - 'git', - ['push', '-u', 'origin', ticket], - repositoryWorkingDirectory, - ) - - const pullRequest = await createOrLinkPullRequest({ - octokit, - repositoryName, - ticket, - title: commitMessage, - body: 'Automated `npm audit fix`.', - }) - - fixedRepositories.push(repositoryName) - pullRequestUrls.push(pullRequest.url) - if (pullRequest.created) { - createdAnyPullRequest = true - } - }, - ) - - completedSuccessfully = true - } finally { - console.log( - boxen( - chalk[completedSuccessfully ? 'green' : 'yellow']( - completedSuccessfully - ? 'npm audit fix complete!!!' - : 'npm audit fix stopped after an error', - ), - { - padding: 1, - }, - ), - ) - - if (fixedRepositories.length) { - console.log( - boxen( - `The following repositories had fixes applied: - - ${fixedRepositories.join(` - `)}`, - { - padding: 1, - }, - ), - ) - } - - if (pullRequestUrls.length) { - const pullRequestHeading = createdAnyPullRequest - ? 'The following Pull Requests were created:' - : 'The following Pull Requests can be created:' - console.log( - boxen( - `${pullRequestHeading} - - ${pullRequestUrls.join(` - `)}`, - { - padding: 1, - }, - ), - ) - } else if (completedSuccessfully) { - console.log( - boxen(chalk.blue('No package-lock.json changes were produced.'), { - padding: 1, - }), - ) - } - } -} - -async function runNpmAuditFix(cwd: string) { - const log = '"npm audit fix --package-lock-only --audit-level=none"' - return await wrapWithLoading( - { - startText: `Running ${log}`, - failText: `Failed to run ${log}`, - }, - async (spinner) => { - // npm audit fix exits non-zero when vulnerabilities remain after applying fixes - const result = await execa( - 'npm', - ['audit', 'fix', '--package-lock-only', '--audit-level=none'], - { - cwd, - reject: false, - }, - ) - - if (result.exitCode !== 0 && !(await hasPackageLockChanges(cwd))) { - spinner.fail(`Failed to run ${log}`) - throw new Error( - result.stderr || - result.stdout || - `npm audit fix --package-lock-only --audit-level=none failed with exit code ${result.exitCode}`, - ) - } - - spinner.succeed(`Ran ${log}`) - return result - }, - ) -} - -async function hasPackageLockChanges(cwd: string): Promise { - return await hasFileChanges(cwd, 'package-lock.json') -} - -async function hasFileChanges(cwd: string, fileName: string): Promise { - const { stdout } = await execa( - 'git', - ['status', '--porcelain', '--', fileName], - { - cwd, - }, - ) - return Boolean(stdout.trim()) -} - -async function fileExists(filePath: string): Promise { - try { - await fs.stat(filePath) - return true - } catch { - return false - } -} diff --git a/src/startFixVulnerabilities.ts b/src/startFixVulnerabilities.ts new file mode 100644 index 0000000..2d8a07a --- /dev/null +++ b/src/startFixVulnerabilities.ts @@ -0,0 +1,147 @@ +import boxen from 'boxen' +import chalk from 'chalk' +import enumerateProductRepositories from './enumerateProductRepositories.js' +import executeCommand from './executeCommand.js' +import createOrLinkPullRequest, { + createPullRequestOctokit, +} from './createOrLinkPullRequest.js' +import resolveTicket from './resolveTicket.js' +import getRepositoryPlugin from './repositories-plugins/plugins-factory.js' + +export default async function startFixVulnerabilities({ + ticket: ticketFlag, + force = false, +}: { + ticket?: string + force?: boolean +}) { + const ticket = await resolveTicket({ ticketFlag, force }) + const octokit = createPullRequestOctokit() + + console.log( + `Beginning vulnerability fixes across product repositories using branch "${ticket}"`, + ) + + const pullRequestUrls: string[] = [] + const fixedRepositories: string[] = [] + let createdAnyPullRequest = false + let completedSuccessfully = false + + try { + await enumerateProductRepositories( + async ({ productRepository, repositoryWorkingDirectory }) => { + const { repositoryName } = productRepository + + const repositoryPlugin = await getRepositoryPlugin({ + cwd: repositoryWorkingDirectory, + repositoryType: productRepository, + }) + + if (!repositoryPlugin.fixVulnerabilities) { + console.log( + `Skipping "${repositoryName}" as "${repositoryPlugin.displayType}" does not support fixing vulnerabilities.`, + ) + return + } + + const fixResult = await repositoryPlugin.fixVulnerabilities({ + ticket, + repositoryName, + }) + + if (!fixResult) { + return + } + + const { filesToStage, commitMessage, pullRequestBody } = fixResult + + await executeCommand( + 'git', + ['checkout', '-b', ticket], + repositoryWorkingDirectory, + ) + await executeCommand( + 'git', + ['add', ...filesToStage], + repositoryWorkingDirectory, + ) + await executeCommand( + 'git', + ['commit', '--message', commitMessage], + repositoryWorkingDirectory, + ) + await executeCommand( + 'git', + ['push', '-u', 'origin', ticket], + repositoryWorkingDirectory, + ) + + const pullRequest = await createOrLinkPullRequest({ + octokit, + repositoryName, + ticket, + title: commitMessage, + body: pullRequestBody, + }) + + fixedRepositories.push(repositoryName) + pullRequestUrls.push(pullRequest.url) + if (pullRequest.created) { + createdAnyPullRequest = true + } + }, + ) + + completedSuccessfully = true + } finally { + console.log( + boxen( + chalk[completedSuccessfully ? 'green' : 'yellow']( + completedSuccessfully + ? 'fix vulnerabilities complete!!!' + : 'fix vulnerabilities stopped after an error', + ), + { + padding: 1, + }, + ), + ) + + if (fixedRepositories.length) { + console.log( + boxen( + `The following repositories had fixes applied: + + ${fixedRepositories.join(` + `)}`, + { + padding: 1, + }, + ), + ) + } + + if (pullRequestUrls.length) { + const pullRequestHeading = createdAnyPullRequest + ? 'The following Pull Requests were created:' + : 'The following Pull Requests can be created:' + console.log( + boxen( + `${pullRequestHeading} + + ${pullRequestUrls.join(` + `)}`, + { + padding: 1, + }, + ), + ) + } else if (completedSuccessfully) { + console.log( + boxen(chalk.blue('No vulnerability fixes were produced.'), { + padding: 1, + }), + ) + } + } +}