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 +}