diff --git a/auditbeat/docs/fields.asciidoc b/auditbeat/docs/fields.asciidoc index 262a8ae5ebbb..979ee40b6947 100644 --- a/auditbeat/docs/fields.asciidoc +++ b/auditbeat/docs/fields.asciidoc @@ -4965,3 +4965,4 @@ Kubernetes container image -- + diff --git a/x-pack/auditbeat/include/list.go b/x-pack/auditbeat/include/list.go index a75d8d536c3f..4b69f25746a8 100644 --- a/x-pack/auditbeat/include/list.go +++ b/x-pack/auditbeat/include/list.go @@ -10,4 +10,5 @@ import ( _ "github.com/elastic/beats/x-pack/auditbeat/module/system/host" _ "github.com/elastic/beats/x-pack/auditbeat/module/system/packages" _ "github.com/elastic/beats/x-pack/auditbeat/module/system/processes" + _ "github.com/elastic/beats/x-pack/auditbeat/module/system/user" ) diff --git a/x-pack/auditbeat/module/system/_meta/config.yml.tmpl b/x-pack/auditbeat/module/system/_meta/config.yml.tmpl index 4e00bdeb6e99..33cca5a4ebdb 100644 --- a/x-pack/auditbeat/module/system/_meta/config.yml.tmpl +++ b/x-pack/auditbeat/module/system/_meta/config.yml.tmpl @@ -1,14 +1,19 @@ {{ if .Reference -}} {{ end -}} - module: system - + metricsets: - host - packages - processes + {{ if eq .GOOS "linux" -}} + - user + {{- end }} + + state.period: 12h report_changes: true - + {{ if eq .GOOS "darwin" -}} {{ else if eq .GOOS "windows" -}} {{ else -}} diff --git a/x-pack/auditbeat/module/system/user/_meta/data.json b/x-pack/auditbeat/module/system/user/_meta/data.json new file mode 100644 index 000000000000..979922760acb --- /dev/null +++ b/x-pack/auditbeat/module/system/user/_meta/data.json @@ -0,0 +1,38 @@ +{ + "@timestamp": "2017-10-12T08:05:34.853Z", + "beat": { + "hostname": "host.example.com", + "name": "host.example.com" + }, + "event": { + "module": "system", + "dataset": "user", + "type": "state", + "action": "existing_user", + "id": "57ee8bb6-a3da-4c43-b0d9-0688ccdc88d0" + }, + "user": { + "id": 1001, + "name": "ubuntu" + }, + "system": { + "user": { + "uid": 1001, + "gid": 1001, + "name": "ubuntu", + "dir": "/home/ubuntu", + "shell": "/bin/bash", + "user_information": "Ubuntu", + "group": [ + { + "name": "sudo", + "gid": 27 + } + ], + "password": { + "type": "shadow_password", + "last_changed": "2018-09-21T00:00:00.000Z" + } + } + } +} diff --git a/x-pack/auditbeat/module/system/user/_meta/docs.asciidoc b/x-pack/auditbeat/module/system/user/_meta/docs.asciidoc new file mode 100644 index 000000000000..d4d77ce0cf21 --- /dev/null +++ b/x-pack/auditbeat/module/system/user/_meta/docs.asciidoc @@ -0,0 +1,8 @@ +The System `user` metricset provides ... TODO. + +The module is implemented for Linux. + +[float] +=== Configuration options + +TODO diff --git a/x-pack/auditbeat/module/system/user/_meta/fields.yml b/x-pack/auditbeat/module/system/user/_meta/fields.yml new file mode 100644 index 000000000000..c66a20891337 --- /dev/null +++ b/x-pack/auditbeat/module/system/user/_meta/fields.yml @@ -0,0 +1,60 @@ +- name: user + type: group + description: > + `user` contains information about the users on a system. + release: experimental + fields: + - name: name + type: keyword + description: > + User name. + - name: uid + type: integer + description: > + User ID. + - name: gid + type: integer + description: > + Group ID. + - name: dir + type: keyword + description: > + User's home directory. + - name: shell + type: keyword + description: > + Program to run at login. + - name: user_information + type: text + description: > + General user information. On Linux, this is the gecos field. + - name: group + type: object + description: > + `group` contains information about any groups the user is part of (beyond the user's primary group). + fields: + - name: name + type: keyword + description: > + Group name. + - name: gid + type: integer + description: > + Group ID. + - name: password + type: group + description: > + `password` contains information about a user's password (not the password itself). + fields: + - name: type + type: keyword + description: > + A user's password type. Possible values are `shadow_password` + (the password hash is in the shadow file), `password_disabled`, + `no_password` (this is dangerous as anyone can log in), and + `crypt_password` (when the password field in /etc/passwd seems + to contain an encrypted password). + - name: last_changed + type: date + description: > + The day the user's password was last changed. diff --git a/x-pack/auditbeat/module/system/user/config.go b/x-pack/auditbeat/module/system/user/config.go new file mode 100644 index 000000000000..1e180d3f02be --- /dev/null +++ b/x-pack/auditbeat/module/system/user/config.go @@ -0,0 +1,31 @@ +// 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. + +package user + +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"` +} + +// Validate validates the host metricset config. +func (c *Config) Validate() error { + return nil +} + +func (c *Config) effectiveStatePeriod() time.Duration { + if c.UserStatePeriod != 0 { + return c.UserStatePeriod + } + return c.StatePeriod +} + +var defaultConfig = Config{ + StatePeriod: 12 * time.Hour, +} diff --git a/x-pack/auditbeat/module/system/user/user.go b/x-pack/auditbeat/module/system/user/user.go new file mode 100644 index 000000000000..652e9d62ce54 --- /dev/null +++ b/x-pack/auditbeat/module/system/user/user.go @@ -0,0 +1,440 @@ +// 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. + +package user + +import ( + "bytes" + "encoding/gob" + "fmt" + "io" + "runtime" + "strconv" + "syscall" + "time" + + "github.com/OneOfOne/xxhash" + "github.com/gofrs/uuid" + "github.com/pkg/errors" + + "github.com/elastic/beats/auditbeat/datastore" + "github.com/elastic/beats/libbeat/common" + "github.com/elastic/beats/libbeat/common/cfgwarn" + "github.com/elastic/beats/libbeat/logp" + "github.com/elastic/beats/metricbeat/mb" + "github.com/elastic/beats/x-pack/auditbeat/cache" +) + +const ( + moduleName = "system" + metricsetName = "user" + + bucketName = "user.v1" + bucketKeyUsers = "users" + bucketKeyStateTimestamp = "state_timestamp" + + eventTypeState = "state" + eventTypeEvent = "event" + + eventActionExistingUser = "existing_user" + eventActionUserAdded = "user_added" + eventActionUserRemoved = "user_removed" + eventActionUserChanged = "user_changed" + eventActionPasswordChanged = "password_changed" +) + +// User represents a user. Fields according to getpwent(3). +type User struct { + Name string + PasswordType string + PasswordChanged time.Time + PasswordHashHash []byte + UID uint32 + GID uint32 + Groups []Group + UserInfo string + Dir string + Shell string + Action string +} + +// Group contains information about a group. +type Group struct { + Name string + GID uint32 +} + +// Hash creates a hash for User. +func (user User) Hash() uint64 { + h := xxhash.New64() + // Use everything except userInfo + h.WriteString(user.Name) + h.WriteString(user.PasswordType) + h.WriteString(user.PasswordChanged.String()) + h.Write(user.PasswordHashHash) + h.WriteString(strconv.Itoa(int(user.UID))) + h.WriteString(strconv.Itoa(int(user.GID))) + h.WriteString(user.Dir) + h.WriteString(user.Shell) + + for _, group := range user.Groups { + h.WriteString(group.Name) + h.WriteString(strconv.Itoa(int(group.GID))) + } + + return h.Sum64() +} + +func (user User) toMapStr() common.MapStr { + evt := common.MapStr{ + "name": user.Name, + "password": common.MapStr{ + "type": user.PasswordType, + }, + "uid": user.UID, + "gid": user.GID, + "dir": user.Dir, + "shell": user.Shell, + } + + if user.UserInfo != "" { + evt.Put("user_information", user.UserInfo) + } + + if !user.PasswordChanged.IsZero() { + evt.Put("password.last_changed", user.PasswordChanged) + } + + if len(user.Groups) > 0 { + var groupMapStr []common.MapStr + for _, group := range user.Groups { + groupMapStr = append(groupMapStr, common.MapStr{ + "name": group.Name, + "gid": group.GID, + }) + } + evt.Put("group", groupMapStr) + } + + return evt +} + +func init() { + mb.Registry.MustAddMetricSet(moduleName, metricsetName, New, + mb.DefaultMetricSet(), + ) +} + +// 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 +} + +// New constructs a new MetricSet. +func New(base mb.BaseMetricSet) (mb.MetricSet, error) { + cfgwarn.Experimental("The %v/%v dataset is experimental", moduleName, metricsetName) + if runtime.GOOS != "linux" { + return nil, fmt.Errorf("the %v/%v dataset is only supported on Linux", moduleName, metricsetName) + } + + config := defaultConfig + if err := base.Module().UnpackConfig(&config); err != nil { + return nil, errors.Wrapf(err, "failed to unpack the %v/%v config", moduleName, metricsetName) + } + + bucket, err := datastore.OpenBucket(bucketName) + if err != nil { + return nil, errors.Wrap(err, "failed to open persistent datastore") + } + + ms := &MetricSet{ + BaseMetricSet: base, + config: config, + log: logp.NewLogger(metricsetName), + cache: cache.New(), + bucket: bucket, + } + + // 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, errors.Wrap(err, "failed to restore users from disk") + } + ms.log.Debugf("Restored %d users from disk", len(users)) + + ms.cache.DiffAndUpdateCache(convertToCacheable(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 { + ms.lastState = time.Now() + + users, err := GetUsers() + if err != nil { + return errors.Wrap(err, "failed to get users") + } + ms.log.Debugf("Found %v users", len(users)) + + stateID, err := uuid.NewV4() + if err != nil { + return errors.Wrap(err, "error generating state ID") + } + for _, user := range users { + event := 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(convertToCacheable(users)) + } + + // Save time so we know when to send the state again (config.StatePeriod) + timeBytes, err := ms.lastState.MarshalBinary() + if err != nil { + return err + } + err = ms.bucket.Store(bucketKeyStateTimestamp, timeBytes) + if err != nil { + return errors.Wrap(err, "error writing state timestamp to disk") + } + + return ms.saveUsersToDisk(users) +} + +// 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 + } + ms.lastChange = currentTime + + users, err := GetUsers() + if err != nil { + return errors.Wrap(err, "failed to get users") + } + ms.log.Debugf("Found %v users", len(users)) + + newInCache, missingFromCache := ms.cache.DiffAndUpdateCache(convertToCacheable(users)) + + if len(newInCache) > 0 && len(missingFromCache) > 0 { + // Check for changes to users + missingUserMap := make(map[uint32](*User)) + for _, missingUser := range missingFromCache { + missingUserMap[missingUser.(*User).UID] = missingUser.(*User) + } + + for _, userFromCache := range newInCache { + newUser := userFromCache.(*User) + matchingMissingUser, 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)) + } + + // 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() { + report.Event(userEvent(newUser, eventTypeEvent, eventActionUserChanged)) + } + + delete(missingUserMap, matchingMissingUser.UID) + } else { + report.Event(userEvent(newUser, eventTypeEvent, eventActionUserAdded)) + } + } + + for _, missingUser := range missingUserMap { + report.Event(userEvent(missingUser, eventTypeEvent, eventActionUserRemoved)) + } + } else { + // No changes to users + for _, user := range newInCache { + report.Event(userEvent(user.(*User), eventTypeEvent, eventActionUserAdded)) + } + + for _, user := range missingFromCache { + report.Event(userEvent(user.(*User), eventTypeEvent, eventActionUserRemoved)) + } + } + + if len(newInCache) > 0 || len(missingFromCache) > 0 { + return ms.saveUsersToDisk(users) + } + + return nil +} + +func userEvent(user *User, eventType string, eventAction string) mb.Event { + return mb.Event{ + RootFields: common.MapStr{ + "event": common.MapStr{ + "type": eventType, + "action": eventAction, + }, + "user": common.MapStr{ + "id": user.UID, + "name": user.Name, + }, + }, + MetricSetFields: user.toMapStr(), + } +} + +func convertToCacheable(users []*User) []cache.Cacheable { + c := make([]cache.Cacheable, 0, len(users)) + + for _, u := range users { + c = append(c, u) + } + + return c +} + +// 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 err == io.EOF { + // Read all users + break + } else { + return nil, errors.Wrap(err, "error decoding users") + } + } + } + + 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 errors.Wrap(err, "error encoding users") + } + } + + err := ms.bucket.Store(bucketKeyUsers, buf.Bytes()) + if err != nil { + return errors.Wrap(err, "error writing users to disk") + } + 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" + + 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 + } + + 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 + } + + 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 false, nil +} diff --git a/x-pack/auditbeat/module/system/user/user_test.go b/x-pack/auditbeat/module/system/user/user_test.go new file mode 100644 index 000000000000..d142b833c049 --- /dev/null +++ b/x-pack/auditbeat/module/system/user/user_test.go @@ -0,0 +1,34 @@ +// 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. + +package user + +import ( + "testing" + + "github.com/elastic/beats/auditbeat/core" + mbtest "github.com/elastic/beats/metricbeat/mb/testing" +) + +func TestData(t *testing.T) { + f := mbtest.NewReportingMetricSetV2(t, getConfig()) + events, errs := mbtest.ReportingFetchV2(f) + if len(errs) > 0 { + t.Fatalf("received error: %+v", errs[0]) + } + + if len(events) == 0 { + t.Fatal("no events were generated") + } + + fullEvent := mbtest.StandardizeEvent(f, events[0], core.AddDatasetToEvent) + mbtest.WriteEventToDataJSON(t, fullEvent, "") +} + +func getConfig() map[string]interface{} { + return map[string]interface{}{ + "module": "system", + "metricsets": []string{"user"}, + } +} diff --git a/x-pack/auditbeat/module/system/user/users_linux.go b/x-pack/auditbeat/module/system/user/users_linux.go new file mode 100644 index 000000000000..a8946a2af8da --- /dev/null +++ b/x-pack/auditbeat/module/system/user/users_linux.go @@ -0,0 +1,182 @@ +// 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. + +// +build linux,cgo + +package user + +// #include +// #include +// #include +// #include +import "C" + +import ( + "crypto/sha512" + "strings" + "time" + "unsafe" + + "github.com/pkg/errors" +) + +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() + if err != nil { + return nil, err + } + + shadowEntries, err := readShadowFile() + if err != nil { + return nil, err + } + + C.setpwent() + defer C.endpwent() + + for passwd, err := C.getpwent(); passwd != nil; passwd, err = C.getpwent() { + if err != nil { + return nil, errors.Wrap(err, "error getting user") + } + + // passwd is C.struct_passwd + user := &User{ + Name: C.GoString(passwd.pw_name), + UID: uint32(passwd.pw_uid), + GID: uint32(passwd.pw_gid), + UserInfo: C.GoString(passwd.pw_gecos), + Dir: C.GoString(passwd.pw_dir), + Shell: C.GoString(passwd.pw_shell), + } + + primaryGroup, found := gidToGroup[user.GID] + if found { + user.Groups = append(user.Groups, primaryGroup) + } + + secondaryGroups, found := userToGroup[user.Name] + 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[:] + } + + if user.PasswordType == shadowPassword { + shadow, found := shadowEntries[user.Name] + if found { + user.PasswordChanged = shadow.LastChanged + + if shadow.Password == "" { + user.PasswordType = noPassword + } else if strings.HasPrefix(shadow.Password, "!") || strings.HasPrefix(shadow.Password, "*") { + user.PasswordType = passwordDisabled + } else { + hash := sha512.Sum512([]byte(shadow.Password)) + user.PasswordHashHash = hash[:] + } + } + } + + users = append(users, user) + } + + return users, nil +} + +// readGroupFile reads /etc/group and returns two maps: +// The first maps group IDs to groups. +// The second maps group members (user names) to groups. +// See getgrent(3) for details of the structs. +func readGroupFile() (map[uint32]Group, map[string][]Group, error) { + C.setgrent() + defer C.endgrent() + + groupIDMap := make(map[uint32]Group) + groupMemberMap := make(map[string][]Group) + for cgroup, err := C.getgrent(); cgroup != nil; cgroup, err = C.getgrent() { + if err != nil { + return nil, nil, errors.Wrap(err, "error while reading group file") + } + + groupName := C.GoString(cgroup.gr_name) + gid := uint32(cgroup.gr_gid) + + group := Group{ + Name: groupName, + GID: gid, + } + + groupIDMap[gid] = group + + /* + group.gr_mem is a NULL-terminated array of pointers to user names (char **) + which makes some pointer arithmetic necessary to read it. + */ + for i := 0; ; i++ { + offset := (unsafe.Sizeof(unsafe.Pointer(*cgroup.gr_mem)) * uintptr(i)) + member := *(**C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(cgroup.gr_mem)) + offset)) + + if member == nil { + break + } + + groupMember := C.GoString(member) + groupMemberMap[groupMember] = append(groupMemberMap[groupMember], group) + } + } + + return groupIDMap, groupMemberMap, nil +} + +// shadowFileEntry represents an entry in /etc/shadow. See getspnam(3) for details. +type shadowFileEntry struct { + LastChanged time.Time + Password string +} + +// readShadowFile reads /etc/shadow and returns a map of the entries keyed to user's names. +func readShadowFile() (map[string]shadowFileEntry, error) { + C.setspent() + defer C.endspent() + + shadowEntries := make(map[string]shadowFileEntry) + for spwd, err := C.getspent(); spwd != nil; spwd, err = C.getspent() { + if err != nil { + return nil, errors.Wrap(err, "error while reading shadow file") + } + + shadow := shadowFileEntry{ + // sp_lstchg is in days since Jan 1, 1970. + LastChanged: epoch.AddDate(0, 0, int(spwd.sp_lstchg)), + + // The password hash is never output to Elasticsearch or any other output, + // but a hash of the hash is persisted to disk in the beat.db file. + Password: C.GoString(spwd.sp_pwdp), + } + + shadowEntries[C.GoString(spwd.sp_namp)] = shadow + } + + return shadowEntries, nil +} diff --git a/x-pack/auditbeat/module/system/user/users_other.go b/x-pack/auditbeat/module/system/user/users_other.go new file mode 100644 index 000000000000..8bc466989029 --- /dev/null +++ b/x-pack/auditbeat/module/system/user/users_other.go @@ -0,0 +1,16 @@ +// 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. + +// +build !linux !cgo + +package user + +import ( + "github.com/pkg/errors" +) + +// GetUsers is not implemented on all systems. +func GetUsers() (users []*User, err error) { + return nil, errors.New("not implemented") +} diff --git a/x-pack/auditbeat/tests/system/test_metricsets.py b/x-pack/auditbeat/tests/system/test_metricsets.py index 9162544f9ce7..2c035a111339 100644 --- a/x-pack/auditbeat/tests/system/test_metricsets.py +++ b/x-pack/auditbeat/tests/system/test_metricsets.py @@ -43,3 +43,13 @@ def test_metricset_processes(self): # Metricset is experimental and that generates a warning, TODO: remove later self.check_metricset("system", "processes", COMMON_FIELDS + fields, warnings_allowed=True) + + def test_metricset_user(self): + """ + user metricset collects information about users on a server. + """ + + fields = ["system.user.name"] + + # Metricset is experimental and that generates a warning, TODO: remove later + self.check_metricset("system", "user", COMMON_FIELDS + fields, warnings_allowed=True)