621e8895c9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
262 lines
7.6 KiB
Go
262 lines
7.6 KiB
Go
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
|
|
}
|