Skip to content

Commit dd7679b

Browse files
authored
Merge pull request #3 from dmno-dev/fix/shell-injection-security
Security hardening: eliminate shell injection vulnerabilities
2 parents d3d6630 + d28c9ac commit dd7679b

15 files changed

Lines changed: 253 additions & 131 deletions

File tree

.bumpy/security-audit.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Security hardening: eliminate shell injection vulnerabilities across all CLI commands
6+
7+
- Replace shell string interpolation with `execFile`-based argument arrays (`runArgs`/`runArgsAsync`) throughout the codebase, preventing command injection via branch names, PR numbers, config values, package names, and registry URLs
8+
- Add input validation for git branch names and PR numbers from environment variables
9+
- Remove broken `escapeShell` function in favor of shell-free execution
10+
- Use `sq()` single-quote escaping for template substitutions in user-defined publish commands
11+
- Restrict dynamic changelog formatter imports to paths within the project root
12+
- Reduce changeset filename collisions by using three-word random names

.github/workflows/ci.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ jobs:
1010
- run: bun install
1111
- run: cd packages/bumpy && bunx varlock load # need ENV types for tsdown config file
1212
- run: bun run check
13-
- run: git config --global user.name "CI" && git config --global user.email "ci@test"
13+
# publish tests create temp git repos with commits, which requires a git identity
14+
- run: git config --global user.name "CI" && git config --global user.email "ci@example.com"
1415
- run: bun run test
1516

1617
bumpy-check:

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ env.d.ts
1919
*.tsbuildinfo
2020

2121
ignore
22+
23+
.claude/worktrees

packages/bumpy/src/commands/check.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { log, colorize } from '../utils/logger.ts';
33
import { loadConfig } from '../core/config.ts';
44
import { discoverWorkspace } from '../core/workspace.ts';
55
import { readChangesets } from '../core/changeset.ts';
6-
import { tryRun } from '../utils/shell.ts';
6+
import { tryRunArgs } from '../utils/shell.ts';
77
import type { WorkspacePackage } from '../types.ts';
88

99
/**
@@ -61,9 +61,9 @@ export async function checkCommand(rootDir: string): Promise<void> {
6161
/** Get files changed on this branch compared to the base branch */
6262
function getChangedFiles(rootDir: string, baseBranch: string): string[] {
6363
// Try merge-base first (works on branches)
64-
const mergeBase = tryRun(`git merge-base HEAD origin/${baseBranch}`, { cwd: rootDir });
64+
const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir });
6565
const ref = mergeBase || `origin/${baseBranch}`;
66-
const diff = tryRun(`git diff --name-only ${ref}`, { cwd: rootDir });
66+
const diff = tryRunArgs(['git', 'diff', '--name-only', ref], { cwd: rootDir });
6767
if (!diff) return [];
6868
return diff.split('\n').filter(Boolean);
6969
}

packages/bumpy/src/commands/ci.ts

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,34 @@ import { discoverWorkspace } from '../core/workspace.ts';
44
import { DependencyGraph } from '../core/dep-graph.ts';
55
import { readChangesets } from '../core/changeset.ts';
66
import { assembleReleasePlan } from '../core/release-plan.ts';
7-
import { run, tryRun, runAsync } from '../utils/shell.ts';
7+
import { runArgs, runArgsAsync, tryRunArgs } from '../utils/shell.ts';
88
import type { BumpyConfig, ReleasePlan, PlannedRelease } from '../types.ts';
99

10+
// ---- Validation helpers ----
11+
12+
/** Validate a git branch name to prevent injection */
13+
function validateBranchName(name: string): string {
14+
if (!/^[a-zA-Z0-9_./-]+$/.test(name)) {
15+
throw new Error(`Invalid branch name: ${name}`);
16+
}
17+
return name;
18+
}
19+
20+
/** Validate a PR number is numeric */
21+
function validatePrNumber(pr: string): string {
22+
if (!/^\d+$/.test(pr)) {
23+
throw new Error(`Invalid PR number: ${pr}`);
24+
}
25+
return pr;
26+
}
27+
1028
/** Configure git identity for CI commits if not already set */
1129
function ensureGitIdentity(rootDir: string, config: BumpyConfig): void {
12-
const name = tryRun('git config user.name', { cwd: rootDir });
30+
const name = tryRunArgs(['git', 'config', 'user.name'], { cwd: rootDir });
1331
if (!name) {
1432
const { name: gitName, email: gitEmail } = config.gitUser;
15-
run(`git config user.name "${gitName}"`, { cwd: rootDir });
16-
run(`git config user.email "${gitEmail}"`, { cwd: rootDir });
33+
runArgs(['git', 'config', 'user.name', gitName], { cwd: rootDir });
34+
runArgs(['git', 'config', 'user.email', gitEmail], { cwd: rootDir });
1735
log.dim(` Using git identity: ${gitName} <${gitEmail}>`);
1836
}
1937
}
@@ -119,11 +137,11 @@ async function autoPublish(rootDir: string, config: BumpyConfig, tag?: string):
119137

120138
// Commit the version changes
121139
log.step('Committing version changes...');
122-
run('git add -A', { cwd: rootDir });
123-
const status = tryRun('git status --porcelain', { cwd: rootDir });
140+
runArgs(['git', 'add', '-A'], { cwd: rootDir });
141+
const status = tryRunArgs(['git', 'status', '--porcelain'], { cwd: rootDir });
124142
if (status) {
125-
run('git commit -m "Version packages"', { cwd: rootDir });
126-
run('git push', { cwd: rootDir });
143+
runArgs(['git', 'commit', '-m', 'Version packages'], { cwd: rootDir });
144+
runArgs(['git', 'push'], { cwd: rootDir });
127145
}
128146

129147
log.step('Running bumpy publish...');
@@ -139,21 +157,25 @@ async function createVersionPr(
139157
config: BumpyConfig,
140158
branchName?: string,
141159
): Promise<void> {
142-
const branch = branchName || config.versionPr.branch;
143-
const baseBranch = tryRun('git rev-parse --abbrev-ref HEAD', { cwd: rootDir }) || 'main';
160+
const branch = validateBranchName(branchName || config.versionPr.branch);
161+
const baseBranch = validateBranchName(
162+
tryRunArgs(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootDir }) || 'main',
163+
);
144164

145165
// Check if a version PR already exists
146-
const existingPr = tryRun(`gh pr list --head "${branch}" --json number --jq ".[0].number"`, { cwd: rootDir });
166+
const existingPr = tryRunArgs(['gh', 'pr', 'list', '--head', branch, '--json', 'number', '--jq', '.[0].number'], {
167+
cwd: rootDir,
168+
});
147169

148170
// Create or update the branch
149171
log.step(`Creating branch ${branch}...`);
150-
const branchExists = tryRun(`git rev-parse --verify ${branch}`, { cwd: rootDir }) !== null;
172+
const branchExists = tryRunArgs(['git', 'rev-parse', '--verify', branch], { cwd: rootDir }) !== null;
151173

152174
if (branchExists) {
153-
run(`git checkout ${branch}`, { cwd: rootDir });
154-
run(`git reset --hard ${baseBranch}`, { cwd: rootDir });
175+
runArgs(['git', 'checkout', branch], { cwd: rootDir });
176+
runArgs(['git', 'reset', '--hard', baseBranch], { cwd: rootDir });
155177
} else {
156-
run(`git checkout -b ${branch}`, { cwd: rootDir });
178+
runArgs(['git', 'checkout', '-b', branch], { cwd: rootDir });
157179
}
158180

