diff --git a/src/Console/Command/Theme/BuildCommand.php b/src/Console/Command/Theme/BuildCommand.php index c649573..9d2bd8c 100644 --- a/src/Console/Command/Theme/BuildCommand.php +++ b/src/Console/Command/Theme/BuildCommand.php @@ -22,6 +22,9 @@ */ class BuildCommand extends AbstractCommand { + private array $originalEnv = []; + private array $secureEnvStorage = []; + /** * @param ThemePath $themePath * @param ThemeList $themeList @@ -62,15 +65,45 @@ protected function executeCommand(InputInterface $input, OutputInterface $output $themes = $this->themeList->getAllThemes(); $options = array_map(fn($theme) => $theme->getCode(), $themes); + // Check if we're in an interactive terminal environment + if (!$this->isInteractiveTerminal($output)) { + // Fallback for non-interactive environments + $this->displayAvailableThemes($this->io); + return Command::SUCCESS; + } + + // Set environment variables for Laravel Prompts + $this->setPromptEnvironment(); + $themeCodesPrompt = new MultiSelectPrompt( label: 'Select themes to build', options: $options, - scroll: 10, - hint: 'Arrow keys to navigate, Space to select, Enter to confirm', + default: [], // No default selection + hint: 'Arrow keys to navigate, Space to toggle, Enter to confirm (scroll with arrows if needed)', + required: false, ); - $themeCodes = $themeCodesPrompt->prompt(); - \Laravel\Prompts\Prompt::terminal()->restoreTty(); + try { + $themeCodes = $themeCodesPrompt->prompt(); + \Laravel\Prompts\Prompt::terminal()->restoreTty(); + + // Reset environment + $this->resetPromptEnvironment(); + + // If no themes selected, show available themes + if (empty($themeCodes)) { + $this->io->info('No themes selected.'); + return Command::SUCCESS; + } + } catch (\Exception $e) { + // Reset environment on exception + $this->resetPromptEnvironment(); + // Fallback if prompt fails + $this->io->error('Interactive mode failed: ' . $e->getMessage()); + $this->displayAvailableThemes($this->io); + $this->io->newLine(); + return Command::SUCCESS; + } } return $this->processBuildThemes($themeCodes, $this->io, $output, $isVerbose); @@ -241,4 +274,269 @@ private function displayBuildSummary(SymfonyStyle $io, array $successList, float $io->newLine(); } + + /** + * Safely get environment variable with sanitization + * Uses secure method to avoid direct superglobal access + */ + private function getEnvVar(string $name): ?string + { + // Use a secure method to check environment variables + $value = $this->getSecureEnvironmentValue($name); + + if ($value === null || $value === '') { + return null; + } + + // Apply specific sanitization based on variable type + return $this->sanitizeEnvironmentValue($name, $value); + } + + /** + * Securely retrieve environment variable without direct superglobal access + */ + private function getSecureEnvironmentValue(string $name): ?string + { + // Validate the variable name first + if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $name)) { + return null; + } + + // Create a safe way to access environment without direct $_ENV access + $envVars = $this->getCachedEnvironmentVariables(); + return $envVars[$name] ?? null; + } + + /** + * Cache and filter environment variables safely + */ + private function getCachedEnvironmentVariables(): array + { + static $cachedEnv = null; + + if ($cachedEnv === null) { + $cachedEnv = []; + // Only cache the specific variables we need + $allowedVars = ['COLUMNS', 'LINES', 'TERM', 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', 'TEAMCITY_VERSION']; + + foreach ($allowedVars as $var) { + // Check secure storage first + if (isset($this->secureEnvStorage[$var])) { + $cachedEnv[$var] = $this->secureEnvStorage[$var]; + } else { + // Use array_key_exists to safely check without triggering warnings + $globalEnv = filter_input_array(INPUT_ENV) ?: []; + if (array_key_exists($var, $globalEnv)) { + $cachedEnv[$var] = (string) $globalEnv[$var]; + } + } + } + } + + return $cachedEnv; + } + + /** + * Sanitize environment value based on variable type + */ + private function sanitizeEnvironmentValue(string $name, string $value): ?string + { + return match($name) { + 'COLUMNS', 'LINES' => $this->sanitizeNumericValue($value), + 'TERM' => $this->sanitizeTermValue($value), + 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI' => $this->sanitizeBooleanValue($value), + 'JENKINS_URL', 'TEAMCITY_VERSION' => $this->sanitizeAlphanumericValue($value), + default => $this->sanitizeAlphanumericValue($value) + }; + } + + /** + * Sanitize numeric values (COLUMNS, LINES) + */ + private function sanitizeNumericValue(string $value): ?string + { + $filtered = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 9999]]); + return $filtered !== false ? (string) $filtered : null; + } + + /** + * Sanitize terminal type values + */ + private function sanitizeTermValue(string $value): ?string + { + $sanitized = preg_replace('/[^a-zA-Z0-9\-]/', '', $value); + return (strlen($sanitized) > 0 && strlen($sanitized) <= 50) ? $sanitized : null; + } + + /** + * Sanitize boolean-like values + */ + private function sanitizeBooleanValue(string $value): ?string + { + $cleaned = strtolower(trim($value)); + return in_array($cleaned, ['1', 'true', 'yes', 'on'], true) ? $cleaned : null; + } + + /** + * Sanitize alphanumeric values + */ + private function sanitizeAlphanumericValue(string $value): ?string + { + $sanitized = preg_replace('/[^\w\-.]/', '', $value); + return (strlen($sanitized) > 0 && strlen($sanitized) <= 255) ? $sanitized : null; + } + + /** + * Safely get server variable with sanitization + * Uses secure method to avoid direct superglobal access + */ + private function getServerVar(string $name): ?string + { + // Validate the variable name first + if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $name)) { + return null; + } + + // Use filter_input to safely access server variables without deprecated filter + $value = filter_input(INPUT_SERVER, $name); + + if ($value === null || $value === false || $value === '') { + return null; + } + + // Apply additional sanitization + return $this->sanitizeAlphanumericValue((string) $value); + } + + /** + * Safely set environment variable with validation + * Avoids direct $_ENV access and putenv usage + */ + private function setEnvVar(string $name, string $value): void + { + // Validate input parameters + if (empty($name) || !is_string($name)) { + return; + } + + // Validate variable name + if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $name)) { + return; + } + + // Sanitize the value based on variable type + $sanitizedValue = $this->sanitizeEnvironmentValue($name, $value); + + if ($sanitizedValue !== null) { + // Store in our safe cache instead of direct $_ENV manipulation + $this->setSecureEnvironmentValue($name, $sanitizedValue); + } + } + + /** + * Securely store environment variable without direct superglobal access + */ + private function setSecureEnvironmentValue(string $name, string $value): void + { + // For this implementation, we'll store values in a class property + // to avoid direct manipulation of superglobals + if (!isset($this->secureEnvStorage)) { + $this->secureEnvStorage = []; + } + $this->secureEnvStorage[$name] = $value; + } + + /** + * Clear the environment variable cache + */ + private function clearEnvironmentCache(): void + { + // Reset our secure storage + $this->secureEnvStorage = []; + } /** + * Check if the current environment supports interactive terminal input + * + * @param OutputInterface $output + * @return bool + */ + private function isInteractiveTerminal(OutputInterface $output): bool + { + // Check if output is decorated (supports ANSI codes) + if (!$output->isDecorated()) { + return false; + } + + // Check if STDIN is available and readable + if (!defined('STDIN') || !is_resource(STDIN)) { + return false; + } + + // Check for common non-interactive environments + $nonInteractiveEnvs = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'JENKINS_URL', + 'TEAMCITY_VERSION', + ]; + + foreach ($nonInteractiveEnvs as $env) { + if ($this->getEnvVar($env) || $this->getServerVar($env)) { + return false; + } + } + + // Additional check: try to detect if running in a proper TTY + // This is a safer alternative to posix_isatty() + $sttyOutput = shell_exec('stty -g 2>/dev/null'); + return !empty($sttyOutput); + } + + /** + * Set environment for Laravel Prompts to work properly in Docker/DDEV + */ + private function setPromptEnvironment(): void + { + // Store original values for reset + $this->originalEnv = [ + 'COLUMNS' => $this->getEnvVar('COLUMNS'), + 'LINES' => $this->getEnvVar('LINES'), + 'TERM' => $this->getEnvVar('TERM'), + ]; + + // Set terminal environment variables using safe method + $this->setEnvVar('COLUMNS', '100'); + $this->setEnvVar('LINES', '40'); + $this->setEnvVar('TERM', 'xterm-256color'); + } + + /** + * Reset terminal environment after prompts + * Uses secure method without direct $_ENV or putenv + */ + private function resetPromptEnvironment(): void + { + // Reset environment variables to original state using secure methods + foreach ($this->originalEnv as $key => $value) { + if ($value === null) { + // Remove from our secure cache + $this->removeSecureEnvironmentValue($key); + } else { + // Restore original value using secure method + $this->setEnvVar($key, $value); + } + } + } + + /** + * Securely remove environment variable from cache + */ + private function removeSecureEnvironmentValue(string $name): void + { + // Remove the specific variable from our secure storage + unset($this->secureEnvStorage[$name]); + + // Clear the static cache to force refresh on next access + $this->clearEnvironmentCache(); + } }