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") } }