Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 96 additions & 5 deletions pkg/runner/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand All @@ -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
})
}

Expand Down
60 changes: 59 additions & 1 deletion pkg/runner/commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
34 changes: 22 additions & 12 deletions pkg/runner/commit_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
}
}

Expand Down
Loading