From e0f06c3c30e9d045bf6d5d4aa01d9c993a4c2f42 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 18:29:23 +0200 Subject: [PATCH 01/17] fix(init): harden install command execution --- src/lib/init/tools/command-utils.ts | 45 ++++++- src/lib/init/tools/run-commands.ts | 9 +- .../tools/run-commands-spawn.mocked.test.ts | 122 ++++++++++++++++++ test/lib/init/tools/run-commands.test.ts | 42 ++++++ 4 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 test/lib/init/tools/run-commands-spawn.mocked.test.ts diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 74461ac8d..827e491ab 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -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 }> = [ @@ -23,6 +24,9 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { pattern: "\r", label: "carriage return" }, { pattern: ">", label: "redirection (>)" }, { pattern: "<", label: "redirection (<)" }, + { pattern: "%", label: "Windows environment variable expansion (%)" }, + { pattern: "^", label: "Windows command escaping (^)" }, + { pattern: "!", label: "Windows delayed environment expansion (!)" }, ]; /** @@ -61,6 +65,9 @@ const BLOCKED_EXECUTABLES = new Set([ "ssh", "scp", "sftp", + "cd", + "pushd", + "popd", "bash", "sh", "zsh", @@ -94,6 +101,27 @@ 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 isRecursiveSentrySetup(tokens: string[]): boolean { + if (tokens.some((token) => token.toLowerCase().includes("@sentry/wizard"))) { + return true; + } + + return tokens.some((token, index) => { + const executable = normalizeExecutableName(token); + if (executable !== "sentry" && executable !== "sentry-cli") { + return false; + } + return tokens.slice(index + 1).some((arg) => arg.toLowerCase() === "init"); + }); +} + function isCommandWhitespace(char: string): boolean { return WHITESPACE_CHAR_RE.test(char); } @@ -256,13 +284,14 @@ export function validateCommand(command: string): string | undefined { } } - 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"; } @@ -271,7 +300,11 @@ export function validateCommand(command: string): string | undefined { return `Blocked command: contains environment variable assignment — "${command}"`; } - const executable = path.basename(firstToken); + if (isRecursiveSentrySetup(tokens)) { + return `Blocked command: invokes Sentry setup recursively — "${command}"`; + } + + const executable = normalizeExecutableName(firstToken); if (BLOCKED_EXECUTABLES.has(executable)) { return `Blocked command: disallowed executable "${executable}" — "${command}"`; } diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 9cece52e3..39a7471e0 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -10,8 +10,14 @@ import { } from "./command-utils.js"; import type { InitToolDefinition, ToolContext } from "./types.js"; +const WINDOWS_BATCH_SHIM_RE = /\.(?:cmd|bat)$/iu; + +function needsWindowsShell(executable: string): boolean { + return process.platform === "win32" && WINDOWS_BATCH_SHIM_RE.test(executable); +} + /** - * Validate and execute a batch of shell-free commands. + * Validate and execute a batch of commands. */ export async function runCommands( payload: RunCommandsPayload, @@ -85,6 +91,7 @@ async function runSingleCommand( try { const child = spawn(executable, command.args, { cwd, + shell: needsWindowsShell(executable), stdio: ["ignore", "pipe", "pipe"], }); const exited = new Promise((resolve) => { diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts new file mode 100644 index 000000000..d9df4abbd --- /dev/null +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for run-commands spawn options. + * + * Kept separate because node:child_process must be mocked before importing + * the tool module. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { RunCommandsPayload } from "../../../../src/lib/init/types.js"; + +const { spawnCalls } = vi.hoisted(() => ({ + spawnCalls: [] as Array<{ + command: string; + args: string[]; + options: { shell?: boolean }; + }>, +})); + +vi.mock("node:child_process", async () => { + const { EventEmitter } = await import("node:events"); + const { Readable } = await import("node:stream"); + + return { + execFileSync: (_file: string, args: string[]) => { + const command = args.at(-1); + if (process.platform !== "win32") { + return `/usr/local/bin/${command}\n`; + } + return command === "pnpm" + ? "C:\\Tools\\pnpm.CMD\r\n" + : `C:\\Tools\\${command}.exe\r\n`; + }, + spawn: (command: string, args: string[], options: { shell?: boolean }) => { + spawnCalls.push({ command, args, options }); + const child = new EventEmitter() as any; + child.stdout = Readable.from(["10.0.0\n"]); + child.stderr = Readable.from([]); + child.kill = vi.fn(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }, + }; +}); + +vi.mock("@sentry/node-core/light", () => ({ + addBreadcrumb: vi.fn(), +})); + +import { runCommands } from "../../../../src/lib/init/tools/run-commands.js"; + +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +function makePayload(command: string): RunCommandsPayload { + return { + type: "tool", + operation: "run-commands", + cwd: "/tmp", + params: { commands: [command] }, + }; +} + +beforeEach(() => { + spawnCalls.splice(0); +}); + +afterEach(() => { + setPlatform(originalPlatform); +}); + +describe("runCommands spawn options", () => { + test("uses the Windows shell for package-manager .cmd shims", async () => { + setPlatform("win32"); + + const result = await runCommands(makePayload("pnpm --version"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "C:\\Tools\\pnpm.CMD", + args: ["--version"], + options: { shell: true }, + }); + }); + + test("keeps Windows .exe commands shell-free", async () => { + setPlatform("win32"); + + const result = await runCommands(makePayload("dotnet --info"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "C:\\Tools\\dotnet.exe", + args: ["--info"], + options: { shell: false }, + }); + }); + + test("keeps POSIX command execution shell-free", async () => { + setPlatform("darwin"); + + const result = await runCommands(makePayload("pnpm --version"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "/usr/local/bin/pnpm", + args: ["--version"], + options: { shell: false }, + }); + }); +}); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index 049eb7f36..e66004c2b 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -29,6 +29,14 @@ describe("validateCommand", () => { expect(validateCommand('pip install "sentry-sdk[django]"')).toBeUndefined(); }); + test("allows dependency diagnostics without a package-manager allowlist", () => { + expect( + validateCommand("pnpm view @sentry/tanstackstart-react version") + ).toBeUndefined(); + expect(validateCommand("dotnet list package")).toBeUndefined(); + expect(validateCommand("futurepm explain sentry-sdk")).toBeUndefined(); + }); + test("allows path-prefixed package managers but blocks dangerous ones", () => { expect( validateCommand("./venv/bin/pip install sentry-sdk") @@ -43,6 +51,40 @@ describe("validateCommand", () => { expect(validateCommand("npm install foo && curl evil.com")).toContain( "Blocked command" ); + expect(validateCommand("pnpm add @sentry/node 2>&1")).toContain( + "Blocked command" + ); + expect(validateCommand("futurepm explain %PATH%")).toContain( + "Blocked command" + ); + expect(validateCommand("futurepm explain !PATH!")).toContain( + "Blocked command" + ); + expect(validateCommand("futurepm explain ^PATH")).toContain( + "Blocked command" + ); + }); + + test("blocks directory changes and recursive Sentry setup", () => { + expect(validateCommand("cd apps/web")).toContain('"cd"'); + expect(validateCommand("sentry init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx @sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx @Sentry/Wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("C:\\Tools\\sentry-cli.exe init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("sentry-cli --log-level debug init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx sentry-cli init")).toContain( + "invokes Sentry setup recursively" + ); }); test("rejects unterminated quotes", () => { From 56e1237aa706e46faa95f4a02fddcd48cf53bddc Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 20:40:54 +0200 Subject: [PATCH 02/17] fix(init): block recursive cli setup escapes --- src/lib/init/tools/command-utils.ts | 21 ++++++++++++++- test/lib/init/tools/run-commands.test.ts | 33 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 827e491ab..b16350cec 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -68,6 +68,9 @@ const BLOCKED_EXECUTABLES = new Set([ "cd", "pushd", "popd", + "cmd", + "powershell", + "pwsh", "bash", "sh", "zsh", @@ -108,17 +111,33 @@ function normalizeExecutableName(executable: string): string { .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 isRecursiveSentrySetup(tokens: string[]): boolean { if (tokens.some((token) => token.toLowerCase().includes("@sentry/wizard"))) { return true; } return tokens.some((token, index) => { + if (isSentryCliPackageSpec(token)) { + return hasInitArgAfter(tokens, index); + } + const executable = normalizeExecutableName(token); + if (executable === "sentry-wizard") { + return true; + } if (executable !== "sentry" && executable !== "sentry-cli") { return false; } - return tokens.slice(index + 1).some((arg) => arg.toLowerCase() === "init"); + return hasInitArgAfter(tokens, index); }); } diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index e66004c2b..c1b222546 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -76,6 +76,15 @@ describe("validateCommand", () => { expect(validateCommand("npx @Sentry/Wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npx @sentry/cli init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx @sentry/cli@latest init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx @Sentry/CLI@latest init")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("C:\\Tools\\sentry-cli.exe init")).toContain( "invokes Sentry setup recursively" ); @@ -85,6 +94,30 @@ describe("validateCommand", () => { expect(validateCommand("npx sentry-cli init")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("sentry-wizard init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx sentry-wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + }); + + test("blocks shell interpreter indirection", () => { + expect(validateCommand("cmd.exe /c del sensitive_file")).toContain('"cmd"'); + expect( + validateCommand("C:\\Windows\\System32\\cmd.exe /c del secrets.txt") + ).toContain('"cmd"'); + expect( + validateCommand( + "powershell.exe -Command Invoke-WebRequest http://evil.com" + ) + ).toContain('"powershell"'); + expect(validateCommand("pwsh -Command Remove-Item foo")).toContain( + '"pwsh"' + ); }); test("rejects unterminated quotes", () => { From bcc4ad58fde53fbd8993c5e0bc2116b9b80410db Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 21:25:37 +0200 Subject: [PATCH 03/17] fix(init): block versioned sentry setup commands --- src/lib/init/tools/command-utils.ts | 11 +++++++++-- test/lib/init/tools/run-commands.test.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index b16350cec..6414fb07e 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -120,6 +120,10 @@ function isSentryCliPackageSpec(token: string): boolean { return lower === "@sentry/cli" || lower.startsWith("@sentry/cli@"); } +function isExecutablePackageSpec(executable: string, name: string): boolean { + return executable === name || executable.startsWith(`${name}@`); +} + function isRecursiveSentrySetup(tokens: string[]): boolean { if (tokens.some((token) => token.toLowerCase().includes("@sentry/wizard"))) { return true; @@ -131,10 +135,13 @@ function isRecursiveSentrySetup(tokens: string[]): boolean { } const executable = normalizeExecutableName(token); - if (executable === "sentry-wizard") { + if (isExecutablePackageSpec(executable, "sentry-wizard")) { return true; } - if (executable !== "sentry" && executable !== "sentry-cli") { + if ( + !isExecutablePackageSpec(executable, "sentry") && + !isExecutablePackageSpec(executable, "sentry-cli") + ) { return false; } return hasInitArgAfter(tokens, index); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index c1b222546..f4257e083 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -94,12 +94,21 @@ describe("validateCommand", () => { expect(validateCommand("npx sentry-cli init")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("sentry-cli@latest init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx sentry-cli@latest init")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("sentry-wizard init")).toContain( "invokes Sentry setup recursively" ); expect(validateCommand("npx sentry-wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npx sentry-wizard@latest -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( "invokes Sentry setup recursively" ); From 0ddb7c58605f829c8f86e2463d2190c4c56c86d1 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 21:29:34 +0200 Subject: [PATCH 04/17] fix(init): satisfy command validation lint --- src/lib/init/tools/command-utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 6414fb07e..c62ea44c0 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -139,8 +139,10 @@ function isRecursiveSentrySetup(tokens: string[]): boolean { return true; } if ( - !isExecutablePackageSpec(executable, "sentry") && - !isExecutablePackageSpec(executable, "sentry-cli") + !( + isExecutablePackageSpec(executable, "sentry") || + isExecutablePackageSpec(executable, "sentry-cli") + ) ) { return false; } From 17b057188fcd53345bcae569db2605a963dd2840 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 21:44:15 +0200 Subject: [PATCH 05/17] fix(init): preserve Windows install command arguments --- src/lib/init/tools/command-utils.ts | 1 - src/lib/init/tools/run-commands.ts | 30 ++++++++++++++-- .../tools/run-commands-spawn.mocked.test.ts | 36 ++++++++++++++++--- test/lib/init/tools/run-commands.test.ts | 5 ++- 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index c62ea44c0..31119af42 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -25,7 +25,6 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { pattern: ">", label: "redirection (>)" }, { pattern: "<", label: "redirection (<)" }, { pattern: "%", label: "Windows environment variable expansion (%)" }, - { pattern: "^", label: "Windows command escaping (^)" }, { pattern: "!", label: "Windows delayed environment expansion (!)" }, ]; diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 39a7471e0..2d175f566 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -12,10 +12,23 @@ import type { InitToolDefinition, ToolContext } from "./types.js"; const WINDOWS_BATCH_SHIM_RE = /\.(?:cmd|bat)$/iu; -function needsWindowsShell(executable: string): boolean { +function isWindowsBatchShim(executable: string): boolean { return process.platform === "win32" && WINDOWS_BATCH_SHIM_RE.test(executable); } +function quoteWindowsCommandArg(value: string): string { + return `"${value.replace(/\^/g, "^^").replace(/"/g, '""')}"`; +} + +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 commands. */ @@ -87,11 +100,22 @@ async function runSingleCommand( stderr: string; }> { const executable = whichSync(command.executable) ?? command.executable; + const spawnCommand = isWindowsBatchShim(executable) + ? { + executable: process.env.ComSpec ?? "cmd.exe", + args: [ + "/d", + "/s", + "/c", + buildWindowsBatchCommand(executable, command.args), + ], + } + : { executable, args: command.args }; try { - const child = spawn(executable, command.args, { + const child = spawn(spawnCommand.executable, spawnCommand.args, { cwd, - shell: needsWindowsShell(executable), + shell: false, stdio: ["ignore", "pipe", "pipe"], }); const exited = new Promise((resolve) => { diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts index d9df4abbd..57a4f8b5c 100644 --- a/test/lib/init/tools/run-commands-spawn.mocked.test.ts +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { RunCommandsPayload } from "../../../../src/lib/init/types.js"; +const originalComSpec = process.env.ComSpec; const { spawnCalls } = vi.hoisted(() => ({ spawnCalls: [] as Array<{ command: string; @@ -68,14 +69,20 @@ function makePayload(command: string): RunCommandsPayload { beforeEach(() => { spawnCalls.splice(0); + delete process.env.ComSpec; }); afterEach(() => { setPlatform(originalPlatform); + if (originalComSpec === undefined) { + delete process.env.ComSpec; + } else { + process.env.ComSpec = originalComSpec; + } }); describe("runCommands spawn options", () => { - test("uses the Windows shell for package-manager .cmd shims", async () => { + test("uses cmd.exe for package-manager .cmd shims", async () => { setPlatform("win32"); const result = await runCommands(makePayload("pnpm --version"), { @@ -84,9 +91,30 @@ describe("runCommands spawn options", () => { expect(result.ok).toBe(true); expect(spawnCalls[0]).toMatchObject({ - command: "C:\\Tools\\pnpm.CMD", - args: ["--version"], - options: { shell: true }, + command: "cmd.exe", + args: ["/d", "/s", "/c", '""C:\\Tools\\pnpm.CMD" "--version""'], + options: { shell: false }, + }); + }); + + test("quotes Windows .cmd shim arguments with spaces", async () => { + setPlatform("win32"); + + const result = await runCommands( + makePayload('pnpm --filter "./apps/web app" add @sentry/nextjs@^8.0.0'), + { dryRun: false } + ); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + args: [ + "/d", + "/s", + "/c", + '""C:\\Tools\\pnpm.CMD" "--filter" "./apps/web app" "add" "@sentry/nextjs@^^8.0.0""', + ], + options: { shell: false }, }); }); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index f4257e083..201153811 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -27,6 +27,8 @@ function makePayload(commands: string[]): RunCommandsPayload { describe("validateCommand", () => { test("allows quoted package specifiers", () => { expect(validateCommand('pip install "sentry-sdk[django]"')).toBeUndefined(); + expect(validateCommand("npm install @sentry/node@^9.0.0")).toBeUndefined(); + expect(validateCommand("pnpm add @sentry/nextjs@^8.0.0")).toBeUndefined(); }); test("allows dependency diagnostics without a package-manager allowlist", () => { @@ -60,9 +62,6 @@ describe("validateCommand", () => { expect(validateCommand("futurepm explain !PATH!")).toContain( "Blocked command" ); - expect(validateCommand("futurepm explain ^PATH")).toContain( - "Blocked command" - ); }); test("blocks directory changes and recursive Sentry setup", () => { From 72f1012e38cd9a441cbd85d6e9a7d35dee65ed46 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 21:57:20 +0200 Subject: [PATCH 06/17] fix(init): refine recursive setup command guards --- src/lib/init/tools/command-utils.ts | 66 +++++++++++++++++-- src/lib/init/tools/run-commands.ts | 2 +- .../tools/run-commands-spawn.mocked.test.ts | 2 +- test/lib/init/tools/run-commands.test.ts | 9 +++ 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 31119af42..74102f5d6 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -119,24 +119,78 @@ function isSentryCliPackageSpec(token: string): boolean { 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 isRecursiveSentrySetup(tokens: string[]): boolean { - if (tokens.some((token) => token.toLowerCase().includes("@sentry/wizard"))) { - return true; +function findFirstNonOptionIndex( + tokens: string[], + startIndex: number +): number | undefined { + for (let index = startIndex; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) { + continue; + } + if (token === "--") { + return index + 1 < tokens.length ? index + 1 : undefined; + } + if (token.startsWith("-")) { + continue; + } + return index; } + return; +} + +function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { + const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); + if ( + isExecutablePackageSpec(firstExecutable, "npx") || + isExecutablePackageSpec(firstExecutable, "bunx") + ) { + return findFirstNonOptionIndex(tokens, 1); + } + + const subcommandIndex = findFirstNonOptionIndex(tokens, 1); + if (subcommandIndex === undefined) { + return; + } + + const subcommand = normalizeExecutableName(tokens[subcommandIndex] ?? ""); + if (subcommand !== "exec" && subcommand !== "dlx") { + return; + } + + return findFirstNonOptionIndex(tokens, subcommandIndex + 1); +} + +function canExecuteToken(tokens: string[], index: number): boolean { + return index === 0 || index === findPackageExecutionTokenIndex(tokens); +} + +function isRecursiveSentrySetup(tokens: string[]): boolean { return tokens.some((token, index) => { - if (isSentryCliPackageSpec(token)) { - return hasInitArgAfter(tokens, index); + if (!canExecuteToken(tokens, index)) { + return false; } const executable = normalizeExecutableName(token); - if (isExecutablePackageSpec(executable, "sentry-wizard")) { + if ( + isSentryWizardPackageSpec(token) || + isExecutablePackageSpec(executable, "sentry-wizard") + ) { return true; } + if (isSentryCliPackageSpec(token)) { + return hasInitArgAfter(tokens, index); + } if ( !( isExecutablePackageSpec(executable, "sentry") || diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 2d175f566..7e6a4bd33 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -17,7 +17,7 @@ function isWindowsBatchShim(executable: string): boolean { } function quoteWindowsCommandArg(value: string): string { - return `"${value.replace(/\^/g, "^^").replace(/"/g, '""')}"`; + return `"${value.replace(/"/g, '""')}"`; } function buildWindowsBatchCommand(executable: string, args: string[]): string { diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts index 57a4f8b5c..5394498dd 100644 --- a/test/lib/init/tools/run-commands-spawn.mocked.test.ts +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -112,7 +112,7 @@ describe("runCommands spawn options", () => { "/d", "/s", "/c", - '""C:\\Tools\\pnpm.CMD" "--filter" "./apps/web app" "add" "@sentry/nextjs@^^8.0.0""', + '""C:\\Tools\\pnpm.CMD" "--filter" "./apps/web app" "add" "@sentry/nextjs@^8.0.0""', ], options: { shell: false }, }); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index 201153811..b9bf070f0 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -37,6 +37,9 @@ describe("validateCommand", () => { ).toBeUndefined(); expect(validateCommand("dotnet list package")).toBeUndefined(); expect(validateCommand("futurepm explain sentry-sdk")).toBeUndefined(); + expect(validateCommand("futurepm explain sentry-wizard")).toBeUndefined(); + expect(validateCommand("npm uninstall sentry-wizard")).toBeUndefined(); + expect(validateCommand("npm uninstall @sentry/wizard")).toBeUndefined(); }); test("allows path-prefixed package managers but blocks dangerous ones", () => { @@ -108,6 +111,12 @@ describe("validateCommand", () => { expect(validateCommand("npx sentry-wizard@latest -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npm exec @sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("pnpm dlx sentry-wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( "invokes Sentry setup recursively" ); From f05a4a3982cba1337e694ba7081d452b76739f67 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 22:10:50 +0200 Subject: [PATCH 07/17] fix(init): preserve verbatim cmd shim arguments --- src/lib/init/tools/run-commands.ts | 12 +++++++++++- .../init/tools/run-commands-spawn.mocked.test.ts | 14 ++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 7e6a4bd33..0ee5d9b16 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -12,6 +12,12 @@ 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 { return process.platform === "win32" && WINDOWS_BATCH_SHIM_RE.test(executable); } @@ -100,7 +106,7 @@ async function runSingleCommand( stderr: string; }> { const executable = whichSync(command.executable) ?? command.executable; - const spawnCommand = isWindowsBatchShim(executable) + const spawnCommand: SpawnCommand = isWindowsBatchShim(executable) ? { executable: process.env.ComSpec ?? "cmd.exe", args: [ @@ -109,6 +115,7 @@ async function runSingleCommand( "/c", buildWindowsBatchCommand(executable, command.args), ], + windowsVerbatimArguments: true, } : { executable, args: command.args }; @@ -117,6 +124,9 @@ async function runSingleCommand( cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], + ...(spawnCommand.windowsVerbatimArguments + ? { windowsVerbatimArguments: true } + : {}), }); const exited = new Promise((resolve) => { child.on("close", (code) => resolve(code ?? 1)); diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts index 5394498dd..d75f21e5e 100644 --- a/test/lib/init/tools/run-commands-spawn.mocked.test.ts +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -13,7 +13,7 @@ const { spawnCalls } = vi.hoisted(() => ({ spawnCalls: [] as Array<{ command: string; args: string[]; - options: { shell?: boolean }; + options: { shell?: boolean; windowsVerbatimArguments?: boolean }; }>, })); @@ -31,7 +31,11 @@ vi.mock("node:child_process", async () => { ? "C:\\Tools\\pnpm.CMD\r\n" : `C:\\Tools\\${command}.exe\r\n`; }, - spawn: (command: string, args: string[], options: { shell?: boolean }) => { + spawn: ( + command: string, + args: string[], + options: { shell?: boolean; windowsVerbatimArguments?: boolean } + ) => { spawnCalls.push({ command, args, options }); const child = new EventEmitter() as any; child.stdout = Readable.from(["10.0.0\n"]); @@ -93,7 +97,7 @@ describe("runCommands spawn options", () => { expect(spawnCalls[0]).toMatchObject({ command: "cmd.exe", args: ["/d", "/s", "/c", '""C:\\Tools\\pnpm.CMD" "--version""'], - options: { shell: false }, + options: { shell: false, windowsVerbatimArguments: true }, }); }); @@ -114,7 +118,7 @@ describe("runCommands spawn options", () => { "/c", '""C:\\Tools\\pnpm.CMD" "--filter" "./apps/web app" "add" "@sentry/nextjs@^8.0.0""', ], - options: { shell: false }, + options: { shell: false, windowsVerbatimArguments: true }, }); }); @@ -131,6 +135,7 @@ describe("runCommands spawn options", () => { args: ["--info"], options: { shell: false }, }); + expect(spawnCalls[0]?.options.windowsVerbatimArguments).toBeUndefined(); }); test("keeps POSIX command execution shell-free", async () => { @@ -146,5 +151,6 @@ describe("runCommands spawn options", () => { args: ["--version"], options: { shell: false }, }); + expect(spawnCalls[0]?.options.windowsVerbatimArguments).toBeUndefined(); }); }); From 94ed55259ab6e859c697853f0db5faa4da98bb14 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 16:53:07 +0200 Subject: [PATCH 08/17] fix(init): handle package runner option values --- src/lib/init/tools/command-utils.ts | 23 +++++++++++++++++++---- test/lib/init/tools/run-commands.test.ts | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 74102f5d6..32d033dcb 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -128,7 +128,15 @@ function isExecutablePackageSpec(executable: string, name: string): boolean { return executable === name || executable.startsWith(`${name}@`); } -function findFirstNonOptionIndex( +function packageRunnerOptionConsumesValue(token: string): boolean { + return token === "-p" || token === "--package"; +} + +function isInlinePackageRunnerOption(token: string): boolean { + return token.startsWith("-p=") || token.startsWith("--package="); +} + +function findPackageRunnerCommandIndex( tokens: string[], startIndex: number ): number | undefined { @@ -140,6 +148,13 @@ function findFirstNonOptionIndex( if (token === "--") { return index + 1 < tokens.length ? index + 1 : undefined; } + if (packageRunnerOptionConsumesValue(token)) { + index += 1; + continue; + } + if (isInlinePackageRunnerOption(token)) { + continue; + } if (token.startsWith("-")) { continue; } @@ -155,10 +170,10 @@ function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { isExecutablePackageSpec(firstExecutable, "npx") || isExecutablePackageSpec(firstExecutable, "bunx") ) { - return findFirstNonOptionIndex(tokens, 1); + return findPackageRunnerCommandIndex(tokens, 1); } - const subcommandIndex = findFirstNonOptionIndex(tokens, 1); + const subcommandIndex = findPackageRunnerCommandIndex(tokens, 1); if (subcommandIndex === undefined) { return; } @@ -168,7 +183,7 @@ function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { return; } - return findFirstNonOptionIndex(tokens, subcommandIndex + 1); + return findPackageRunnerCommandIndex(tokens, subcommandIndex + 1); } function canExecuteToken(tokens: string[], index: number): boolean { diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index b9bf070f0..0853c0818 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -78,6 +78,18 @@ describe("validateCommand", () => { expect(validateCommand("npx @Sentry/Wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect( + validateCommand("npx -p innocuous-pkg @sentry/wizard -i nextjs") + ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("npx --package innocuous-pkg @sentry/wizard -i nextjs") + ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("npx --package=innocuous-pkg @sentry/wizard -i nextjs") + ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("npx -p=innocuous-pkg @sentry/wizard -i nextjs") + ).toContain("invokes Sentry setup recursively"); expect(validateCommand("npx @sentry/cli init")).toContain( "invokes Sentry setup recursively" ); @@ -114,9 +126,15 @@ describe("validateCommand", () => { expect(validateCommand("npm exec @sentry/wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect( + validateCommand("npm exec --package lodash @sentry/wizard -i nextjs") + ).toContain("invokes Sentry setup recursively"); expect(validateCommand("pnpm dlx sentry-wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect( + validateCommand("pnpm dlx --package lodash sentry-wizard -i nextjs") + ).toContain("invokes Sentry setup recursively"); expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( "invokes Sentry setup recursively" ); From 909d07105ae372b302fc5f68f67d0e4dc0643a44 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 17:09:36 +0200 Subject: [PATCH 09/17] fix(init): escape trailing backslashes in cmd shims --- src/lib/init/tools/run-commands.ts | 25 ++++++++++++++++++- .../tools/run-commands-spawn.mocked.test.ts | 20 +++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 0ee5d9b16..0398238ea 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -23,7 +23,30 @@ function isWindowsBatchShim(executable: string): boolean { } function quoteWindowsCommandArg(value: string): string { - return `"${value.replace(/"/g, '""')}"`; + let quoted = '"'; + let backslashes = 0; + + for (const char of value) { + if (char === "\\") { + backslashes += 1; + continue; + } + + if (char === '"') { + quoted += "\\".repeat(backslashes * 2 + 1); + quoted += char; + backslashes = 0; + continue; + } + + quoted += "\\".repeat(backslashes); + quoted += char; + backslashes = 0; + } + + quoted += "\\".repeat(backslashes * 2); + quoted += '"'; + return quoted; } function buildWindowsBatchCommand(executable: string, args: string[]): string { diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts index d75f21e5e..220bd95f1 100644 --- a/test/lib/init/tools/run-commands-spawn.mocked.test.ts +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -122,6 +122,26 @@ describe("runCommands spawn options", () => { }); }); + test("doubles trailing backslashes for Windows .cmd shim arguments", async () => { + setPlatform("win32"); + + const result = await runCommands(makePayload("pnpm add C:\\some\\path\\"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + args: [ + "/d", + "/s", + "/c", + '""C:\\Tools\\pnpm.CMD" "add" "C:\\some\\path\\\\""', + ], + options: { shell: false, windowsVerbatimArguments: true }, + }); + }); + test("keeps Windows .exe commands shell-free", async () => { setPlatform("win32"); From d5458547322cedc21a69bc26777905f1c1a0abc4 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 17:17:51 +0200 Subject: [PATCH 10/17] fix(init): inspect package runner option values --- src/lib/init/tools/command-utils.ts | 141 +++++++++++++++++++---- test/lib/init/tools/run-commands.test.ts | 21 ++++ 2 files changed, 142 insertions(+), 20 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 32d033dcb..fe86ff66f 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -132,8 +132,18 @@ function packageRunnerOptionConsumesValue(token: string): boolean { return token === "-p" || token === "--package"; } +function getInlinePackageRunnerOptionValue(token: string): string | undefined { + if (token.startsWith("-p=")) { + return token.slice("-p=".length); + } + if (token.startsWith("--package=")) { + return token.slice("--package=".length); + } + return; +} + function isInlinePackageRunnerOption(token: string): boolean { - return token.startsWith("-p=") || token.startsWith("--package="); + return getInlinePackageRunnerOptionValue(token) !== undefined; } function findPackageRunnerCommandIndex( @@ -164,6 +174,39 @@ function findPackageRunnerCommandIndex( return; } +function findPackageRunnerPackageOptionValues( + tokens: string[], + startIndex: number, + endIndex = tokens.length +): Array<{ token: string; index: number }> { + const values: Array<{ token: string; index: number }> = []; + + for (let index = startIndex; index < endIndex; index += 1) { + const token = tokens[index]; + if (!token) { + continue; + } + if (token === "--") { + break; + } + if (packageRunnerOptionConsumesValue(token)) { + const value = tokens[index + 1]; + if (value) { + values.push({ token: value, index: index + 1 }); + } + index += 1; + continue; + } + + const inlineValue = getInlinePackageRunnerOptionValue(token); + if (inlineValue) { + values.push({ token: inlineValue, index }); + } + } + + return values; +} + function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); if ( @@ -186,35 +229,93 @@ function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { return findPackageRunnerCommandIndex(tokens, subcommandIndex + 1); } +function findPackageExecutionPackageOptionValues( + tokens: string[] +): Array<{ token: string; index: number }> { + const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); + if ( + isExecutablePackageSpec(firstExecutable, "npx") || + isExecutablePackageSpec(firstExecutable, "bunx") + ) { + const commandIndex = findPackageRunnerCommandIndex(tokens, 1); + return findPackageRunnerPackageOptionValues( + tokens, + 1, + commandIndex ?? tokens.length + ); + } + + const subcommandIndex = findPackageRunnerCommandIndex(tokens, 1); + if (subcommandIndex === undefined) { + return []; + } + + const subcommand = normalizeExecutableName(tokens[subcommandIndex] ?? ""); + if (subcommand !== "exec" && subcommand !== "dlx") { + return []; + } + + const commandIndex = findPackageRunnerCommandIndex( + tokens, + subcommandIndex + 1 + ); + + return [ + ...findPackageRunnerPackageOptionValues(tokens, 1, subcommandIndex), + ...findPackageRunnerPackageOptionValues( + tokens, + subcommandIndex + 1, + commandIndex ?? tokens.length + ), + ]; +} + function canExecuteToken(tokens: string[], index: number): boolean { return index === 0 || index === findPackageExecutionTokenIndex(tokens); } +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); + } + if ( + !( + isExecutablePackageSpec(executable, "sentry") || + isExecutablePackageSpec(executable, "sentry-cli") + ) + ) { + return false; + } + return hasInitArgAfter(tokens, index); +} + function isRecursiveSentrySetup(tokens: string[]): boolean { + const packageOptionInvokesSentry = findPackageExecutionPackageOptionValues( + tokens + ).some(({ token, index }) => + isRecursiveSentrySetupToken(token, tokens, index) + ); + if (packageOptionInvokesSentry) { + return true; + } + return tokens.some((token, index) => { if (!canExecuteToken(tokens, index)) { return false; } - const executable = normalizeExecutableName(token); - if ( - isSentryWizardPackageSpec(token) || - isExecutablePackageSpec(executable, "sentry-wizard") - ) { - return true; - } - if (isSentryCliPackageSpec(token)) { - return hasInitArgAfter(tokens, index); - } - if ( - !( - isExecutablePackageSpec(executable, "sentry") || - isExecutablePackageSpec(executable, "sentry-cli") - ) - ) { - return false; - } - return hasInitArgAfter(tokens, index); + return isRecursiveSentrySetupToken(token, tokens, index); }); } diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index 0853c0818..82dd0dc96 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -40,6 +40,9 @@ describe("validateCommand", () => { expect(validateCommand("futurepm explain sentry-wizard")).toBeUndefined(); expect(validateCommand("npm uninstall sentry-wizard")).toBeUndefined(); expect(validateCommand("npm uninstall @sentry/wizard")).toBeUndefined(); + expect( + validateCommand("npx harmless --package=@sentry/wizard") + ).toBeUndefined(); }); test("allows path-prefixed package managers but blocks dangerous ones", () => { @@ -90,6 +93,18 @@ describe("validateCommand", () => { expect( validateCommand("npx -p=innocuous-pkg @sentry/wizard -i nextjs") ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("npx --package=@sentry/wizard -i nextjs") + ).toContain("invokes Sentry setup recursively"); + expect(validateCommand("npx -p=@sentry/cli init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx --package @sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx -p @sentry/cli init")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("npx @sentry/cli init")).toContain( "invokes Sentry setup recursively" ); @@ -129,12 +144,18 @@ describe("validateCommand", () => { expect( validateCommand("npm exec --package lodash @sentry/wizard -i nextjs") ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("npm exec --package=@sentry/wizard -i nextjs") + ).toContain("invokes Sentry setup recursively"); expect(validateCommand("pnpm dlx sentry-wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); expect( validateCommand("pnpm dlx --package lodash sentry-wizard -i nextjs") ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("pnpm dlx --package=@sentry/wizard -i nextjs") + ).toContain("invokes Sentry setup recursively"); expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( "invokes Sentry setup recursively" ); From b5f00d9f69a0e8183d4f05b7f0fc99908f783df5 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 17:23:11 +0200 Subject: [PATCH 11/17] fix(init): parse package runner value options --- src/lib/init/tools/command-utils.ts | 108 +++++++++++++++++++++-- test/lib/init/tools/run-commands.test.ts | 42 +++++++++ 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index fe86ff66f..8f0567bd9 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -6,6 +6,42 @@ import { MAX_OUTPUT_BYTES } from "../constants.js"; const WHITESPACE_CHAR_RE = /\s/u; const WINDOWS_EXECUTABLE_EXTENSION_RE = /\.(?:cmd|exe|bat|ps1)$/u; const PATH_SEPARATOR_RE = /\\/g; +const PACKAGE_RUNNER_SUBCOMMANDS = new Set(["exec", "dlx"]); +const PACKAGE_RUNNER_VALUE_OPTIONS = new Set([ + "allow-build", + "cache", + "cafile", + "call", + "cert", + "changed-files-ignore-pattern", + "config", + "dir", + "filter", + "filter-prod", + "globalconfig", + "https-proxy", + "key", + "lockfile-dir", + "loglevel", + "noproxy", + "node-options", + "package", + "prefix", + "proxy", + "registry", + "reporter", + "resume-from", + "script-shell", + "shell", + "store-dir", + "test-pattern", + "use-node-version", + "userconfig", + "virtual-store-dir", + "workspace", + "workspace-concurrency", +]); +const PACKAGE_RUNNER_VALUE_SHORT_OPTIONS = new Set(["-p", "-w", "-C", "-c"]); /** * Patterns that indicate shell injection. Windows package-manager shims require @@ -24,6 +60,12 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { pattern: "\r", label: "carriage return" }, { pattern: ">", label: "redirection (>)" }, { 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 (!)" }, ]; @@ -128,11 +170,48 @@ function isExecutablePackageSpec(executable: string, name: string): boolean { return executable === name || executable.startsWith(`${name}@`); } -function packageRunnerOptionConsumesValue(token: string): boolean { +function isPackageRunnerSubcommand(token: string): boolean { + return PACKAGE_RUNNER_SUBCOMMANDS.has(normalizeExecutableName(token)); +} + +function getLongPackageRunnerOptionName(token: string): string | undefined { + if (!token.startsWith("--") || token === "--" || token.startsWith("--no-")) { + return; + } + + const [name = ""] = token.slice(2).split("=", 1); + return name.toLowerCase(); +} + +function packageRunnerPackageOptionConsumesValue(token: string): boolean { return token === "-p" || token === "--package"; } -function getInlinePackageRunnerOptionValue(token: string): string | undefined { +function packageRunnerOptionConsumesValue( + token: string, + nextToken: string | undefined +): boolean { + if (!nextToken || nextToken === "--") { + return false; + } + if (token.startsWith("--") && token.includes("=")) { + return false; + } + if (packageRunnerPackageOptionConsumesValue(token)) { + return true; + } + if ( + PACKAGE_RUNNER_VALUE_SHORT_OPTIONS.has(token) || + PACKAGE_RUNNER_VALUE_OPTIONS.has(getLongPackageRunnerOptionName(token) ?? "") + ) { + return !nextToken.startsWith("-") && !isPackageRunnerSubcommand(nextToken); + } + return false; +} + +function getInlinePackageRunnerPackageOptionValue( + token: string +): string | undefined { if (token.startsWith("-p=")) { return token.slice("-p=".length); } @@ -143,7 +222,12 @@ function getInlinePackageRunnerOptionValue(token: string): string | undefined { } function isInlinePackageRunnerOption(token: string): boolean { - return getInlinePackageRunnerOptionValue(token) !== undefined; + if (token.startsWith("--")) { + return token.includes("="); + } + return PACKAGE_RUNNER_VALUE_SHORT_OPTIONS.has(token.slice(0, 2)) + ? token.startsWith(`${token.slice(0, 2)}=`) + : false; } function findPackageRunnerCommandIndex( @@ -158,7 +242,7 @@ function findPackageRunnerCommandIndex( if (token === "--") { return index + 1 < tokens.length ? index + 1 : undefined; } - if (packageRunnerOptionConsumesValue(token)) { + if (packageRunnerOptionConsumesValue(token, tokens[index + 1])) { index += 1; continue; } @@ -189,7 +273,7 @@ function findPackageRunnerPackageOptionValues( if (token === "--") { break; } - if (packageRunnerOptionConsumesValue(token)) { + if (packageRunnerPackageOptionConsumesValue(token)) { const value = tokens[index + 1]; if (value) { values.push({ token: value, index: index + 1 }); @@ -198,7 +282,7 @@ function findPackageRunnerPackageOptionValues( continue; } - const inlineValue = getInlinePackageRunnerOptionValue(token); + const inlineValue = getInlinePackageRunnerPackageOptionValue(token); if (inlineValue) { values.push({ token: inlineValue, index }); } @@ -222,7 +306,7 @@ function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { } const subcommand = normalizeExecutableName(tokens[subcommandIndex] ?? ""); - if (subcommand !== "exec" && subcommand !== "dlx") { + if (!PACKAGE_RUNNER_SUBCOMMANDS.has(subcommand)) { return; } @@ -251,7 +335,7 @@ function findPackageExecutionPackageOptionValues( } const subcommand = normalizeExecutableName(tokens[subcommandIndex] ?? ""); - if (subcommand !== "exec" && subcommand !== "dlx") { + if (!PACKAGE_RUNNER_SUBCOMMANDS.has(subcommand)) { return []; } @@ -481,6 +565,14 @@ export function validateCommand(command: string): string | undefined { } } + if (process.platform === "win32") { + for (const { pattern, label } of WINDOWS_SHELL_METACHARACTER_PATTERNS) { + if (command.includes(pattern)) { + return `Blocked command: contains ${label} — "${command}"`; + } + } + } + let tokens: string[]; try { tokens = tokenizeCommand(command); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index 82dd0dc96..d1a0e3c6e 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -6,6 +6,14 @@ import { runCommands } from "../../../../src/lib/init/tools/run-commands.js"; import type { RunCommandsPayload } from "../../../../src/lib/init/types.js"; let testDir: string; +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} beforeEach(() => { testDir = fs.mkdtempSync(path.join("/tmp", "run-commands-")); @@ -13,6 +21,7 @@ beforeEach(() => { afterEach(() => { fs.rmSync(testDir, { recursive: true, force: true }); + setPlatform(originalPlatform); }); function makePayload(commands: string[]): RunCommandsPayload { @@ -43,6 +52,9 @@ describe("validateCommand", () => { expect( validateCommand("npx harmless --package=@sentry/wizard") ).toBeUndefined(); + expect( + validateCommand("npx harmless --registry myregistry @sentry/wizard") + ).toBeUndefined(); }); test("allows path-prefixed package managers but blocks dangerous ones", () => { @@ -62,6 +74,21 @@ describe("validateCommand", () => { expect(validateCommand("pnpm add @sentry/node 2>&1")).toContain( "Blocked command" ); + }); + + test("allows Windows shell expansion characters outside Windows", () => { + setPlatform("darwin"); + + expect(validateCommand("printf %s hello")).toBeUndefined(); + expect( + validateCommand("futurepm explain https://example.com/a%20b") + ).toBeUndefined(); + expect(validateCommand("futurepm explain bang!value")).toBeUndefined(); + }); + + test("blocks Windows shell expansion characters on Windows", () => { + setPlatform("win32"); + expect(validateCommand("futurepm explain %PATH%")).toContain( "Blocked command" ); @@ -156,6 +183,21 @@ describe("validateCommand", () => { expect( validateCommand("pnpm dlx --package=@sentry/wizard -i nextjs") ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("npm --registry myregistry exec @sentry/wizard") + ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("npm exec --registry myregistry @sentry/wizard") + ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("pnpm --registry myregistry dlx @sentry/wizard") + ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("pnpm dlx --registry myregistry @sentry/wizard") + ).toContain("invokes Sentry setup recursively"); + expect( + validateCommand("npx --registry myregistry @sentry/wizard") + ).toContain("invokes Sentry setup recursively"); expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( "invokes Sentry setup recursively" ); From 79496a6f96108753179cfb3bf3f1f2d551f9a9b0 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 17:37:49 +0200 Subject: [PATCH 12/17] fix(init): cover package runner review bypasses --- src/lib/init/tools/command-utils.ts | 140 +++++++++++++----- src/lib/init/tools/run-commands.ts | 4 +- .../tools/run-commands-spawn.mocked.test.ts | 19 +++ test/lib/init/tools/run-commands.test.ts | 48 +++++- 4 files changed, 169 insertions(+), 42 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 8f0567bd9..a77b4053b 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -6,7 +6,18 @@ import { MAX_OUTPUT_BYTES } from "../constants.js"; const WHITESPACE_CHAR_RE = /\s/u; const WINDOWS_EXECUTABLE_EXTENSION_RE = /\.(?:cmd|exe|bat|ps1)$/u; const PATH_SEPARATOR_RE = /\\/g; -const PACKAGE_RUNNER_SUBCOMMANDS = new Set(["exec", "dlx"]); +const TOP_LEVEL_PACKAGE_RUNNERS = new Set(["npx", "pnpx", "bunx"]); +const PACKAGE_MANAGER_EXECUTION_SUBCOMMANDS = new Map>([ + ["bun", new Set(["x"])], + ["npm", new Set(["exec", "x", "init", "create"])], + ["pnpm", new Set(["exec", "dlx", "create"])], + ["yarn", new Set(["create", "dlx", "exec"])], +]); +const PACKAGE_RUNNER_SUBCOMMANDS = new Set( + [...PACKAGE_MANAGER_EXECUTION_SUBCOMMANDS.values()].flatMap((subcommands) => [ + ...subcommands, + ]) +); const PACKAGE_RUNNER_VALUE_OPTIONS = new Set([ "allow-build", "cache", @@ -66,9 +77,9 @@ const WINDOWS_SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string; }> = [ - { pattern: "%", label: "Windows environment variable expansion (%)" }, - { pattern: "!", label: "Windows delayed environment expansion (!)" }, - ]; + { pattern: "%", label: "Windows environment variable expansion (%)" }, + { pattern: "!", label: "Windows delayed environment expansion (!)" }, +]; /** * Executables that should never appear in a workflow-provided command. @@ -170,10 +181,27 @@ function isExecutablePackageSpec(executable: string, name: string): boolean { return executable === name || executable.startsWith(`${name}@`); } +function isTopLevelPackageRunner(executable: string): boolean { + return [...TOP_LEVEL_PACKAGE_RUNNERS].some((runner) => + isExecutablePackageSpec(executable, runner) + ); +} + function isPackageRunnerSubcommand(token: string): boolean { return PACKAGE_RUNNER_SUBCOMMANDS.has(normalizeExecutableName(token)); } +function isPackageManagerExecutionSubcommand( + executable: string, + subcommand: string +): boolean { + const subcommands = [...PACKAGE_MANAGER_EXECUTION_SUBCOMMANDS].find( + ([manager]) => isExecutablePackageSpec(executable, manager) + )?.[1]; + + return subcommands?.has(normalizeExecutableName(subcommand)) ?? false; +} + function getLongPackageRunnerOptionName(token: string): string | undefined { if (!token.startsWith("--") || token === "--" || token.startsWith("--no-")) { return; @@ -189,7 +217,8 @@ function packageRunnerPackageOptionConsumesValue(token: string): boolean { function packageRunnerOptionConsumesValue( token: string, - nextToken: string | undefined + nextToken: string | undefined, + options: { preserveSubcommands?: boolean } = {} ): boolean { if (!nextToken || nextToken === "--") { return false; @@ -202,9 +231,13 @@ function packageRunnerOptionConsumesValue( } if ( PACKAGE_RUNNER_VALUE_SHORT_OPTIONS.has(token) || - PACKAGE_RUNNER_VALUE_OPTIONS.has(getLongPackageRunnerOptionName(token) ?? "") + PACKAGE_RUNNER_VALUE_OPTIONS.has( + getLongPackageRunnerOptionName(token) ?? "" + ) ) { - return !nextToken.startsWith("-") && !isPackageRunnerSubcommand(nextToken); + return options.preserveSubcommands + ? !(nextToken.startsWith("-") || isPackageRunnerSubcommand(nextToken)) + : !nextToken.startsWith("-"); } return false; } @@ -215,6 +248,9 @@ function getInlinePackageRunnerPackageOptionValue( if (token.startsWith("-p=")) { return token.slice("-p=".length); } + if (token.startsWith("-p") && token.length > "-p".length) { + return token.slice("-p".length); + } if (token.startsWith("--package=")) { return token.slice("--package=".length); } @@ -222,6 +258,9 @@ function getInlinePackageRunnerPackageOptionValue( } function isInlinePackageRunnerOption(token: string): boolean { + if (getInlinePackageRunnerPackageOptionValue(token) !== undefined) { + return true; + } if (token.startsWith("--")) { return token.includes("="); } @@ -232,7 +271,8 @@ function isInlinePackageRunnerOption(token: string): boolean { function findPackageRunnerCommandIndex( tokens: string[], - startIndex: number + startIndex: number, + options: { preserveSubcommands?: boolean } = {} ): number | undefined { for (let index = startIndex; index < tokens.length; index += 1) { const token = tokens[index]; @@ -242,7 +282,7 @@ function findPackageRunnerCommandIndex( if (token === "--") { return index + 1 < tokens.length ? index + 1 : undefined; } - if (packageRunnerOptionConsumesValue(token, tokens[index + 1])) { + if (packageRunnerOptionConsumesValue(token, tokens[index + 1], options)) { index += 1; continue; } @@ -293,20 +333,23 @@ function findPackageRunnerPackageOptionValues( function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); - if ( - isExecutablePackageSpec(firstExecutable, "npx") || - isExecutablePackageSpec(firstExecutable, "bunx") - ) { + if (isTopLevelPackageRunner(firstExecutable)) { return findPackageRunnerCommandIndex(tokens, 1); } - const subcommandIndex = findPackageRunnerCommandIndex(tokens, 1); + const subcommandIndex = findPackageRunnerCommandIndex(tokens, 1, { + preserveSubcommands: true, + }); if (subcommandIndex === undefined) { return; } - const subcommand = normalizeExecutableName(tokens[subcommandIndex] ?? ""); - if (!PACKAGE_RUNNER_SUBCOMMANDS.has(subcommand)) { + if ( + !isPackageManagerExecutionSubcommand( + firstExecutable, + tokens[subcommandIndex] ?? "" + ) + ) { return; } @@ -317,10 +360,7 @@ function findPackageExecutionPackageOptionValues( tokens: string[] ): Array<{ token: string; index: number }> { const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); - if ( - isExecutablePackageSpec(firstExecutable, "npx") || - isExecutablePackageSpec(firstExecutable, "bunx") - ) { + if (isTopLevelPackageRunner(firstExecutable)) { const commandIndex = findPackageRunnerCommandIndex(tokens, 1); return findPackageRunnerPackageOptionValues( tokens, @@ -329,13 +369,19 @@ function findPackageExecutionPackageOptionValues( ); } - const subcommandIndex = findPackageRunnerCommandIndex(tokens, 1); + const subcommandIndex = findPackageRunnerCommandIndex(tokens, 1, { + preserveSubcommands: true, + }); if (subcommandIndex === undefined) { return []; } - const subcommand = normalizeExecutableName(tokens[subcommandIndex] ?? ""); - if (!PACKAGE_RUNNER_SUBCOMMANDS.has(subcommand)) { + if ( + !isPackageManagerExecutionSubcommand( + firstExecutable, + tokens[subcommandIndex] ?? "" + ) + ) { return []; } @@ -358,6 +404,23 @@ function canExecuteToken(tokens: string[], index: number): boolean { return index === 0 || index === findPackageExecutionTokenIndex(tokens); } +function findBlockedExecutable(tokens: string[]): string | undefined { + const candidateIndexes = new Set([0]); + const packageExecutionIndex = findPackageExecutionTokenIndex(tokens); + if (packageExecutionIndex !== undefined) { + candidateIndexes.add(packageExecutionIndex); + } + + for (const index of candidateIndexes) { + const executable = normalizeExecutableName(tokens[index] ?? ""); + if (BLOCKED_EXECUTABLES.has(executable)) { + return executable; + } + } + + return; +} + function isRecursiveSentrySetupToken( token: string, tokens: string[], @@ -403,6 +466,18 @@ function isRecursiveSentrySetup(tokens: string[]): boolean { }); } +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); } @@ -559,18 +634,9 @@ 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}"`; - } - } - - if (process.platform === "win32") { - for (const { pattern, label } of WINDOWS_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 tokens: string[]; @@ -593,8 +659,8 @@ export function validateCommand(command: string): string | undefined { return `Blocked command: invokes Sentry setup recursively — "${command}"`; } - const executable = normalizeExecutableName(firstToken); - if (BLOCKED_EXECUTABLES.has(executable)) { + const executable = findBlockedExecutable(tokens); + if (executable) { return `Blocked command: disallowed executable "${executable}" — "${command}"`; } diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 0398238ea..da487e32d 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -33,8 +33,8 @@ function quoteWindowsCommandArg(value: string): string { } if (char === '"') { - quoted += "\\".repeat(backslashes * 2 + 1); - quoted += char; + quoted += "\\".repeat(backslashes); + quoted += '""'; backslashes = 0; continue; } diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts index 220bd95f1..4f6c07faf 100644 --- a/test/lib/init/tools/run-commands-spawn.mocked.test.ts +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -142,6 +142,25 @@ describe("runCommands spawn options", () => { }); }); + test("doubles embedded quotes for Windows .cmd shim arguments", async () => { + setPlatform("win32"); + + const result = await runCommands( + makePayload('pnpm add "value \\"quoted\\""'), + { dryRun: false } + ); + + const commandLine = spawnCalls[0]?.args.at(-1) ?? ""; + + expect(result.ok).toBe(true); + expect(commandLine).toContain('"value ""quoted"""'); + expect(commandLine).not.toContain('\\"'); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + options: { shell: false, windowsVerbatimArguments: true }, + }); + }); + test("keeps Windows .exe commands shell-free", async () => { setPlatform("win32"); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index d1a0e3c6e..6404008c4 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -120,12 +120,18 @@ describe("validateCommand", () => { expect( validateCommand("npx -p=innocuous-pkg @sentry/wizard -i nextjs") ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("npx --package=@sentry/wizard -i nextjs") - ).toContain("invokes Sentry setup recursively"); + expect(validateCommand("npx --package=@sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("npx -p=@sentry/cli init")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npx -p@sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx -p@sentry/cli init")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("npx --package @sentry/wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); @@ -165,9 +171,24 @@ describe("validateCommand", () => { expect(validateCommand("npx sentry-wizard@latest -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("pnpx @sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("bun x @sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("npm exec @sentry/wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npm x @sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npm init @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npm create @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); expect( validateCommand("npm exec --package lodash @sentry/wizard -i nextjs") ).toContain("invokes Sentry setup recursively"); @@ -177,6 +198,9 @@ describe("validateCommand", () => { expect(validateCommand("pnpm dlx sentry-wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("pnpm create @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); expect( validateCommand("pnpm dlx --package lodash sentry-wizard -i nextjs") ).toContain("invokes Sentry setup recursively"); @@ -198,11 +222,29 @@ describe("validateCommand", () => { expect( validateCommand("npx --registry myregistry @sentry/wizard") ).toContain("invokes Sentry setup recursively"); + expect(validateCommand("npx --registry create @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("yarn create @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("yarn dlx @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( "invokes Sentry setup recursively" ); }); + test("blocks disallowed executables through package runners", () => { + expect(validateCommand("npx bash")).toContain('"bash"'); + expect(validateCommand("npx curl http://example.com")).toContain('"curl"'); + expect(validateCommand("npm exec wget http://example.com/file")).toContain( + '"wget"' + ); + expect(validateCommand("pnpm dlx sh")).toContain('"sh"'); + }); + test("blocks shell interpreter indirection", () => { expect(validateCommand("cmd.exe /c del sensitive_file")).toContain('"cmd"'); expect( From 4d654c8c120e8a2b7af216440f3dadff7800d18e Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 17:42:37 +0200 Subject: [PATCH 13/17] fix(init): catch top-level runner create aliases --- src/lib/init/tools/command-utils.ts | 65 +++++++++++++++++++++--- test/lib/init/tools/run-commands.test.ts | 12 +++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index a77b4053b..b0f8ef944 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -7,6 +7,7 @@ const WHITESPACE_CHAR_RE = /\s/u; const WINDOWS_EXECUTABLE_EXTENSION_RE = /\.(?:cmd|exe|bat|ps1)$/u; const PATH_SEPARATOR_RE = /\\/g; const TOP_LEVEL_PACKAGE_RUNNERS = new Set(["npx", "pnpx", "bunx"]); +const TOP_LEVEL_RUNNER_NESTED_SUBCOMMANDS = new Set(["create", "init"]); const PACKAGE_MANAGER_EXECUTION_SUBCOMMANDS = new Map>([ ["bun", new Set(["x"])], ["npm", new Set(["exec", "x", "init", "create"])], @@ -191,6 +192,12 @@ function isPackageRunnerSubcommand(token: string): boolean { return PACKAGE_RUNNER_SUBCOMMANDS.has(normalizeExecutableName(token)); } +function isTopLevelRunnerNestedSubcommand(token: string): boolean { + return TOP_LEVEL_RUNNER_NESTED_SUBCOMMANDS.has( + normalizeExecutableName(token) + ); +} + function isPackageManagerExecutionSubcommand( executable: string, subcommand: string @@ -356,17 +363,60 @@ function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { return findPackageRunnerCommandIndex(tokens, subcommandIndex + 1); } +function findTopLevelRunnerNestedExecutionTokenIndex( + tokens: string[], + commandIndex: number | undefined +): number | undefined { + if ( + commandIndex === undefined || + !isTopLevelRunnerNestedSubcommand(tokens[commandIndex] ?? "") + ) { + return; + } + + return findPackageRunnerCommandIndex(tokens, commandIndex + 1); +} + +function findPackageExecutionTokenIndexes(tokens: string[]): number[] { + const commandIndex = findPackageExecutionTokenIndex(tokens); + if (commandIndex === undefined) { + return []; + } + + const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); + const nestedCommandIndex = isTopLevelPackageRunner(firstExecutable) + ? findTopLevelRunnerNestedExecutionTokenIndex(tokens, commandIndex) + : undefined; + + return nestedCommandIndex === undefined + ? [commandIndex] + : [commandIndex, nestedCommandIndex]; +} + function findPackageExecutionPackageOptionValues( tokens: string[] ): Array<{ token: string; index: number }> { const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); if (isTopLevelPackageRunner(firstExecutable)) { const commandIndex = findPackageRunnerCommandIndex(tokens, 1); - return findPackageRunnerPackageOptionValues( + const nestedCommandIndex = findTopLevelRunnerNestedExecutionTokenIndex( tokens, - 1, - commandIndex ?? tokens.length + commandIndex ); + return [ + ...findPackageRunnerPackageOptionValues( + tokens, + 1, + commandIndex ?? tokens.length + ), + ...(commandIndex === undefined || nestedCommandIndex === undefined + ? [] + : findPackageRunnerPackageOptionValues( + tokens, + commandIndex + 1, + nestedCommandIndex + )), + ]; } const subcommandIndex = findPackageRunnerCommandIndex(tokens, 1, { @@ -401,13 +451,16 @@ function findPackageExecutionPackageOptionValues( } function canExecuteToken(tokens: string[], index: number): boolean { - return index === 0 || index === findPackageExecutionTokenIndex(tokens); + return ( + index === 0 || findPackageExecutionTokenIndexes(tokens).includes(index) + ); } function findBlockedExecutable(tokens: string[]): string | undefined { const candidateIndexes = new Set([0]); - const packageExecutionIndex = findPackageExecutionTokenIndex(tokens); - if (packageExecutionIndex !== undefined) { + for (const packageExecutionIndex of findPackageExecutionTokenIndexes( + tokens + )) { candidateIndexes.add(packageExecutionIndex); } diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index 6404008c4..b51ac15ba 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -171,6 +171,18 @@ describe("validateCommand", () => { expect(validateCommand("npx sentry-wizard@latest -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npx create @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx init @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("pnpx create @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("bunx init @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("pnpx @sentry/wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); From abce2790ba35afe24ce39801a6a3ee8dcca4260a Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 17:51:47 +0200 Subject: [PATCH 14/17] fix(init): double slashes before cmd quotes --- src/lib/init/tools/run-commands.ts | 2 +- .../tools/run-commands-spawn.mocked.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index da487e32d..11bca9700 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -33,7 +33,7 @@ function quoteWindowsCommandArg(value: string): string { } if (char === '"') { - quoted += "\\".repeat(backslashes); + quoted += "\\".repeat(backslashes * 2); quoted += '""'; backslashes = 0; continue; diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts index 4f6c07faf..e59e09c73 100644 --- a/test/lib/init/tools/run-commands-spawn.mocked.test.ts +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -161,6 +161,25 @@ describe("runCommands spawn options", () => { }); }); + test("doubles backslashes before embedded quotes for Windows .cmd shim arguments", async () => { + setPlatform("win32"); + + const result = await runCommands( + makePayload(String.raw`pnpm add "path\\\"name"`), + { dryRun: false } + ); + + const commandLine = spawnCalls[0]?.args.at(-1) ?? ""; + + expect(result.ok).toBe(true); + expect(commandLine).toContain(String.raw`"path\\""name"`); + expect(commandLine).not.toContain(String.raw`"path\""name"`); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + options: { shell: false, windowsVerbatimArguments: true }, + }); + }); + test("keeps Windows .exe commands shell-free", async () => { setPlatform("win32"); From 5a1dabcbbdf822073e0282fca42c0d106ac60576 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 18:09:50 +0200 Subject: [PATCH 15/17] fix(init): inspect nested runner package options --- src/lib/init/tools/command-utils.ts | 30 ++++++++++++++++-------- test/lib/init/tools/run-commands.test.ts | 15 ++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index b0f8ef944..cd592904d 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -168,6 +168,10 @@ function hasInitArgAfter(tokens: string[], index: number): boolean { return tokens.slice(index + 1).some((arg) => arg.toLowerCase() === "init"); } +function hasInitArg(tokens: string[]): boolean { + return tokens.some((arg) => arg.toLowerCase() === "init"); +} + function isSentryCliPackageSpec(token: string): boolean { const lower = token.toLowerCase(); return lower === "@sentry/cli" || lower.startsWith("@sentry/cli@"); @@ -399,23 +403,29 @@ function findPackageExecutionPackageOptionValues( const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); if (isTopLevelPackageRunner(firstExecutable)) { const commandIndex = findPackageRunnerCommandIndex(tokens, 1); + const packageOptionValues = findPackageRunnerPackageOptionValues( + tokens, + 1, + commandIndex ?? tokens.length + ); + if ( + commandIndex === undefined || + !isTopLevelRunnerNestedSubcommand(tokens[commandIndex] ?? "") + ) { + return packageOptionValues; + } + const nestedCommandIndex = findTopLevelRunnerNestedExecutionTokenIndex( tokens, commandIndex ); return [ + ...packageOptionValues, ...findPackageRunnerPackageOptionValues( tokens, - 1, - commandIndex ?? tokens.length + commandIndex + 1, + nestedCommandIndex ?? tokens.length ), - ...(commandIndex === undefined || nestedCommandIndex === undefined - ? [] - : findPackageRunnerPackageOptionValues( - tokens, - commandIndex + 1, - nestedCommandIndex - )), ]; } @@ -487,7 +497,7 @@ function isRecursiveSentrySetupToken( return true; } if (isSentryCliPackageSpec(token)) { - return hasInitArgAfter(tokens, index); + return hasInitArg(tokens); } if ( !( diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index b51ac15ba..e07acf3e9 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -177,9 +177,24 @@ describe("validateCommand", () => { expect(validateCommand("npx init @sentry/wizard")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npx init --package=@sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx init --package @sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx init --package=@sentry/cli")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx create --package=@sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("pnpx create @sentry/wizard")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("pnpx init --package=@sentry/wizard")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("bunx init @sentry/wizard")).toContain( "invokes Sentry setup recursively" ); From 28047464741e4edca35cb5f6b8efa95717bce331 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 18:21:31 +0200 Subject: [PATCH 16/17] fix(init): avoid cli package init false positives --- src/lib/init/tools/command-utils.ts | 21 +++++++++++++-------- test/lib/init/tools/run-commands.test.ts | 9 +++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index cd592904d..7e30caf70 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -168,10 +168,6 @@ function hasInitArgAfter(tokens: string[], index: number): boolean { return tokens.slice(index + 1).some((arg) => arg.toLowerCase() === "init"); } -function hasInitArg(tokens: string[]): boolean { - return tokens.some((arg) => arg.toLowerCase() === "init"); -} - function isSentryCliPackageSpec(token: string): boolean { const lower = token.toLowerCase(); return lower === "@sentry/cli" || lower.startsWith("@sentry/cli@"); @@ -484,6 +480,12 @@ function findBlockedExecutable(tokens: string[]): string | undefined { return; } +function packageRunnerExecutesInit(tokens: string[]): boolean { + return findPackageExecutionTokenIndexes(tokens).some( + (index) => normalizeExecutableName(tokens[index] ?? "") === "init" + ); +} + function isRecursiveSentrySetupToken( token: string, tokens: string[], @@ -497,7 +499,7 @@ function isRecursiveSentrySetupToken( return true; } if (isSentryCliPackageSpec(token)) { - return hasInitArg(tokens); + return hasInitArgAfter(tokens, index); } if ( !( @@ -513,9 +515,12 @@ function isRecursiveSentrySetupToken( function isRecursiveSentrySetup(tokens: string[]): boolean { const packageOptionInvokesSentry = findPackageExecutionPackageOptionValues( tokens - ).some(({ token, index }) => - isRecursiveSentrySetupToken(token, tokens, index) - ); + ).some(({ token }) => { + if (isSentryWizardPackageSpec(token)) { + return true; + } + return isSentryCliPackageSpec(token) && packageRunnerExecutesInit(tokens); + }); if (packageOptionInvokesSentry) { return true; } diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index e07acf3e9..ed098adfe 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -55,6 +55,12 @@ describe("validateCommand", () => { expect( validateCommand("npx harmless --registry myregistry @sentry/wizard") ).toBeUndefined(); + expect( + validateCommand("npx --package=@sentry/cli cowsay init") + ).toBeUndefined(); + expect( + validateCommand("npm exec --package=@sentry/cli cowsay init") + ).toBeUndefined(); }); test("allows path-prefixed package managers but blocks dangerous ones", () => { @@ -126,6 +132,9 @@ describe("validateCommand", () => { expect(validateCommand("npx -p=@sentry/cli init")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npx --package=@sentry/cli init")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("npx -p@sentry/wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); From b5bac929f57a6e581d605d7bf4d0f02d3c2654b3 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 5 Jun 2026 19:01:44 +0200 Subject: [PATCH 17/17] fix(init): keep command guardrails generic --- src/lib/init/tools/command-utils.ts | 370 +---------------------- test/lib/init/tools/run-commands.test.ts | 154 +--------- 2 files changed, 15 insertions(+), 509 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 7e30caf70..a08205e15 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -6,54 +6,6 @@ import { MAX_OUTPUT_BYTES } from "../constants.js"; const WHITESPACE_CHAR_RE = /\s/u; const WINDOWS_EXECUTABLE_EXTENSION_RE = /\.(?:cmd|exe|bat|ps1)$/u; const PATH_SEPARATOR_RE = /\\/g; -const TOP_LEVEL_PACKAGE_RUNNERS = new Set(["npx", "pnpx", "bunx"]); -const TOP_LEVEL_RUNNER_NESTED_SUBCOMMANDS = new Set(["create", "init"]); -const PACKAGE_MANAGER_EXECUTION_SUBCOMMANDS = new Map>([ - ["bun", new Set(["x"])], - ["npm", new Set(["exec", "x", "init", "create"])], - ["pnpm", new Set(["exec", "dlx", "create"])], - ["yarn", new Set(["create", "dlx", "exec"])], -]); -const PACKAGE_RUNNER_SUBCOMMANDS = new Set( - [...PACKAGE_MANAGER_EXECUTION_SUBCOMMANDS.values()].flatMap((subcommands) => [ - ...subcommands, - ]) -); -const PACKAGE_RUNNER_VALUE_OPTIONS = new Set([ - "allow-build", - "cache", - "cafile", - "call", - "cert", - "changed-files-ignore-pattern", - "config", - "dir", - "filter", - "filter-prod", - "globalconfig", - "https-proxy", - "key", - "lockfile-dir", - "loglevel", - "noproxy", - "node-options", - "package", - "prefix", - "proxy", - "registry", - "reporter", - "resume-from", - "script-shell", - "shell", - "store-dir", - "test-pattern", - "use-node-version", - "userconfig", - "virtual-store-dir", - "workspace", - "workspace-concurrency", -]); -const PACKAGE_RUNNER_VALUE_SHORT_OPTIONS = new Set(["-p", "-w", "-C", "-c"]); /** * Patterns that indicate shell injection. Windows package-manager shims require @@ -182,310 +134,15 @@ function isExecutablePackageSpec(executable: string, name: string): boolean { return executable === name || executable.startsWith(`${name}@`); } -function isTopLevelPackageRunner(executable: string): boolean { - return [...TOP_LEVEL_PACKAGE_RUNNERS].some((runner) => - isExecutablePackageSpec(executable, runner) - ); -} - -function isPackageRunnerSubcommand(token: string): boolean { - return PACKAGE_RUNNER_SUBCOMMANDS.has(normalizeExecutableName(token)); -} - -function isTopLevelRunnerNestedSubcommand(token: string): boolean { - return TOP_LEVEL_RUNNER_NESTED_SUBCOMMANDS.has( - normalizeExecutableName(token) - ); -} - -function isPackageManagerExecutionSubcommand( - executable: string, - subcommand: string -): boolean { - const subcommands = [...PACKAGE_MANAGER_EXECUTION_SUBCOMMANDS].find( - ([manager]) => isExecutablePackageSpec(executable, manager) - )?.[1]; - - return subcommands?.has(normalizeExecutableName(subcommand)) ?? false; -} - -function getLongPackageRunnerOptionName(token: string): string | undefined { - if (!token.startsWith("--") || token === "--" || token.startsWith("--no-")) { - return; - } - - const [name = ""] = token.slice(2).split("=", 1); - return name.toLowerCase(); -} - -function packageRunnerPackageOptionConsumesValue(token: string): boolean { - return token === "-p" || token === "--package"; -} - -function packageRunnerOptionConsumesValue( - token: string, - nextToken: string | undefined, - options: { preserveSubcommands?: boolean } = {} -): boolean { - if (!nextToken || nextToken === "--") { - return false; - } - if (token.startsWith("--") && token.includes("=")) { - return false; - } - if (packageRunnerPackageOptionConsumesValue(token)) { - return true; - } - if ( - PACKAGE_RUNNER_VALUE_SHORT_OPTIONS.has(token) || - PACKAGE_RUNNER_VALUE_OPTIONS.has( - getLongPackageRunnerOptionName(token) ?? "" - ) - ) { - return options.preserveSubcommands - ? !(nextToken.startsWith("-") || isPackageRunnerSubcommand(nextToken)) - : !nextToken.startsWith("-"); - } - return false; -} - -function getInlinePackageRunnerPackageOptionValue( - token: string -): string | undefined { - if (token.startsWith("-p=")) { - return token.slice("-p=".length); - } - if (token.startsWith("-p") && token.length > "-p".length) { - return token.slice("-p".length); - } - if (token.startsWith("--package=")) { - return token.slice("--package=".length); - } - return; -} - -function isInlinePackageRunnerOption(token: string): boolean { - if (getInlinePackageRunnerPackageOptionValue(token) !== undefined) { - return true; - } - if (token.startsWith("--")) { - return token.includes("="); - } - return PACKAGE_RUNNER_VALUE_SHORT_OPTIONS.has(token.slice(0, 2)) - ? token.startsWith(`${token.slice(0, 2)}=`) - : false; -} - -function findPackageRunnerCommandIndex( - tokens: string[], - startIndex: number, - options: { preserveSubcommands?: boolean } = {} -): number | undefined { - for (let index = startIndex; index < tokens.length; index += 1) { - const token = tokens[index]; - if (!token) { - continue; - } - if (token === "--") { - return index + 1 < tokens.length ? index + 1 : undefined; - } - if (packageRunnerOptionConsumesValue(token, tokens[index + 1], options)) { - index += 1; - continue; - } - if (isInlinePackageRunnerOption(token)) { - continue; - } - if (token.startsWith("-")) { - continue; - } - return index; - } - - return; -} - -function findPackageRunnerPackageOptionValues( - tokens: string[], - startIndex: number, - endIndex = tokens.length -): Array<{ token: string; index: number }> { - const values: Array<{ token: string; index: number }> = []; - - for (let index = startIndex; index < endIndex; index += 1) { - const token = tokens[index]; - if (!token) { - continue; - } - if (token === "--") { - break; - } - if (packageRunnerPackageOptionConsumesValue(token)) { - const value = tokens[index + 1]; - if (value) { - values.push({ token: value, index: index + 1 }); - } - index += 1; - continue; - } - - const inlineValue = getInlinePackageRunnerPackageOptionValue(token); - if (inlineValue) { - values.push({ token: inlineValue, index }); - } - } - - return values; -} - -function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { - const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); - if (isTopLevelPackageRunner(firstExecutable)) { - return findPackageRunnerCommandIndex(tokens, 1); - } - - const subcommandIndex = findPackageRunnerCommandIndex(tokens, 1, { - preserveSubcommands: true, - }); - if (subcommandIndex === undefined) { - return; - } - - if ( - !isPackageManagerExecutionSubcommand( - firstExecutable, - tokens[subcommandIndex] ?? "" - ) - ) { - return; - } - - return findPackageRunnerCommandIndex(tokens, subcommandIndex + 1); -} - -function findTopLevelRunnerNestedExecutionTokenIndex( - tokens: string[], - commandIndex: number | undefined -): number | undefined { - if ( - commandIndex === undefined || - !isTopLevelRunnerNestedSubcommand(tokens[commandIndex] ?? "") - ) { - return; - } - - return findPackageRunnerCommandIndex(tokens, commandIndex + 1); -} - -function findPackageExecutionTokenIndexes(tokens: string[]): number[] { - const commandIndex = findPackageExecutionTokenIndex(tokens); - if (commandIndex === undefined) { - return []; - } - - const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); - const nestedCommandIndex = isTopLevelPackageRunner(firstExecutable) - ? findTopLevelRunnerNestedExecutionTokenIndex(tokens, commandIndex) - : undefined; - - return nestedCommandIndex === undefined - ? [commandIndex] - : [commandIndex, nestedCommandIndex]; -} - -function findPackageExecutionPackageOptionValues( - tokens: string[] -): Array<{ token: string; index: number }> { - const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); - if (isTopLevelPackageRunner(firstExecutable)) { - const commandIndex = findPackageRunnerCommandIndex(tokens, 1); - const packageOptionValues = findPackageRunnerPackageOptionValues( - tokens, - 1, - commandIndex ?? tokens.length - ); - if ( - commandIndex === undefined || - !isTopLevelRunnerNestedSubcommand(tokens[commandIndex] ?? "") - ) { - return packageOptionValues; - } - - const nestedCommandIndex = findTopLevelRunnerNestedExecutionTokenIndex( - tokens, - commandIndex - ); - return [ - ...packageOptionValues, - ...findPackageRunnerPackageOptionValues( - tokens, - commandIndex + 1, - nestedCommandIndex ?? tokens.length - ), - ]; - } - - const subcommandIndex = findPackageRunnerCommandIndex(tokens, 1, { - preserveSubcommands: true, - }); - if (subcommandIndex === undefined) { - return []; - } - - if ( - !isPackageManagerExecutionSubcommand( - firstExecutable, - tokens[subcommandIndex] ?? "" - ) - ) { - return []; - } - - const commandIndex = findPackageRunnerCommandIndex( - tokens, - subcommandIndex + 1 - ); - - return [ - ...findPackageRunnerPackageOptionValues(tokens, 1, subcommandIndex), - ...findPackageRunnerPackageOptionValues( - tokens, - subcommandIndex + 1, - commandIndex ?? tokens.length - ), - ]; -} - -function canExecuteToken(tokens: string[], index: number): boolean { - return ( - index === 0 || findPackageExecutionTokenIndexes(tokens).includes(index) - ); -} - function findBlockedExecutable(tokens: string[]): string | undefined { - const candidateIndexes = new Set([0]); - for (const packageExecutionIndex of findPackageExecutionTokenIndexes( - tokens - )) { - candidateIndexes.add(packageExecutionIndex); - } - - for (const index of candidateIndexes) { - const executable = normalizeExecutableName(tokens[index] ?? ""); - if (BLOCKED_EXECUTABLES.has(executable)) { - return executable; - } + const executable = normalizeExecutableName(tokens[0] ?? ""); + if (BLOCKED_EXECUTABLES.has(executable)) { + return executable; } return; } -function packageRunnerExecutesInit(tokens: string[]): boolean { - return findPackageExecutionTokenIndexes(tokens).some( - (index) => normalizeExecutableName(tokens[index] ?? "") === "init" - ); -} - function isRecursiveSentrySetupToken( token: string, tokens: string[], @@ -513,25 +170,8 @@ function isRecursiveSentrySetupToken( } function isRecursiveSentrySetup(tokens: string[]): boolean { - const packageOptionInvokesSentry = findPackageExecutionPackageOptionValues( - tokens - ).some(({ token }) => { - if (isSentryWizardPackageSpec(token)) { - return true; - } - return isSentryCliPackageSpec(token) && packageRunnerExecutesInit(tokens); - }); - if (packageOptionInvokesSentry) { - return true; - } - - return tokens.some((token, index) => { - if (!canExecuteToken(tokens, index)) { - return false; - } - - return isRecursiveSentrySetupToken(token, tokens, index); - }); + const [firstToken = ""] = tokens; + return isRecursiveSentrySetupToken(firstToken, tokens, 0); } function findShellMetacharacterLabel(command: string): string | undefined { diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index ed098adfe..600a79f00 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -108,52 +108,19 @@ describe("validateCommand", () => { expect(validateCommand("sentry init")).toContain( "invokes Sentry setup recursively" ); - expect(validateCommand("npx @sentry/wizard -i nextjs")).toContain( + expect(validateCommand("@sentry/wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); - expect(validateCommand("npx @Sentry/Wizard -i nextjs")).toContain( + expect(validateCommand("@Sentry/Wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); - expect( - validateCommand("npx -p innocuous-pkg @sentry/wizard -i nextjs") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("npx --package innocuous-pkg @sentry/wizard -i nextjs") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("npx --package=innocuous-pkg @sentry/wizard -i nextjs") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("npx -p=innocuous-pkg @sentry/wizard -i nextjs") - ).toContain("invokes Sentry setup recursively"); - expect(validateCommand("npx --package=@sentry/wizard -i nextjs")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx -p=@sentry/cli init")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx --package=@sentry/cli init")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx -p@sentry/wizard -i nextjs")).toContain( + expect(validateCommand("@sentry/cli init")).toContain( "invokes Sentry setup recursively" ); - expect(validateCommand("npx -p@sentry/cli init")).toContain( + expect(validateCommand("@sentry/cli@latest init")).toContain( "invokes Sentry setup recursively" ); - expect(validateCommand("npx --package @sentry/wizard -i nextjs")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx -p @sentry/cli init")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx @sentry/cli init")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx @sentry/cli@latest init")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx @Sentry/CLI@latest init")).toContain( + expect(validateCommand("@Sentry/CLI@latest init")).toContain( "invokes Sentry setup recursively" ); expect(validateCommand("C:\\Tools\\sentry-cli.exe init")).toContain( @@ -162,123 +129,22 @@ describe("validateCommand", () => { expect(validateCommand("sentry-cli --log-level debug init")).toContain( "invokes Sentry setup recursively" ); - expect(validateCommand("npx sentry-cli init")).toContain( - "invokes Sentry setup recursively" - ); expect(validateCommand("sentry-cli@latest init")).toContain( "invokes Sentry setup recursively" ); - expect(validateCommand("npx sentry-cli@latest init")).toContain( - "invokes Sentry setup recursively" - ); expect(validateCommand("sentry-wizard init")).toContain( "invokes Sentry setup recursively" ); - expect(validateCommand("npx sentry-wizard -i nextjs")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx sentry-wizard@latest -i nextjs")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx create @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx init @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx init --package=@sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx init --package @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx init --package=@sentry/cli")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npx create --package=@sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("pnpx create @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("pnpx init --package=@sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("bunx init @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("pnpx @sentry/wizard -i nextjs")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("bun x @sentry/wizard -i nextjs")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npm exec @sentry/wizard -i nextjs")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npm x @sentry/wizard -i nextjs")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npm init @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("npm create @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect( - validateCommand("npm exec --package lodash @sentry/wizard -i nextjs") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("npm exec --package=@sentry/wizard -i nextjs") - ).toContain("invokes Sentry setup recursively"); - expect(validateCommand("pnpm dlx sentry-wizard -i nextjs")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("pnpm create @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect( - validateCommand("pnpm dlx --package lodash sentry-wizard -i nextjs") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("pnpm dlx --package=@sentry/wizard -i nextjs") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("npm --registry myregistry exec @sentry/wizard") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("npm exec --registry myregistry @sentry/wizard") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("pnpm --registry myregistry dlx @sentry/wizard") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("pnpm dlx --registry myregistry @sentry/wizard") - ).toContain("invokes Sentry setup recursively"); - expect( - validateCommand("npx --registry myregistry @sentry/wizard") - ).toContain("invokes Sentry setup recursively"); - expect(validateCommand("npx --registry create @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("yarn create @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); - expect(validateCommand("yarn dlx @sentry/wizard")).toContain( - "invokes Sentry setup recursively" - ); expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( "invokes Sentry setup recursively" ); }); - test("blocks disallowed executables through package runners", () => { - expect(validateCommand("npx bash")).toContain('"bash"'); - expect(validateCommand("npx curl http://example.com")).toContain('"curl"'); - expect(validateCommand("npm exec wget http://example.com/file")).toContain( - '"wget"' - ); - expect(validateCommand("pnpm dlx sh")).toContain('"sh"'); + test("blocks disallowed executables directly", () => { + expect(validateCommand("bash -lc echo")).toContain('"bash"'); + expect(validateCommand("curl http://example.com")).toContain('"curl"'); + expect(validateCommand("wget http://example.com/file")).toContain('"wget"'); + expect(validateCommand("sh -c echo")).toContain('"sh"'); }); test("blocks shell interpreter indirection", () => {