159181
// Run bumpy version
@@ -162,40 +184,41 @@ async function createVersionPr(
162184
await versionCommand(rootDir);
163185

164186
// Commit and push
165-
run('git add -A', { cwd: rootDir });
166-
const status = tryRun('git status --porcelain', { cwd: rootDir });
187+
runArgs(['git', 'add', '-A'], { cwd: rootDir });
188+
const status = tryRunArgs(['git', 'status', '--porcelain'], { cwd: rootDir });
167189
if (!status) {
168190
log.info('No version changes to commit.');
169-
run(`git checkout ${baseBranch}`, { cwd: rootDir });
191+
runArgs(['git', 'checkout', baseBranch], { cwd: rootDir });
170192
return;
171193
}
172194

173195
const commitMsg = ['Version packages', '', ...plan.releases.map((r) => `${r.name}@${r.newVersion}`)].join('\n');
174-
run('git commit -F -', { cwd: rootDir, input: commitMsg });
175-
run(`git push -u origin ${branch} --force`, { cwd: rootDir });
196+
runArgs(['git', 'commit', '-F', '-'], { cwd: rootDir, input: commitMsg });
197+
runArgs(['git', 'push', '-u', 'origin', branch, '--force'], { cwd: rootDir });
176198

177199
// Create or update PR
178200
const prBody = formatVersionPrBody(plan, config.versionPr.preamble);
179201

180202
if (existingPr) {
181-
log.step(`Updating existing PR #${existingPr}...`);
182-
await runAsync(`gh pr edit ${existingPr} --title "${config.versionPr.title}" --body-file -`, {
203+
const validPr = validatePrNumber(existingPr);
204+
log.step(`Updating existing PR #${validPr}...`);
205+
await runArgsAsync(['gh', 'pr', 'edit', validPr, '--title', config.versionPr.title, '--body-file', '-'], {
183206
cwd: rootDir,
184207
input: prBody,
185208
});
186-
log.success(`Updated PR #${existingPr}`);
209+
log.success(`Updated PR #${validPr}`);
187210
} else {
188211
log.step('Creating version PR...');
189212
const prTitle = config.versionPr.title;
190-
const result = await runAsync(
191-
`gh pr create --title "${prTitle}" --body-file - --base "${baseBranch}" --head "${branch}"`,
213+
const result = await runArgsAsync(
214+
['gh', 'pr', 'create', '--title', prTitle, '--body-file', '-', '--base', baseBranch, '--head', branch],
192215
{ cwd: rootDir, input: prBody },
193216
);
194217
log.success(`Created PR: ${result}`);
195218
}
196219

197220
// Switch back to the base branch
198-
run(`git checkout ${baseBranch}`, { cwd: rootDir });
221+
runArgs(['git', 'checkout', baseBranch], { cwd: rootDir });
199222
}
200223

201224
// ---- PR comment helpers ----
@@ -277,23 +300,27 @@ function formatVersionPrBody(plan: ReleasePlan, preamble: string): string {
277300
const COMMENT_MARKER = '<!-- bumpy-release-plan -->';
278301

279302
async function postOrUpdatePrComment(prNumber: string, body: string, rootDir: string): Promise<void> {
303+
const validPr = validatePrNumber(prNumber);
280304
const markedBody = `${COMMENT_MARKER}\n${body}`;
281305

282306
try {
283-
// Find existing bumpy comment
284-
const existingComment = tryRun(
285-
`gh pr view ${prNumber} --json comments --jq '.comments[] | select(.body | startswith("${COMMENT_MARKER}")) | .id' | head -1`,
286-
{ cwd: rootDir },
287-
);
307+
// Find existing bumpy comment using gh with jq
308+
const jqFilter = `.comments[] | select(.body | startswith("${COMMENT_MARKER}")) | .id`;
309+
const existingComment = tryRunArgs(['gh', 'pr', 'view', validPr, '--json', 'comments', '--jq', jqFilter], {
310+
cwd: rootDir,
311+
});
312+
313+
// Take the first result if multiple
314+
const commentId = existingComment?.split('\n')[0]?.trim();
288315

289-
if (existingComment) {
290-
await runAsync(`gh api repos/{owner}/{repo}/issues/comments/${existingComment} -X PATCH -f body=@-`, {
291-
cwd: rootDir,
292-
input: markedBody,
293-
});
316+
if (commentId) {
317+
await runArgsAsync(
318+
['gh', 'api', `repos/{owner}/{repo}/issues/comments/${commentId}`, '-X', 'PATCH', '-f', 'body=@-'],
319+
{ cwd: rootDir, input: markedBody },
320+
);
294321
log.dim(' Updated PR comment');
295322
} else {
296-
await runAsync(`gh pr comment ${prNumber} --body-file -`, { cwd: rootDir, input: markedBody });
323+
await runArgsAsync(['gh', 'pr', 'comment', validPr, '--body-file', '-'], { cwd: rootDir, input: markedBody });
297324
log.dim(' Posted PR comment');
298325
}
299326
} catch (err) {
@@ -308,6 +335,11 @@ function detectPrNumber(): string | null {
308335
const match = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\//);
309336
if (match) return match[1]!;
310337
}
311-
// Also check for explicit env var
312-
return process.env.BUMPY_PR_NUMBER || process.env.PR_NUMBER || null;
338+
// Also check for explicit env var — validate it's numeric
339+
const envPr = process.env.BUMPY_PR_NUMBER || process.env.PR_NUMBER || null;
340+
if (envPr && !/^\d+$/.test(envPr)) {
341+
log.warn(`Ignoring invalid PR number from environment: ${envPr}`);
342+
return null;
343+
}
344+
return envPr;
313345
}

packages/bumpy/src/commands/generate.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { log, colorize } from '../utils/logger.ts';
2-
import { tryRun } from '../utils/shell.ts';
2+
import { tryRunArgs } from '../utils/shell.ts';
33
import { loadConfig } from '../core/config.ts';
44
import { discoverPackages } from '../core/workspace.ts';
55
import { writeChangeset } from '../core/changeset.ts';
@@ -50,7 +50,7 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P
5050
log.step(`Scanning commits from ${colorize(from, 'cyan')}...`);
5151

5252
// Get commits since ref
53-
const rawLog = tryRun(`git log ${from}..HEAD --format="%H%n%s%n%b%n---END---"`, { cwd: rootDir });
53+
const rawLog = tryRunArgs(['git', 'log', `${from}..HEAD`, '--format=%H%n%s%n%b%n---END---'], { cwd: rootDir });
5454

5555
if (!rawLog) {
5656
log.info('No commits found since ' + from);
@@ -239,9 +239,8 @@ function bumpPriority(type: BumpType): number {
239239
/** Find the most recent version tag in the repo */
240240
function findLastVersionTag(rootDir: string): string | null {
241241
// Look for tags matching common patterns: v1.2.3, pkg@1.2.3, etc.
242-
const tag = tryRun(
243-
'git describe --tags --abbrev=0 --match "v*" 2>/dev/null || git describe --tags --abbrev=0 --match "*@*" 2>/dev/null',
244-
{ cwd: rootDir },
245-
);
242+
const tag =
243+
tryRunArgs(['git', 'describe', '--tags', '--abbrev=0', '--match', 'v*'], { cwd: rootDir }) ||
244+
tryRunArgs(['git', 'describe', '--tags', '--abbrev=0', '--match', '*@*'], { cwd: rootDir });
246245
return tag || null;
247246
}

packages/bumpy/src/commands/publish.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,9 @@ async function findUnpublishedPackages(
158158
}
159159

160160
async function checkIfPublished(name: string, version: string, pkgConfig?: PackageConfig): Promise<boolean> {
161-
const { runAsync } = await import('../utils/shell.ts');
162-
const { tryRun } = await import('../utils/shell.ts');
161+
const { runAsync, runArgsAsync, tryRunArgs } = await import('../utils/shell.ts');
163162

164-
// 1. Custom check command
163+
// 1. Custom check command (user-defined, runs in shell by design)
165164
if (pkgConfig?.checkPublished) {
166165
try {
167166
const result = await runAsync(pkgConfig.checkPublished);
@@ -174,13 +173,14 @@ async function checkIfPublished(name: string, version: string, pkgConfig?: Packa
174173
// 2. Non-npm packages — check git tags
175174
if (pkgConfig?.skipNpmPublish || pkgConfig?.publishCommand) {
176175
const tag = `${name}@${version}`;
177-
return tryRun(`git tag -l "${tag}"`) === tag;
176+
return tryRunArgs(['git', 'tag', '-l', tag]) === tag;
178177
}
179178

180179
// 3. Default — check npm registry
181180
try {
182-
const regFlag = pkgConfig?.registry ? `--registry ${pkgConfig.registry}` : '';
183-
const result = await runAsync(`npm info "${name}@${version}" version ${regFlag}`.trim());
181+
const args = ['npm', 'info', `${name}@${version}`, 'version'];
182+
if (pkgConfig?.registry) args.push('--registry', pkgConfig.registry);
183+
const result = await runArgsAsync(args);
184184
return result === version;
185185
} catch {
186186
return false;

packages/bumpy/src/commands/version.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { DependencyGraph } from '../core/dep-graph.ts';
55
import { readChangesets } from '../core/changeset.ts';
66
import { assembleReleasePlan } from '../core/release-plan.ts';
77
import { applyReleasePlan } from '../core/apply-release-plan.ts';
8-
import { run, tryRun } from '../utils/shell.ts';
8+
import { runArgs, tryRunArgs } from '../utils/shell.ts';
99
import { detectWorkspaces } from '../utils/package-manager.ts';
1010

1111
export async function versionCommand(rootDir: string): Promise<void> {
@@ -46,18 +46,18 @@ export async function versionCommand(rootDir: string): Promise<void> {
4646
if (config.commit) {
4747
try {
4848
// Stage version changes, changelogs, deleted changesets, and lockfile
49-
run('git add -A .bumpy/', { cwd: rootDir });
49+
runArgs(['git', 'add', '-A', '.bumpy/'], { cwd: rootDir });
5050
for (const r of plan.releases) {
5151
const pkg = packages.get(r.name)!;
52-
run(`git add "${pkg.relativeDir}/package.json"`, { cwd: rootDir });
53-
run(`git add "${pkg.relativeDir}/CHANGELOG.md"`, { cwd: rootDir });
52+
runArgs(['git', 'add', '--', `${pkg.relativeDir}/package.json`], { cwd: rootDir });
53+
runArgs(['git', 'add', '--', `${pkg.relativeDir}/CHANGELOG.md`], { cwd: rootDir });
5454
}
5555
// Stage lockfile if it changed
5656
for (const lockfile of ['bun.lock', 'bun.lockb', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json']) {
57-
tryRun(`git add "${lockfile}"`, { cwd: rootDir });
57+
tryRunArgs(['git', 'add', '--', lockfile], { cwd: rootDir });
5858
}
5959
const msg = ['Version packages', '', ...plan.releases.map((r) => `${r.name}@${r.newVersion}`)].join('\n');
60-
run('git commit -F -', { cwd: rootDir, input: msg });
60+
runArgs(['git', 'commit', '-F', '-'], { cwd: rootDir, input: msg });
6161
log.success('Created git commit');
6262
} catch (e) {
6363
log.warn(`Git commit failed: ${e}`);
@@ -68,26 +68,26 @@ export async function versionCommand(rootDir: string): Promise<void> {
6868
/** Run the package manager's install to update the lockfile */
6969
async function updateLockfile(rootDir: string): Promise<void> {
7070
const { packageManager } = await detectWorkspaces(rootDir);
71-
const installCmd = getInstallCommand(packageManager);
71+
const installArgs = getInstallArgs(packageManager);
7272

73-
log.step(`Updating lockfile (${installCmd})...`);
73+
log.step(`Updating lockfile (${installArgs.join(' ')})...`);
7474
try {
75-
run(installCmd, { cwd: rootDir });
75+
runArgs(installArgs, { cwd: rootDir });
7676
log.dim(' Lockfile updated');
7777
} catch (err) {
7878
log.warn(` Lockfile update failed: ${err instanceof Error ? err.message : err}`);
7979
}
8080
}
8181

82-
function getInstallCommand(pm: string): string {
82+
function getInstallArgs(pm: string): string[] {
8383
switch (pm) {
8484
case 'pnpm':
85-
return 'pnpm install --lockfile-only';
85+
return ['pnpm', 'install', '--lockfile-only'];
8686
case 'bun':
87-
return 'bun install';
87+
return ['bun', 'install'];
8888
case 'yarn':
89-
return 'yarn install --mode update-lockfile';
89+
return ['yarn', 'install', '--mode', 'update-lockfile'];
9090
default:
91-
return 'npm install --package-lock-only';
91+
return ['npm', 'install', '--package-lock-only'];
9292
}
9393
}

0 commit comments

Comments
 (0)