post inicial
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user