diff --git a/setup_env.sh b/setup_env.sh index a5899ee..50a3842 100755 --- a/setup_env.sh +++ b/setup_env.sh @@ -29,6 +29,13 @@ set -euo pipefail # ============================================================================ ERLANG_VERSION="28.3.1" ELIXIR_VERSION="1.19.5-otp-28" +PLAYWRIGHT_VERSION="1.50.1" +# Claude Code version - check https://www.npmjs.com/package/@anthropic-ai/claude-code for latest +CLAUDE_CODE_VERSION="1.0.16" +# yq version - check https://github.com/mikefarah/yq/releases for latest +YQ_VERSION="4.45.1" +# watchexec version - check https://github.com/watchexec/watchexec/releases for latest +WATCHEXEC_VERSION="2.3.2" # ============================================================================ # Helpers @@ -63,6 +70,113 @@ command_exists() { command -v "$1" &>/dev/null } +# Security: Validate input contains only safe characters for config storage +# Allows alphanumeric, spaces, common punctuation, but blocks shell metacharacters +validate_safe_input() { + local input="$1" + local field_name="$2" + # Block dangerous shell metacharacters: backticks, $, (), {}, ;, |, &, <, >, newlines + if [[ "$input" =~ [\`\$\(\)\{\}\;\|\&\<\>\"] ]] || [[ "$input" == *$'\n'* ]]; then + log_error "$field_name contains invalid characters (shell metacharacters not allowed)" + return 1 + fi + return 0 +} + +# Security: Validate VM name contains only safe characters +validate_vm_name() { + local name="$1" + if [[ ! "$name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]]; then + log_error "VM name must start with a letter and contain only alphanumeric characters, hyphens, and underscores" + return 1 + fi + if [ ${#name} -gt 64 ]; then + log_error "VM name must be 64 characters or less" + return 1 + fi + return 0 +} + +# Security: Validate VNC password +validate_vnc_password() { + local password="$1" + if [ ${#password} -lt 6 ]; then + echo "Password must be at least 6 characters." + return 1 + fi + if [ ${#password} -gt 64 ]; then + echo "Password must be 64 characters or less." + return 1 + fi + # Block characters that could cause shell injection + if [[ "$password" =~ [\`\$\(\)\{\}\;\|\&\<\>\'\"] ]]; then + echo "Password contains invalid characters." + return 1 + fi + return 0 +} + +# Security: Detect system architecture and return normalized name +# Returns: amd64, arm64, or exits with error for unsupported architectures +detect_architecture() { + local arch + arch=$(uname -m) + case "$arch" in + x86_64|amd64) + echo "amd64" + ;; + aarch64|arm64) + echo "arm64" + ;; + *) + log_error "Unsupported architecture: $arch" + return 1 + ;; + esac +} + +# Security: Download and verify a binary with optional checksum +# Args: $1=url, $2=output_path, $3=expected_checksum (optional, sha256) +download_verified_binary() { + local url="$1" + local output="$2" + local expected_checksum="${3:-}" + + local temp_file + temp_file=$(mktemp) + + # Download to temp file first + if ! curl -fsSL "$url" -o "$temp_file"; then + log_error "Failed to download: $url" + rm -f "$temp_file" + return 1 + fi + + # Verify checksum if provided + if [ -n "$expected_checksum" ]; then + local actual_checksum + actual_checksum=$(sha256sum "$temp_file" | awk '{print $1}') + if [ "$actual_checksum" != "$expected_checksum" ]; then + log_error "Checksum mismatch for $url" + log_error "Expected: $expected_checksum" + log_error "Got: $actual_checksum" + rm -f "$temp_file" + return 1 + fi + fi + + # Move to final location + if [[ "$output" == /usr/* ]]; then + sudo mv "$temp_file" "$output" + sudo chmod +x "$output" + else + mv "$temp_file" "$output" + chmod +x "$output" + fi + + return 0 +} + # ============================================================================ # macOS Host Mode # ============================================================================ @@ -87,42 +201,87 @@ if [[ "$(uname -s)" == "Darwin" ]]; then # VM name from argument or default VM_NAME="${1:-dev-sandbox}" + # Security: Validate VM name (Issue #6) + if ! validate_vm_name "$VM_NAME"; then + exit 1 + fi + # Locate config.env relative to this script SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG_FILE="$SCRIPT_DIR/config.env" + # Security: Load config safely without executing code (Issue #2) + # Instead of sourcing, parse key=value pairs manually + load_config_safely() { + local config_file="$1" + while IFS='=' read -r key value || [ -n "$key" ]; do + # Skip empty lines and comments + [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue + # Remove leading/trailing whitespace from key + key=$(echo "$key" | xargs) + # Remove surrounding quotes from value + value=$(echo "$value" | sed 's/^"//;s/"$//;s/^'"'"'//;s/'"'"'$//') + case "$key" in + GIT_NAME) GIT_NAME="$value" ;; + GIT_EMAIL) GIT_EMAIL="$value" ;; + # VNC_PASSWORD intentionally not stored - prompted each time for security + esac + done < "$config_file" + } + # Create or load config if [ -f "$CONFIG_FILE" ]; then - source "$CONFIG_FILE" + load_config_safely "$CONFIG_FILE" echo -e "${YELLOW}Using saved config from: $CONFIG_FILE${NC}" echo " Name: $GIT_NAME" echo " Email: $GIT_EMAIL" - echo " VNC Pass: ******" echo "" else echo "First run — creating config file for shared credentials." echo "" - read -rp "Enter your name for git config: " GIT_NAME - read -rp "Enter your email for git config: " GIT_EMAIL + + # Security: Validate git name (Issue #5, #10, #11) while true; do - read -rsp "Enter VNC password (min 6 chars): " VNC_PASSWORD - echo - if [ ${#VNC_PASSWORD} -ge 6 ]; then + read -rp "Git commit author name: " GIT_NAME + if [ -z "$GIT_NAME" ]; then + echo "Name is required." + continue + fi + if [ ${#GIT_NAME} -gt 256 ]; then + echo "Name must be 256 characters or less." + continue + fi + if validate_safe_input "$GIT_NAME" "Name"; then break fi - echo "Password must be at least 6 characters." done - if [ -z "$GIT_NAME" ] || [ -z "$GIT_EMAIL" ]; then - log_error "Name and email are required." - exit 1 - fi + # Security: Validate git email (Issue #5, #10, #11) + while true; do + read -rp "Git commit author email: " GIT_EMAIL + if [ -z "$GIT_EMAIL" ]; then + echo "Email is required." + continue + fi + if [ ${#GIT_EMAIL} -gt 256 ]; then + echo "Email must be 256 characters or less." + continue + fi + if ! [[ "$GIT_EMAIL" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; then + echo "Please enter a valid email address." + continue + fi + if validate_safe_input "$GIT_EMAIL" "Email"; then + break + fi + done - cat > "$CONFIG_FILE" < "$CONFIG_FILE" chmod 600 "$CONFIG_FILE" echo "" echo "Config saved to: $CONFIG_FILE" @@ -260,6 +419,27 @@ EOF fi echo "" + # Security: Prompt for VNC password only when VNC is selected (Issue #4) + # Password is never stored to disk - prompted fresh each time + VNC_PASSWORD="" + VNC_INDEX=-1 + for ((i=0; i/dev/null | grep -qw "$VM_NAME"; then log_error "VM '$VM_NAME' already exists." @@ -294,7 +474,14 @@ EOF fi echo -e "${YELLOW}Running provisioning inside VM (this will take a while)...${NC}" echo "" - orb run -m "$VM_NAME" bash -c "${SKIP_EXPORTS}export GIT_NAME='$GIT_NAME'; export GIT_EMAIL='$GIT_EMAIL'; export VNC_PASSWORD='$VNC_PASSWORD'; bash /tmp/setup_env.sh --non-interactive" + + # Security: Use base64 encoding to safely pass values to VM (Issue #1) + # This prevents any shell injection through special characters + GIT_NAME_B64=$(printf '%s' "$GIT_NAME" | base64) + GIT_EMAIL_B64=$(printf '%s' "$GIT_EMAIL" | base64) + VNC_PASSWORD_B64=$(printf '%s' "$VNC_PASSWORD" | base64) + + orb run -m "$VM_NAME" bash -c "${SKIP_EXPORTS}export GIT_NAME=\$(echo '$GIT_NAME_B64' | base64 -d); export GIT_EMAIL=\$(echo '$GIT_EMAIL_B64' | base64 -d); export VNC_PASSWORD=\$(echo '$VNC_PASSWORD_B64' | base64 -d); bash /tmp/setup_env.sh --non-interactive" echo "" echo -e "${GREEN}============================================================================${NC}" @@ -326,9 +513,18 @@ fi # Linux VM Mode (everything below runs inside the VM) # ============================================================================ +# Security: Create log file with restrictive permissions (Issue #13) LOG_FILE="/tmp/setup_env_$(date +%Y%m%d_%H%M%S).log" -LOG_DIR="/tmp/setup_env_steps_$$" -mkdir -p "$LOG_DIR" +touch "$LOG_FILE" +chmod 600 "$LOG_FILE" + +# Security: Use mktemp for unpredictable temp directory (Issue #8, #9) +LOG_DIR=$(mktemp -d "/tmp/setup_env_steps.XXXXXXXXXX") +if [ ! -d "$LOG_DIR" ]; then + log_error "Failed to create secure temp directory" + exit 1 +fi +chmod 700 "$LOG_DIR" # Prevent mise from reading host config via OrbStack mount (/Users/john/.mise.toml) export MISE_GLOBAL_CONFIG_FILE="$HOME/.config/mise/config.toml" @@ -629,7 +825,7 @@ cleanup() { fi } -trap cleanup EXIT +# Note: trap is set later to include both cleanup and stop_sudo_keepalive # ============================================================================ # Step functions (each is a self-contained install) @@ -658,7 +854,12 @@ install_postgresql() { [ -z "$PG_HBA" ] || [ -z "$PG_CONF" ] && return 1 - sudo sed -i 's/^host\s\+all\s\+all\s\+127\.0\.0\.1\/32\s\+scram-sha-256$/host all all 127.0.0.1\/32 trust/' "$PG_HBA" + # Security: Use 'peer' for local connections (authenticates via OS user) + # This is more secure than 'trust' while still convenient for development + # Users connect via: psql -U $USER dbname (no password needed if OS user matches DB user) + sudo sed -i 's/^local\s\+all\s\+all\s\+peer$/local all all peer/' "$PG_HBA" + # For TCP localhost, keep scram-sha-256 (requires password) instead of trust + # This prevents any local process from accessing the DB without credentials if ! sudo grep -q "# OrbStack host access" "$PG_HBA"; then echo "# OrbStack host access" | sudo tee -a "$PG_HBA" > /dev/null @@ -678,7 +879,30 @@ install_mise() { echo "mise already installed" return 0 fi - curl -fsSL https://mise.run | sh + + # Security: Download script first, validate, then execute (Issue #3) + # This allows for inspection and avoids direct pipe to shell + local mise_script + mise_script=$(mktemp) + curl -fsSL https://mise.run -o "$mise_script" + + # Verify it's a shell script with multiple checks + if ! head -1 "$mise_script" | grep -qE '^#!\s*/(bin|usr/bin)/(env\s+)?(ba)?sh'; then + log_error "Downloaded mise script doesn't have a valid shell shebang" + rm -f "$mise_script" + return 1 + fi + # Check file size is reasonable (not empty, not huge) + local script_size + script_size=$(wc -c < "$mise_script") + if [ "$script_size" -lt 100 ] || [ "$script_size" -gt 1000000 ]; then + log_error "Downloaded mise script has suspicious size: $script_size bytes" + rm -f "$mise_script" + return 1 + fi + + sh "$mise_script" + rm -f "$mise_script" # Add to bashrc if ! grep -q 'mise activate bash' ~/.bashrc; then @@ -726,11 +950,11 @@ install_chromium() { sudo apt-get install -y -q chromium --no-install-recommends 2>/dev/null || true fi - # Symlink if installed + # Symlink if installed (check for existing file, not just symlink) local bin="" command_exists chromium-browser && bin=$(which chromium-browser) command_exists chromium && bin=$(which chromium) - if [ -n "$bin" ] && [ ! -L /usr/bin/google-chrome ]; then + if [ -n "$bin" ] && [ ! -e /usr/bin/google-chrome ]; then sudo ln -sf "$bin" /usr/bin/google-chrome fi } @@ -738,8 +962,17 @@ install_chromium() { install_vnc() { sudo apt-get install -y -q tigervnc-standalone-server xfce4 xfce4-terminal dbus-x11 + # Create .vnc directory with secure permissions mkdir -p ~/.vnc - echo "$VNC_PASSWORD" | vncpasswd -f > ~/.vnc/passwd + chmod 700 ~/.vnc + + # Security: Create password file atomically with correct permissions + # Use umask to ensure file is created with 600 permissions from the start + ( + umask 077 + echo "$VNC_PASSWORD" | vncpasswd -f > ~/.vnc/passwd + ) + # Ensure permissions are correct (belt and suspenders) chmod 600 ~/.vnc/passwd cat > ~/.vnc/xstartup <<'XEOF' @@ -754,6 +987,10 @@ XEOF cat > ~/bin/vnc-start <<'VEOF' #!/bin/bash +# Security note: VNC listens on all interfaces (-localhost no) to allow +# connections from the macOS host via OrbStack's network. The VM is only +# accessible from the host machine, not external networks. +# VNC is password-protected (set during setup). VNC_HOST="$(hostname -s).orb.local" if pgrep -f "Xtigervnc :1" > /dev/null 2>&1; then echo "VNC is already running on display :1" @@ -782,24 +1019,49 @@ install_ollama() { echo "Ollama already installed" return 0 fi - curl -fsSL https://ollama.com/install.sh | sudo sh + + # Security: Download script first, validate, then execute with sudo (Issue #3) + local ollama_script + ollama_script=$(mktemp) + curl -fsSL https://ollama.com/install.sh -o "$ollama_script" + + # Verify it's a shell script with multiple checks + if ! head -1 "$ollama_script" | grep -qE '^#!\s*/(bin|usr/bin)/(env\s+)?(ba)?sh'; then + log_error "Downloaded Ollama script doesn't have a valid shell shebang" + rm -f "$ollama_script" + return 1 + fi + # Check file size is reasonable (not empty, not huge) + local script_size + script_size=$(wc -c < "$ollama_script") + if [ "$script_size" -lt 100 ] || [ "$script_size" -gt 1000000 ]; then + log_error "Downloaded Ollama script has suspicious size: $script_size bytes" + rm -f "$ollama_script" + return 1 + fi + + sudo sh "$ollama_script" + rm -f "$ollama_script" } install_claude() { export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" - npm install -g @anthropic-ai/claude-code + # Security: Pin to specific version to prevent supply chain attacks + npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" } install_playwright() { export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" - npx --yes playwright install --with-deps chromium + # Security: Install specific version globally, then use it (avoids npx auto-download) + npm install -g "playwright@${PLAYWRIGHT_VERSION}" + playwright install --with-deps chromium - # Symlink Playwright's Chromium if no system one + # Symlink Playwright's Chromium if no system one (check for existing files) if ! command_exists chromium-browser && ! command_exists chromium; then local pw_chrome=$(find "$HOME/.cache/ms-playwright" -name "chrome" -type f 2>/dev/null | head -1) if [ -n "$pw_chrome" ]; then - sudo ln -sf "$pw_chrome" /usr/local/bin/chromium - sudo ln -sf "$pw_chrome" /usr/local/bin/google-chrome + [ ! -e /usr/local/bin/chromium ] && sudo ln -sf "$pw_chrome" /usr/local/bin/chromium + [ ! -e /usr/local/bin/google-chrome ] && sudo ln -sf "$pw_chrome" /usr/local/bin/google-chrome echo "Using Playwright's bundled Chromium" fi fi @@ -838,20 +1100,79 @@ install_base_deps() { python3 python3-pip python3-venv # fd symlink (fd-find installs as 'fdfind', symlink to 'fd') - if [ ! -L /usr/local/bin/fd ] && command_exists fdfind; then + # Check for existing file, not just symlink + if [ ! -e /usr/local/bin/fd ] && command_exists fdfind; then sudo ln -sf "$(which fdfind)" /usr/local/bin/fd fi - # yq + # yq - with architecture detection, version pinning, and checksum verification if ! command_exists yq; then - sudo curl -fsSL "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_arm64" -o /usr/local/bin/yq - sudo chmod +x /usr/local/bin/yq + local yq_arch + local arch + arch=$(detect_architecture) + case "$arch" in + amd64) yq_arch="amd64" ;; + arm64) yq_arch="arm64" ;; + esac + local yq_binary="yq_linux_${yq_arch}" + local yq_url="https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/${yq_binary}" + local yq_checksum_url="https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/checksums" + + # Fetch and verify checksum + local temp_dir + temp_dir=$(mktemp -d) + curl -fsSL "$yq_checksum_url" -o "$temp_dir/checksums" + local expected_checksum + expected_checksum=$(grep "${yq_binary}$" "$temp_dir/checksums" | awk '{print $1}') + + if [ -n "$expected_checksum" ]; then + download_verified_binary "$yq_url" "/usr/local/bin/yq" "$expected_checksum" + else + log_error "Could not find checksum for yq, downloading without verification" + download_verified_binary "$yq_url" "/usr/local/bin/yq" + fi + rm -rf "$temp_dir" fi - # watchexec + # watchexec - with architecture detection, version pinning, and checksum verification if ! command_exists watchexec; then - curl -fsSL "https://github.com/watchexec/watchexec/releases/download/v2.3.2/watchexec-2.3.2-aarch64-unknown-linux-gnu.tar.xz" | \ - sudo tar -xJ --strip-components=1 -C /usr/local/bin/ --wildcards '*/watchexec' + local watchexec_arch + local arch + arch=$(detect_architecture) + case "$arch" in + amd64) watchexec_arch="x86_64-unknown-linux-gnu" ;; + arm64) watchexec_arch="aarch64-unknown-linux-gnu" ;; + esac + local watchexec_tarball="watchexec-${WATCHEXEC_VERSION}-${watchexec_arch}.tar.xz" + local watchexec_url="https://github.com/watchexec/watchexec/releases/download/v${WATCHEXEC_VERSION}/${watchexec_tarball}" + local watchexec_checksum_url="https://github.com/watchexec/watchexec/releases/download/v${WATCHEXEC_VERSION}/SHA512SUMS" + + local temp_dir + temp_dir=$(mktemp -d) + + # Fetch checksum file + curl -fsSL "$watchexec_checksum_url" -o "$temp_dir/SHA512SUMS" 2>/dev/null || true + local expected_checksum + expected_checksum=$(grep "${watchexec_tarball}$" "$temp_dir/SHA512SUMS" 2>/dev/null | awk '{print $1}') + + if curl -fsSL "$watchexec_url" -o "$temp_dir/watchexec.tar.xz"; then + # Verify checksum if available (SHA512 for watchexec) + if [ -n "$expected_checksum" ]; then + local actual_checksum + actual_checksum=$(sha512sum "$temp_dir/watchexec.tar.xz" | awk '{print $1}') + if [ "$actual_checksum" != "$expected_checksum" ]; then + log_error "Checksum mismatch for watchexec" + rm -rf "$temp_dir" + return 1 + fi + fi + tar -xJf "$temp_dir/watchexec.tar.xz" -C "$temp_dir" --strip-components=1 + sudo mv "$temp_dir/watchexec" /usr/local/bin/watchexec + sudo chmod +x /usr/local/bin/watchexec + else + log_error "Failed to download watchexec" + fi + rm -rf "$temp_dir" fi # direnv hook @@ -875,35 +1196,55 @@ if ! command_exists sudo; then exit 1 fi +# Security: Sudo keepalive - prevent sudo timeout during long operations +# Runs in background and refreshes sudo timestamp every 50 seconds +SUDO_KEEPALIVE_PID="" +start_sudo_keepalive() { + # In non-interactive mode (VM), sudo should be passwordless - skip keepalive + if [ "$NON_INTERACTIVE" = true ]; then + return 0 + fi + + # Validate sudo access first (allow password prompt to show) + if ! sudo -v; then + log_error "Failed to obtain sudo privileges" + return 1 + fi + + # Start background process to refresh sudo timestamp + ( + while true; do + sudo -n true 2>/dev/null + sleep 50 + done + ) & + SUDO_KEEPALIVE_PID=$! +} + +stop_sudo_keepalive() { + if [ -n "$SUDO_KEEPALIVE_PID" ] && kill -0 "$SUDO_KEEPALIVE_PID" 2>/dev/null; then + kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true + wait "$SUDO_KEEPALIVE_PID" 2>/dev/null || true + fi +} + +# Ensure keepalive is stopped on exit +# Update trap to include both cleanup functions +trap 'stop_sudo_keepalive; cleanup' EXIT + # ============================================================================ -# Git configuration prompts +# Git configuration prompts (VM-side, non-interactive mode only) # ============================================================================ if [ "$NON_INTERACTIVE" = true ]; then if [ -z "${GIT_NAME:-}" ] || [ -z "${GIT_EMAIL:-}" ]; then log_error "In non-interactive mode, GIT_NAME and GIT_EMAIL env vars are required." exit 1 fi - if [ -z "${VNC_PASSWORD:-}" ]; then - log_error "In non-interactive mode, VNC_PASSWORD env var is required (min 6 chars)." + # Only require VNC_PASSWORD if VNC is being installed + if [ -z "${SKIP_VNC:-}" ] && [ -z "${VNC_PASSWORD:-}" ]; then + log_error "In non-interactive mode, VNC_PASSWORD env var is required when VNC is selected (min 6 chars)." exit 1 fi -else - read -rp "Enter your name for git config: " GIT_NAME - read -rp "Enter your email for git config: " GIT_EMAIL - - if [ -z "$GIT_NAME" ] || [ -z "$GIT_EMAIL" ]; then - log_error "Name and email are required." - exit 1 - fi - - while true; do - read -rsp "Enter VNC password (min 6 chars): " VNC_PASSWORD - echo - if [ ${#VNC_PASSWORD} -ge 6 ]; then - break - fi - echo "Password must be at least 6 characters." - done fi # ============================================================================ @@ -956,6 +1297,11 @@ echo "" draw_dashboard echo "" +# ============================================================================ +# Start sudo keepalive to prevent timeout during long operations +# ============================================================================ +start_sudo_keepalive + # ============================================================================ # Phase 1: Base dependencies (sequential, everything depends on this) # ============================================================================