* fix: Prevent command injection in branch operations Replace Bun shell template literals with Node.js execFileSync to prevent command injection attacks via malicious branch names. Branch names from PR data (headRefName) are now validated against a strict whitelist pattern before use in git commands. Changes: - Add validateBranchName() function with strict character whitelist - Replace $`git ...` shell templates with execGit() using execFileSync - Validate all branch names before use in git operations * fix: Address review comments for branch validation security - Enhanced execGit JSDoc to explain security benefits of execFileSync - Added comprehensive branch name validation: - Leading dash check (prevents option injection) - Control characters and special git characters (~^:?*[\]) - Leading/trailing period checks - Trailing slash and consecutive slash checks - Added -- separator to git checkout commands - Added 30 unit tests for validateBranchName covering: - Valid branch names - Command injection attempts - Option injection attempts - Path traversal attempts - Git-specific invalid patterns - Control characters and edge cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
271 lines
9.2 KiB
TypeScript
271 lines
9.2 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
/**
|
|
* Setup the appropriate branch based on the event type:
|
|
* - For PRs: Checkout the PR branch
|
|
* - For Issues: Create a new branch
|
|
*/
|
|
|
|
import { execFileSync } from "child_process";
|
|
import * as core from "@actions/core";
|
|
import type { ParsedGitHubContext } from "../context";
|
|
import type { GitHubPullRequest } from "../types";
|
|
import type { Octokits } from "../api/client";
|
|
import type { FetchDataResult } from "../data/fetcher";
|
|
|
|
/**
|
|
* Validates a git branch name against a strict whitelist pattern.
|
|
* This prevents command injection by ensuring only safe characters are used.
|
|
*
|
|
* Valid branch names:
|
|
* - Start with alphanumeric character (not dash, to prevent option injection)
|
|
* - Contain only alphanumeric, forward slash, hyphen, underscore, or period
|
|
* - Do not start or end with a period
|
|
* - Do not end with a slash
|
|
* - Do not contain '..' (path traversal)
|
|
* - Do not contain '//' (consecutive slashes)
|
|
* - Do not end with '.lock'
|
|
* - Do not contain '@{'
|
|
* - Do not contain control characters or special git characters (~^:?*[\])
|
|
*/
|
|
export function validateBranchName(branchName: string): void {
|
|
// Check for empty or whitespace-only names
|
|
if (!branchName || branchName.trim().length === 0) {
|
|
throw new Error("Branch name cannot be empty");
|
|
}
|
|
|
|
// Check for leading dash (prevents option injection like --help, -x)
|
|
if (branchName.startsWith("-")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot start with a dash.`,
|
|
);
|
|
}
|
|
|
|
// Check for control characters and special git characters (~^:?*[\])
|
|
// eslint-disable-next-line no-control-regex
|
|
if (/[\x00-\x1F\x7F ~^:?*[\]\\]/.test(branchName)) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot contain control characters, spaces, or special git characters (~^:?*[\\]).`,
|
|
);
|
|
}
|
|
|
|
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period
|
|
const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$/;
|
|
|
|
if (!validPattern.test(branchName)) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, or periods.`,
|
|
);
|
|
}
|
|
|
|
// Check for leading/trailing periods
|
|
if (branchName.startsWith(".") || branchName.endsWith(".")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot start or end with a period.`,
|
|
);
|
|
}
|
|
|
|
// Check for trailing slash
|
|
if (branchName.endsWith("/")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot end with a slash.`,
|
|
);
|
|
}
|
|
|
|
// Check for consecutive slashes
|
|
if (branchName.includes("//")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot contain consecutive slashes.`,
|
|
);
|
|
}
|
|
|
|
// Additional git-specific validations
|
|
if (branchName.includes("..")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot contain '..'`,
|
|
);
|
|
}
|
|
|
|
if (branchName.endsWith(".lock")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot end with '.lock'`,
|
|
);
|
|
}
|
|
|
|
if (branchName.includes("@{")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes a git command safely using execFileSync to avoid shell interpolation.
|
|
*
|
|
* Security: execFileSync passes arguments directly to the git binary without
|
|
* invoking a shell, preventing command injection attacks where malicious input
|
|
* could be interpreted as shell commands (e.g., branch names containing `;`, `|`, `&&`).
|
|
*
|
|
* @param args - Git command arguments (e.g., ["checkout", "branch-name"])
|
|
*/
|
|
function execGit(args: string[]): void {
|
|
execFileSync("git", args, { stdio: "inherit" });
|
|
}
|
|
|
|
export type BranchInfo = {
|
|
baseBranch: string;
|
|
claudeBranch?: string;
|
|
currentBranch: string;
|
|
};
|
|
|
|
export async function setupBranch(
|
|
octokits: Octokits,
|
|
githubData: FetchDataResult,
|
|
context: ParsedGitHubContext,
|
|
): Promise<BranchInfo> {
|
|
const { owner, repo } = context.repository;
|
|
const entityNumber = context.entityNumber;
|
|
const { baseBranch, branchPrefix } = context.inputs;
|
|
const isPR = context.isPR;
|
|
|
|
if (isPR) {
|
|
const prData = githubData.contextData as GitHubPullRequest;
|
|
const prState = prData.state;
|
|
|
|
// Check if PR is closed or merged
|
|
if (prState === "CLOSED" || prState === "MERGED") {
|
|
console.log(
|
|
`PR #${entityNumber} is ${prState}, creating new branch from source...`,
|
|
);
|
|
// Fall through to create a new branch like we do for issues
|
|
} else {
|
|
// Handle open PR: Checkout the PR branch
|
|
console.log("This is an open PR, checking out PR branch...");
|
|
|
|
const branchName = prData.headRefName;
|
|
|
|
// Determine optimal fetch depth based on PR commit count, with a minimum of 20
|
|
const commitCount = prData.commits.totalCount;
|
|
const fetchDepth = Math.max(commitCount, 20);
|
|
|
|
console.log(
|
|
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
|
);
|
|
|
|
// Validate branch names before use to prevent command injection
|
|
validateBranchName(branchName);
|
|
|
|
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
|
// Using execFileSync instead of shell template literals for security
|
|
execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]);
|
|
execGit(["checkout", branchName, "--"]);
|
|
|
|
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
|
|
|
// For open PRs, we need to get the base branch of the PR
|
|
const baseBranch = prData.baseRefName;
|
|
validateBranchName(baseBranch);
|
|
|
|
return {
|
|
baseBranch,
|
|
currentBranch: branchName,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Determine source branch - use baseBranch if provided, otherwise fetch default
|
|
let sourceBranch: string;
|
|
|
|
if (baseBranch) {
|
|
// Use provided base branch for source
|
|
sourceBranch = baseBranch;
|
|
} else {
|
|
// No base branch provided, fetch the default branch to use as source
|
|
const repoResponse = await octokits.rest.repos.get({
|
|
owner,
|
|
repo,
|
|
});
|
|
sourceBranch = repoResponse.data.default_branch;
|
|
}
|
|
|
|
// Generate branch name for either an issue or closed/merged PR
|
|
const entityType = isPR ? "pr" : "issue";
|
|
|
|
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
|
|
const now = new Date();
|
|
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
|
|
|
|
// Ensure branch name is Kubernetes-compatible:
|
|
// - Lowercase only
|
|
// - Alphanumeric with hyphens
|
|
// - No underscores
|
|
// - Max 50 chars (to allow for prefixes)
|
|
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
|
|
const newBranch = branchName.toLowerCase().substring(0, 50);
|
|
|
|
try {
|
|
// Get the SHA of the source branch to verify it exists
|
|
const sourceBranchRef = await octokits.rest.git.getRef({
|
|
owner,
|
|
repo,
|
|
ref: `heads/${sourceBranch}`,
|
|
});
|
|
|
|
const currentSHA = sourceBranchRef.data.object.sha;
|
|
console.log(`Source branch SHA: ${currentSHA}`);
|
|
|
|
// For commit signing, defer branch creation to the file ops server
|
|
if (context.inputs.useCommitSigning) {
|
|
console.log(
|
|
`Branch name generated: ${newBranch} (will be created by file ops server on first commit)`,
|
|
);
|
|
|
|
// Ensure we're on the source branch
|
|
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
|
validateBranchName(sourceBranch);
|
|
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
|
execGit(["checkout", sourceBranch, "--"]);
|
|
|
|
// Set outputs for GitHub Actions
|
|
core.setOutput("CLAUDE_BRANCH", newBranch);
|
|
core.setOutput("BASE_BRANCH", sourceBranch);
|
|
return {
|
|
baseBranch: sourceBranch,
|
|
claudeBranch: newBranch,
|
|
currentBranch: sourceBranch, // Stay on source branch for now
|
|
};
|
|
}
|
|
|
|
// For non-signing case, create and checkout the branch locally only
|
|
console.log(
|
|
`Creating local branch ${newBranch} for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
|
|
);
|
|
|
|
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
|
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
|
validateBranchName(sourceBranch);
|
|
validateBranchName(newBranch);
|
|
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
|
execGit(["checkout", sourceBranch, "--"]);
|
|
|
|
// Create and checkout the new branch from the source branch
|
|
execGit(["checkout", "-b", newBranch]);
|
|
|
|
console.log(
|
|
`Successfully created and checked out local branch: ${newBranch}`,
|
|
);
|
|
|
|
// Set outputs for GitHub Actions
|
|
core.setOutput("CLAUDE_BRANCH", newBranch);
|
|
core.setOutput("BASE_BRANCH", sourceBranch);
|
|
return {
|
|
baseBranch: sourceBranch,
|
|
claudeBranch: newBranch,
|
|
currentBranch: newBranch,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error in branch setup:", error);
|
|
process.exit(1);
|
|
}
|
|
}
|