package infra import ( "bytes" "context" "fmt" "os" "os/exec" "os/user" "path/filepath" "strconv" "strings" "syscall" "time" ) // ShellExecOpts configura la ejecucion de un comando shell con whitelist de binarios. type ShellExecOpts struct { // Cmd es el argv completo. Cmd[0] es el binario (absoluto o nombre en PATH). Cmd []string // BinariesAllowed es la whitelist de binarios permitidos. // EMPTY = rechaza todo (defense in depth). Obligatorio. BinariesAllowed []string // Env son variables de entorno KEY=VAL adicionales. // Si vacio, se usa un entorno minimo: PATH=/usr/bin:/bin, HOME, USER, LANG. Env []string // WorkingDir es el directorio de trabajo. Si vacio usa HOME del usuario actual. WorkingDir string // TimeoutSeconds es el timeout maximo. Default 30. Hard kill al cumplir. TimeoutSeconds int // StdinPayload es el contenido a pasar como stdin al proceso. StdinPayload []byte // MaxOutputBytes es el limite de stdout+stderr combinado (cada uno). // Default 1 MB. Trunca la salida y activa Truncated=true. MaxOutputBytes int // User es el usuario con el que ejecutar el proceso (requiere uid=0). // Vacio = usuario actual. User string } // ShellExecResult contiene el resultado de la ejecucion shell. type ShellExecResult struct { ExitCode int // Codigo de salida del proceso. Stdout string // Salida estandar capturada (puede estar truncada). Stderr string // Salida de error capturada (puede estar truncada). Duration int64 // Duracion real de ejecucion en milisegundos. Truncated bool // true si stdout o stderr fue truncado por MaxOutputBytes. TimedOut bool // true si el proceso fue matado por timeout. } const ( defaultTimeoutSeconds = 30 defaultMaxOutputBytes = 1 * 1024 * 1024 // 1 MB sigkillWait = time.Second ) // ShellExecWhitelist ejecuta un comando shell con whitelist obligatoria de binarios, // sin shell expansion, timeout context-cancellable con SIGTERM+SIGKILL, // stdout/stderr separados con truncate opcional. // // Validaciones previas al spawn (ninguna hace I/O): // - Cmd vacio → error. // - BinariesAllowed vacio → error (defense in depth; NUNCA pasar [] en prod). // - Cmd[0] debe estar en la whitelist: entry absoluta (/usr/bin/ls) se compara // con el path resolvido de Cmd[0] via exec.LookPath; entry bare name (ls) // se compara con filepath.Base(resolvido). Basta con que una entry haga match. // - User != "" con uid != 0 → error (se necesita root para cambiar usuario). func ShellExecWhitelist(opts ShellExecOpts) (ShellExecResult, error) { // --- Validacion de seguridad (sin I/O) --- if len(opts.Cmd) == 0 { return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: Cmd must not be empty") } if len(opts.BinariesAllowed) == 0 { return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: no binaries whitelisted: refusing exec") } // Resolver el binario real (LookPath solo si no es path absoluto). resolvedBin, err := exec.LookPath(opts.Cmd[0]) if err != nil { return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: binary %q not found in PATH: %w", opts.Cmd[0], err) } baseName := filepath.Base(resolvedBin) inWhitelist := false for _, entry := range opts.BinariesAllowed { if strings.HasPrefix(entry, "/") { // Entry es path absoluto: comparar con el path resolvido. if entry == resolvedBin { inWhitelist = true break } } else { // Entry es bare name: comparar con el basename del resolvido. if entry == baseName { inWhitelist = true break } } } if !inWhitelist { return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: binary %q (resolved: %q) not in whitelist %v", opts.Cmd[0], resolvedBin, opts.BinariesAllowed) } // --- Validacion de user switch --- if opts.User != "" { if os.Getuid() != 0 { return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: need root to switch user to %q", opts.User) } } // --- Defaults --- timeout := opts.TimeoutSeconds if timeout <= 0 { timeout = defaultTimeoutSeconds } maxOut := opts.MaxOutputBytes if maxOut <= 0 { maxOut = defaultMaxOutputBytes } // Working dir workDir := opts.WorkingDir if workDir == "" { if h := os.Getenv("HOME"); h != "" { workDir = h } else { workDir = "/" } } // Env env := opts.Env if len(env) == 0 { lang := os.Getenv("LANG") if lang == "" { lang = "C.UTF-8" } home := os.Getenv("HOME") if home == "" { home = "/" } usr := os.Getenv("USER") if usr == "" { usr = "root" } env = []string{ "PATH=/usr/bin:/bin", "HOME=" + home, "USER=" + usr, "LANG=" + lang, } } // --- Contexto con timeout --- ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // --- Construir comando --- argv := append([]string{resolvedBin}, opts.Cmd[1:]...) cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) //nolint:gosec // whitelist validated above cmd.Env = env cmd.Dir = workDir // SysProcAttr para user switching (solo si root y User != ""). if opts.User != "" { cred, err := buildCredential(opts.User) if err != nil { return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: resolving user %q: %w", opts.User, err) } cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred} } else { // Asegurar que el proceso puede ser matado como grupo. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } // Buffers de captura. var stdoutBuf, stderrBuf bytes.Buffer cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf // Stdin opcional. if len(opts.StdinPayload) > 0 { cmd.Stdin = bytes.NewReader(opts.StdinPayload) } start := time.Now() // --- Ejecucion --- runErr := cmd.Run() duration := time.Since(start).Milliseconds() // Determinar timedOut y exitCode. timedOut := false exitCode := 0 if runErr != nil { if ctx.Err() == context.DeadlineExceeded { timedOut = true // SIGTERM ya fue enviado por exec.CommandContext; esperar 1s y SIGKILL. if cmd.Process != nil { time.Sleep(sigkillWait) _ = cmd.Process.Kill() } } if exitErr, ok := runErr.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } else if !timedOut { // Error de spawn u otro — no es de exit. return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: running %q: %w", opts.Cmd[0], runErr) } } // Truncar salida si supera el limite. truncated := false stdout := stdoutBuf.String() stderr := stderrBuf.String() if len(stdout) > maxOut { stdout = stdout[:maxOut] truncated = true } if len(stderr) > maxOut { stderr = stderr[:maxOut] truncated = true } return ShellExecResult{ ExitCode: exitCode, Stdout: stdout, Stderr: stderr, Duration: duration, Truncated: truncated, TimedOut: timedOut, }, nil } // buildCredential construye un syscall.Credential para el usuario dado. // Acepta nombre de usuario ("www-data") o "uid:gid" ("1000:1000"). func buildCredential(userStr string) (*syscall.Credential, error) { // Intentar formato "uid:gid". if strings.Contains(userStr, ":") { parts := strings.SplitN(userStr, ":", 2) uid, err := strconv.ParseUint(parts[0], 10, 32) if err != nil { return nil, fmt.Errorf("invalid uid %q: %w", parts[0], err) } gid, err := strconv.ParseUint(parts[1], 10, 32) if err != nil { return nil, fmt.Errorf("invalid gid %q: %w", parts[1], err) } return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil } // Nombre de usuario. u, err := user.Lookup(userStr) if err != nil { return nil, fmt.Errorf("user %q not found: %w", userStr, err) } uid, _ := strconv.ParseUint(u.Uid, 10, 32) gid, _ := strconv.ParseUint(u.Gid, 10, 32) return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil }