Files
kandev/apps/backend/internal/agent/docker/seccomp/seccomp_test.go
T

344 lines
10 KiB
Go

package seccomp
import (
"bytes"
"encoding/json"
"slices"
"strings"
"testing"
)
func TestProcessSyscallRulesDoesNotMutateInput(t *testing.T) {
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal(defaultProfileJSON, &profile); err != nil {
t.Fatalf("unmarshal default profile: %v", err)
}
before := make([]json.RawMessage, len(profile.Syscalls))
for i, rule := range profile.Syscalls {
before[i] = bytes.Clone(rule)
}
processed, err := processSyscallRules(profile.Syscalls)
if err != nil {
t.Fatalf("processSyscallRules: %v", err)
}
if len(processed) >= len(profile.Syscalls) {
t.Fatal("processSyscallRules did not remove the clone restrictions")
}
for i, rule := range profile.Syscalls {
if !bytes.Equal(rule, before[i]) {
t.Fatalf("processSyscallRules modified input rule %d", i)
}
}
}
func TestMergeUnconditionalAllowDoesNotMutateInput(t *testing.T) {
rules := []json.RawMessage{
json.RawMessage(`{"names":["read"],"action":"SCMP_ACT_ALLOW"}`),
}
before := bytes.Clone(rules[0])
merged, err := mergeUnconditionalAllow(rules, []string{"unshare"})
if err != nil {
t.Fatalf("mergeUnconditionalAllow: %v", err)
}
if !bytes.Equal(rules[0], before) {
t.Fatal("mergeUnconditionalAllow modified input")
}
allowSet := unconditionalAllowSet(t, mustMarshalRules(t, merged))
if !allowSet["read"] || !allowSet["unshare"] {
t.Fatalf("merged allow set = %v, want read and unshare", allowSet)
}
}
func mustMarshalRules(t *testing.T, rules []json.RawMessage) string {
t.Helper()
encoded, err := json.Marshal(struct {
Syscalls []json.RawMessage `json:"syscalls"`
}{Syscalls: rules})
if err != nil {
t.Fatalf("marshal syscall rules: %v", err)
}
return string(encoded)
}
// usernsProfileAllowedSyscalls returns the set of syscalls that appear in a
// SCMP_ACT_ALLOW rule with no capability gating, no arch specifics, no
// kernel version gating, no argument restrictions, and no excludes — i.e.
// the unconditional allow list.
func unconditionalAllowSet(t *testing.T, profileJSON string) map[string]bool {
t.Helper()
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal profile: %v", err)
}
allowSet := make(map[string]bool)
for _, raw := range profile.Syscalls {
var rule struct {
Names []string `json:"names"`
Action string `json:"action"`
Includes *json.RawMessage `json:"includes,omitempty"`
Excludes *json.RawMessage `json:"excludes,omitempty"`
Args []json.RawMessage `json:"args,omitempty"`
}
if err := json.Unmarshal(raw, &rule); err != nil {
continue
}
if rule.Action == "SCMP_ACT_ALLOW" && rule.Includes == nil && rule.Excludes == nil && len(rule.Args) == 0 {
for _, name := range rule.Names {
allowSet[name] = true
}
}
}
return allowSet
}
// capAdminGated returns the syscalls that are allowed only with CAP_SYS_ADMIN.
func capAdminGated(t *testing.T, profileJSON string) map[string]bool {
t.Helper()
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal profile: %v", err)
}
gated := make(map[string]bool)
for _, raw := range profile.Syscalls {
var rule struct {
Names []string `json:"names"`
Action string `json:"action"`
Includes *struct {
Caps []string `json:"caps,omitempty"`
} `json:"includes,omitempty"`
}
if err := json.Unmarshal(raw, &rule); err != nil {
continue
}
if rule.Action == "SCMP_ACT_ALLOW" && rule.Includes != nil && slices.Contains(rule.Includes.Caps, "CAP_SYS_ADMIN") {
for _, name := range rule.Names {
gated[name] = true
}
}
}
return gated
}
func TestUsernsProfile_IsValidJSON(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
if !strings.HasPrefix(profileJSON, "{") {
t.Fatalf("profile does not start with {, got: %s", profileJSON[:20])
}
}
func TestUsernsProfile_NamespaceSyscalls_Unconditional(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
allowSet := unconditionalAllowSet(t, profileJSON)
capAdminSet := capAdminGated(t, profileJSON)
for _, sc := range usernsRelaxedSyscalls {
if !allowSet[sc] {
t.Errorf("namespace syscall %q is not in the unconditional allow list; in CAP_ADMIN-gated: %v", sc, capAdminSet[sc])
}
if capAdminSet[sc] {
t.Errorf("namespace syscall %q is still in the CAP_SYS_ADMIN-gated group", sc)
}
}
}
func TestUsernsProfile_DockerBlockedSyscalls_StayBlocked(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
allowSet := unconditionalAllowSet(t, profileJSON)
// Syscalls Docker blocks by default (not in the allow set, not in
// any conditional allow) must remain blocked.
//
// Note: some of these like mount, setns, unshare etc are now in the
// allow set — they are the whole point of the change. We check only
// the ones Docker denies even to CAP_SYS_ADMIN.
//
// The key check is that high-risk syscalls that Docker blocks even
// with capabilities stay blocked.
syscallsThatMustStayBlocked := []string{
"add_key",
"io_uring_enter",
"io_uring_register",
"io_uring_setup",
"kexec_file_load",
"kexec_load",
"nfsservctl",
"userfaultfd",
}
for _, sc := range syscallsThatMustStayBlocked {
if allowSet[sc] {
t.Errorf("high-risk syscall %q is now unconditionally allowed — it should remain blocked", sc)
}
}
}
func TestUsernsProfile_OmitsSyscallsUnsupportedByLegacyDaemons(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
legacySyscalls := []string{
"futex_requeue",
"getxattrat",
"listmount",
"listxattrat",
"mseal",
"removexattrat",
"setxattrat",
"statmount",
"uretprobe",
}
for _, syscall := range legacySyscalls {
if strings.Contains(profileJSON, `"`+syscall+`"`) {
t.Fatalf("profile contains syscall %q unsupported by legacy daemons", syscall)
}
}
}
func TestUsernsProfile_DefaultActionIsErrno(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
var profile struct {
DefaultAction string `json:"defaultAction"`
DefaultErrnoRet *uint `json:"defaultErrnoRet"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if profile.DefaultAction != "SCMP_ACT_ERRNO" {
t.Errorf("defaultAction = %q, want SCMP_ACT_ERRNO", profile.DefaultAction)
}
if profile.DefaultErrnoRet == nil || *profile.DefaultErrnoRet != 1 {
t.Errorf("defaultErrnoRet should be 1")
}
}
func TestUsernsProfile_CloneMaskedEqRemoved(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for i, raw := range profile.Syscalls {
var rule struct {
Names []string `json:"names"`
Args []json.RawMessage `json:"args"`
}
if err := json.Unmarshal(raw, &rule); err != nil {
continue
}
if slices.Contains(rule.Names, "clone") && len(rule.Args) > 0 {
for _, argRaw := range rule.Args {
var arg struct {
Op string `json:"op"`
}
if json.Unmarshal(argRaw, &arg) == nil && arg.Op == "SCMP_CMP_MASKED_EQ" {
t.Errorf("rule %d still has SCMP_CMP_MASKED_EQ clone restriction (index %d)", i, rule.Args)
}
}
}
}
}
func TestUsernsProfile_Clone3ErrnoRemoved(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for i, raw := range profile.Syscalls {
var rule struct {
Names []string `json:"names"`
Action string `json:"action"`
}
if err := json.Unmarshal(raw, &rule); err != nil {
continue
}
if slices.Contains(rule.Names, "clone3") && rule.Action == "SCMP_ACT_ERRNO" {
t.Errorf("rule %d still has clone3 ERRNO restriction (action=%s)", i, rule.Action)
}
}
}
func TestUsernsProfile_OnlyChangedSyscalls(t *testing.T) {
// Parse both the default and the userns profile.
var defaultProfile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal(defaultProfileJSON, &defaultProfile); err != nil {
t.Fatalf("unmarshal default: %v", err)
}
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON(): %v", err)
}
var modifiedProfile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &modifiedProfile); err != nil {
t.Fatalf("unmarshal modified: %v", err)
}
// Convert both to allow sets for comparison.
defaultUncond := unconditionalAllowSet(t, string(defaultProfileJSON))
modifiedUncond := unconditionalAllowSet(t, profileJSON)
// The expected changes are namespace syscalls moved from CAP_ADMIN-gated to
// unconditional allow, plus removal of optional legacy-incompatible allows.
for _, sc := range usernsRelaxedSyscalls {
if !defaultUncond[sc] && !modifiedUncond[sc] {
t.Errorf("expected syscall %q to be added to unconditional allow, but it is not present", sc)
}
}
// No other syscalls should have changed from default to unconditional allow.
for sc := range defaultUncond {
if !modifiedUncond[sc] && !slices.Contains(legacyDaemonUnsupportedSyscalls, sc) {
t.Errorf("syscall %q was in default unconditional allow but is missing from modified", sc)
}
}
for sc := range modifiedUncond {
if !defaultUncond[sc] && !slices.Contains(usernsRelaxedSyscalls, sc) {
t.Errorf("syscall %q was added to unconditional allow but is not in usernsRelaxedSyscalls", sc)
}
}
}