diff --git a/pkg/runner/commit.go b/pkg/runner/commit.go index eb3b81d..7112b6d 100644 --- a/pkg/runner/commit.go +++ b/pkg/runner/commit.go @@ -33,6 +33,8 @@ const ( passwdFilePath = "/etc/passwd" groupFilePath = "/etc/group" + shellsFilePath = "/etc/shells" + shadowFilePath = "/etc/shadow" IFF_UP = 1 << 0 // Interface is up IFF_LOOPBACK = 1 << 3 // Loopback interface @@ -385,9 +387,84 @@ func getTimeData() (TimeData, error) { }, nil } +// loadValidShells reads /etc/shells and returns the list of valid login shells +func loadValidShells() []string { + var shells []string + + file, err := os.Open(shellsFilePath) + if err != nil { + log.Debug().Err(err).Msg("Failed to open /etc/shells, skipping shell validation") + return nil + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + shells = append(shells, line) + } + + if err := scanner.Err(); err != nil { + log.Debug().Err(err).Msg("Error reading /etc/shells") + return nil + } + + return shells +} + +// loadShadowData reads /etc/shadow and returns expiration info by username +func loadShadowData() map[string]shadowEntry { + entries := make(map[string]shadowEntry) + + file, err := os.Open(shadowFilePath) + if err != nil { + log.Debug().Err(err).Msg("Failed to open /etc/shadow, skipping expiration checks") + return nil + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Split(line, ":") + if len(fields) < 2 { + continue + } + + username := fields[0] + + entry := shadowEntry{ + username: username, + } + + // Get raw expire date (8th field, index 7) + if len(fields) >= 8 && fields[7] != "" { + if expireDate, err := strconv.ParseInt(fields[7], 10, 64); err == nil { + entry.expireDate = &expireDate + } + } + + entries[username] = entry + } + + if err := scanner.Err(); err != nil { + log.Debug().Err(err).Msg("Error reading /etc/shadow") + return nil + } + + return entries +} + func getUserData() ([]UserData, error) { users := []UserData{} + // Load validation data once for all users + validShells := loadValidShells() // []string - /etc/shells list + shadowData := loadShadowData() + file, err := os.Open(passwdFilePath) if err != nil { log.Debug().Err(err).Msg("Failed to open passwd file.") @@ -413,12 +490,26 @@ func getUserData() ([]UserData, error) { continue } + username := fields[0] + + // Collect raw data for server-side login_enabled determination + var shadowExpireDate *int64 + + // /etc/shadow data + if shadowData != nil { + if entry, exists := shadowData[username]; exists { + shadowExpireDate = entry.expireDate // raw days since epoch + } + } + users = append(users, UserData{ - Username: fields[0], - UID: uid, - GID: gid, - Directory: fields[5], - Shell: fields[6], + Username: username, + UID: uid, + GID: gid, + Directory: fields[5], + Shell: fields[6], + ShadowExpireDate: shadowExpireDate, + ValidShells: validShells, // same list for all users }) } diff --git a/pkg/runner/commit_test.go b/pkg/runner/commit_test.go index effb52e..95ea319 100644 --- a/pkg/runner/commit_test.go +++ b/pkg/runner/commit_test.go @@ -55,7 +55,65 @@ func TestGetUserData(t *testing.T) { assert.NotNil(t, user.UID, "uid should not be empty.") assert.NotNil(t, user.GID, "GID should not be empty.") assert.NotEmpty(t, user.Directory, "Directory should not be empty.") - assert.NotEmpty(t, user.Shell, "Shell should not be empty.") + // Raw data fields may be nil if the corresponding system file is not readable + // Server will determine login_enabled from these raw data fields + } +} + +func TestLoadValidShells(t *testing.T) { + // This test verifies that loadValidShells can read and parse the shells file + // On macOS/Linux, /etc/shells should exist with common shells + shells := loadValidShells() + + // shells may be nil if /etc/shells is not readable, which is acceptable + if shells != nil { + // If shells file was read, it should contain at least one shell + assert.True(t, len(shells) > 0, "Valid shells slice should not be empty when /etc/shells is readable") + + // Common shells that should be in the file on most systems + commonShells := []string{"/bin/sh", "/bin/bash", "/bin/zsh"} + foundAny := false + for _, commonShell := range commonShells { + for _, shell := range shells { + if shell == commonShell { + foundAny = true + break + } + } + if foundAny { + break + } + } + assert.True(t, foundAny, "At least one common shell should be in /etc/shells") + } +} + +func TestLoadShadowData(t *testing.T) { + // This test verifies that loadShadowData can attempt to read the shadow file + // Note: /etc/shadow requires root privileges on most systems + shadowData := loadShadowData() + + // shadowData may be nil if /etc/shadow is not readable (permission denied) + // This is expected behavior on non-root execution + // The function should not panic or error + // Note: range over nil map is safe and does nothing + for username, entry := range shadowData { + assert.NotEmpty(t, username, "Username should not be empty") + assert.Equal(t, username, entry.username, "Entry username should match key") + // expireDate is *int64 (may be nil) + } +} + +func TestGetUserDataWithRawFields(t *testing.T) { + userData, err := getUserData() + assert.NoError(t, err, "Failed to get user data") + + assert.NotEmpty(t, userData, "User data should not be empty.") + for _, user := range userData { + assert.NotEmpty(t, user.Username, "Username should not be empty.") + // ValidShells will be set if /etc/shells was readable (same for all users) + // ShadowExpireDate will be set if /etc/shadow was readable + // These raw data fields enable server-side login_enabled determination } } diff --git a/pkg/runner/commit_types.go b/pkg/runner/commit_types.go index ad8fdf7..0ca75a3 100644 --- a/pkg/runner/commit_types.go +++ b/pkg/runner/commit_types.go @@ -105,13 +105,15 @@ type TimeData struct { } type UserData struct { - ID string `json:"id,omitempty"` - UID int `json:"uid"` - GID int `json:"gid"` - Username string `json:"username"` - Description string `json:"description"` - Directory string `json:"directory"` - Shell string `json:"shell"` + ID string `json:"id,omitempty"` + UID int `json:"uid"` + GID int `json:"gid"` + Username string `json:"username"` + Description string `json:"description"` + Directory string `json:"directory"` + Shell string `json:"shell"` + ShadowExpireDate *int64 `json:"shadow_expire_date,omitempty"` // /etc/shadow: raw expiration date (days since epoch) + ValidShells []string `json:"valid_shells,omitempty"` // /etc/shells: full list of valid login shells } type GroupData struct { @@ -154,6 +156,12 @@ type Partition struct { IsVirtual bool `json:"is_virtual"` } +// shadowEntry represents a parsed /etc/shadow entry (internal use only) +type shadowEntry struct { + username string + expireDate *int64 // days since epoch, nil if not set +} + type commitData struct { Version string `json:"version"` Load float64 `json:"load"` @@ -247,11 +255,13 @@ func (u UserData) GetKey() interface{} { func (u UserData) GetData() ComparableData { return UserData{ - Username: u.Username, - UID: u.UID, - GID: u.GID, - Directory: u.Directory, - Shell: u.Shell, + Username: u.Username, + UID: u.UID, + GID: u.GID, + Directory: u.Directory, + Shell: u.Shell, + ShadowExpireDate: u.ShadowExpireDate, + ValidShells: u.ValidShells, } }