Files
fn_registry/functions/infra/shell_exec_whitelist.go
T
egutierrez 621e8895c9 feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:15 +02:00

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
}