From e633977504aa74d968737f8100e64eb089d25758 Mon Sep 17 00:00:00 2001 From: Kiza Online Date: Thu, 6 Aug 2026 13:51:33 +1000 Subject: [PATCH 1/3] AP-9943 # `audit-fix` also runs `dotnet package update --vulnerable` for NuGet repositories --- CHANGELOG.md | 4 + src/bin.ts | 8 +- src/startAuditFix.ts | 209 ++++++++++++++++++++++++++++++------------- 3 files changed, 156 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 962d957..a43bdff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `audit-fix` 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..52c7718 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -38,9 +38,11 @@ ${chalk.bold('Examples')} ${chalk.bold.blue('oneblink-release audit-fix [--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. diff --git a/src/startAuditFix.ts b/src/startAuditFix.ts index 946bc32..e6f200e 100644 --- a/src/startAuditFix.ts +++ b/src/startAuditFix.ts @@ -22,7 +22,7 @@ export default async function startAuditFix({ const octokit = createPullRequestOctokit() console.log( - `Beginning npm audit fix across product repositories using branch "${ticket}"`, + `Beginning audit fix across product repositories using branch "${ticket}"`, ) const pullRequestUrls: string[] = [] @@ -35,37 +35,24 @@ export default async function startAuditFix({ 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 - } + const auditFix = + type === 'NUGET' + ? await prepareNugetAuditFix({ + cwd: repositoryWorkingDirectory, + repositoryName, + ticket, + }) + : await prepareNpmAuditFix({ + cwd: repositoryWorkingDirectory, + repositoryName, + ticket, + }) - await runNpmAuditFix(repositoryWorkingDirectory) - - const packageLockChanged = await hasPackageLockChanges( - repositoryWorkingDirectory, - ) - if (!packageLockChanged) { - console.log( - `Skipping "${repositoryName}" as npm audit fix did not change package-lock.json.`, - ) + if (!auditFix) { return } - const commitMessage = `${ticket} # npm audit fix` + const { filesToStage, commitMessage, pullRequestBody } = auditFix await executeCommand( 'git', @@ -74,23 +61,9 @@ export default async function startAuditFix({ ) await executeCommand( 'git', - ['add', 'package-lock.json'], + ['add', ...filesToStage], 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], @@ -107,7 +80,7 @@ export default async function startAuditFix({ repositoryName, ticket, title: commitMessage, - body: 'Automated `npm audit fix`.', + body: pullRequestBody, }) fixedRepositories.push(repositoryName) @@ -124,8 +97,8 @@ export default async function startAuditFix({ boxen( chalk[completedSuccessfully ? 'green' : 'yellow']( completedSuccessfully - ? 'npm audit fix complete!!!' - : 'npm audit fix stopped after an error', + ? 'audit fix complete!!!' + : 'audit fix stopped after an error', ), { padding: 1, @@ -164,7 +137,7 @@ export default async function startAuditFix({ ) } else if (completedSuccessfully) { console.log( - boxen(chalk.blue('No package-lock.json changes were produced.'), { + boxen(chalk.blue('No dependency audit fixes were produced.'), { padding: 1, }), ) @@ -172,30 +145,119 @@ export default async function startAuditFix({ } } -async function runNpmAuditFix(cwd: string) { - const log = '"npm audit fix --package-lock-only --audit-level=none"' +type AuditFixResult = { + filesToStage: string[] + commitMessage: string + pullRequestBody: string +} + +async function prepareNpmAuditFix({ + cwd, + repositoryName, + ticket, +}: { + cwd: string + repositoryName: string + ticket: string +}): Promise { + const packageLockPath = path.join(cwd, 'package-lock.json') + if (!(await fileExists(packageLockPath))) { + 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, + hasChanges: () => hasFileChanges(cwd, 'package-lock.json'), + }) + + if (!(await hasFileChanges(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(cwd, 'package.json')) { + filesToStage.push('package.json') + } + + return { + filesToStage, + commitMessage: `${ticket} # npm audit fix`, + pullRequestBody: 'Automated `npm audit fix`.', + } +} + +async function prepareNugetAuditFix({ + cwd, + repositoryName, + ticket, +}: { + cwd: string + repositoryName: string + ticket: string +}): Promise { + await runCommandAllowingRemainingVulnerabilities({ + command: 'dotnet', + args: ['package', 'update', '--vulnerable'], + cwd, + hasChanges: () => hasNugetPackageChanges(cwd), + }) + + const filesToStage = await getChangedNugetPackageFiles(cwd) + 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`.', + } +} + +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) => { - // 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, - }, - ) + // 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 hasPackageLockChanges(cwd))) { + if (result.exitCode !== 0 && !(await hasChanges())) { 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}`, + `${command} ${args.join(' ')} failed with exit code ${result.exitCode}`, ) } @@ -205,8 +267,31 @@ async function runNpmAuditFix(cwd: string) { ) } -async function hasPackageLockChanges(cwd: string): Promise { - return await hasFileChanges(cwd, 'package-lock.json') +async function hasNugetPackageChanges(cwd: string): Promise { + return (await getChangedNugetPackageFiles(cwd)).length > 0 +} + +async function getChangedNugetPackageFiles(cwd: string): Promise { + const { stdout } = await execa( + 'git', + ['status', '--porcelain', '--', '*.csproj'], + { + 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() + }) } async function hasFileChanges(cwd: string, fileName: string): Promise { From c9feb72ca5202af94ab48d8cde272bc3f8a27312 Mon Sep 17 00:00:00 2001 From: Kiza Online Date: Thu, 6 Aug 2026 14:13:51 +1000 Subject: [PATCH 2/3] AP-9943 # Fix nuget cmd not including csproj path --- CHANGELOG.md | 4 ++++ src/startAuditFix.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a43bdff..9ab2e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `audit-fix` passes `--project` to `dotnet package update --vulnerable` so nested NuGet project files are updated + ### Changed - `audit-fix` also runs `dotnet package update --vulnerable` for NuGet repositories diff --git a/src/startAuditFix.ts b/src/startAuditFix.ts index e6f200e..da52f3a 100644 --- a/src/startAuditFix.ts +++ b/src/startAuditFix.ts @@ -41,6 +41,7 @@ export default async function startAuditFix({ cwd: repositoryWorkingDirectory, repositoryName, ticket, + relativeProjectFile: productRepository.relativeProjectFile, }) : await prepareNpmAuditFix({ cwd: repositoryWorkingDirectory, @@ -200,14 +201,22 @@ async function prepareNugetAuditFix({ cwd, repositoryName, ticket, + relativeProjectFile, }: { cwd: string repositoryName: string ticket: string + relativeProjectFile: string }): Promise { await runCommandAllowingRemainingVulnerabilities({ command: 'dotnet', - args: ['package', 'update', '--vulnerable'], + args: [ + 'package', + 'update', + '--vulnerable', + '--project', + relativeProjectFile, + ], cwd, hasChanges: () => hasNugetPackageChanges(cwd), }) From 84071a3f5c745dfdbe5085816b0dbb164faa3104 Mon Sep 17 00:00:00 2001 From: Kiza Online Date: Thu, 6 Aug 2026 14:25:45 +1000 Subject: [PATCH 3/3] AP-9943 # Refactor logic to repo plugin, rename to generic --- CHANGELOG.md | 7 +- src/bin.ts | 14 +- src/repositories-plugins/NpmPlugin.ts | 56 ++- src/repositories-plugins/NugetPlugin.ts | 45 ++- src/repositories-plugins/RepositoryPlugin.ts | 11 + .../fix-vulnerabilities-helpers.ts | 82 +++++ src/startAuditFix.ts | 324 ------------------ src/startFixVulnerabilities.ts | 147 ++++++++ 8 files changed, 348 insertions(+), 338 deletions(-) create mode 100644 src/repositories-plugins/fix-vulnerabilities-helpers.ts delete mode 100644 src/startAuditFix.ts create mode 100644 src/startFixVulnerabilities.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab2e61..b7ab2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- `audit-fix` passes `--project` to `dotnet package update --vulnerable` so nested NuGet project files are updated - ### Changed -- `audit-fix` also runs `dotnet package update --vulnerable` for NuGet repositories +- Rename `audit-fix` command to `fix-vulnerabilities` +- `fix-vulnerabilities` also runs `dotnet package update --vulnerable` for NuGet repositories ## [5.1.0] - 2026-08-06 diff --git a/src/bin.ts b/src/bin.ts index 52c7718..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,7 +35,7 @@ ${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" for NPM repositories and "dotnet @@ -53,9 +53,9 @@ 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]', @@ -238,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 da52f3a..0000000 --- a/src/startAuditFix.ts +++ /dev/null @@ -1,324 +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 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 - - const auditFix = - type === 'NUGET' - ? await prepareNugetAuditFix({ - cwd: repositoryWorkingDirectory, - repositoryName, - ticket, - relativeProjectFile: productRepository.relativeProjectFile, - }) - : await prepareNpmAuditFix({ - cwd: repositoryWorkingDirectory, - repositoryName, - ticket, - }) - - if (!auditFix) { - return - } - - const { filesToStage, commitMessage, pullRequestBody } = auditFix - - 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 - ? 'audit fix complete!!!' - : '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 dependency audit fixes were produced.'), { - padding: 1, - }), - ) - } - } -} - -type AuditFixResult = { - filesToStage: string[] - commitMessage: string - pullRequestBody: string -} - -async function prepareNpmAuditFix({ - cwd, - repositoryName, - ticket, -}: { - cwd: string - repositoryName: string - ticket: string -}): Promise { - const packageLockPath = path.join(cwd, 'package-lock.json') - if (!(await fileExists(packageLockPath))) { - 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, - hasChanges: () => hasFileChanges(cwd, 'package-lock.json'), - }) - - if (!(await hasFileChanges(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(cwd, 'package.json')) { - filesToStage.push('package.json') - } - - return { - filesToStage, - commitMessage: `${ticket} # npm audit fix`, - pullRequestBody: 'Automated `npm audit fix`.', - } -} - -async function prepareNugetAuditFix({ - cwd, - repositoryName, - ticket, - relativeProjectFile, -}: { - cwd: string - repositoryName: string - ticket: string - relativeProjectFile: string -}): Promise { - await runCommandAllowingRemainingVulnerabilities({ - command: 'dotnet', - args: [ - 'package', - 'update', - '--vulnerable', - '--project', - relativeProjectFile, - ], - cwd, - hasChanges: () => hasNugetPackageChanges(cwd), - }) - - const filesToStage = await getChangedNugetPackageFiles(cwd) - 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`.', - } -} - -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 - }, - ) -} - -async function hasNugetPackageChanges(cwd: string): Promise { - return (await getChangedNugetPackageFiles(cwd)).length > 0 -} - -async function getChangedNugetPackageFiles(cwd: string): Promise { - const { stdout } = await execa( - 'git', - ['status', '--porcelain', '--', '*.csproj'], - { - 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() - }) -} - -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, + }), + ) + } + } +}