Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 107 additions & 11 deletions src/lib/init/tools/command-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { MAX_OUTPUT_BYTES } from "../constants.js";

/** Characters treated as command token separators. */
const WHITESPACE_CHAR_RE = /\s/u;
const WINDOWS_EXECUTABLE_EXTENSION_RE = /\.(?:cmd|exe|bat|ps1)$/u;
const PATH_SEPARATOR_RE = /\\/g;

/**
* Patterns that indicate shell injection. Commands run via `child_process.spawn`
* without a shell, so these patterns are defense-in-depth for chaining,
* piping, redirection, and command substitution.
* Patterns that indicate shell injection. Windows package-manager shims require
* shell execution, so workflow commands must reject shell syntax before spawn.
*/
const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> =
[
Expand All @@ -25,6 +26,14 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> =
{ pattern: "<", label: "redirection (<)" },
];

const WINDOWS_SHELL_METACHARACTER_PATTERNS: Array<{
pattern: string;
label: string;
}> = [
{ pattern: "%", label: "Windows environment variable expansion (%)" },
{ pattern: "!", label: "Windows delayed environment expansion (!)" },
];

/**
* Executables that should never appear in a workflow-provided command.
*/
Expand Down Expand Up @@ -61,6 +70,12 @@ const BLOCKED_EXECUTABLES = new Set([
"ssh",
"scp",
"sftp",
"cd",
"pushd",
"popd",
"cmd",
"powershell",
"pwsh",
"bash",
"sh",
"zsh",
Expand Down Expand Up @@ -94,6 +109,83 @@ export type ParsedCommand = {
args: string[];
};

function normalizeExecutableName(executable: string): string {
return path.posix
.basename(executable.replace(PATH_SEPARATOR_RE, "/"))
.toLowerCase()
.replace(WINDOWS_EXECUTABLE_EXTENSION_RE, "");
}

function hasInitArgAfter(tokens: string[], index: number): boolean {
return tokens.slice(index + 1).some((arg) => arg.toLowerCase() === "init");
}

function isSentryCliPackageSpec(token: string): boolean {
const lower = token.toLowerCase();
return lower === "@sentry/cli" || lower.startsWith("@sentry/cli@");
}

function isSentryWizardPackageSpec(token: string): boolean {
const lower = token.toLowerCase();
return lower === "@sentry/wizard" || lower.startsWith("@sentry/wizard@");
}

function isExecutablePackageSpec(executable: string, name: string): boolean {
return executable === name || executable.startsWith(`${name}@`);
}

function findBlockedExecutable(tokens: string[]): string | undefined {
const executable = normalizeExecutableName(tokens[0] ?? "");
if (BLOCKED_EXECUTABLES.has(executable)) {
return executable;
}

Comment thread
betegon marked this conversation as resolved.
return;
}

function isRecursiveSentrySetupToken(
token: string,
tokens: string[],
index: number
): boolean {
const executable = normalizeExecutableName(token);
if (
isSentryWizardPackageSpec(token) ||
isExecutablePackageSpec(executable, "sentry-wizard")
) {
return true;
}
if (isSentryCliPackageSpec(token)) {
return hasInitArgAfter(tokens, index);
}
Comment thread
betegon marked this conversation as resolved.
if (
!(
isExecutablePackageSpec(executable, "sentry") ||
isExecutablePackageSpec(executable, "sentry-cli")
)
) {
return false;
}
return hasInitArgAfter(tokens, index);
}

function isRecursiveSentrySetup(tokens: string[]): boolean {
const [firstToken = ""] = tokens;
return isRecursiveSentrySetupToken(firstToken, tokens, 0);
}

function findShellMetacharacterLabel(command: string): string | undefined {
const patterns =
process.platform === "win32"
? [
...SHELL_METACHARACTER_PATTERNS,
...WINDOWS_SHELL_METACHARACTER_PATTERNS,
]
: SHELL_METACHARACTER_PATTERNS;

return patterns.find(({ pattern }) => command.includes(pattern))?.label;
}

function isCommandWhitespace(char: string): boolean {
return WHITESPACE_CHAR_RE.test(char);
}
Expand Down Expand Up @@ -250,19 +342,19 @@ export function parseCommand(command: string): ParsedCommand {
* Validate a command before execution.
*/
export function validateCommand(command: string): string | undefined {
for (const { pattern, label } of SHELL_METACHARACTER_PATTERNS) {
if (command.includes(pattern)) {
return `Blocked command: contains ${label} — "${command}"`;
}
const metacharacterLabel = findShellMetacharacterLabel(command);
if (metacharacterLabel) {
return `Blocked command: contains ${metacharacterLabel} — "${command}"`;
}

let firstToken: string;
let tokens: string[];
try {
[firstToken = ""] = tokenizeCommand(command);
tokens = tokenizeCommand(command);
} catch (error) {
return error instanceof Error ? error.message : String(error);
}

const [firstToken = ""] = tokens;
if (!firstToken) {
return "Blocked command: empty command";
}
Expand All @@ -271,8 +363,12 @@ export function validateCommand(command: string): string | undefined {
return `Blocked command: contains environment variable assignment — "${command}"`;
}

const executable = path.basename(firstToken);
if (BLOCKED_EXECUTABLES.has(executable)) {
if (isRecursiveSentrySetup(tokens)) {
Comment thread
betegon marked this conversation as resolved.
return `Blocked command: invokes Sentry setup recursively — "${command}"`;
Comment thread
betegon marked this conversation as resolved.
}
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
betegon marked this conversation as resolved.

const executable = findBlockedExecutable(tokens);
if (executable) {
return `Blocked command: disallowed executable "${executable}" — "${command}"`;
}

Expand Down
68 changes: 66 additions & 2 deletions src/lib/init/tools/run-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,56 @@ import {
} from "./command-utils.js";
import type { InitToolDefinition, ToolContext } from "./types.js";

const WINDOWS_BATCH_SHIM_RE = /\.(?:cmd|bat)$/iu;

type SpawnCommand = {
executable: string;
args: string[];
windowsVerbatimArguments?: true;
};

function isWindowsBatchShim(executable: string): boolean {
Comment thread
sentry[bot] marked this conversation as resolved.
return process.platform === "win32" && WINDOWS_BATCH_SHIM_RE.test(executable);
}

function quoteWindowsCommandArg(value: string): string {
let quoted = '"';
let backslashes = 0;

for (const char of value) {
if (char === "\\") {
backslashes += 1;
continue;
}

if (char === '"') {
quoted += "\\".repeat(backslashes * 2);
quoted += '""';
backslashes = 0;
Comment thread
sentry[bot] marked this conversation as resolved.
continue;
}

quoted += "\\".repeat(backslashes);
quoted += char;
backslashes = 0;
}

quoted += "\\".repeat(backslashes * 2);
quoted += '"';
return quoted;
}
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

function buildWindowsBatchCommand(executable: string, args: string[]): string {
const commandLine = [executable, ...args]
.map(quoteWindowsCommandArg)
.join(" ");

// cmd.exe /s strips the outer quote pair, leaving a quoted exe + argv.
return `"${commandLine}"`;
}

/**
* Validate and execute a batch of shell-free commands.
* Validate and execute a batch of commands.
*/
export async function runCommands(
payload: RunCommandsPayload,
Expand Down Expand Up @@ -81,11 +129,27 @@ async function runSingleCommand(
stderr: string;
}> {
const executable = whichSync(command.executable) ?? command.executable;
const spawnCommand: SpawnCommand = isWindowsBatchShim(executable)
? {
executable: process.env.ComSpec ?? "cmd.exe",
Comment thread
sentry-warden[bot] marked this conversation as resolved.
args: [
"/d",
"/s",
"/c",
buildWindowsBatchCommand(executable, command.args),
Comment thread
betegon marked this conversation as resolved.
],
windowsVerbatimArguments: true,
}
: { executable, args: command.args };

try {
const child = spawn(executable, command.args, {
const child = spawn(spawnCommand.executable, spawnCommand.args, {
cwd,
shell: false,
Comment thread
betegon marked this conversation as resolved.
stdio: ["ignore", "pipe", "pipe"],
...(spawnCommand.windowsVerbatimArguments
? { windowsVerbatimArguments: true }
: {}),
});
const exited = new Promise<number>((resolve) => {
child.on("close", (code) => resolve(code ?? 1));
Expand Down
Loading
Loading