Files
2026-09-27 16:04:02 -06:00

172 lines
6.9 KiB
Go

package tools
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAskSecretAndPlaceholders(t *testing.T) {
r, env, dir := newEnv(t, "EXECUTION_MODE=privileged")
env.Secrets = NewSecretStore()
// Modo tareas: sin operador no se puede pedir.
if res := call(t, r, "ask_secret", map[string]any{"name": "db", "prompt": "pw"}); res.OK || !strings.Contains(res.Output, "no interactive operator") {
t.Fatalf("task mode: %+v", res)
}
var asked string
env.Prompt = func(_ context.Context, prompt string) (string, error) {
asked = prompt
return `s3cr3t'; rm -rf / #`, nil
}
res := call(t, r, "ask_secret", map[string]any{"name": "db", "prompt": "Password of admin on db01"})
if !res.OK || !strings.Contains(res.Output, "{{secret:db}}") || strings.Contains(res.Output, "s3cr3t") {
t.Fatalf("%+v", res)
}
if !strings.Contains(asked, "Password of admin on db01") {
t.Errorf("prompt = %q", asked)
}
// Segunda petición: no se vuelve a preguntar.
asked = ""
call(t, r, "ask_secret", map[string]any{"name": "db", "prompt": "x"})
if asked != "" {
t.Error("known secret must not be asked again")
}
// exec: el valor llega al proceso, pero la salida muestra el marcador.
res = call(t, r, "exec", map[string]any{"command": "printf", "args": []string{"%s", "{{secret:db}}"}})
if !res.OK || strings.Contains(res.Output, "s3cr3t") || !strings.Contains(res.Output, "{{secret:db}}") {
t.Errorf("exec output must mask the secret: %s", res.Output)
}
// shell: se pasa por variable de entorno; las comillas del valor no rompen
// ni inyectan nada en el script.
res = call(t, r, "shell", map[string]any{"script": `printf '%s' "{{secret:db}}" > out.txt; echo written`})
if !res.OK {
t.Fatalf("shell: %+v", res)
}
if data, _ := os.ReadFile(filepath.Join(dir, "out.txt")); string(data) != `s3cr3t'; rm -rf / #` {
t.Errorf("shell received %q", data)
}
if strings.Contains(res.Output, "s3cr3t") {
t.Error("shell output leaked the secret")
}
// Archivos: se escribe el valor real; el diff mostrado lo enmascara.
os.WriteFile(filepath.Join(dir, "app.conf"), []byte("password=old\n"), 0o600)
res = call(t, r, "edit_file", map[string]any{"path": "app.conf", "old_string": "password=old", "new_string": "password={{secret:db}}"})
if !res.OK {
t.Fatalf("%+v", res)
}
if data, _ := os.ReadFile(filepath.Join(dir, "app.conf")); !strings.Contains(string(data), "s3cr3t'") {
t.Errorf("file content = %q", data)
}
if strings.Contains(res.Output, "s3cr3t") || !strings.Contains(res.Output, "+password={{secret:db}}") {
t.Errorf("diff must mask the secret: %s", res.Output)
}
// El marcador funciona en cualquier contexto de comillas.
for _, script := range []string{
`printf %s {{secret:db}} > q.txt`,
`printf %s "{{secret:db}}" > q.txt`,
`printf %s '{{secret:db}}' > q.txt`,
`printf %s 'pre-{{secret:db}}-post' | sed 's/^pre-//; s/-post$//' > q.txt`,
`printf %s "$(printf %s '{{secret:db}}')" > q.txt`,
"printf %s \"`printf %s '{{secret:db}}'`\" > q.txt",
`x="$(echo "$(printf %s {{secret:db}})")"; printf %s "$x" > q.txt`,
} {
res = call(t, r, "shell", map[string]any{"script": script})
data, _ := os.ReadFile(filepath.Join(dir, "q.txt"))
if !res.OK || string(data) != `s3cr3t'; rm -rf / #` {
t.Errorf("%s: got %q (%s)", script, data, res.Summary)
}
}
// Marcador desconocido: error, nunca se usa el texto literal.
if res := call(t, r, "exec", map[string]any{"command": "echo", "args": []string{"{{secret:nope}}"}}); res.OK {
t.Error("unknown placeholder must fail")
}
logData, _ := os.ReadFile(filepath.Join(dir, "agent.log"))
if strings.Contains(string(logData), "s3cr3t") {
t.Error("secret leaked into agent.log")
}
}
func TestAskSecretErrors(t *testing.T) {
r, env, _ := newEnv(t, "")
env.Secrets = NewSecretStore()
env.Prompt = func(context.Context, string) (string, error) { return "", errors.New("secret input cancelled") }
if res := call(t, r, "ask_secret", map[string]any{"name": "bad name!", "prompt": "x"}); res.OK {
t.Error("invalid name must fail")
}
if res := call(t, r, "ask_secret", map[string]any{"name": "x", "prompt": "x"}); res.OK {
t.Error("cancelled input must fail")
}
}
func TestTransientRetry(t *testing.T) {
old := retryScale
retryScale = 0
defer func() { retryScale = old }()
r, _, dir := newEnv(t, "EXECUTION_MODE=privileged")
script := `n=$(cat count 2>/dev/null || echo 0); n=$((n+1)); echo $n > count
if [ "$n" -lt 3 ]; then echo "E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 123" >&2; exit 100; fi
echo installed`
res := call(t, r, "shell", map[string]any{"script": script})
if !res.OK || !strings.Contains(res.Output, "installed") || !strings.Contains(res.Output, "retry 2/3") {
t.Fatalf("%+v", res.Output)
}
data, _ := os.ReadFile(filepath.Join(dir, "count"))
if strings.TrimSpace(string(data)) != "3" {
t.Errorf("attempts = %s", data)
}
// Un error permanente no se reintenta.
os.Remove(filepath.Join(dir, "count"))
res = call(t, r, "shell", map[string]any{"script": `n=$(cat count 2>/dev/null || echo 0); echo $((n+1)) > count; echo "E: Unable to locate package foo" >&2; exit 100`})
data, _ = os.ReadFile(filepath.Join(dir, "count"))
if res.OK || strings.TrimSpace(string(data)) != "1" {
t.Errorf("permanent error retried: attempts=%s", data)
}
// Límite de reintentos.
res = call(t, r, "shell", map[string]any{"script": `echo "Temporary failure in name resolution" >&2; exit 2`})
if res.OK || !strings.Contains(res.Output, "retry 2/2") || strings.Contains(res.Output, "retry 3/") {
t.Errorf("retry limit: %s", res.Output)
}
}
func TestPermissionDeniedHint(t *testing.T) {
r, _, _ := newEnv(t, "EXECUTION_MODE=privileged")
res := call(t, r, "shell", map[string]any{"script": `echo "open /etc/shadow: Permission denied" >&2; exit 1`})
if !strings.Contains(res.Output, "hint:") {
t.Errorf("missing escalation hint: %s", res.Output)
}
res = call(t, r, "exec", map[string]any{"command": "false"})
if strings.Contains(res.Output, "hint:") {
t.Error("hint must appear only for permission errors")
}
}
func TestWriteRecordsDiff(t *testing.T) {
r, _, dir := newEnv(t, "")
os.WriteFile(filepath.Join(dir, "sshd_config"), []byte("Port 22\nPermitRootLogin yes\n"), 0o600)
res := call(t, r, "edit_file", map[string]any{"path": "sshd_config", "old_string": "PermitRootLogin yes", "new_string": "PermitRootLogin no", "reason": "hardening"})
if !res.OK || !strings.Contains(res.Diff, "-PermitRootLogin yes\n+PermitRootLogin no") || !strings.Contains(res.Output, "change:") {
t.Fatalf("%+v", res)
}
logData, _ := os.ReadFile(filepath.Join(dir, "agent.log"))
if !strings.Contains(string(logData), `diff=`) || !strings.Contains(string(logData), `reason="hardening"`) {
t.Errorf("agent.log must record diff and reason:\n%s", logData)
}
// Archivo nuevo: sin diff.
res = call(t, r, "write_file", map[string]any{"path": "new.txt", "content": "x\n"})
if res.Diff != "" {
t.Error("new files have no previous value")
}
}