diff --git a/.gitattributes b/.gitattributes index 35be3cd7..be4ad18d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ testdata/controlled-session/*.jsonl text eol=lf +internal/dockerdeploy/seccomp/docker-v0.2.4.json text eol=lf diff --git a/internal/dockerdeploy/podman_mapped_seccomp.go b/internal/dockerdeploy/podman_mapped_seccomp.go new file mode 100644 index 00000000..05859af7 --- /dev/null +++ b/internal/dockerdeploy/podman_mapped_seccomp.go @@ -0,0 +1,202 @@ +package dockerdeploy + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "io" +) + +const mappedCloneNamespaceMaskV1 uint64 = 0x7e020080 + +// This matches the closed Linux v6.6 capability catalog in blueprint. The +// observed kernel limit is a separate caller-supplied qualification fact. +const mappedCapabilityCatalogMaskV1 uint64 = (1 << 41) - 1 + +// All fields are scalar values. In particular, profile cannot be mutated after +// its identity is computed. The compiler neither grants capabilities nor creates +// or qualifies a container; its caller must authenticate these inputs. +type mappedSeccompPolicyV1 struct { + abi string + finalCapabilities uint64 + setupCapabilities uint64 + kernelLastCapability int + profile string + identity string +} + +type mappedSeccompSpecV1 struct { + DefaultAction string `json:"defaultAction"` + DefaultErrnoRet *uint32 `json:"defaultErrnoRet"` + ArchMap []mappedSeccompArchV1 `json:"archMap"` + Syscalls []mappedSeccompRuleV1 `json:"syscalls"` +} + +type mappedSeccompArchV1 struct { + Architecture string `json:"architecture"` + SubArchitectures []string `json:"subArchitectures"` +} + +type mappedSeccompRuleV1 struct { + Names []string `json:"names"` + Action string `json:"action"` + Args []mappedSeccompArgV1 `json:"args,omitempty"` + Comment string `json:"comment,omitempty"` + ErrnoRet *uint32 `json:"errnoRet,omitempty"` + Includes *mappedSeccompConditionV1 `json:"includes,omitempty"` + Excludes *mappedSeccompConditionV1 `json:"excludes,omitempty"` +} + +type mappedSeccompArgV1 struct { + Index uint32 `json:"index"` + Value uint64 `json:"value"` + ValueTwo uint64 `json:"valueTwo,omitempty"` + Op string `json:"op"` +} + +type mappedSeccompConditionV1 struct { + Caps []string `json:"caps,omitempty"` + Arches []string `json:"arches,omitempty"` + MinKernel string `json:"minKernel,omitempty"` +} + +func decodeMappedSeccompSeedV1(seed []byte) (mappedSeccompSpecV1, error) { + var spec mappedSeccompSpecV1 + decoder := json.NewDecoder(bytes.NewReader(seed)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&spec); err != nil { + return spec, fmt.Errorf("decode mapped seccomp seed: %w", err) + } + if err := decoder.Decode(new(any)); err != io.EOF { + return spec, fmt.Errorf("mapped seccomp seed has trailing content") + } + if spec.DefaultAction != "SCMP_ACT_ERRNO" || spec.DefaultErrnoRet == nil || + *spec.DefaultErrnoRet != 1 || len(spec.ArchMap) == 0 || len(spec.Syscalls) == 0 { + return spec, fmt.Errorf("mapped seccomp seed requires its pinned default-deny shape") + } + for _, arch := range spec.ArchMap { + if arch.Architecture == "" { + return spec, fmt.Errorf("mapped seccomp seed has an empty architecture") + } + } + for _, rule := range spec.Syscalls { + if len(rule.Names) == 0 || rule.Action != "SCMP_ACT_ALLOW" && rule.Action != "SCMP_ACT_ERRNO" || + rule.Action == "SCMP_ACT_ALLOW" && rule.ErrnoRet != nil { + return spec, fmt.Errorf("mapped seccomp seed has an unsupported syscall rule") + } + for _, name := range rule.Names { + if name == "" { + return spec, fmt.Errorf("mapped seccomp seed has an empty syscall name") + } + } + for _, arg := range rule.Args { + if arg.Index > 5 || arg.Op != "SCMP_CMP_EQ" && arg.Op != "SCMP_CMP_LT" && arg.Op != "SCMP_CMP_MASKED_EQ" { + return spec, fmt.Errorf("mapped seccomp seed has an unsupported argument rule") + } + } + } + return spec, nil +} + +func forbiddenMappedSyscallV1(name string) bool { + switch name { + case "mount", "umount", "umount2", "pivot_root", "fsopen", "fsconfig", "fsmount", "fspick", + "open_tree", "move_mount", "mount_setattr", "setns", "unshare", "open_by_handle_at", "name_to_handle_at": + return true + } + return false +} + +func compileMappedSeccompV1(seed []byte, abi string, final, setup uint64, kernelLastCapability int) (mappedSeccompPolicyV1, error) { + fail := func(reason string) (mappedSeccompPolicyV1, error) { + return mappedSeccompPolicyV1{}, fmt.Errorf("mapped seccomp policy: %s", reason) + } + if len(seed) != len(podmanDockerSeccompProfile) { + return fail("embedded seed integrity mismatch") + } + // Hash and decode the same bounded, compiler-owned snapshot. + seed = bytes.Clone(seed) + if fmt.Sprintf("%x", sha256.Sum256(seed)) != podmanDockerSeccompSHA256 { + return fail("embedded seed integrity mismatch") + } + var architecture string + switch abi { + case "amd64": + architecture = "SCMP_ARCH_X86_64" + case "arm64": + architecture = "SCMP_ARCH_AARCH64" + default: + return fail("unsupported native ABI") + } + if kernelLastCapability < 0 || kernelLastCapability >= 64 { + return fail("unsupported kernel capability limit") + } + kernelMask := ^uint64(0) >> (63 - uint(kernelLastCapability)) + if (final|setup)&^(kernelMask&mappedCapabilityCatalogMaskV1) != 0 { + return fail("capabilities exceed kernel or catalog") + } + if final&^setup != 0 { + return fail("setup capability set does not contain the final ceiling") + } + spec, err := decodeMappedSeccompSeedV1(seed) + if err != nil { + return mappedSeccompPolicyV1{}, err + } + foundArchitecture := false + for _, arch := range spec.ArchMap { + foundArchitecture = foundArchitecture || arch.Architecture == architecture + } + if !foundArchitecture { + return fail("native architecture is absent from the pinned seed") + } + // No compatibility architecture may enter the engine's libseccomp build. + // Actual engine/ABI qualification remains required by the consuming gate. + spec.ArchMap = []mappedSeccompArchV1{{Architecture: architecture, SubArchitectures: []string{}}} + rules := make([]mappedSeccompRuleV1, 0, len(spec.Syscalls)+2) + for _, rule := range spec.Syscalls { + names := make([]string, 0, len(rule.Names)) + for _, name := range rule.Names { + // Replace every clone rule, not just the seed's non-SYS_ADMIN case. + if name == "clone" || name == "clone3" || rule.Action == "SCMP_ACT_ALLOW" && forbiddenMappedSyscallV1(name) { + continue + } + names = append(names, name) + } + if len(names) != 0 { + rule.Names = names + rules = append(rules, rule) + } + } + // Both admitted native ABIs place legacy clone flags in argument zero. + // ENOSYS, rather than EPERM, lets qualified libc implementations fall back. + enosys := uint32(38) + spec.Syscalls = append(rules, + mappedSeccompRuleV1{Names: []string{"clone"}, Action: "SCMP_ACT_ALLOW", Args: []mappedSeccompArgV1{ + {Index: 0, Value: mappedCloneNamespaceMaskV1, Op: "SCMP_CMP_MASKED_EQ"}, + }}, + mappedSeccompRuleV1{Names: []string{"clone3"}, Action: "SCMP_ACT_ERRNO", ErrnoRet: &enosys}, + ) + profile, err := json.Marshal(spec) + if err != nil { + return mappedSeccompPolicyV1{}, fmt.Errorf("encode mapped seccomp profile: %w", err) + } + bound := struct { + Version int `json:"version"` + Seed string `json:"seed"` + ABI string `json:"abi"` + FinalCapabilities uint64 `json:"final_capabilities"` + SetupCapabilities uint64 `json:"setup_capabilities"` + KernelLastCapability int `json:"kernel_last_capability"` + Profile json.RawMessage `json:"profile"` + }{1, podmanDockerSeccompSHA256, abi, final, setup, kernelLastCapability, profile} + canonical, err := json.Marshal(bound) + if err != nil { + return mappedSeccompPolicyV1{}, fmt.Errorf("encode mapped seccomp identity: %w", err) + } + return mappedSeccompPolicyV1{ + abi: abi, finalCapabilities: final, setupCapabilities: setup, + kernelLastCapability: kernelLastCapability, profile: string(profile), + identity: fmt.Sprintf("sha256:%x", sha256.Sum256(canonical)), + }, nil +} diff --git a/internal/dockerdeploy/podman_mapped_seccomp_test.go b/internal/dockerdeploy/podman_mapped_seccomp_test.go new file mode 100644 index 00000000..e6c58be7 --- /dev/null +++ b/internal/dockerdeploy/podman_mapped_seccomp_test.go @@ -0,0 +1,348 @@ +package dockerdeploy + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +// These tests inspect the compiler contract independently of its typed parser. +// They do not qualify an engine/libseccomp build or a live runtime tuple. +type mappedPolicyDocumentForTest struct { + DefaultAction string `json:"defaultAction"` + DefaultErrnoRet uint32 `json:"defaultErrnoRet"` + ArchMap []map[string]any `json:"archMap"` + Syscalls []map[string]json.RawMessage `json:"syscalls"` +} + +func mappedPolicyForTest(t *testing.T, abi string, final, setup uint64) (mappedSeccompPolicyV1, mappedPolicyDocumentForTest) { + t.Helper() + policy, err := compileMappedSeccompV1(podmanDockerSeccompProfile, abi, final, setup, 40) + if err != nil { + t.Fatal(err) + } + var document mappedPolicyDocumentForTest + if err := json.Unmarshal([]byte(policy.profile), &document); err != nil { + t.Fatal(err) + } + return policy, document +} + +func mappedRuleNamesForTest(t *testing.T, rule map[string]json.RawMessage) []string { + t.Helper() + var names []string + if err := json.Unmarshal(rule["names"], &names); err != nil { + t.Fatal(err) + } + return names +} + +func mappedRuleActionForTest(t *testing.T, rule map[string]json.RawMessage) string { + t.Helper() + var action string + if err := json.Unmarshal(rule["action"], &action); err != nil { + t.Fatal(err) + } + return action +} + +func TestMappedSeccompCompilerRejectsUnqualifiedInputs(t *testing.T) { + changedSeed := bytes.Clone(podmanDockerSeccompProfile) + changedSeed[bytes.IndexByte(changedSeed, '\n')] = ' ' + if !json.Valid(changedSeed) { + t.Fatal("same-length corruption fixture must remain valid JSON") + } + tests := []struct { + name string + seed []byte + abi string + final, setup uint64 + last int + }{ + {"missing seed", nil, "amd64", 0, 0, 40}, + {"changed seed", append(bytes.Clone(podmanDockerSeccompProfile), '\n'), "amd64", 0, 0, 40}, + {"same-length changed seed", changedSeed, "amd64", 0, 0, 40}, + {"empty ABI", podmanDockerSeccompProfile, "", 0, 0, 40}, + {"compat x86", podmanDockerSeccompProfile, "386", 0, 0, 40}, + {"compat ARM", podmanDockerSeccompProfile, "arm", 0, 0, 40}, + {"different clone ABI", podmanDockerSeccompProfile, "s390x", 0, 0, 40}, + {"unknown ABI", podmanDockerSeccompProfile, "riscv64", 0, 0, 40}, + {"negative kernel limit", podmanDockerSeccompProfile, "amd64", 0, 0, -1}, + {"unrepresentable kernel", podmanDockerSeccompProfile, "amd64", 0, 0, 64}, + {"unknown final bit", podmanDockerSeccompProfile, "amd64", 1 << 41, 1 << 41, 63}, + {"unknown setup bit", podmanDockerSeccompProfile, "amd64", 0, 1 << 41, 63}, + {"final outside setup", podmanDockerSeccompProfile, "amd64", 1 << 7, 1 << 6, 40}, + {"final outside kernel", podmanDockerSeccompProfile, "amd64", 1 << 40, 1 << 40, 39}, + {"setup outside kernel", podmanDockerSeccompProfile, "amd64", 0, 1 << 40, 39}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := compileMappedSeccompV1(test.seed, test.abi, test.final, test.setup, test.last) + if err == nil || got != (mappedSeccompPolicyV1{}) { + t.Fatalf("unqualified input returned usable policy: %+v, %v", got, err) + } + }) + } + for _, last := range []int{0, 40, 63} { + if _, err := compileMappedSeccompV1(podmanDockerSeccompProfile, "amd64", 1, 1, last); err != nil { + t.Fatalf("valid bounded input with kernel last=%d: %v", last, err) + } + } +} + +func TestMappedSeccompSeedDecoderRejectsUnknownShapes(t *testing.T) { + mutations := map[string]func(map[string]any){ + "unknown top field": func(m map[string]any) { m["listenerPath"] = "/tmp/listener" }, + "allow default": func(m map[string]any) { m["defaultAction"] = "SCMP_ACT_ALLOW" }, + "missing errno": func(m map[string]any) { delete(m, "defaultErrnoRet") }, + "different errno": func(m map[string]any) { m["defaultErrnoRet"] = 0 }, + "missing arches": func(m map[string]any) { m["archMap"] = []any{} }, + "missing rules": func(m map[string]any) { m["syscalls"] = []any{} }, + "unknown arch field": func(m map[string]any) { + m["archMap"].([]any)[0].(map[string]any)["flags"] = []any{} + }, + "empty arch": func(m map[string]any) { + m["archMap"].([]any)[0].(map[string]any)["architecture"] = "" + }, + "unknown rule field": func(m map[string]any) { + m["syscalls"].([]any)[0].(map[string]any)["flags"] = []any{} + }, + "unknown action": func(m map[string]any) { + m["syscalls"].([]any)[0].(map[string]any)["action"] = "SCMP_ACT_TRACE" + }, + "empty names": func(m map[string]any) { + m["syscalls"].([]any)[0].(map[string]any)["names"] = []string{} + }, + "empty name": func(m map[string]any) { + m["syscalls"].([]any)[0].(map[string]any)["names"] = []string{""} + }, + "allow with errno": func(m map[string]any) { + m["syscalls"].([]any)[0].(map[string]any)["errnoRet"] = 0 + }, + "unknown condition": func(m map[string]any) { + m["syscalls"].([]any)[0].(map[string]any)["includes"] = map[string]any{"other": true} + }, + "unknown arg": func(m map[string]any) { + m["syscalls"].([]any)[0].(map[string]any)["args"] = []any{map[string]any{"index": 0, "value": 0, "op": "SCMP_CMP_GE"}} + }, + "bad arg index": func(m map[string]any) { + m["syscalls"].([]any)[0].(map[string]any)["args"] = []any{map[string]any{"index": 6, "value": 0, "op": "SCMP_CMP_EQ"}} + }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + var raw map[string]any + if err := json.Unmarshal(podmanDockerSeccompProfile, &raw); err != nil { + t.Fatal(err) + } + mutate(raw) + seed, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if _, err := decodeMappedSeccompSeedV1(seed); err == nil { + t.Fatal("accepted unsupported seed shape") + } + }) + } + for _, suffix := range []string{"{}", " trailing", "null"} { + if _, err := decodeMappedSeccompSeedV1(append(bytes.Clone(podmanDockerSeccompProfile), []byte(suffix)...)); err == nil { + t.Fatalf("accepted trailing %q", suffix) + } + } +} + +func TestMappedSeccompCompilerRemovesEveryEscapeAllowance(t *testing.T) { + forbidden := []string{"mount", "umount", "umount2", "pivot_root", "fsopen", "fsconfig", "fsmount", "fspick", + "open_tree", "move_mount", "mount_setattr", "setns", "unshare", "open_by_handle_at", "name_to_handle_at"} + for _, abi := range []string{"amd64", "arm64"} { + for _, caps := range []uint64{0, 1 << 21, 1 << 2, (1 << 41) - 1} { + t.Run(fmt.Sprintf("%s-%x", abi, caps), func(t *testing.T) { + _, doc := mappedPolicyForTest(t, abi, caps, caps) + for _, rule := range doc.Syscalls { + if mappedRuleActionForTest(t, rule) != "SCMP_ACT_ALLOW" { + continue + } + for _, name := range mappedRuleNamesForTest(t, rule) { + for _, blocked := range forbidden { + if name == blocked { + t.Fatalf("allow for %s survived with includes=%s excludes=%s", name, rule["includes"], rule["excludes"]) + } + } + } + } + }) + } + } +} + +func TestMappedSeccompCompilerNativeClonePolicy(t *testing.T) { + arches := map[string]string{"amd64": "SCMP_ARCH_X86_64", "arm64": "SCMP_ARCH_AARCH64"} + namespaceBits := []uint64{0x00020000, 0x02000000, 0x04000000, 0x08000000, 0x10000000, 0x20000000, 0x40000000, 0x80} + for abi, native := range arches { + for _, caps := range []uint64{0, 1 << 21, (1 << 41) - 1} { + _, doc := mappedPolicyForTest(t, abi, caps, caps) + if len(doc.ArchMap) != 1 || doc.ArchMap[0]["architecture"] != native { + t.Fatalf("wrong native architecture: %+v", doc.ArchMap) + } + sub, ok := doc.ArchMap[0]["subArchitectures"].([]any) + if !ok || len(sub) != 0 { + t.Fatalf("compat architectures retained: %+v", doc.ArchMap) + } + var clone, clone3 []map[string]json.RawMessage + for _, rule := range doc.Syscalls { + for _, name := range mappedRuleNamesForTest(t, rule) { + if name == "clone" { + clone = append(clone, rule) + } + if name == "clone3" { + clone3 = append(clone3, rule) + } + } + } + if len(clone) != 1 || len(clone3) != 1 { + t.Fatalf("conditional clone rule survived: clone=%d clone3=%d", len(clone), len(clone3)) + } + for _, rule := range []map[string]json.RawMessage{clone[0], clone3[0]} { + if _, ok := rule["includes"]; ok { + t.Fatal("replacement has includes condition") + } + if _, ok := rule["excludes"]; ok { + t.Fatal("replacement has excludes condition") + } + } + if mappedRuleActionForTest(t, clone[0]) != "SCMP_ACT_ALLOW" || mappedRuleActionForTest(t, clone3[0]) != "SCMP_ACT_ERRNO" || string(clone3[0]["errnoRet"]) != "38" { + t.Fatal("clone actions or clone3 fallback errno changed") + } + if _, ok := clone3[0]["args"]; ok { + t.Fatal("clone3 ENOSYS must be unconditional") + } + var args []struct { + Index uint32 `json:"index"` + Value uint64 `json:"value"` + ValueTwo uint64 `json:"valueTwo"` + Op string `json:"op"` + } + if err := json.Unmarshal(clone[0]["args"], &args); err != nil { + t.Fatal(err) + } + if len(args) != 1 || args[0].Index != 0 || args[0].Op != "SCMP_CMP_MASKED_EQ" || args[0].ValueTwo != 0 { + t.Fatalf("wrong native clone argument: %+v", args) + } + var mask uint64 + for _, bit := range namespaceBits { + mask |= bit + } + if args[0].Value != mask { + t.Fatalf("namespace mask=%x, want %x", args[0].Value, mask) + } + for _, ordinary := range []uint64{17, 0x4111, 0x3d0f00} { // fork, vfork, pthread-style clone + if ordinary&args[0].Value != args[0].ValueTwo { + t.Fatalf("ordinary clone flags %x denied", ordinary) + } + for combination := 1; combination < 1<