162 lines
7.0 KiB
Go
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, ©Doc); 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")
|
|
}
|
|
}
|
|
}
|