298 lines
9.0 KiB
Go
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)
|
|
}
|
|
}
|