From cf51267c3623e353f30988af505074f451427f94 Mon Sep 17 00:00:00 2001 From: Dom <464714+domid@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:34:18 +0100 Subject: [PATCH] security: sanitize tokens from error messages Add sanitizeTokens() helper to prevent credential leaks in error output. Redacts: - Cloudflare API tokens (cf_XXX pattern) - CLOUDFLARE_API_TOKEN env var assignments - Bearer tokens in auth headers --- src/utils/shell.ts | 346 +++++++++++++++++++++++++++------------------ 1 file changed, 206 insertions(+), 140 deletions(-) diff --git a/src/utils/shell.ts b/src/utils/shell.ts index a561512..4a07ec5 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -1,8 +1,23 @@ -import { execa, type ExecaError } from 'execa'; -import chalk from 'chalk'; -import ora from 'ora'; +import { execa, type ExecaError } from "execa"; +import chalk from "chalk"; +import ora from "ora"; -export type ProjectType = 'workers' | 'pages' | 'unknown'; +/** + * Sanitize sensitive tokens from error messages to prevent credential leaks + */ +function sanitizeTokens(message: string): string { + // Cloudflare API tokens (format: cf_XXXX or longer alphanumeric strings) + // Also catches tokens passed via environment variables that might appear in errors + return message + .replace(/cf_[a-zA-Z0-9_-]+/g, "[REDACTED]") + .replace( + /CLOUDFLARE_API_TOKEN=["']?[^"'\s]+["']?/g, + "CLOUDFLARE_API_TOKEN=[REDACTED]", + ) + .replace(/Bearer\s+[a-zA-Z0-9_-]+/gi, "Bearer [REDACTED]"); +} + +export type ProjectType = "workers" | "pages" | "unknown"; export interface WranglerOptions { env?: string | undefined; @@ -17,17 +32,17 @@ export interface WranglerOptions { export async function checkWranglerInstalled(): Promise { try { // First try direct wrangler command - await execa('wrangler', ['--version'], { - stdio: 'pipe', - timeout: 5000 + await execa("wrangler", ["--version"], { + stdio: "pipe", + timeout: 5000, }); return true; } catch { try { // Fallback to npx wrangler - await execa('npx', ['wrangler', '--version'], { - stdio: 'pipe', - timeout: 10000 + await execa("npx", ["wrangler", "--version"], { + stdio: "pipe", + timeout: 10000, }); return true; } catch { @@ -39,39 +54,44 @@ export async function checkWranglerInstalled(): Promise { /** * Get the appropriate wrangler command (direct or via npx) */ -export async function getWranglerCommand(): Promise<{ cmd: string; args: string[] }> { +export async function getWranglerCommand(): Promise<{ + cmd: string; + args: string[]; +}> { try { // First try direct wrangler - await execa('wrangler', ['--version'], { - stdio: 'pipe', - timeout: 5000 + await execa("wrangler", ["--version"], { + stdio: "pipe", + timeout: 5000, }); - return { cmd: 'wrangler', args: [] }; + return { cmd: "wrangler", args: [] }; } catch { // Fallback to npx - return { cmd: 'npx', args: ['wrangler'] }; + return { cmd: "npx", args: ["wrangler"] }; } } /** * Get current Wrangler auth status */ -export async function getWranglerAuthStatus(): Promise<'authenticated' | 'not-authenticated' | 'unknown'> { +export async function getWranglerAuthStatus(): Promise< + "authenticated" | "not-authenticated" | "unknown" +> { try { const { cmd, args } = await getWranglerCommand(); - const result = await execa(cmd, [...args, 'whoami'], { - stdio: 'pipe', + const result = await execa(cmd, [...args, "whoami"], { + stdio: "pipe", timeout: 10000, - reject: false + reject: false, }); - + if (result.exitCode === 0) { - return 'authenticated'; + return "authenticated"; } else { - return 'not-authenticated'; + return "not-authenticated"; } } catch { - return 'unknown'; + return "unknown"; } } @@ -79,46 +99,52 @@ export async function getWranglerAuthStatus(): Promise<'authenticated' | 'not-au * Run a Wrangler command with the specified token */ export async function runWranglerCommand( - command: string[], - options: WranglerOptions -): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> { + command: string[], + options: WranglerOptions, +): Promise<{ + success: boolean; + output?: string | undefined; + error?: string | undefined; +}> { const { token, silent = false, extraArgs = [] } = options; - + // Get the appropriate wrangler command const { cmd, args } = await getWranglerCommand(); - + // Combine command with extra arguments const fullCommand = [...args, ...command, ...extraArgs]; - - const spinner = silent ? null : ora(`Running: ${cmd} ${fullCommand.join(' ')}`).start(); - + + const spinner = silent + ? null + : ora(`Running: ${cmd} ${fullCommand.join(" ")}`).start(); + try { const result = await execa(cmd, fullCommand, { env: { ...process.env, CLOUDFLARE_API_TOKEN: token, }, - stdio: silent ? 'pipe' : 'inherit', + stdio: silent ? "pipe" : "inherit", timeout: 300000, // 5 minutes timeout }); - + if (spinner) { spinner.succeed(chalk.green(`āœ“ Command completed successfully`)); } - + return { success: true, output: result.stdout || undefined, }; } catch (error) { const execaError = error as ExecaError; - + if (spinner) { spinner.fail(chalk.red(`āœ— Command failed`)); } - - let errorMessage = 'Unknown error'; - + + let errorMessage = "Unknown error"; + if (execaError.exitCode) { errorMessage = `Command failed with exit code ${execaError.exitCode}`; if (execaError.stderr) { @@ -129,11 +155,14 @@ export async function runWranglerCommand( } else if (execaError.message) { errorMessage = execaError.message; } - + + // Sanitize any tokens that might appear in error messages + errorMessage = sanitizeTokens(errorMessage); + if (!silent) { - console.error(chalk.red('Error details:'), errorMessage); + console.error(chalk.red("Error details:"), errorMessage); } - + return { success: false, error: errorMessage, @@ -149,38 +178,46 @@ export async function deployWithWrangler( token: string, env?: string | undefined, extraArgs?: string[] | undefined, - projectType?: ProjectType + projectType?: ProjectType, ): Promise<{ success: boolean; error?: string | undefined }> { const deployCommand = []; - - if (projectType === 'pages') { - deployCommand.push('pages', 'deploy'); + + if (projectType === "pages") { + deployCommand.push("pages", "deploy"); // For Pages, add output directory if not specified in extraArgs - if (!extraArgs?.some(arg => !arg.startsWith('--'))) { - deployCommand.push('out'); // Default for Next.js + if (!extraArgs?.some((arg) => !arg.startsWith("--"))) { + deployCommand.push("out"); // Default for Next.js } } else { - deployCommand.push('deploy'); + deployCommand.push("deploy"); if (env) { - deployCommand.push('--env', env); + deployCommand.push("--env", env); } } - - const projectTypeDisplay = projectType === 'pages' ? 'Pages' : 'Workers'; - console.log(chalk.blue(`šŸš€ Deploying ${projectTypeDisplay} with account: ${chalk.bold(accountName)}`)); - if (env && projectType !== 'pages') { + + const projectTypeDisplay = projectType === "pages" ? "Pages" : "Workers"; + console.log( + chalk.blue( + `šŸš€ Deploying ${projectTypeDisplay} with account: ${chalk.bold(accountName)}`, + ), + ); + if (env && projectType !== "pages") { console.log(chalk.blue(`šŸ“¦ Environment: ${chalk.bold(env)}`)); } - + const result = await runWranglerCommand(deployCommand, { token, extraArgs: extraArgs || undefined, }); - + if (result.success) { - console.log(chalk.green(`\nšŸŽ‰ Successfully deployed ${projectTypeDisplay} using account "${accountName}"`)); + console.log( + chalk.green( + `\nšŸŽ‰ Successfully deployed ${projectTypeDisplay} using account "${accountName}"`, + ), + ); } - + return result; } @@ -189,114 +226,121 @@ export async function deployWithWrangler( */ export async function detectDeploymentType( forcePagesMode?: boolean, - forceWorkersMode?: boolean + forceWorkersMode?: boolean, ): Promise { - if (forcePagesMode) return 'pages'; - if (forceWorkersMode) return 'workers'; - + if (forcePagesMode) return "pages"; + if (forceWorkersMode) return "workers"; + try { - const { promises: fs } = await import('fs'); - + const { promises: fs } = await import("fs"); + // Check for wrangler.toml (Workers) try { - const wranglerConfig = await fs.readFile('wrangler.toml', 'utf-8'); + const wranglerConfig = await fs.readFile("wrangler.toml", "utf-8"); // Check if it's a Pages project in wrangler.toml - if (wranglerConfig.includes('[env.') && wranglerConfig.includes('pages')) { - return 'pages'; + if ( + wranglerConfig.includes("[env.") && + wranglerConfig.includes("pages") + ) { + return "pages"; } - return 'workers'; + return "workers"; } catch { // No wrangler.toml, check for Pages indicators } - + // Check for typical Next.js/Pages structure try { - const packageJson = await fs.readFile('package.json', 'utf-8'); + const packageJson = await fs.readFile("package.json", "utf-8"); const pkg = JSON.parse(packageJson); - + // Check for Next.js or Pages-related dependencies/scripts const isPagesProject = !!( - pkg.dependencies?.['next'] || - pkg.devDependencies?.['next'] || - pkg.dependencies?.['@cloudflare/next-on-pages'] || - pkg.devDependencies?.['@cloudflare/next-on-pages'] || - pkg.scripts?.['pages:build'] || - pkg.scripts?.deploy?.includes('pages') + pkg.dependencies?.["next"] || + pkg.devDependencies?.["next"] || + pkg.dependencies?.["@cloudflare/next-on-pages"] || + pkg.devDependencies?.["@cloudflare/next-on-pages"] || + pkg.scripts?.["pages:build"] || + pkg.scripts?.deploy?.includes("pages") ); - - if (isPagesProject) return 'pages'; - + + if (isPagesProject) return "pages"; + // Check for Workers indicators const isWorkersProject = !!( pkg.dependencies?.wrangler || pkg.devDependencies?.wrangler || - pkg.scripts?.deploy?.includes('wrangler deploy') || - pkg.scripts?.dev?.includes('wrangler') + pkg.scripts?.deploy?.includes("wrangler deploy") || + pkg.scripts?.dev?.includes("wrangler") ); - - if (isWorkersProject) return 'workers'; + + if (isWorkersProject) return "workers"; } catch { // No package.json } - + // Check for typical file structures try { - await fs.access('out'); - await fs.access('next.config.js'); - return 'pages'; // Likely a Next.js project + await fs.access("out"); + await fs.access("next.config.js"); + return "pages"; // Likely a Next.js project } catch { // Not a Next.js project } - + try { - await fs.access('dist'); - await fs.access('src/index.ts'); - return 'workers'; // Likely a Workers project + await fs.access("dist"); + await fs.access("src/index.ts"); + return "workers"; // Likely a Workers project } catch { // Unknown structure } - - return 'unknown'; + + return "unknown"; } catch { - return 'unknown'; + return "unknown"; } } /** * Check if we're in a valid project directory */ -export async function isProjectDirectory(projectType: ProjectType): Promise { - if (projectType === 'unknown') return false; - +export async function isProjectDirectory( + projectType: ProjectType, +): Promise { + if (projectType === "unknown") return false; + try { - const { promises: fs } = await import('fs'); - - if (projectType === 'pages') { + const { promises: fs } = await import("fs"); + + if (projectType === "pages") { // For Pages, check for typical Next.js structure or build output try { - await fs.access('package.json'); + await fs.access("package.json"); return true; // Any directory with package.json can potentially be a Pages project } catch { return false; } } - - if (projectType === 'workers') { + + if (projectType === "workers") { // For Workers, prefer wrangler.toml but also accept package.json with wrangler try { - await fs.access('wrangler.toml'); + await fs.access("wrangler.toml"); return true; } catch { try { - const packageJson = await fs.readFile('package.json', 'utf-8'); + const packageJson = await fs.readFile("package.json", "utf-8"); const pkg = JSON.parse(packageJson); - return !!(pkg.dependencies?.wrangler || pkg.devDependencies?.wrangler); + return !!( + pkg.dependencies?.wrangler || pkg.devDependencies?.wrangler + ); } catch { return false; } } } - + return false; } catch { return false; @@ -307,32 +351,52 @@ export async function isProjectDirectory(projectType: ProjectType): Promise { try { - const { promises: fs } = await import('fs'); - + const { promises: fs } = await import("fs"); + // Check for wrangler.toml - await fs.access('wrangler.toml'); + await fs.access("wrangler.toml"); return true; } catch { // Check for package.json with wrangler dependency try { - const { promises: fs } = await import('fs'); - const packageJson = await fs.readFile('package.json', 'utf-8'); + const { promises: fs } = await import("fs"); + const packageJson = await fs.readFile("package.json", "utf-8"); const pkg = JSON.parse(packageJson); - + return !!( pkg.dependencies?.wrangler || pkg.devDependencies?.wrangler || - pkg.scripts?.deploy?.includes('wrangler') + pkg.scripts?.deploy?.includes("wrangler") ); } catch { return false; @@ -368,12 +432,14 @@ export async function isWranglerProject(): Promise { * Provide helpful suggestions when not in a Wrangler project */ export function showWranglerProjectHelp(): void { - console.log(chalk.yellow('āš ļø You don\'t seem to be in a Wrangler project directory.')); + console.log( + chalk.yellow("āš ļø You don't seem to be in a Wrangler project directory."), + ); console.log(); - console.log('To create a new Cloudflare Worker project:'); - console.log(chalk.cyan(' npm create cloudflare@latest my-worker')); + console.log("To create a new Cloudflare Worker project:"); + console.log(chalk.cyan(" npm create cloudflare@latest my-worker")); console.log(); - console.log('Or if you have an existing project, make sure you have:'); - console.log(chalk.cyan(' • wrangler.toml file in your project root')); - console.log(chalk.cyan(' • Wrangler installed (npm install wrangler)')); + console.log("Or if you have an existing project, make sure you have:"); + console.log(chalk.cyan(" • wrangler.toml file in your project root")); + console.log(chalk.cyan(" • Wrangler installed (npm install wrangler)")); }