Skip to content

Commit 99aad22

Browse files
authored
Merge pull request #24 from dmno-dev/fix/security-hardening
Harden security across publishing, auth, and input validation
2 parents ec02d0b + ca2fd77 commit 99aad22

9 files changed

Lines changed: 165 additions & 25 deletions

File tree

.bumpy/security-hardening.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Security hardening: ephemeral git token auth, custom command gating via allowCustomCommands, bump file input validation, structured tarball path parsing, changelog formatter path traversal fix, and force-push safeguard

docs/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ bumpy publish --filter "@myorg/*"
8484

8585
**How bumpy detects unpublished packages:**
8686

87-
1. Custom `checkPublished` command (if configured per-package)
87+
1. Custom `checkPublished` command (if configured per-package — see [`allowCustomCommands`](./configuration.md#custom-commands-and-allowcustomcommands))
8888
2. Git tags (for packages with `skipNpmPublish` or custom `publishCommand`)
8989
3. npm registry query (default)
9090

docs/configuration.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack
2121
| `publish` | `object` | see below | Publishing pipeline config |
2222
| `gitUser` | `{ name, email }` | bumpy-bot | Git identity for CI commits |
2323
| `versionPr` | `{ title, branch, preamble }` | see below | Customize the version PR |
24+
| `allowCustomCommands` | `boolean \| string[]` | `false` | Allow per-package custom commands from `package.json` (see below) |
2425
| `packages` | `object` | `{}` | Per-package config overrides (keyed by package name) |
2526

2627
### Dependency bump rules
@@ -87,8 +88,48 @@ Per-package settings can be defined in two places:
8788
| `dependencyBumpRules` | `object` | Per-package override for dependency propagation rules |
8889
| `cascadeTo` | `object` | Explicit cascade targets — glob pattern mapped to `{ trigger, bumpAs }` |
8990

91+
### Custom commands and `allowCustomCommands`
92+
93+
The `publishCommand`, `buildCommand`, and `checkPublished` fields run shell commands during publishing. Because these execute with CI credentials, bumpy distinguishes between two trust levels:
94+
95+
- **Root config** (`.bumpy/_config.json``packages`): always trusted — repo admins control this file.
96+
- **Per-package config** (`package.json``"bumpy"`): requires opt-in via `allowCustomCommands` in the root config.
97+
98+
By default, custom commands defined in `package.json` are **ignored** with a warning. To enable them, set `allowCustomCommands` in `.bumpy/_config.json`:
99+
100+
```json
101+
{
102+
"allowCustomCommands": true
103+
}
104+
```
105+
106+
Or restrict to specific packages/globs:
107+
108+
```json
109+
{
110+
"allowCustomCommands": ["@myorg/vscode-extension", "@myorg/deploy-*"]
111+
}
112+
```
113+
114+
This prevents a contributor from introducing arbitrary shell commands via a package's `package.json` without the root config explicitly allowing it.
115+
90116
### Example: custom publish for a VSCode extension
91117

118+
In `.bumpy/_config.json` (recommended — no `allowCustomCommands` needed):
119+
120+
```json
121+
{
122+
"packages": {
123+
"my-vscode-extension": {
124+
"publishCommand": "vsce publish",
125+
"skipNpmPublish": true
126+
}
127+
}
128+
}
129+
```
130+
131+
Or in the package's `package.json` (requires `allowCustomCommands`):
132+
92133
```json
93134
{
94135
"name": "my-vscode-extension",
@@ -137,6 +178,7 @@ See the [Changelog Formatters](./changelog-formatters.md) docs for full details
137178
"publishCommand": "vsce publish",
138179
"skipNpmPublish": true
139180
}
140-
}
181+
},
182+
"allowCustomCommands": ["@myorg/deploy-*"]
141183
}
142184
```

packages/bumpy/src/commands/ci.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -229,20 +229,28 @@ async function autoPublish(rootDir: string, config: BumpyConfig, tag?: string):
229229
* but PR workflows won't be triggered automatically.
230230
*/
231231
function pushWithToken(rootDir: string, branch: string): void {
232+
// Guard against misconfigured versionPr.branch pointing at the base branch
233+
const baseBranch = tryRunArgs(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootDir });
234+
if (branch === baseBranch || branch === 'main' || branch === 'master') {
235+
throw new Error(`Refusing to force-push to "${branch}" — this looks like a base branch, not a version PR branch`);
236+
}
237+
232238
const token = process.env.BUMPY_GH_TOKEN;
233239
const repo = process.env.GITHUB_REPOSITORY; // e.g. "owner/repo"
234240
const server = process.env.GITHUB_SERVER_URL || 'https://github.com';
235241

236242
if (token && repo) {
237-
const authedUrl = `${server.replace('://', `://x-access-token:${token}@`)}/${repo}.git`;
238-
const originalUrl = tryRunArgs(['git', 'remote', 'get-url', 'origin'], { cwd: rootDir });
243+
// Use an ephemeral `-c` flag to inject auth so the token never touches .git/config.
244+
// GitHub accepts HTTP basic auth with "x-access-token" as the username.
245+
const basicAuth = Buffer.from(`x-access-token:${token}`).toString('base64');
246+
const extraHeaderKey = `http.${server}/.extraheader`;
247+
const authHeader = `Authorization: basic ${basicAuth}`;
239248

240249
// `actions/checkout@v6` persists the default GITHUB_TOKEN in two ways:
241250
// 1. Direct http.<server>/.extraheader config
242251
// 2. includeIf.gitdir entries pointing to a credentials config file
243252
// that also sets http.<server>/.extraheader
244253
// Both must be cleared for our custom token to be used.
245-
const extraHeaderKey = `http.${server}/.extraheader`;
246254
const savedHeader = tryRunArgs(['git', 'config', '--local', extraHeaderKey], { cwd: rootDir });
247255

248256
// Collect includeIf entries that point to credential config files
@@ -266,13 +274,12 @@ function pushWithToken(rootDir: string, branch: string): void {
266274
for (const entry of savedIncludeIfs) {
267275
tryRunArgs(['git', 'config', '--local', '--unset', entry.key], { cwd: rootDir });
268276
}
269-
runArgs(['git', 'remote', 'set-url', 'origin', authedUrl], { cwd: rootDir });
270-
runArgs(['git', 'push', '-u', 'origin', branch, '--force'], { cwd: rootDir });
277+
// Pass auth via ephemeral -c flag — never written to .git/config
278+
runArgs(['git', '-c', `${extraHeaderKey}=${authHeader}`, 'push', '-u', 'origin', branch, '--force'], {
279+
cwd: rootDir,
280+
});
271281
} finally {
272-
// Restore original URL, extraheader, and includeIf entries
273-
if (originalUrl) {
274-
runArgs(['git', 'remote', 'set-url', 'origin', originalUrl], { cwd: rootDir });
275-
}
282+
// Restore extraheader and includeIf entries cleared above
276283
if (savedHeader) {
277284
runArgs(['git', 'config', '--local', extraHeaderKey, savedHeader], { cwd: rootDir });
278285
}

packages/bumpy/src/core/bump-file.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ import { readText, writeText, listFiles, removeFile } from '../utils/fs.ts';
44
import { getBumpyDir } from './config.ts';
55
import { tryRunArgs } from '../utils/shell.ts';
66
import type { BumpFile, BumpFileRelease, BumpFileReleaseCascade, BumpType, BumpTypeWithNone } from '../types.ts';
7+
import { log } from '../utils/logger.ts';
8+
9+
const VALID_BUMP_TYPES = new Set<string>(['major', 'minor', 'patch', 'none']);
10+
11+
/**
12+
* Reject package names that contain characters which could cause injection
13+
* when used in git tags, markdown, URLs, or shell-quoted strings.
14+
* Intentionally permissive — we don't enforce npm naming rules because
15+
* bumpy may be used with other registries or non-JS packages.
16+
*/
17+
function validatePackageName(name: string): boolean {
18+
if (!name || name.length > 214) return false;
19+
// disallow control chars, HTML/shell metacharacters, whitespace
20+
if (/[\u0000-\u001f\u007f<>"'`&;|$(){}[\]\\!#%\s]/.test(name)) return false;
21+
// must not start with - (could be interpreted as a CLI flag)
22+
if (name.startsWith('-')) return false;
23+
return true;
24+
}
725

826
/** Read all bump files from .bumpy/ directory, sorted by git creation order */
927
export async function readBumpFiles(rootDir: string): Promise<BumpFile[]> {
@@ -81,12 +99,25 @@ export function parseBumpFile(content: string, id: string): BumpFile | null {
8199

82100
const releases: BumpFileRelease[] = [];
83101
for (const [name, value] of Object.entries(parsed)) {
102+
if (!validatePackageName(name)) {
103+
log.warn(`Skipping invalid package name in bump file "${id}": ${name}`);
104+
continue;
105+
}
106+
84107
if (typeof value === 'string') {
108+
if (!VALID_BUMP_TYPES.has(value)) {
109+
log.warn(`Skipping unknown bump type "${value}" for ${name} in bump file "${id}"`);
110+
continue;
111+
}
85112
// Simple format: "pkg-name": minor
86113
releases.push({ name, type: value as BumpTypeWithNone });
87114
} else if (value && typeof value === 'object') {
88115
// Nested format: "pkg-name": { bump: minor, cascade: { ... } }
89116
const obj = value as { bump: BumpTypeWithNone; cascade?: Record<string, BumpType> };
117+
if (!VALID_BUMP_TYPES.has(obj.bump)) {
118+
log.warn(`Skipping unknown bump type "${obj.bump}" for ${name} in bump file "${id}"`);
119+
continue;
120+
}
90121
const release: BumpFileReleaseCascade = {
91122
name,
92123
type: obj.bump,

packages/bumpy/src/core/changelog.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { resolve } from 'node:path';
1+
import { resolve, relative } from 'node:path';
2+
import { realpathSync } from 'node:fs';
23
import { log } from '../utils/logger.ts';
34
import type { BumpFile, PlannedRelease, BumpyConfig } from '../types.ts';
45

@@ -95,9 +96,15 @@ export async function loadFormatter(changelog: BumpyConfig['changelog'], rootDir
9596
try {
9697
let modulePath: string;
9798
if (name.startsWith('.')) {
98-
// Relative path — resolve and verify it stays within the project root
99+
// Relative path — resolve symlinks and verify it stays within the project root
99100
modulePath = resolve(rootDir, name);
100-
if (!modulePath.startsWith(rootDir + '/')) {
101+
try {
102+
modulePath = realpathSync(modulePath);
103+
} catch {
104+
// File doesn't exist yet — use the resolved path as-is
105+
}
106+
const rel = relative(realpathSync(rootDir), modulePath);
107+
if (rel.startsWith('..') || resolve('/', rel) === resolve('/')) {
101108
throw new Error(`Changelog formatter path "${name}" resolves outside the project root`);
102109
}
103110
} else {

packages/bumpy/src/core/config.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@ export async function loadPackageConfig(
5757
// ignore
5858
}
5959

60+
// Block custom commands from per-package config unless the root explicitly allows them.
61+
// Commands defined in the root config's `packages` map are always trusted.
62+
const CUSTOM_CMD_KEYS = ['buildCommand', 'publishCommand', 'checkPublished'] as const;
63+
const disallowedKeys = CUSTOM_CMD_KEYS.filter((k) => pkgJsonConfig[k] != null);
64+
if (disallowedKeys.length > 0 && !isCustomCommandAllowed(pkgName, rootConfig)) {
65+
const fields = disallowedKeys.map((k) => `"${k}"`).join(', ');
66+
throw new Error(
67+
`Package "${pkgName}" defines custom command(s) (${fields}) in its package.json "bumpy" config, ` +
68+
'but the root config does not allow this.\n' +
69+
'Custom commands execute shell commands during publishing and must be explicitly enabled.\n\n' +
70+
'To fix this, either:\n' +
71+
' 1. Move the command(s) to .bumpy/_config.json under "packages" (always trusted)\n' +
72+
` 2. Add "allowCustomCommands": true (or ["${pkgName}"]) to .bumpy/_config.json`,
73+
);
74+
}
75+
6076
// Merge: root packages map → package.json["bumpy"] (later wins)
6177
return mergePackageConfig(rootPkgConfig, pkgJsonConfig);
6278
}
@@ -124,6 +140,16 @@ function mergePackageConfig(...configs: PackageConfig[]): PackageConfig {
124140
return result;
125141
}
126142

143+
/** Check if a package is allowed to define custom commands via package.json */
144+
function isCustomCommandAllowed(pkgName: string, config: BumpyConfig): boolean {
145+
const { allowCustomCommands } = config;
146+
if (allowCustomCommands === true) return true;
147+
if (Array.isArray(allowCustomCommands)) {
148+
return allowCustomCommands.some((pattern) => matchGlob(pkgName, pattern));
149+
}
150+
return false;
151+
}
152+
127153
export function getBumpyDir(rootDir: string): string {
128154
return resolve(rootDir, BUMPY_DIR);
129155
}

packages/bumpy/src/core/publish-pipeline.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,7 @@ async function packThenPublish(
246246

247247
// Pack and capture the tarball filename
248248
const packOutput = await runArgsAsync(packArgs, { cwd: pkg.dir });
249-
// Pack commands output the tarball filename on the last line
250-
const tarball = parseTarballPath(packOutput, pkg.dir);
249+
const tarball = parseTarballPath(packOutput, pkg.dir, packManager);
251250

252251
try {
253252
// Publish the tarball
@@ -281,14 +280,14 @@ async function npmPublishDirect(
281280
function getPackArgs(pm: PackageManager): string[] {
282281
switch (pm) {
283282
case 'pnpm':
284-
return ['pnpm', 'pack'];
283+
return ['pnpm', 'pack', '--json'];
285284
case 'bun':
286285
return ['bun', 'pm', 'pack'];
287286
case 'yarn':
288287
return ['yarn', 'pack'];
289288
case 'npm':
290289
default:
291-
return ['npm', 'pack'];
290+
return ['npm', 'pack', '--json'];
292291
}
293292
}
294293

@@ -332,20 +331,32 @@ function buildPublishArgs(
332331

333332
/**
334333
* Parse the tarball path from pack command output.
335-
* Each PM has different output formats:
336-
* npm/pnpm: tarball filename on the last line
337-
* bun: tarball filename mid-output, summary lines after
338-
* yarn: 'success Wrote tarball to "/path/to/foo.tgz".'
334+
* npm/pnpm use --json for structured output; bun/yarn fall back to regex parsing.
339335
*/
340-
function parseTarballPath(output: string, cwd: string): string {
341-
// Extract any .tgz path — handles both bare filenames and quoted paths (yarn)
336+
function parseTarballPath(output: string, cwd: string, pm: PackageManager): string {
337+
// npm and pnpm support --json which gives us a deterministic filename
338+
if (pm === 'npm' || pm === 'pnpm') {
339+
try {
340+
const parsed = JSON.parse(output);
341+
// npm returns an array, pnpm returns an object or array
342+
const entry = Array.isArray(parsed) ? parsed[0] : parsed;
343+
if (entry?.filename) {
344+
return resolve(cwd, entry.filename);
345+
}
346+
} catch {
347+
// JSON parse failed — fall through to regex
348+
}
349+
}
350+
351+
// Fallback for bun/yarn or if JSON parsing failed:
352+
// extract any .tgz path — handles both bare filenames and quoted paths (yarn)
342353
const tgzMatch = output.match(/(?:^|["'\s])([^\s"']*\.tgz)/m);
343354
if (tgzMatch) {
344355
const tarball = tgzMatch[1]!;
345356
return tarball.startsWith('/') ? tarball : resolve(cwd, tarball);
346357
}
347358

348-
// Fallback: last non-empty line
359+
// Last resort: last non-empty line
349360
const lines = output.trim().split('\n').filter(Boolean);
350361
const lastLine = lines[lines.length - 1]?.trim() || '';
351362
return lastLine.startsWith('/') ? lastLine : resolve(cwd, lastLine);

packages/bumpy/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ export interface BumpyConfig {
7070
updateInternalDependencies: 'patch' | 'minor' | 'out-of-range';
7171
dependencyBumpRules: Partial<Record<DepType, DependencyBumpRule | false>>;
7272
privatePackages: { version: boolean; tag: boolean };
73+
/**
74+
* Allow per-package custom commands (buildCommand, publishCommand, checkPublished)
75+
* defined in package.json "bumpy" fields.
76+
* Commands defined in the root config's `packages` map are always trusted.
77+
*
78+
* true = allow all packages to define custom commands
79+
* string[] = allow only matching package names/globs
80+
* false = only root-config commands are allowed (default)
81+
*/
82+
allowCustomCommands: boolean | string[];
7383
packages: Record<string, PackageConfig>;
7484
publish: PublishConfig;
7585
/**
@@ -125,6 +135,7 @@ export const DEFAULT_CONFIG: BumpyConfig = {
125135
updateInternalDependencies: 'out-of-range',
126136
dependencyBumpRules: {},
127137
privatePackages: { version: false, tag: false },
138+
allowCustomCommands: false,
128139
packages: {},
129140
publish: { ...DEFAULT_PUBLISH_CONFIG },
130141
aggregateRelease: false,

0 commit comments

Comments
 (0)