x-pack/auditbeat/module/system/user/user.go (484 lines of code) (raw):

// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one // or more contributor license agreements. Licensed under the Elastic License; // you may not use this file except in compliance with the Elastic License. //go:build linux && cgo package user import ( "bytes" "encoding/binary" "encoding/gob" "errors" "fmt" "io" "os/user" "runtime" "strings" "syscall" "time" "github.com/cespare/xxhash/v2" "github.com/gofrs/uuid/v5" "github.com/elastic/beats/v7/auditbeat/ab" "github.com/elastic/beats/v7/auditbeat/datastore" "github.com/elastic/beats/v7/libbeat/common/cfgwarn" "github.com/elastic/beats/v7/metricbeat/mb" "github.com/elastic/beats/v7/x-pack/auditbeat/cache" "github.com/elastic/beats/v7/x-pack/auditbeat/module/system" "github.com/elastic/elastic-agent-libs/logp" "github.com/elastic/elastic-agent-libs/mapstr" ) const ( metricsetName = "user" namespace = "system.audit.user" passwdFile = "/etc/passwd" groupFile = "/etc/group" shadowFile = "/etc/shadow" bucketName = "user.v1" bucketKeyUsers = "users" bucketKeyStateTimestamp = "state_timestamp" eventTypeState = "state" eventTypeEvent = "event" ) type eventAction uint8 const ( eventActionExistingUser eventAction = iota eventActionUserAdded eventActionUserRemoved eventActionUserChanged eventActionPasswordChanged ) func (action eventAction) String() string { switch action { case eventActionExistingUser: return "existing_user" case eventActionUserAdded: return "user_added" case eventActionUserRemoved: return "user_removed" case eventActionUserChanged: return "user_changed" case eventActionPasswordChanged: return "password_changed" default: return "" } } func (action eventAction) Type() string { switch action { case eventActionExistingUser: return "info" case eventActionUserAdded: return "creation" case eventActionUserRemoved: return "deletion" case eventActionUserChanged: return "change" case eventActionPasswordChanged: return "change" default: return "info" } } type passwordType uint8 const ( detectionDisabled passwordType = iota shadowPassword passwordDisabled noPassword cryptPassword ) func (t passwordType) String() string { switch t { case shadowPassword: return "shadow_password" case passwordDisabled: return "password_disabled" case noPassword: return "no_password" case cryptPassword: return "crypt_password" default: return "" } } // User represents a user. Fields according to getpwent(3). type User struct { Name string PasswordType passwordType PasswordChanged time.Time PasswordHashHash []byte UID string GID string Groups []*user.Group UserInfo string Dir string Shell string Action string } // Hash creates a hash for User. func (user User) Hash() uint64 { h := xxhash.New() // Use everything except userInfo //nolint:errcheck // err always nil h.WriteString(user.Name) //nolint:errcheck // err always nil binary.Write(h, binary.BigEndian, uint8(user.PasswordType)) //nolint:errcheck // err always nil h.WriteString(user.PasswordChanged.String()) //nolint:errcheck // err always nil h.Write(user.PasswordHashHash) //nolint:errcheck // err always nil h.WriteString(user.UID) //nolint:errcheck // err always nil h.WriteString(user.GID) //nolint:errcheck // err always nil h.WriteString(user.Dir) //nolint:errcheck // err always nil h.WriteString(user.Shell) for _, group := range user.Groups { //nolint:errcheck // err always nil h.WriteString(group.Name) //nolint:errcheck // err always nil h.WriteString(group.Gid) } return h.Sum64() } func (user User) toMapStr() mapstr.M { evt := mapstr.M{ "name": user.Name, "uid": user.UID, "gid": user.GID, "dir": user.Dir, "shell": user.Shell, } if user.UserInfo != "" { evt.Put("user_information", user.UserInfo) } if user.PasswordType != detectionDisabled { evt.Put("password.type", user.PasswordType.String()) } if !user.PasswordChanged.IsZero() { evt.Put("password.last_changed", user.PasswordChanged) } if len(user.Groups) > 0 { var groupMapStr []mapstr.M for _, group := range user.Groups { groupMapStr = append(groupMapStr, mapstr.M{ "name": group.Name, "gid": group.Gid, "id": group.Gid, }) } evt.Put("group", groupMapStr) } return evt } func (user User) PrimaryGroup() *user.Group { for _, group := range user.Groups { if group.Gid == user.GID { return group } } return nil } // entityID creates an ID that uniquely identifies this user across machines. func (user User) entityID(hostID string) string { h := system.NewEntityHash() h.Write([]byte(hostID)) h.Write([]byte(user.Name)) h.Write([]byte(user.UID)) return h.Sum() } func init() { ab.Registry.MustAddMetricSet(system.ModuleName, metricsetName, New, mb.DefaultMetricSet(), mb.WithNamespace(namespace), ) } // MetricSet collects data about a system's users. type MetricSet struct { system.SystemMetricSet config config log *logp.Logger cache *cache.Cache[*User] bucket datastore.Bucket lastState time.Time userFiles []string lastRead time.Time } // New constructs a new MetricSet. func New(base mb.BaseMetricSet) (mb.MetricSet, error) { cfgwarn.Beta("The %v/%v dataset is beta", system.ModuleName, metricsetName) if runtime.GOOS != "linux" { return nil, fmt.Errorf("the %v/%v dataset is only supported on Linux", system.ModuleName, metricsetName) } config := defaultConfig() if err := base.Module().UnpackConfig(&config); err != nil { return nil, fmt.Errorf("failed to unpack the %v/%v config: %w", system.ModuleName, metricsetName, err) } bucket, err := datastore.OpenBucket(bucketName) if err != nil { return nil, fmt.Errorf("failed to open persistent datastore: %w", err) } ms := &MetricSet{ SystemMetricSet: system.NewSystemMetricSet(base), config: config, log: logp.NewLogger(metricsetName), cache: cache.New[*User](), bucket: bucket, } if ms.config.DetectPasswordChanges { ms.userFiles = []string{passwdFile, groupFile, shadowFile} } else { ms.userFiles = []string{passwdFile, groupFile} } // Load from disk: Time when state was last sent err = bucket.Load(bucketKeyStateTimestamp, func(blob []byte) error { if len(blob) > 0 { return ms.lastState.UnmarshalBinary(blob) } return nil }) if err != nil { return nil, err } if !ms.lastState.IsZero() { ms.log.Debugf("Last state was sent at %v. Next state update by %v.", ms.lastState, ms.lastState.Add(ms.config.effectiveStatePeriod())) } else { ms.log.Debug("No state timestamp found") } // Load from disk: Users users, err := ms.restoreUsersFromDisk() if err != nil { return nil, fmt.Errorf("failed to restore users from disk: %w", err) } ms.log.Debugf("Restored %d users from disk", len(users)) ms.cache.DiffAndUpdateCache(users) return ms, nil } // Close cleans up the MetricSet when it finishes. func (ms *MetricSet) Close() error { if ms.bucket != nil { return ms.bucket.Close() } return nil } // Fetch collects the user information. It is invoked periodically. func (ms *MetricSet) Fetch(report mb.ReporterV2) { needsStateUpdate := time.Since(ms.lastState) > ms.config.effectiveStatePeriod() if needsStateUpdate || ms.cache.IsEmpty() { ms.log.Debugf("State update needed (needsStateUpdate=%v, cache.IsEmpty()=%v)", needsStateUpdate, ms.cache.IsEmpty()) err := ms.reportState(report) if err != nil { ms.log.Error(err) report.Error(err) } ms.log.Debugf("Next state update by %v", ms.lastState.Add(ms.config.effectiveStatePeriod())) } err := ms.reportChanges(report) if err != nil { ms.log.Error(err) report.Error(err) } } // reportState reports all existing users on the system. func (ms *MetricSet) reportState(report mb.ReporterV2) error { var errs []error ms.lastState = time.Now() users, err := GetUsers(ms.config.DetectPasswordChanges) if err != nil { errs = append(errs, fmt.Errorf("error while getting users: %w", err)) } ms.log.Debugf("Found %v users", len(users)) if len(users) > 0 { stateID, err := uuid.NewV4() if err != nil { errs = append(errs, fmt.Errorf("error generating state ID: %w", err)) } for _, user := range users { event := ms.userEvent(user, eventTypeState, eventActionExistingUser) event.RootFields.Put("event.id", stateID.String()) report.Event(event) } if ms.cache != nil { // This will initialize the cache with the current processes ms.cache.DiffAndUpdateCache(users) } // Save time so we know when to send the state again (config.StatePeriod) timeBytes, err := ms.lastState.MarshalBinary() if err != nil { errs = append(errs, err) } else { err = ms.bucket.Store(bucketKeyStateTimestamp, timeBytes) if err != nil { errs = append(errs, fmt.Errorf("error writing state timestamp to disk: %w", err)) } } err = ms.saveUsersToDisk(users) if err != nil { errs = append(errs, err) } } return errors.Join(errs...) } // reportChanges detects and reports any changes to users on this system since the last call. func (ms *MetricSet) reportChanges(report mb.ReporterV2) error { var errs []error currentTime := time.Now() // If this is not the first call to Fetch/reportChanges, // check if files have changed since the last time before going any further. if !ms.lastRead.IsZero() { changed, err := ms.haveFilesChanged() if err != nil { return err } if !changed { return nil } } ms.lastRead = currentTime users, err := GetUsers(ms.config.DetectPasswordChanges) if err != nil { errs = append(errs, fmt.Errorf("error while getting users: %w", err)) } ms.log.Debugf("Found %v users", len(users)) if len(users) > 0 { newInCache, missingFromCache := ms.cache.DiffAndUpdateCache(users) if len(newInCache) > 0 && len(missingFromCache) > 0 { // Check for changes to users missingUserMap := make(map[string](*User)) for _, missingUser := range missingFromCache { missingUserMap[missingUser.UID] = missingUser } for _, userFromCache := range newInCache { newUser := userFromCache oldUser, found := missingUserMap[newUser.UID] if found { // Report password change separately if ms.config.DetectPasswordChanges && newUser.PasswordType != detectionDisabled && oldUser.PasswordType != detectionDisabled { passwordChanged := newUser.PasswordChanged.Before(oldUser.PasswordChanged) || !bytes.Equal(newUser.PasswordHashHash, oldUser.PasswordHashHash) || newUser.PasswordType != oldUser.PasswordType if passwordChanged { report.Event(ms.userEvent(newUser, eventTypeEvent, eventActionPasswordChanged)) } } // Hack to check if only the password changed oldUser.PasswordChanged = newUser.PasswordChanged oldUser.PasswordHashHash = newUser.PasswordHashHash oldUser.PasswordType = newUser.PasswordType if newUser.Hash() != oldUser.Hash() { report.Event(ms.userEvent(newUser, eventTypeEvent, eventActionUserChanged)) } delete(missingUserMap, oldUser.UID) } else { report.Event(ms.userEvent(newUser, eventTypeEvent, eventActionUserAdded)) } } for _, missingUser := range missingUserMap { report.Event(ms.userEvent(missingUser, eventTypeEvent, eventActionUserRemoved)) } } else { // No changes to users for _, user := range newInCache { report.Event(ms.userEvent(user, eventTypeEvent, eventActionUserAdded)) } for _, user := range missingFromCache { report.Event(ms.userEvent(user, eventTypeEvent, eventActionUserRemoved)) } } if len(newInCache) > 0 || len(missingFromCache) > 0 { err = ms.saveUsersToDisk(users) if err != nil { errs = append(errs, err) } } } return errors.Join(errs...) } func (ms *MetricSet) userEvent(user *User, eventType string, action eventAction) mb.Event { event := mb.Event{ RootFields: mapstr.M{ "event": mapstr.M{ "kind": eventType, "category": []string{"iam"}, "type": []string{action.Type()}, "action": action.String(), }, "user": mapstr.M{ "id": user.UID, "name": user.Name, }, "related": mapstr.M{ "user": []string{user.Name}, }, "message": userMessage(user, action), }, MetricSetFields: user.toMapStr(), } if ms.HostID() != "" { event.RootFields.Put("user.entity_id", user.entityID(ms.HostID())) } primaryGroup := user.PrimaryGroup() if primaryGroup != nil { event.RootFields.Put("user.group", mapstr.M{ "id": primaryGroup.Gid, "name": primaryGroup.Name, }) } else if user.GID != "" { // fallback to just filling out the GID event.RootFields.Put("user.group", mapstr.M{ "id": user.GID, }) } return event } func userMessage(user *User, action eventAction) string { var actionString string switch action { case eventActionExistingUser: actionString = "Existing" case eventActionUserAdded: actionString = "New" case eventActionUserRemoved: actionString = "Removed" case eventActionUserChanged: actionString = "Changed" case eventActionPasswordChanged: actionString = "Password changed for" } return fmt.Sprintf("%v user %v (UID: %v, Groups: %v)", actionString, user.Name, user.UID, fmtGroups(user.Groups)) } func fmtGroups(groups []*user.Group) string { var b strings.Builder if len(groups) > 0 { b.WriteString(groups[0].Name) for _, group := range groups[1:] { b.WriteString(",") b.WriteString(group.Name) } } return b.String() } // restoreUsersFromDisk loads the user cache from disk. func (ms *MetricSet) restoreUsersFromDisk() (users []*User, err error) { var decoder *gob.Decoder err = ms.bucket.Load(bucketKeyUsers, func(blob []byte) error { if len(blob) > 0 { buf := bytes.NewBuffer(blob) decoder = gob.NewDecoder(buf) } return nil }) if err != nil { return nil, err } if decoder != nil { for { user := new(User) err = decoder.Decode(user) if err == nil { users = append(users, user) } else if errors.Is(err, io.EOF) { // Read all users break } else { return nil, fmt.Errorf("error decoding users: %w", err) } } } return users, nil } // Save user cache to disk. func (ms *MetricSet) saveUsersToDisk(users []*User) error { var buf bytes.Buffer encoder := gob.NewEncoder(&buf) for _, user := range users { err := encoder.Encode(*user) if err != nil { return fmt.Errorf("error encoding users: %w", err) } } err := ms.bucket.Store(bucketKeyUsers, buf.Bytes()) if err != nil { return fmt.Errorf("error writing users to disk: %w", err) } return nil } // haveFilesChanged checks if the ctime of any of the user files has changed. func (ms *MetricSet) haveFilesChanged() (bool, error) { var stats syscall.Stat_t for _, path := range ms.userFiles { if err := syscall.Stat(path, &stats); err != nil { return true, fmt.Errorf("failed to stat %v: %w", path, err) } //nolint:unconvert // false positive ctime := time.Unix(int64(stats.Ctim.Sec), int64(stats.Ctim.Nsec)) if ms.lastRead.Before(ctime) { ms.log.Debugf("File changed: %v (lastRead=%v, ctime=%v)", path, ms.lastRead, ctime) return true, nil } } return false, nil }