diff --git a/lib/secretsscanner/authorizedkeys/authorized_keys.go b/lib/secretsscanner/authorizedkeys/authorized_keys.go
index 7919ac6162e6a..ae6e2adb1b8c8 100644
--- a/lib/secretsscanner/authorizedkeys/authorized_keys.go
+++ b/lib/secretsscanner/authorizedkeys/authorized_keys.go
@@ -27,7 +27,6 @@ import (
"os/user"
"path/filepath"
"runtime"
- "strings"
"sync"
"time"
@@ -53,11 +52,11 @@ var (
// scanning enabled, the watcher will hold until the feature is enabled.
type Watcher struct {
// client is the client to use to communicate with the cluster.
- client ClusterClient
- logger *slog.Logger
- clock clockwork.Clock
- hostID string
- usersAccountFile string
+ client ClusterClient
+ logger *slog.Logger
+ clock clockwork.Clock
+ hostID string
+ getHostUsers func() ([]user.User, error)
}
// ClusterClient is the client to use to communicate with the cluster.
@@ -79,17 +78,19 @@ type WatcherConfig struct {
// getRuntimeOS returns the runtime operating system.
// used for testing purposes.
getRuntimeOS func() string
- // etcPasswdFile is the path to the file that contains the users account information on the system.
- // This file is used to get the list of users on the system and their home directories.
- // Value is set to "/etc/passwd" by default.
- etcPasswdFile string
+ // getHostUsers is a function that returns the list of users on the system.
+ // used for testing purposes. When nil, it uses the default implementation
+ // that leverages getpwent.
+ getHostUsers func() ([]user.User, error)
}
// NewWatcher creates a new Watcher instance.
// Returns [ErrUnsupportedPlatform] if the operating system is not supported.
func NewWatcher(ctx context.Context, config WatcherConfig) (*Watcher, error) {
- if getOS(config) != constants.LinuxOS {
+ switch platform := getOS(config); platform {
+ case constants.LinuxOS, constants.DarwinOS:
+ default:
return nil, trace.Wrap(ErrUnsupportedPlatform)
}
@@ -105,19 +106,16 @@ func NewWatcher(ctx context.Context, config WatcherConfig) (*Watcher, error) {
if config.Clock == nil {
config.Clock = clockwork.NewRealClock()
}
- if config.etcPasswdFile == "" {
- // etcPasswordPath is the path to the password file.
- // This file is used to get the list of users on the system and their home directories.
- const etcPasswordPath = "/etc/passwd"
- config.etcPasswdFile = etcPasswordPath
+ if config.getHostUsers == nil {
+ config.getHostUsers = getHostUsers
}
w := &Watcher{
- client: config.Client,
- logger: config.Logger,
- clock: config.Clock,
- hostID: config.HostID,
- usersAccountFile: config.etcPasswdFile,
+ client: config.Client,
+ logger: config.Logger,
+ clock: config.Clock,
+ hostID: config.HostID,
+ getHostUsers: config.getHostUsers,
}
return w, nil
@@ -180,7 +178,8 @@ func (w *Watcher) start(ctx context.Context) error {
}
}()
- if err := fileWatcher.Add(w.usersAccountFile); err != nil {
+ const etcPasswd = "/etc/passwd"
+ if err := fileWatcher.Add(etcPasswd); err != nil {
w.logger.WarnContext(ctx, "Failed to add watcher for file", "error", err)
}
@@ -239,7 +238,7 @@ func (w *Watcher) fetchAndReportAuthorizedKeys(
stream accessgraphsecretsv1pb.SecretsScannerService_ReportAuthorizedKeysClient,
fileWatcher *fsnotify.Watcher,
) error {
- users, err := userList(ctx, w.logger, w.usersAccountFile)
+ users, err := w.getHostUsers()
if err != nil {
return trace.Wrap(err)
}
@@ -294,47 +293,6 @@ func (w *Watcher) fetchAndReportAuthorizedKeys(
return nil
}
-// userList retrieves all users on the system
-func userList(ctx context.Context, log *slog.Logger, filePath string) ([]user.User, error) {
- file, err := os.Open(filePath)
- if err != nil {
- return nil, err
- }
- defer func() {
- if err := file.Close(); err != nil {
- log.DebugContext(ctx, "Failed to close file", "error", err, "file", filePath)
- }
- }()
-
- var users []user.User
- scanner := bufio.NewScanner(file)
- for scanner.Scan() {
- line := scanner.Text()
- // Skip empty lines and comments
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- // username:password:uid:gid:gecos:home:shell
- parts := strings.Split(line, ":")
- if len(parts) < 7 {
- continue
- }
- users = append(users, user.User{
- Username: parts[0],
- Uid: parts[2],
- Gid: parts[3],
- Name: parts[4],
- HomeDir: parts[5],
- })
- }
-
- if err := scanner.Err(); err != nil {
- return nil, err
- }
-
- return users, nil
-}
-
func (w *Watcher) parseAuthorizedKeysFile(ctx context.Context, u user.User, authorizedKeysPath string) ([]*accessgraphsecretsv1pb.AuthorizedKey, error) {
file, err := os.Open(authorizedKeysPath)
if err != nil {
diff --git a/lib/secretsscanner/authorizedkeys/authorized_keys_test.go b/lib/secretsscanner/authorizedkeys/authorized_keys_test.go
index b6d995dcdcf62..1a512221041c8 100644
--- a/lib/secretsscanner/authorizedkeys/authorized_keys_test.go
+++ b/lib/secretsscanner/authorizedkeys/authorized_keys_test.go
@@ -23,6 +23,7 @@ import (
"fmt"
"log/slog"
"os"
+ "os/user"
"path/filepath"
"slices"
"sync"
@@ -47,18 +48,20 @@ import (
func TestAuthorizedKeys(t *testing.T) {
hostID := "hostID"
- etcPasswdFile := createFSData(t)
+ dir := createFSData(t)
clock := clockwork.NewFakeClockAt(time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC))
client := &fakeClient{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watcher, err := NewWatcher(ctx, WatcherConfig{
- Client: client,
- etcPasswdFile: etcPasswdFile,
- HostID: hostID,
- Clock: clock,
- Logger: slog.Default(),
+ Client: client,
+ getHostUsers: func() ([]user.User, error) {
+ return exampleUsers(dir)
+ },
+ HostID: hostID,
+ Clock: clock,
+ Logger: slog.Default(),
getRuntimeOS: func() string {
return constants.LinuxOS
},
@@ -107,9 +110,6 @@ func TestAuthorizedKeys(t *testing.T) {
// Clear the requests
client.clear()
- // Update the etcPasswdFile
- createUsersAndAuthorizedKeys(t, filepath.Dir(etcPasswdFile))
-
cancel()
err = group.Wait()
require.NoError(t, err)
@@ -118,11 +118,9 @@ func TestAuthorizedKeys(t *testing.T) {
func createFSData(t *testing.T) string {
dir := t.TempDir()
- etcPasswd := exampleEtcPasswdFile(dir)
- createFile(t, dir, "passwd", etcPasswd)
createUsersAndAuthorizedKeys(t, dir)
- return filepath.Join(dir, "passwd")
+ return dir
}
func createFile(t *testing.T, dir, name, content string) {
@@ -133,14 +131,31 @@ func createFile(t *testing.T, dir, name, content string) {
require.NoError(t, err)
}
-func exampleEtcPasswdFile(dir string) string {
- return fmt.Sprintf(
- `root:x:0:0::%s/root:/usr/bin/bash
-bin:x:1:1::/:/usr/bin/nologin
-user:x:1000:1000::%s/user:/usr/bin/zsh`,
- dir,
- dir,
- )
+func exampleUsers(dir string) ([]user.User, error) {
+ return []user.User{
+ {
+ Name: "root",
+ Username: "root",
+ Uid: "0",
+ Gid: "0",
+ HomeDir: fmt.Sprintf("%s/root", dir),
+ },
+ {
+ Name: "bin",
+ Username: "bin",
+ Uid: "1",
+ Gid: "1",
+ HomeDir: "/",
+ },
+ {
+ Name: "user",
+ Username: "user",
+ Uid: "1000",
+ Gid: "1000",
+ HomeDir: fmt.Sprintf("%s/user", dir),
+ },
+ }, nil
+
}
const authorizedFileExample = `
diff --git a/lib/secretsscanner/authorizedkeys/users_list.go b/lib/secretsscanner/authorizedkeys/users_list.go
new file mode 100644
index 0000000000000..70f3bc2bbdff1
--- /dev/null
+++ b/lib/secretsscanner/authorizedkeys/users_list.go
@@ -0,0 +1,43 @@
+//go:build !windows
+
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package authorizedkeys
+
+/*
+#cgo CFLAGS: -D_POSIX_PTHREAD_SEMANTICS
+#include
+*/
+import "C"
+
+import (
+ "os/user"
+ "strconv"
+)
+
+// passwdC2Go converts `passwd` struct from C to golang native struct
+func passwdC2Go(passwdC *C.struct_passwd) user.User {
+ return user.User{
+ Name: C.GoString(passwdC.pw_name),
+ Username: C.GoString(passwdC.pw_name),
+ Uid: strconv.FormatUint(uint64(passwdC.pw_uid), 10),
+ Gid: strconv.FormatUint(uint64(passwdC.pw_gid), 10),
+ HomeDir: C.GoString(passwdC.pw_dir),
+ }
+}
diff --git a/lib/secretsscanner/authorizedkeys/users_list_darwin.go b/lib/secretsscanner/authorizedkeys/users_list_darwin.go
new file mode 100644
index 0000000000000..9155cf4392e68
--- /dev/null
+++ b/lib/secretsscanner/authorizedkeys/users_list_darwin.go
@@ -0,0 +1,47 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package authorizedkeys
+
+/*
+#cgo CFLAGS: -D_POSIX_PTHREAD_SEMANTICS
+#include
+*/
+import "C"
+
+import (
+ "os/user"
+)
+
+// getHostUsers returns a list of all users on the host
+// from local /etc/passwd file, LDAP, or other user databases.
+func getHostUsers() (results []user.User, _ error) {
+ C.setpwent()
+ var result *C.struct_passwd
+ for {
+ result = C.getpwent() /* on darwin, getpwent() is reentrant */
+ if result == nil {
+ break
+ }
+ results = append(results, passwdC2Go(result))
+ }
+
+ C.endpwent()
+
+ return results, nil
+}
diff --git a/lib/secretsscanner/authorizedkeys/users_list_unix.go b/lib/secretsscanner/authorizedkeys/users_list_unix.go
new file mode 100644
index 0000000000000..b8c2dc45ffa9c
--- /dev/null
+++ b/lib/secretsscanner/authorizedkeys/users_list_unix.go
@@ -0,0 +1,67 @@
+//go:build !darwin
+
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package authorizedkeys
+
+/*
+#cgo CFLAGS: -D_POSIX_PTHREAD_SEMANTICS -D__USE_MISC
+#include
+#include
+#include
+#include
+*/
+import "C"
+
+import (
+ "os/user"
+
+ "github.com/gravitational/trace"
+)
+
+// getHostUsers returns a list of all users on the host
+// from local /etc/passwd file, LDAP, or other user databases.
+func getHostUsers() (results []user.User, _ error) {
+
+ bufSize := C.sysconf(C._SC_GETPW_R_SIZE_MAX)
+ if bufSize == -1 {
+ bufSize = 16384
+ }
+ if bufSize <= 0 || bufSize > 1<<20 {
+ return nil, trace.BadParameter("unreasonable _SC_GETPW_R_SIZE_MAX of %d", bufSize)
+ }
+ buf := C.malloc(C.size_t(bufSize))
+ defer C.free(buf)
+
+ C.setpwent()
+
+ var pwdBuf C.struct_passwd
+ for {
+ var result *C.struct_passwd
+ rv := C.getpwent_r(&pwdBuf, (*C.char)(buf), C.size_t(bufSize), &result)
+ if rv != 0 || result == nil {
+ break
+ }
+ results = append(results, passwdC2Go(&pwdBuf))
+ }
+
+ C.endpwent()
+
+ return results, nil
+}