Files

162 lines
7.0 KiB
Go

package composepolicy
import (
"encoding/json"
"strings"
"testing"
"server-deploy/internal/appbundle"
)
func fixture() (appbundle.Manifest, map[string]any) {
image := "example/api@sha256:" + strings.Repeat("a", 64)
m := appbundle.Manifest{Components: []appbundle.Component{{Name: "api", Image: image}}}
d := map[string]any{
"services": map[string]any{"api": map[string]any{
"image": image, "user": "1000:1000", "read_only": true,
"cap_drop": []string{"ALL"}, "security_opt": []string{"no-new-privileges:true"},
"restart": "unless-stopped", "networks": []string{"backend"},
"volumes": []any{map[string]any{"type": "volume", "source": "data", "target": "/data", "read_only": false}},
}},
"networks": map[string]any{"backend": map[string]any{"internal": true}},
"volumes": map[string]any{"data": map[string]any{}},
}
return m, d
}
func TestAcceptRestrictedService(t *testing.T) {
m, d := fixture()
raw, _ := json.Marshal(d)
if err := Check(m, raw); err != nil {
t.Fatal(err)
}
}
func TestRejectPrivilegeAndExternalInputs(t *testing.T) {
for _, field := range []string{"privileged", "build", "container_name", "network_mode", "pid", "ipc", "userns_mode", "devices", "cap_add", "env_file", "environment", "extends", "ports", "labels", "use_api_socket", "post_start", "pre_stop", "command", "entrypoint", "volumes_from", "develop", "provider"} {
t.Run(field, func(t *testing.T) {
m, d := fixture()
d["services"].(map[string]any)["api"].(map[string]any)[field] = "secret-sentinel"
raw, _ := json.Marshal(d)
err := Check(m, raw)
if err == nil || strings.Contains(err.Error(), "secret-sentinel") {
t.Fatal("forbidden field accepted or echoed")
}
})
}
}
func TestRejectUnsafeValuesAndReferences(t *testing.T) {
mutations := map[string]func(map[string]any, map[string]any){
"image drift": func(d, s map[string]any) { s["image"] = "example/api:latest" },
"root": func(d, s map[string]any) { s["user"] = "0:0" },
"named user": func(d, s map[string]any) { s["user"] = "root" },
"writable root": func(d, s map[string]any) { s["read_only"] = false },
"caps": func(d, s map[string]any) { s["cap_drop"] = []string{} },
"escalation": func(d, s map[string]any) { s["security_opt"] = []string{"seccomp:unconfined"} },
"implicit network": func(d, s map[string]any) { s["networks"] = []string{} },
"other network": func(d, s map[string]any) { s["networks"] = []string{"default"} },
"external network": func(d, s map[string]any) {
d["networks"] = map[string]any{"backend": map[string]any{"internal": true, "external": true}}
},
"outbound network": func(d, s map[string]any) {
d["networks"] = map[string]any{"backend": map[string]any{"internal": false}}
},
"bind": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["type"] = "bind" },
"host source": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["source"] = "/var/run/docker.sock" },
"missing volume": func(d, s map[string]any) { d["volumes"] = map[string]any{} },
"volume driver": func(d, s map[string]any) {
d["volumes"] = map[string]any{"data": map[string]any{"driver_opts": map[string]string{"device": "/"}}}
},
"root mount": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["target"] = "/" },
"traversal": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["target"] = "/data/../etc" },
"interpolation": func(d, s map[string]any) { s["user"] = "${UID}:1000" },
"include": func(d, s map[string]any) { d["include"] = []string{"/secret"} },
"null service": func(d, s map[string]any) { d["services"].(map[string]any)["api"] = nil },
"missing security": func(d, s map[string]any) { delete(s, "security_opt") },
"null volume": func(d, s map[string]any) { d["volumes"] = map[string]any{"data": nil} },
"extra service": func(d, s map[string]any) { d["services"].(map[string]any)["rogue"] = s },
"no services": func(d, s map[string]any) { d["services"] = map[string]any{} },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
m, d := fixture()
s := d["services"].(map[string]any)["api"].(map[string]any)
mutate(d, s)
raw, _ := json.Marshal(d)
if Check(m, raw) == nil {
t.Fatal("unsafe document accepted")
}
})
}
}
func TestRejectMalformedDocument(t *testing.T) {
m, d := fixture()
raw, _ := json.Marshal(d)
for _, input := range []string{"services: {}", string(raw) + "{}", strings.Replace(string(raw), `"read_only":true`, `"read_only":true,"read_only":false`, 1), strings.Repeat(" ", 4<<20) + string(raw)} {
if Check(m, []byte(input)) == nil {
t.Fatal("malformed input accepted")
}
}
}
func TestMountAndIdentityBoundaries(t *testing.T) {
for _, target := range []string{"/proc/x", "/var", "/var/run/docker.sock", "/sys", "/dev", "/etc", "/run", "/usr/local", "/lib", "/lib64", "/bin", "/sbin", "/data/", "/data//x", "/data/$HOME", `C:\data`, "/" + strings.Repeat("a", 241)} {
m, d := fixture()
s := d["services"].(map[string]any)["api"].(map[string]any)
s["volumes"].([]any)[0].(map[string]any)["target"] = target
raw, _ := json.Marshal(d)
if Check(m, raw) == nil {
t.Errorf("accepted protected/noncanonical target %q", target)
}
}
for _, user := range []string{"1000:0", "01:1000", "+1:1000", "-1:1000", "4294967295:1000", "4294967296:1000", "1", "1:2:3"} {
m, d := fixture()
d["services"].(map[string]any)["api"].(map[string]any)["user"] = user
raw, _ := json.Marshal(d)
if Check(m, raw) == nil {
t.Errorf("accepted ambiguous/root identity %q", user)
}
}
}
func TestTwoServicesRequireExactManifestAndSeparateVolumes(t *testing.T) {
m, d := fixture()
raw, _ := json.Marshal(d)
var copyDoc map[string]any
if err := json.Unmarshal(raw, &copyDoc); err != nil {
t.Fatal(err)
}
second := copyDoc["services"].(map[string]any)["api"].(map[string]any)
second["volumes"].([]any)[0].(map[string]any)["source"] = "db-data"
d["services"].(map[string]any)["db"] = second
d["volumes"].(map[string]any)["db-data"] = map[string]any{}
m.Components = append(m.Components, appbundle.Component{Name: "db", Image: m.Components[0].Image})
raw, _ = json.Marshal(d)
if err := Check(m, raw); err != nil {
t.Fatal("valid separate-service volumes rejected", err)
}
second["volumes"].([]any)[0].(map[string]any)["source"] = "data"
delete(d["volumes"].(map[string]any), "db-data")
raw, _ = json.Marshal(d)
if Check(m, raw) == nil {
t.Fatal("shared service data accepted")
}
}
func TestOverlappingAndUnusedVolumesRejected(t *testing.T) {
for _, target := range []string{"/data", "/data/nested", "/other"} {
m, d := fixture()
d["volumes"].(map[string]any)["second"] = map[string]any{}
if target != "/other" {
s := d["services"].(map[string]any)["api"].(map[string]any)
s["volumes"] = append(s["volumes"].([]any), map[string]any{"type": "volume", "source": "second", "target": target, "read_only": false})
}
raw, _ := json.Marshal(d)
if Check(m, raw) == nil {
t.Fatal("overlapping or unused volume accepted")
}
}
}