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 0875205..dcf0f4b 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" @@ -53,10 +54,19 @@ type mlsRange struct { high *level } +type openReaderCloser func() (io.ReadCloser, error) + +func createOpener(path string) openReaderCloser { + return func() (io.ReadCloser, error) { + return os.Open(path) + } +} + type defaultSECtx struct { - userRdr io.Reader + openUserRdr openReaderCloser verifier func(string) error - defaultRdr io.Reader + openDefaultRdr openReaderCloser + openFailsafeRdr openReaderCloser user, level, scon string } @@ -1181,6 +1191,122 @@ 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 + reader := bufio.NewReader(r) + for { + lineBytes, readErr := reader.ReadBytes('\n') + if readErr != nil { + if !errors.Is(readErr, io.EOF) { + return "", "", fmt.Errorf("failed to read seusers file: %w", readErr) + } + } + lineNum++ + + // remove any trailing comments, then extra whitespace + line, _, _ := strings.Cut(string(lineBytes), "#") + line = strings.TrimSpace(line) + if line == "" { + continue + } + + userField, rest, ok := strings.Cut(line, ":") + if !ok { + return "", "", fmt.Errorf("line %d: malformed line", lineNum) + } + if userField == "" { + return "", "", fmt.Errorf("line %d: user_id or group_id is empty", lineNum) + } + seUserField, rest, ok := strings.Cut(rest, ":") + if seUserField == "" { + return "", "", fmt.Errorf("line %d: seuser_id is empty", lineNum) + } + var levelField string + // level is optional + if ok { + levelField = rest + } + + // 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 errors.Is(readErr, io.EOF) { + break + } + } + + 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) (string, string, 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 "", "", err + } + gids, err := usr.GroupIds() + if err != nil { + return "", "", err + } + 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 +1364,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 @@ -1252,18 +1405,45 @@ func getDefaultContextFromReaders(c *defaultSECtx) (string, error) { context["user"] = c.user context["level"] = c.level - conn, err := findUserInContext(context, c.userRdr, c.verifier) + userRdr, err := c.openUserRdr() if err != nil { - return "", err + return "", fmt.Errorf("failed to open user context file: %w", err) + } + defer userRdr.Close() + + conn, err := findUserInContext(context, userRdr, c.verifier) + if err != nil { + return "", fmt.Errorf("failed to read %q's user context file: %w", c.user, err) } if conn != "" { return conn, nil } - conn, err = findUserInContext(context, c.defaultRdr, c.verifier) + defaultRdr, err := c.openDefaultRdr() if err != nil { - return "", err + return "", fmt.Errorf("failed to open default context file: %w", err) + } + defer defaultRdr.Close() + + conn, err = findUserInContext(context, defaultRdr, c.verifier) + if err != nil { + return "", fmt.Errorf("failed to read default user context file: %w", err) + } + + if conn != "" { + return conn, nil + } + + failsafeRdr, err := c.openFailsafeRdr() + if err != nil { + return "", fmt.Errorf("failed to open failsafe context file: %w", err) + } + defer failsafeRdr.Close() + + conn, err = getFailsafeContext(context, failsafeRdr, c.verifier) + if err != nil { + return "", fmt.Errorf("failed to read failsafe_context: %w", err) } if conn != "" { @@ -1275,26 +1455,17 @@ func getDefaultContextFromReaders(c *defaultSECtx) (string, error) { func getDefaultContextWithLevel(user, level, scon string) (string, error) { userPath := filepath.Join(policyRoot(), selinuxUsersDir, user) - fu, err := os.Open(userPath) - if err != nil { - return "", err - } - defer fu.Close() - defaultPath := filepath.Join(policyRoot(), defaultContexts) - fd, err := os.Open(defaultPath) - if err != nil { - return "", err - } - defer fd.Close() + failsafePath := filepath.Join(policyRoot(), failsafeContext) c := defaultSECtx{ - user: user, - level: level, - scon: scon, - userRdr: fu, - defaultRdr: fd, - verifier: securityCheckContext, + user: user, + level: level, + scon: scon, + openUserRdr: createOpener(userPath), + openDefaultRdr: createOpener(defaultPath), + openFailsafeRdr: createOpener(failsafePath), + verifier: securityCheckContext, } return getDefaultContextFromReaders(&c) diff --git a/go-selinux/selinux_linux_test.go b/go-selinux/selinux_linux_test.go index 71aa0b8..74552b9 100644 --- a/go-selinux/selinux_linux_test.go +++ b/go-selinux/selinux_linux_test.go @@ -5,7 +5,9 @@ import ( "bytes" "errors" "fmt" + "io" "os" + "os/user" "path/filepath" "runtime" "strconv" @@ -580,6 +582,163 @@ 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", + }, + { + name: "one entry match with whitespace", + username: "bob", + seUserBuf: " bob:staff_u:s0 ", + seUser: "staff_u", + level: "s0", + }, + { + name: "one entry match with trailing comment", + username: "bob", + seUserBuf: "bob:staff_u:s0#comment", + seUser: "staff_u", + level: "s0", + }, + { + name: "one entry match with whitespace and trailing comment", + username: "bob", + seUserBuf: " bob:staff_u:s0 #comment ", + seUser: "staff_u", + level: "s0", + }, + } + + 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 +746,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 +757,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 +766,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,19 +775,24 @@ 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, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := defaultSECtx{ - user: "bob", - level: "SystemLow-SystemHigh", - scon: "system_u:staff_r:staff_t:s0", - userRdr: bytes.NewBufferString(tt.userBuff), - defaultRdr: bytes.NewBufferString(tt.defaultBuff), - verifier: verifier, + user: "bob", + level: "SystemLow-SystemHigh", + scon: "system_u:staff_r:staff_t:s0", + openUserRdr: func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewBufferString(tt.userBuff)), nil + }, + openDefaultRdr: func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewBufferString(tt.defaultBuff)), nil + }, + verifier: verifier, } got, err := getDefaultContextFromReaders(&c) @@ -648,17 +814,31 @@ 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", + openUserRdr: func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewBufferString(badUserBuff)), nil + }, + openDefaultRdr: func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewBufferString(badDefaultBuff)), nil + }, + openFailsafeRdr: func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewBufferString(goodFailsafeBuff)), nil + }, + verifier: func(s string) error { + return nil + }, + } + + got, err := getDefaultContextFromReaders(&c) + if err != nil { + t.Fatalf("err should not exist but is: %v", err) } - _, err := getDefaultContextFromReaders(&c) - if err == nil { - t.Fatalf("err was expected") + 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 }