From b0b16740c335ed06a059aeedb5f9f6f53d662140 Mon Sep 17 00:00:00 2001 From: Andrew LeFevre Date: Wed, 7 May 2025 13:31:14 -0400 Subject: [PATCH] add GetSeUserByName, fallback to failsafe context in GetDefaultContextWithLevel --- go-selinux/selinux.go | 10 +- go-selinux/selinux_linux.go | 177 +++++++++++++++++++++++++++++-- go-selinux/selinux_linux_test.go | 174 +++++++++++++++++++++++++++--- go-selinux/selinux_stub.go | 4 + 4 files changed, 342 insertions(+), 23 deletions(-) diff --git a/go-selinux/selinux.go b/go-selinux/selinux.go index 9f0740e..d025272 100644 --- a/go-selinux/selinux.go +++ b/go-selinux/selinux.go @@ -305,11 +305,19 @@ func DisableSecOpt() []string { return []string{"disable"} } +// GetSeUserByName retrieves the SELinux username and security level for a given +// Linux username. The username and security level is based on the +// /etc/selinux/{SELINUXTYPE}/seusers file. +func GetSeUserByName(username string) (seUser string, level string, err error) { + return getSeUserByName(username) +} + // GetDefaultContextWithLevel gets a single context for the specified SELinux user // identity that is reachable from the specified scon context. The context is based // on the per-user /etc/selinux/{SELINUXTYPE}/contexts/users/ if it exists, // and falls back to the global /etc/selinux/{SELINUXTYPE}/contexts/default_contexts -// file. +// file and finally the global /etc/selinux/{SELINUXTYPE}/contexts/failsafe_context +// file if no match can be found anywhere else. func GetDefaultContextWithLevel(user, level, scon string) (string, error) { return getDefaultContextWithLevel(user, level, scon) } diff --git a/go-selinux/selinux_linux.go b/go-selinux/selinux_linux.go index a607980..72f5755 100644 --- a/go-selinux/selinux_linux.go +++ b/go-selinux/selinux_linux.go @@ -27,6 +27,7 @@ const ( selinuxDir = "/etc/selinux/" selinuxUsersDir = "contexts/users" defaultContexts = "contexts/default_contexts" + failsafeContext = "contexts/failsafe_context" selinuxConfig = selinuxDir + "config" selinuxfsMount = "/sys/fs/selinux" selinuxTypeTag = "SELINUXTYPE" @@ -57,6 +58,7 @@ type defaultSECtx struct { userRdr io.Reader verifier func(string) error defaultRdr io.Reader + failsafeRdr io.Reader user, level, scon string } @@ -1181,6 +1183,117 @@ func dupSecOpt(src string) ([]string, error) { return dup, nil } +// checkGroup returns true if group's GID is in the list of GIDs gids. +func checkGroup(group string, gids []string, lookupGroup func(string) (*user.Group, error)) bool { + grp, err := lookupGroup(group) + if err != nil { + return false + } + + for _, gid := range gids { + if grp.Gid == gid { + return true + } + } + return false +} + +// getSeUserFromReader reads the seusers file: https://www.man7.org/linux/man-pages/man5/seusers.5.html +func getSeUserFromReader(username string, gids []string, r io.Reader, lookupGroup func(string) (*user.Group, error)) (seUser string, level string, err error) { + var defaultSeUser, defaultLevel string + var groupSeUser, groupLevel string + + lineNum := -1 + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + lineNum++ + + // remove any trailing comments, then extra whitespace + parts := strings.SplitN(line, "#", 2) + line = strings.TrimSpace(parts[0]) + if line == "" { + continue + } + + parts = strings.SplitN(line, ":", 3) + if len(parts) < 2 { + return "", "", fmt.Errorf("line %d: malformed line", lineNum) + } + userField := parts[0] + if userField == "" { + return "", "", fmt.Errorf("line %d: user_id or group_id is empty", lineNum) + } + seUserField := parts[1] + if seUserField == "" { + return "", "", fmt.Errorf("line %d: seuser_id is empty", lineNum) + } + var levelField string + // level is optional + if len(parts) > 2 { + levelField = parts[2] + } + + // we found a match, return it + if userField == username { + return seUserField, levelField, nil + } + + // if the first field starts with '%' it's a group, check if + // the user is a member of that group and set the group + // SELinux user and level if so + if userField[0] == '%' && groupSeUser == "" { + if checkGroup(userField[1:], gids, lookupGroup) { + groupSeUser = seUserField + groupLevel = levelField + } + } else if userField == "__default__" && defaultSeUser == "" { + defaultSeUser = seUserField + defaultLevel = levelField + } + } + if err := scanner.Err(); err != nil { + return "", "", fmt.Errorf("failed to read seusers file: %w", err) + } + + if groupSeUser != "" { + return groupSeUser, groupLevel, nil + } + if defaultSeUser != "" { + return defaultSeUser, defaultLevel, nil + } + + return "", "", fmt.Errorf("could not find SELinux user for %q login", username) +} + +// getSeUserByName returns an SELinux user and MLS level that is +// mapped to a given Linux user. +func getSeUserByName(username string) (seUser string, level string, err error) { + seUsersConf := filepath.Join(policyRoot(), "seusers") + confFile, err := os.Open(seUsersConf) + if err != nil { + return "", "", fmt.Errorf("failed to open seusers file: %w", err) + } + defer confFile.Close() + + usr, err := user.Lookup(username) + if err != nil { + return "", "", fmt.Errorf("failed to lookup user %q", username) + } + gids, err := usr.GroupIds() + if err != nil { + return "", "", fmt.Errorf("failed to find user %q's groups", username) + } + gids = append([]string{usr.Gid}, gids...) + + seUser, level, err = getSeUserFromReader(username, gids, confFile, user.LookupGroup) + if err != nil { + return "", "", fmt.Errorf("failed to parse seusers file: %w", err) + } + + return seUser, level, nil +} + // findUserInContext scans the reader for a valid SELinux context // match that is verified with the verifier. Invalid contexts are // skipped. It returns a matched context or an empty string if no @@ -1238,6 +1351,33 @@ func findUserInContext(context Context, r io.Reader, verifier func(string) error return "", nil } +// getFailsafeContext returns the context in the failsafe_context file: +// https://www.man7.org/linux/man-pages/man5/failsafe_context.5.html +func getFailsafeContext(context Context, r io.Reader, verifier func(string) error) (string, error) { + conn := make([]byte, 256) + limReader := io.LimitReader(r, int64(len(conn))) + _, err := limReader.Read(conn) + if err != nil { + return "", fmt.Errorf("failed to read failsafe context: %w", err) + } + + conn = bytes.TrimSpace(conn) + toConns := strings.SplitN(string(conn), ":", 4) + if len(toConns) != 3 { + return "", nil + } + + context["role"] = toConns[0] + context["type"] = toConns[1] + + outConn := context.get() + if err := verifier(outConn); err != nil { + return "", nil + } + + return outConn, nil +} + func getDefaultContextFromReaders(c *defaultSECtx) (string, error) { if c.verifier == nil { return "", ErrVerifierNil @@ -1254,7 +1394,7 @@ func getDefaultContextFromReaders(c *defaultSECtx) (string, error) { conn, err := findUserInContext(context, c.userRdr, c.verifier) if err != nil { - return "", err + return "", fmt.Errorf("failed to read %q's user context file: %w", c.user, err) } if conn != "" { @@ -1263,7 +1403,16 @@ func getDefaultContextFromReaders(c *defaultSECtx) (string, error) { conn, err = findUserInContext(context, c.defaultRdr, c.verifier) if err != nil { - return "", err + return "", fmt.Errorf("failed to read default user context file: %w", err) + } + + if conn != "" { + return conn, nil + } + + conn, err = getFailsafeContext(context, c.failsafeRdr, c.verifier) + if err != nil { + return "", fmt.Errorf("failed to read failsafe_context: %w", err) } if conn != "" { @@ -1277,24 +1426,32 @@ func getDefaultContextWithLevel(user, level, scon string) (string, error) { userPath := filepath.Join(policyRoot(), selinuxUsersDir, user) fu, err := os.Open(userPath) if err != nil { - return "", err + return "", fmt.Errorf("failed to open %q's user context file: %w", user, err) } defer fu.Close() defaultPath := filepath.Join(policyRoot(), defaultContexts) fd, err := os.Open(defaultPath) if err != nil { - return "", err + return "", fmt.Errorf("failed to open default user context file: %w", err) } defer fd.Close() + failsafePath := filepath.Join(policyRoot(), failsafeContext) + fs, err := os.Open(failsafePath) + if err != nil { + return "", fmt.Errorf("failed to open failsafe user context file: %w", err) + } + defer fs.Close() + c := defaultSECtx{ - user: user, - level: level, - scon: scon, - userRdr: fu, - defaultRdr: fd, - verifier: securityCheckContext, + user: user, + level: level, + scon: scon, + userRdr: fu, + defaultRdr: fd, + failsafeRdr: fs, + verifier: securityCheckContext, } return getDefaultContextFromReaders(&c) diff --git a/go-selinux/selinux_linux_test.go b/go-selinux/selinux_linux_test.go index 71aa0b8..305d7d8 100644 --- a/go-selinux/selinux_linux_test.go +++ b/go-selinux/selinux_linux_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "os/user" "path/filepath" "runtime" "strconv" @@ -580,6 +581,143 @@ func TestGlbLub(t *testing.T) { } } +func TestGetSeUser(t *testing.T) { + lookupGroup := func(string) (*user.Group, error) { + return &user.Group{ + Gid: "42", + Name: "group", + }, nil + } + + tests := []struct { + name string + username string + gids []string + seUserBuf string + seUser string + level string + expectedErr string + }{ + { + name: "one entry match", + username: "bob", + seUserBuf: "bob:staff_u:s0", + seUser: "staff_u", + level: "s0", + }, + { + name: "match with no level", + username: "bob", + seUserBuf: "bob:staff_u", + seUser: "staff_u", + }, + { + name: "match", + username: "bob", + seUserBuf: ` +system_u:system_u:s0-s15:c0.c255 +root:root:s0-s15:c0.c255 +bob:staff_u:s0-s15:c0.c255`, + seUser: "staff_u", + level: "s0-s15:c0.c255", + }, + { + name: "match with comment", + username: "bob", + seUserBuf: ` +system_u:system_u:s0-s15:c0.c255 +# foobar +root:root:s0-s15:c0.c255 +bob:staff_u:s0-s15:c0.c255 #baz`, + seUser: "staff_u", + level: "s0-s15:c0.c255", + }, + { + name: "no match", + username: "bob", + seUserBuf: ` +system_u:system_u:s0-s15:c0.c255 +root:root:s0-s15:c0.c255`, + expectedErr: `could not find SELinux user for "bob" login`, + }, + { + name: "group match", + username: "bob", + gids: []string{"42"}, + seUserBuf: ` +system_u:system_u:s0-s15:c0.c255 +root:root:s0-s15:c0.c255 +%group:staff_u:s0`, + seUser: "staff_u", + level: "s0", + }, + { + name: "no group match", + username: "bob", + gids: []string{"99"}, + seUserBuf: ` +system_u:system_u:s0-s15:c0.c255 +root:root:s0-s15:c0.c255 +%group:staff_u:s0`, + expectedErr: `could not find SELinux user for "bob" login`, + }, + { + name: "malformed line", + username: "bob", + seUserBuf: ` +system_u:system_u:s0-s15:c0.c255 +root:root:s0-s15:c0.c255 +foobar +bob:staff_u:s0-s15:c0.c255`, + expectedErr: "line 3: malformed line", + }, + { + name: "empty user", + username: "bob", + seUserBuf: ` +system_u:system_u:s0-s15:c0.c255 +root:root:s0-s15:c0.c255 +:seuser_u +bob:staff_u:s0-s15:c0.c255`, + expectedErr: "line 3: user_id or group_id is empty", + }, + { + name: "empty seuser", + username: "bob", + seUserBuf: ` +system_u:system_u:s0-s15:c0.c255 +root:root:s0-s15:c0.c255 +user::s0 +bob:staff_u:s0-s15:c0.c255`, + expectedErr: "line 3: seuser_id is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + r := bytes.NewBufferString(tt.seUserBuf) + seUser, level, err := getSeUserFromReader(tt.username, tt.gids, r, lookupGroup) + if tt.expectedErr != "" { + if err == nil { + t.Fatal("expected an error but got nil") + } else if err.Error() != tt.expectedErr { + t.Fatalf("got error: %q but expected %q", err.Error(), tt.expectedErr) + } + } else if tt.expectedErr == "" && err != nil { + t.Fatalf("err should not exist but is: %v", err) + } + + if seUser != tt.seUser { + t.Fatalf("got seUser: %q but expected %q", seUser, tt.seUser) + } + if level != tt.level { + t.Fatalf("got level: %q but expected %q", level, tt.level) + } + }) + } +} + func TestContextWithLevel(t *testing.T) { want := "bob:sysadm_r:sysadm_t:SystemLow-SystemHigh" @@ -587,6 +725,7 @@ func TestContextWithLevel(t *testing.T) { foo_r:foo_t:s0 sysadm_r:sysadm_t:s0 staff_r:staff_t:s0 baz_r:baz_t:s0 sysadm_r:sysadm_t:s0 ` + goodFailsafeBuff := "unconfined_r:unconfined_t:s0" verifier := func(con string) error { if con != want { @@ -597,7 +736,7 @@ staff_r:staff_t:s0 baz_r:baz_t:s0 sysadm_r:sysadm_t:s0 } tests := []struct { - name, userBuff, defaultBuff string + name, userBuff, defaultBuff, failsafeBuff string }{ { name: "match exists in user context file", @@ -606,7 +745,8 @@ foo_r:foo_t:s0 sysadm_r:sysadm_t:s0 staff_r:staff_t:s0 baz_r:baz_t:s0 sysadm_r:sysadm_t:s0 `, - defaultBuff: goodDefaultBuff, + defaultBuff: goodDefaultBuff, + failsafeBuff: goodFailsafeBuff, }, { name: "match exists in default context file, but not in user file", @@ -614,7 +754,8 @@ staff_r:staff_t:s0 baz_r:baz_t:s0 sysadm_r:sysadm_t:s0 foo_r:foo_t:s0 sysadm_r:sysadm_t:s0 fake_r:fake_t:s0 baz_r:baz_t:s0 sysadm_r:sysadm_t:s0 `, - defaultBuff: goodDefaultBuff, + defaultBuff: goodDefaultBuff, + failsafeBuff: goodFailsafeBuff, }, } @@ -648,18 +789,27 @@ fake_r:fake_t:s0 baz_r:baz_t:s0 sysadm_r:sysadm_t:s0 dne_r:dne_t:s0 baz_r:baz_t:s0 sysadm_r:sysadm_t:s0 ` c := defaultSECtx{ - user: "bob", - level: "SystemLow-SystemHigh", - scon: "system_u:staff_r:staff_t:s0", - userRdr: bytes.NewBufferString(badUserBuff), - defaultRdr: bytes.NewBufferString(badDefaultBuff), - verifier: verifier, + user: "bob", + level: "SystemLow-SystemHigh", + scon: "system_u:staff_r:staff_t:s0", + userRdr: bytes.NewBufferString(badUserBuff), + defaultRdr: bytes.NewBufferString(badDefaultBuff), + failsafeRdr: bytes.NewBufferString(goodFailsafeBuff), + verifier: func(s string) error { + return nil + }, } - _, err := getDefaultContextFromReaders(&c) - if err == nil { - t.Fatalf("err was expected") + got, err := getDefaultContextFromReaders(&c) + if err != nil { + t.Fatalf("err should not exist but is: %v", err) } + + const want string = "bob:unconfined_r:unconfined_t:SystemLow-SystemHigh" + if got != want { + t.Fatalf("got context: %q but expected %q", got, want) + } + }) } diff --git a/go-selinux/selinux_stub.go b/go-selinux/selinux_stub.go index 0889fbe..4105c4c 100644 --- a/go-selinux/selinux_stub.go +++ b/go-selinux/selinux_stub.go @@ -146,6 +146,10 @@ func dupSecOpt(string) ([]string, error) { return nil, nil } +func getSeUserByName(string) (string, string, error) { + return "", "", nil +} + func getDefaultContextWithLevel(string, string, string) (string, error) { return "", nil }