Files
device_agent/capability_shell_eval_test.go
2026-05-30 17:28:38 +02:00

298 lines
9.0 KiB
Go

package main
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// setupTestEnv crea un dir temporal, redirige approvalQueuePath alli, y devuelve cleanup.
func setupTestEnv(t *testing.T) (string, func()) {
t.Helper()
tmp, err := os.MkdirTemp("", "device_agent_test_*")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
prevWD, _ := os.Getwd()
if err := os.Chdir(tmp); err != nil {
_ = os.RemoveAll(tmp)
t.Fatalf("chdir: %v", err)
}
prevApprovalPath := approvalQueuePath
approvalQueuePath = filepath.Join(tmp, "local_files", "approval_queue.jsonl")
cleanup := func() {
approvalQueuePath = prevApprovalPath
_ = os.Chdir(prevWD)
_ = os.RemoveAll(tmp)
}
return tmp, cleanup
}
// TestShellEval_BlocklistRmRf comprueba que rm -rf / es rechazado sin ejecucion.
func TestShellEval_BlocklistRmRf(t *testing.T) {
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{Name: "shell.eval"}
_, _, _, err := runShellEval(cap, map[string]any{"cmd": "rm -rf /"})
if err == nil {
t.Fatal("expected error from blocklist, got nil")
}
if !strings.Contains(err.Error(), "blocked") {
t.Fatalf("expected 'blocked' in err, got: %v", err)
}
}
// TestShellEval_BlocklistCaseInsensitive comprueba que case-insensitive funciona.
func TestShellEval_BlocklistCaseInsensitive(t *testing.T) {
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{Name: "shell.eval"}
_, _, _, err := runShellEval(cap, map[string]any{"cmd": "RM -RF /"})
if err == nil {
t.Fatal("expected error from blocklist (case insensitive), got nil")
}
if !strings.Contains(err.Error(), "blocked") {
t.Fatalf("expected 'blocked' in err, got: %v", err)
}
}
// TestShellEval_AutoApproveGitStatus comprueba que git status entra en defaultAutoApprove.
func TestShellEval_AutoApproveGitStatus(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{Name: "shell.eval"}
res, exit, extra, err := runShellEval(cap, map[string]any{"cmd": "git status --porcelain"})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if res["approval_status"] != "auto-approved" {
t.Fatalf("expected approval_status=auto-approved, got: %v", res["approval_status"])
}
// git might exit non-zero if not in a repo, but it should have *executed*
if extra == nil {
t.Fatal("expected extra audit record, got nil")
}
if extra.Cmd != "git status --porcelain" {
t.Fatalf("extra.Cmd mismatch: %q", extra.Cmd)
}
_ = exit
}
// TestShellEval_QueuedWhenRequiresApproval comprueba que un cmd no auto + requires_approval = queued + error.
func TestShellEval_QueuedWhenRequiresApproval(t *testing.T) {
tmp, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{
Name: "shell.eval",
RequiresApproval: true,
AutoApprove: []string{`^echo\s`}, // override defaults — solo echo entra
}
_, _, _, err := runShellEval(cap, map[string]any{"cmd": "ls -la"})
if err == nil {
t.Fatal("expected approval_required error, got nil")
}
if !strings.Contains(err.Error(), "approval_required") {
t.Fatalf("expected 'approval_required' in err, got: %v", err)
}
// queue file existe + contiene la entry
qPath := filepath.Join(tmp, "local_files", "approval_queue.jsonl")
data, ferr := os.ReadFile(qPath)
if ferr != nil {
t.Fatalf("expected approval queue file, got: %v", ferr)
}
if !strings.Contains(string(data), `"cmd":"ls -la"`) {
t.Fatalf("approval queue missing cmd, got: %s", data)
}
if !strings.Contains(string(data), `"status":"pending"`) {
t.Fatalf("approval queue missing status=pending, got: %s", data)
}
}
// TestShellEval_NoApprovalNeededExecutes: cmd no auto + no requires_approval -> ejecuta directo.
func TestShellEval_NoApprovalNeededExecutes(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{
Name: "shell.eval",
RequiresApproval: false,
AutoApprove: []string{`^echo\s`}, // strict autoapprove (irrelevant since no approval needed)
}
res, _, extra, err := runShellEval(cap, map[string]any{"cmd": "printf hello"})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if res["approval_status"] != "none-required" {
t.Fatalf("expected approval_status=none-required, got: %v", res["approval_status"])
}
if extra.Stdout != "hello" {
t.Fatalf("expected stdout 'hello', got: %q", extra.Stdout)
}
}
// TestShellEval_Timeout: sleep 5 con TimeoutSeconds=1 -> exit_code != 0.
func TestShellEval_Timeout(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{
Name: "shell.eval",
TimeoutSeconds: 1,
AutoApprove: []string{`^sleep\s`}, // autoapprove sleep for the test
}
res, _, _, err := runShellEval(cap, map[string]any{"cmd": "sleep 5"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
exit, _ := res["exit_code"].(int)
if exit == 0 {
t.Fatalf("expected non-zero exit on timeout, got 0; result: %+v", res)
}
}
// TestShellEval_OutputTruncation: output > MaxOutputBytes -> truncated=true.
func TestShellEval_OutputTruncation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{
Name: "shell.eval",
MaxOutputBytes: 50,
AutoApprove: []string{`^printf`},
}
// produce > 50 bytes
res, _, _, err := runShellEval(cap, map[string]any{
"cmd": `printf '%0.s-' {1..500}`,
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if res["truncated"] != true {
t.Fatalf("expected truncated=true, got: %v (full result %+v)", res["truncated"], res)
}
}
// TestShellEval_PowershellOnLinuxWithoutPwsh: si pwsh no esta en PATH y se pide powershell, error.
func TestShellEval_PowershellOnLinuxWithoutPwsh(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test only meaningful on non-windows")
}
// Save PATH and clear it to ensure pwsh not found
origPATH := os.Getenv("PATH")
defer os.Setenv("PATH", origPATH)
os.Setenv("PATH", "/nonexistent")
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{Name: "shell.eval"}
_, _, _, err := runShellEval(cap, map[string]any{
"cmd": "echo hi",
"shell": "powershell",
})
if err == nil {
t.Fatal("expected error when powershell requested on linux without pwsh")
}
if !strings.Contains(err.Error(), "powershell") && !strings.Contains(err.Error(), "pwsh") {
t.Fatalf("err msg doesn't mention powershell/pwsh: %v", err)
}
}
// TestShellEval_AuditVerboseWritesCleartext: tras una exec, audit_shell_eval tiene row con cmd en claro.
func TestShellEval_AuditVerboseWritesCleartext(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
tmp, cleanup := setupTestEnv(t)
defer cleanup()
auditPath := filepath.Join(tmp, "audit.db")
audit, err := OpenAudit(auditPath)
if err != nil {
t.Fatalf("open audit: %v", err)
}
defer audit.Close()
cap := &Capability{
Name: "shell.eval",
AutoApprove: []string{`^printf`},
}
_, exitCode, extra, err := runShellEval(cap, map[string]any{"cmd": "printf forensic-cleartext"})
if err != nil {
t.Fatalf("runShellEval: %v", err)
}
if extra == nil {
t.Fatal("expected extra record")
}
_, err = audit.AppendVerbose("req-1", "shell.eval", []string{"printf forensic-cleartext"}, exitCode, *extra)
if err != nil {
t.Fatalf("AppendVerbose: %v", err)
}
// Read back
rows, err := audit.db.Query(`SELECT cmd, shell, stdout_b64 FROM audit_shell_eval`)
if err != nil {
t.Fatalf("query: %v", err)
}
defer rows.Close()
found := false
for rows.Next() {
var cmd, shell, stdoutB64 string
if err := rows.Scan(&cmd, &shell, &stdoutB64); err != nil {
t.Fatalf("scan: %v", err)
}
if cmd != "printf forensic-cleartext" {
t.Fatalf("expected cleartext cmd, got: %q", cmd)
}
if !strings.HasPrefix(stdoutB64, "plain:") {
t.Fatalf("expected plain-base64 stdout (short), got: %q", stdoutB64)
}
// Decode + verify
raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(stdoutB64, "plain:"))
if string(raw) != "forensic-cleartext" {
t.Fatalf("decoded stdout mismatch: %q", raw)
}
_ = shell
found = true
}
if !found {
t.Fatal("expected at least one row in audit_shell_eval")
}
}
// TestShellEval_ParseArgsJSONObject roundtrip.
func TestShellEval_ParseArgsJSONObject(t *testing.T) {
jsonStr, _ := json.Marshal(map[string]any{"cmd": "ls", "cwd": "/tmp"})
m, err := parseShellEvalArgs([]string{string(jsonStr)})
if err != nil {
t.Fatalf("parse: %v", err)
}
if m["cmd"] != "ls" || m["cwd"] != "/tmp" {
t.Fatalf("unexpected parsed: %+v", m)
}
}
// TestShellEval_ParseArgsPositional fallback.
func TestShellEval_ParseArgsPositional(t *testing.T) {
m, err := parseShellEvalArgs([]string{"ls -la", "/tmp", "bash"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if m["cmd"] != "ls -la" || m["cwd"] != "/tmp" || m["shell"] != "bash" {
t.Fatalf("unexpected parsed: %+v", m)
}
}