diff --git a/x-pack/auditbeat/auditbeat.reference.yml b/x-pack/auditbeat/auditbeat.reference.yml index 306bff27bea2..34ec9d95665f 100644 --- a/x-pack/auditbeat/auditbeat.reference.yml +++ b/x-pack/auditbeat/auditbeat.reference.yml @@ -119,6 +119,11 @@ auditbeat.modules: state.period: 12h + # Enabled by default. Auditbeat will read password fields in + # /etc/passwd and /etc/shadow and store a hash locally to + # detect any changes. + user.detect_password_changes: true + report_changes: true diff --git a/x-pack/auditbeat/auditbeat.yml b/x-pack/auditbeat/auditbeat.yml index da592146a4e5..bf196386f863 100644 --- a/x-pack/auditbeat/auditbeat.yml +++ b/x-pack/auditbeat/auditbeat.yml @@ -57,6 +57,11 @@ auditbeat.modules: state.period: 12h + # Enabled by default. Auditbeat will read password fields in + # /etc/passwd and /etc/shadow and store a hash locally to + # detect any changes. + user.detect_password_changes: true + report_changes: true diff --git a/x-pack/auditbeat/module/system/_meta/config.yml.tmpl b/x-pack/auditbeat/module/system/_meta/config.yml.tmpl index 48ec0ba2439a..031c90c61efb 100644 --- a/x-pack/auditbeat/module/system/_meta/config.yml.tmpl +++ b/x-pack/auditbeat/module/system/_meta/config.yml.tmpl @@ -14,6 +14,13 @@ state.period: 12h + {{ if eq .GOOS "linux" -}} + # Enabled by default. Auditbeat will read password fields in + # /etc/passwd and /etc/shadow and store a hash locally to + # detect any changes. + user.detect_password_changes: true + {{- end }} + report_changes: true {{- end }} {{ if .Reference }} diff --git a/x-pack/auditbeat/module/system/user/config.go b/x-pack/auditbeat/module/system/user/config.go index 1e180d3f02be..de6d438b257e 100644 --- a/x-pack/auditbeat/module/system/user/config.go +++ b/x-pack/auditbeat/module/system/user/config.go @@ -8,24 +8,23 @@ import ( "time" ) -// Config defines the metricset's configuration options. -type Config struct { - StatePeriod time.Duration `config:"state.period"` - UserStatePeriod time.Duration `config:"user.state.period"` +// config defines the metricset's configuration options. +type config struct { + StatePeriod time.Duration `config:"state.period"` + UserStatePeriod time.Duration `config:"user.state.period"` + DetectPasswordChanges bool `config:"user.detect_password_changes"` } -// Validate validates the host metricset config. -func (c *Config) Validate() error { - return nil -} - -func (c *Config) effectiveStatePeriod() time.Duration { +func (c *config) effectiveStatePeriod() time.Duration { if c.UserStatePeriod != 0 { return c.UserStatePeriod } return c.StatePeriod } -var defaultConfig = Config{ - StatePeriod: 12 * time.Hour, +func defaultConfig() config { + return config{ + StatePeriod: 12 * time.Hour, + DetectPasswordChanges: false, + } } diff --git a/x-pack/auditbeat/module/system/user/user.go b/x-pack/auditbeat/module/system/user/user.go index 82e12a11d19e..0cbe57adefb2 100644 --- a/x-pack/auditbeat/module/system/user/user.go +++ b/x-pack/auditbeat/module/system/user/user.go @@ -8,6 +8,7 @@ package user import ( "bytes" + "encoding/binary" "encoding/gob" "fmt" "io" @@ -32,6 +33,10 @@ const ( moduleName = "system" metricsetName = "user" + passwdFile = "/etc/passwd" + groupFile = "/etc/group" + shadowFile = "/etc/shadow" + bucketName = "user.v1" bucketKeyUsers = "users" bucketKeyStateTimestamp = "state_timestamp" @@ -46,10 +51,35 @@ const ( eventActionPasswordChanged = "password_changed" ) +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 string + PasswordType passwordType PasswordChanged time.Time PasswordHashHash []byte UID uint32 @@ -72,7 +102,7 @@ func (user User) Hash() uint64 { h := xxhash.New64() // Use everything except userInfo h.WriteString(user.Name) - h.WriteString(user.PasswordType) + binary.Write(h, binary.BigEndian, uint8(user.PasswordType)) h.WriteString(user.PasswordChanged.String()) h.Write(user.PasswordHashHash) h.WriteString(strconv.Itoa(int(user.UID))) @@ -90,10 +120,7 @@ func (user User) Hash() uint64 { func (user User) toMapStr() common.MapStr { evt := common.MapStr{ - "name": user.Name, - "password": common.MapStr{ - "type": user.PasswordType, - }, + "name": user.Name, "uid": user.UID, "gid": user.GID, "dir": user.Dir, @@ -104,6 +131,10 @@ func (user User) toMapStr() common.MapStr { 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) } @@ -131,12 +162,13 @@ func init() { // MetricSet collects data about a system's users. type MetricSet struct { mb.BaseMetricSet - config Config - log *logp.Logger - cache *cache.Cache - bucket datastore.Bucket - lastState time.Time - lastChange time.Time + config config + log *logp.Logger + cache *cache.Cache + bucket datastore.Bucket + lastState time.Time + userFiles []string + lastRead time.Time } // New constructs a new MetricSet. @@ -146,7 +178,7 @@ func New(base mb.BaseMetricSet) (mb.MetricSet, error) { return nil, fmt.Errorf("the %v/%v dataset is only supported on Linux", moduleName, metricsetName) } - config := defaultConfig + config := defaultConfig() if err := base.Module().UnpackConfig(&config); err != nil { return nil, errors.Wrapf(err, "failed to unpack the %v/%v config", moduleName, metricsetName) } @@ -164,6 +196,12 @@ func New(base mb.BaseMetricSet) (mb.MetricSet, error) { 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 { @@ -224,7 +262,7 @@ func (ms *MetricSet) Fetch(report mb.ReporterV2) { func (ms *MetricSet) reportState(report mb.ReporterV2) error { ms.lastState = time.Now() - users, err := GetUsers() + users, err := GetUsers(ms.config.DetectPasswordChanges) if err != nil { return errors.Wrap(err, "failed to get users") } @@ -261,16 +299,21 @@ func (ms *MetricSet) reportState(report mb.ReporterV2) error { // reportChanges detects and reports any changes to users on this system since the last call. func (ms *MetricSet) reportChanges(report mb.ReporterV2) error { currentTime := time.Now() - changed, err := haveFilesChanged(ms.lastChange) - if err != nil { - return err - } - if !changed { - return nil + + // 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.lastChange = currentTime + ms.lastRead = currentTime - users, err := GetUsers() + users, err := GetUsers(ms.config.DetectPasswordChanges) if err != nil { return errors.Wrap(err, "failed to get users") } @@ -287,25 +330,31 @@ func (ms *MetricSet) reportChanges(report mb.ReporterV2) error { for _, userFromCache := range newInCache { newUser := userFromCache.(*User) - matchingMissingUser, found := missingUserMap[newUser.UID] + oldUser, found := missingUserMap[newUser.UID] if found { // Report password change separately - if newUser.PasswordChanged.Before(matchingMissingUser.PasswordChanged) || - !bytes.Equal(newUser.PasswordHashHash, matchingMissingUser.PasswordHashHash) || - newUser.PasswordType != matchingMissingUser.PasswordType { - report.Event(userEvent(newUser, eventTypeEvent, eventActionPasswordChanged)) + 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(userEvent(newUser, eventTypeEvent, eventActionPasswordChanged)) + } } // Hack to check if only the password changed - matchingMissingUser.PasswordChanged = newUser.PasswordChanged - matchingMissingUser.PasswordHashHash = newUser.PasswordHashHash - matchingMissingUser.PasswordType = newUser.PasswordType - if newUser.Hash() != matchingMissingUser.Hash() { + oldUser.PasswordChanged = newUser.PasswordChanged + oldUser.PasswordHashHash = newUser.PasswordHashHash + oldUser.PasswordType = newUser.PasswordType + if newUser.Hash() != oldUser.Hash() { report.Event(userEvent(newUser, eventTypeEvent, eventActionUserChanged)) } - delete(missingUserMap, matchingMissingUser.UID) + delete(missingUserMap, oldUser.UID) } else { report.Event(userEvent(newUser, eventTypeEvent, eventActionUserAdded)) } @@ -409,33 +458,20 @@ func (ms *MetricSet) saveUsersToDisk(users []*User) error { return nil } -// haveFilesChanged checks if any of the relevant files (/etc/passwd, /etc/shadow, /etc/group) -// have changed. -func haveFilesChanged(since time.Time) (bool, error) { - const passwdFile = "/etc/passwd" - const shadowFile = "/etc/shadow" - const groupFile = "/etc/group" - +// haveFilesChanged checks if the ctime of any of the user files has changed. +func (ms *MetricSet) haveFilesChanged() (bool, error) { var stats syscall.Stat_t - if err := syscall.Stat(passwdFile, &stats); err != nil { - return true, errors.Wrapf(err, "failed to stat %v", passwdFile) - } - if since.Before(time.Unix(stats.Ctim.Sec, stats.Ctim.Nsec)) { - return true, nil - } + for _, path := range ms.userFiles { + if err := syscall.Stat(path, &stats); err != nil { + return true, errors.Wrapf(err, "failed to stat %v", path) + } - if err := syscall.Stat(shadowFile, &stats); err != nil { - return true, errors.Wrapf(err, "failed to stat %v", shadowFile) - } - if since.Before(time.Unix(stats.Ctim.Sec, stats.Ctim.Nsec)) { - return true, nil - } + ctime := time.Unix(stats.Ctim.Sec, stats.Ctim.Nsec) + if ms.lastRead.Before(ctime) { + ms.log.Debugf("File changed: %v (lastRead=%v, ctime=%v)", path, ms.lastRead, ctime) - if err := syscall.Stat(groupFile, &stats); err != nil { - return true, errors.Wrapf(err, "failed to stat %v", groupFile) - } - if since.Before(time.Unix(stats.Ctim.Sec, stats.Ctim.Nsec)) { - return true, nil + return true, nil + } } return false, nil diff --git a/x-pack/auditbeat/module/system/user/users_linux.go b/x-pack/auditbeat/module/system/user/users_linux.go index a8946a2af8da..a65bbaa17a2b 100644 --- a/x-pack/auditbeat/module/system/user/users_linux.go +++ b/x-pack/auditbeat/module/system/user/users_linux.go @@ -25,18 +25,32 @@ var ( epoch = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) ) -// GetUsers retrieves a list of users using getpwent(3). -func GetUsers() (users []*User, err error) { - gidToGroup, userToGroup, err := readGroupFile() +// GetUsers retrieves a list of users using information from +// /etc/passwd, /etc/group, and - if configured - /etc/shadow. +func GetUsers(readPasswords bool) ([]*User, error) { + users, err := readPasswdFile(readPasswords) if err != nil { return nil, err } - shadowEntries, err := readShadowFile() + err = enrichWithGroups(users) if err != nil { return nil, err } + if readPasswords { + err = enrichWithShadow(users) + if err != nil { + return nil, err + } + } + + return users, nil +} + +func readPasswdFile(readPasswords bool) ([]*User, error) { + var users []*User + C.setpwent() defer C.endpwent() @@ -55,6 +69,35 @@ func GetUsers() (users []*User, err error) { Shell: C.GoString(passwd.pw_shell), } + if readPasswords { + switch C.GoString(passwd.pw_passwd) { + case "x": + user.PasswordType = shadowPassword + case "*": + user.PasswordType = passwordDisabled + case "": + user.PasswordType = noPassword + default: + user.PasswordType = cryptPassword + user.PasswordHashHash = multiRoundHash(C.GoString(passwd.pw_passwd)) + } + } else { + user.PasswordType = detectionDisabled + } + + users = append(users, user) + } + + return users, nil +} + +func enrichWithGroups(users []*User) error { + gidToGroup, userToGroup, err := readGroupFile() + if err != nil { + return err + } + + for _, user := range users { primaryGroup, found := gidToGroup[user.GID] if found { user.Groups = append(user.Groups, primaryGroup) @@ -64,24 +107,18 @@ func GetUsers() (users []*User, err error) { if found { user.Groups = append(user.Groups, secondaryGroups...) } + } - const shadowPassword = "shadow_password" - const passwordDisabled = "password_disabled" - const noPassword = "no_password" - const cryptPassword = "crypt_password" - switch C.GoString(passwd.pw_passwd) { - case "x": - user.PasswordType = shadowPassword - case "*": - user.PasswordType = passwordDisabled - case "": - user.PasswordType = noPassword - default: - user.PasswordType = cryptPassword - hash := sha512.Sum512([]byte(C.GoString(passwd.pw_passwd))) - user.PasswordHashHash = hash[:] - } + return nil +} + +func enrichWithShadow(users []*User) error { + shadowEntries, err := readShadowFile() + if err != nil { + return err + } + for _, user := range users { if user.PasswordType == shadowPassword { shadow, found := shadowEntries[user.Name] if found { @@ -92,16 +129,13 @@ func GetUsers() (users []*User, err error) { } else if strings.HasPrefix(shadow.Password, "!") || strings.HasPrefix(shadow.Password, "*") { user.PasswordType = passwordDisabled } else { - hash := sha512.Sum512([]byte(shadow.Password)) - user.PasswordHashHash = hash[:] + user.PasswordHashHash = multiRoundHash(shadow.Password) } } } - - users = append(users, user) } - return users, nil + return nil } // readGroupFile reads /etc/group and returns two maps: @@ -180,3 +214,12 @@ func readShadowFile() (map[string]shadowFileEntry, error) { return shadowEntries, nil } + +// multiRoundHash performs 10 rounds of SHA-512 hashing. +func multiRoundHash(s string) []byte { + hash := sha512.Sum512([]byte(s)) + for i := 0; i < 9; i++ { + hash = sha512.Sum512(hash[:]) + } + return hash[:] +}