307 lines
9.8 KiB
Go
307 lines
9.8 KiB
Go
package policy
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func argvs(s ShellScript) string {
|
|
var parts []string
|
|
for _, c := range s.Commands {
|
|
parts = append(parts, strings.Join(c.Argv, " "))
|
|
}
|
|
return strings.Join(parts, " | ")
|
|
}
|
|
|
|
func TestParseShell(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{`ls -la /etc`, `ls -la /etc`},
|
|
{`cat a | grep "x y" && echo 'z;w'`, `cat a | grep x y | echo z;w`},
|
|
{`FOO=1 BAR=2 env`, `env`},
|
|
{`echo $(rm -rf /tmp/x)`, `rm -rf /tmp/x | echo $(...)`},
|
|
{"echo `whoami`", `whoami | echo $(...)`},
|
|
{`diff <(ls a) <(ls b)`, `ls a | ls b | diff <(...) <(...)`},
|
|
{`if systemctl is-active nginx; then echo ok; fi`, `systemctl is-active nginx | echo ok`},
|
|
{`for f in a b c; do rm "$f"; done`, `rm $f`},
|
|
{"cat <<'EOF' > /etc/x\n$(evil)\nEOF\nls", `cat | ls`},
|
|
{`echo $((1+2))`, `echo $((...))`},
|
|
{`# comment only`, ``},
|
|
{"a \\\n b", `a b`},
|
|
{`[[ -f /x && -d /y ]] && echo yes`, `[[ | echo yes`},
|
|
}
|
|
for _, c := range cases {
|
|
got := argvs(ParseShell(c.in))
|
|
if got != c.want {
|
|
t.Errorf("ParseShell(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseShellRedirects(t *testing.T) {
|
|
s := ParseShell(`echo hi > /etc/motd 2>&1; sort < in.txt >> out.txt; cmd &> /dev/null`)
|
|
var got []string
|
|
for _, c := range s.Commands {
|
|
for _, r := range c.Redirects {
|
|
got = append(got, r.Op+r.Target)
|
|
}
|
|
}
|
|
want := ">/etc/motd >&1 <in.txt >>out.txt &>/dev/null"
|
|
if strings.Join(got, " ") != want {
|
|
t.Errorf("redirects = %q, want %q", strings.Join(got, " "), want)
|
|
}
|
|
}
|
|
|
|
func TestParseShellHeredocUnquotedExpands(t *testing.T) {
|
|
s := ParseShell("cat <<EOF\n$(reboot)\nEOF\n")
|
|
if !strings.Contains(argvs(s), "reboot") {
|
|
t.Errorf("unquoted heredoc substitution not detected: %q", argvs(s))
|
|
}
|
|
}
|
|
|
|
func TestParseShellOpaque(t *testing.T) {
|
|
for _, in := range []string{`echo "unterminated`, `case $x in a) rm -rf /;; esac`, "echo `x"} {
|
|
if len(ParseShell(in).Opaque) == 0 {
|
|
t.Errorf("%q should be opaque", in)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassify(t *testing.T) {
|
|
cases := map[string]Risk{
|
|
"ls -la": RiskReadOnly,
|
|
"systemctl status nginx": RiskReadOnly,
|
|
"systemctl restart nginx": RiskService,
|
|
"systemctl reboot": RiskReboot,
|
|
"sudo systemctl restart nginx": RiskService,
|
|
"sudo -u postgres psql": RiskUnknown,
|
|
"apt list --installed": RiskReadOnly,
|
|
"apt-get install -y nginx": RiskPackage,
|
|
"dpkg -l nginx": RiskReadOnly,
|
|
"dpkg -i x.deb": RiskPackage,
|
|
"ip addr": RiskReadOnly,
|
|
"ip -br a show": RiskReadOnly,
|
|
"ip route add default via 1.2.3.4": RiskAdmin,
|
|
"nft list ruleset": RiskReadOnly,
|
|
"nft add rule inet f c drop": RiskAdmin,
|
|
"iptables -L -n -v": RiskReadOnly,
|
|
"iptables -A INPUT -j DROP": RiskAdmin,
|
|
"rm -rf /tmp/x": RiskDelete,
|
|
"dd if=/dev/zero of=/dev/sda": RiskDestructive,
|
|
"mkfs.ext4 /dev/sdb1": RiskDestructive,
|
|
"reboot": RiskReboot,
|
|
"shutdown -h now": RiskReboot,
|
|
"curl -s https://x": RiskNetwork,
|
|
"sed -n 1,10p f": RiskReadOnly,
|
|
"sed -i s/a/b/ f": RiskWrite,
|
|
"timeout 5 ping -c1 host": RiskNetwork,
|
|
"env A=1 rm x": RiskDelete,
|
|
"python3 -c print(1)": RiskUnknown,
|
|
"nginx -t": RiskReadOnly,
|
|
"nginx -s reload": RiskService,
|
|
"tar -tzf x.tgz": RiskReadOnly,
|
|
"tar -xzf x.tgz": RiskWrite,
|
|
"sysctl net.ipv4.ip_forward": RiskReadOnly,
|
|
"sysctl -w net.ipv4.ip_forward=1": RiskAdmin,
|
|
"useradd bob": RiskAdmin,
|
|
"userdel bob": RiskDestructive,
|
|
"somebinary --version": RiskReadOnly,
|
|
"somebinary --run": RiskUnknown,
|
|
"$CMD x": RiskUnknown,
|
|
"git status": RiskReadOnly,
|
|
"git clone https://x": RiskNetwork,
|
|
"journalctl -u nginx -n 50": RiskReadOnly,
|
|
"journalctl --vacuum-time=2d": RiskDelete,
|
|
}
|
|
for cmd, want := range cases {
|
|
c := Classify(strings.Fields(cmd))
|
|
found := false
|
|
for _, r := range c.AllRisks() {
|
|
if r == want {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Classify(%q) risks = %v, want to include %s", cmd, c.AllRisks(), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassifyNested(t *testing.T) {
|
|
c := Classify([]string{"bash", "-lc", "ls && reboot"})
|
|
if c.ReadOnly() {
|
|
t.Error("bash -c with reboot must not be read-only")
|
|
}
|
|
c = Classify([]string{"find", "/tmp", "-name", "*.log", "-exec", "rm", "{}", ";"})
|
|
if c.ReadOnly() {
|
|
t.Error("find -exec rm must not be read-only")
|
|
}
|
|
c = Classify([]string{"find", "/var/log", "-name", "*.gz"})
|
|
if !c.ReadOnly() {
|
|
t.Errorf("plain find should be read-only: %v", c.AllRisks())
|
|
}
|
|
}
|
|
|
|
// guardFor crea un proyecto temporal con la política dada.
|
|
func guardFor(t *testing.T, directives string) (*Guard, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "agent.md"), []byte("x"), 0o644)
|
|
p, err := Parse("```policy\n" + directives + "\n```\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g, err := NewGuard(p, dir, "agent.md")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return g, dir
|
|
}
|
|
|
|
func TestGuardPaths(t *testing.T) {
|
|
g, dir := guardFor(t, "EXECUTION_MODE=privileged\nALLOW_OUTSIDE_PROJECT_READ=true\nALLOW_DELETE=true")
|
|
|
|
allowed := []struct {
|
|
op Op
|
|
p string
|
|
}{
|
|
{OpRead, "notes.txt"},
|
|
{OpWrite, "sub/file.txt"},
|
|
{OpWrite, "memoria.md"},
|
|
{OpRead, "/etc/hostname"},
|
|
{OpRead, "agent.md"},
|
|
{OpDelete, "sub/old.txt"},
|
|
}
|
|
for _, c := range allowed {
|
|
if err := g.CheckPath(c.op, c.p, ""); err != nil {
|
|
t.Errorf("%s %s: unexpected %v", c.op, c.p, err)
|
|
}
|
|
}
|
|
|
|
denied := []struct {
|
|
op Op
|
|
p string
|
|
}{
|
|
{OpWrite, "agent.md"},
|
|
{OpWrite, "init.conf"},
|
|
{OpRead, "init.conf"},
|
|
{OpWrite, "02-new-task.md"},
|
|
{OpWrite, "agent.log"},
|
|
{OpWrite, "/etc/motd"}, // ALLOW_OUTSIDE_PROJECT_WRITE no declarado
|
|
{OpDelete, "/"},
|
|
{OpDelete, "/etc"},
|
|
{OpDelete, "/etc/*"},
|
|
{OpDelete, dir},
|
|
{OpDelete, "*.md"},
|
|
}
|
|
for _, c := range denied {
|
|
err := g.CheckPath(c.op, c.p, "")
|
|
if !IsDenied(err) {
|
|
t.Errorf("%s %s: expected denial, got %v", c.op, c.p, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGuardSymlinkEscape(t *testing.T) {
|
|
g, dir := guardFor(t, "EXECUTION_MODE=privileged")
|
|
outside := t.TempDir()
|
|
if err := os.Symlink(outside, filepath.Join(dir, "link")); err != nil {
|
|
t.Skip(err)
|
|
}
|
|
if err := g.CheckPath(OpWrite, "link/x.conf", ""); !IsDenied(err) {
|
|
t.Errorf("write through symlink to outside dir must be denied, got %v", err)
|
|
}
|
|
if err := g.CheckPath(OpRead, "link/x.conf", ""); !IsDenied(err) {
|
|
t.Errorf("read through symlink needs ALLOW_OUTSIDE_PROJECT_READ, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGuardToolsDirNeedsCapability(t *testing.T) {
|
|
g, _ := guardFor(t, "EXECUTION_MODE=privileged")
|
|
if err := g.CheckPath(OpWrite, "tools/check.sh", ""); !IsDenied(err) {
|
|
t.Errorf("tools/ requires ALLOW_SELF_TOOL_CREATION: %v", err)
|
|
}
|
|
g2, _ := guardFor(t, "ALLOW_SELF_TOOL_CREATION=true")
|
|
if err := g2.CheckPath(OpWrite, "tools/check.sh", ""); err != nil {
|
|
t.Errorf("unexpected %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGuardCommandsGuarded(t *testing.T) {
|
|
g, dir := guardFor(t, "EXECUTION_MODE=guarded\nALLOW_NETWORK=true\nALLOW_OUTSIDE_PROJECT_READ=true")
|
|
ok := [][]string{
|
|
{"ls", "-la", "/etc"},
|
|
{"systemctl", "status", "nginx"},
|
|
{"ping", "-c1", "example.com"},
|
|
{"mkdir", "-p", "build"}, // escritura dentro del proyecto
|
|
{"cat", "/etc/os-release"},
|
|
}
|
|
for _, argv := range ok {
|
|
if _, err := g.CheckArgv(argv, dir); err != nil {
|
|
t.Errorf("%v: unexpected %v", argv, err)
|
|
}
|
|
}
|
|
bad := [][]string{
|
|
{"systemctl", "restart", "nginx"},
|
|
{"apt-get", "install", "x"},
|
|
{"rm", "x"},
|
|
{"cp", "a", "/etc/b"},
|
|
{"useradd", "bob"},
|
|
{"python3", "script.py"},
|
|
{"reboot"},
|
|
{"cat", "init.conf"},
|
|
{"sed", "-i", "s/a/b/", "agent.md"},
|
|
}
|
|
for _, argv := range bad {
|
|
if _, err := g.CheckArgv(argv, dir); !IsDenied(err) {
|
|
t.Errorf("%v: expected denial, got %v", argv, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGuardScripts(t *testing.T) {
|
|
g, dir := guardFor(t, "EXECUTION_MODE=privileged\nALLOW_OUTSIDE_PROJECT_READ=true\nALLOW_DELETE=true\nALLOW_SERVICE_RESTART=true")
|
|
ok := []string{
|
|
"journalctl -u nginx | tail -n 50",
|
|
"echo hello > out.txt",
|
|
"systemctl restart nginx && systemctl is-active nginx",
|
|
"ls /etc 2>&1 | grep ssh",
|
|
"rm -f build/*.o",
|
|
}
|
|
for _, s := range ok {
|
|
if _, err := g.CheckScript(s, dir); err != nil {
|
|
t.Errorf("%q: unexpected %v", s, err)
|
|
}
|
|
}
|
|
bad := []string{
|
|
"echo x > /etc/motd", // escritura fuera sin ALLOW_OUTSIDE_PROJECT_WRITE
|
|
"cd /etc && rm motd", // cd seguido: rm afecta a /etc/motd
|
|
"ls; reboot", // ALLOW_REBOOT no declarado
|
|
"echo $(shutdown -h now)", // sustitución
|
|
"bash -c 'dd if=/dev/zero of=/dev/sda'", // destructivo anidado
|
|
"rm -rf /",
|
|
"echo evil >> agent.md",
|
|
"cat init.conf",
|
|
"apt-get install -y x",
|
|
}
|
|
for _, s := range bad {
|
|
if _, err := g.CheckScript(s, dir); !IsDenied(err) {
|
|
t.Errorf("%q: expected denial, got %v", s, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGuardUnrestrictedStillRespectsReboot(t *testing.T) {
|
|
g, dir := guardFor(t, "EXECUTION_MODE=unrestricted\nALLOW_DELETE=true\nALLOW_OUTSIDE_PROJECT_WRITE=true")
|
|
if _, err := g.CheckArgv([]string{"mkfs.ext4", "/dev/sdz9"}, dir); err != nil {
|
|
t.Errorf("unrestricted should allow mkfs: %v", err)
|
|
}
|
|
if _, err := g.CheckArgv([]string{"systemctl", "reboot"}, dir); !IsDenied(err) {
|
|
t.Errorf("ALLOW_REBOOT=false must block reboot even in unrestricted: %v", err)
|
|
}
|
|
if _, err := g.CheckArgv([]string{"rm", "-rf", "/usr"}, dir); !IsDenied(err) {
|
|
t.Errorf("critical path deletion must always be denied: %v", err)
|
|
}
|
|
}
|