feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user