Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 22 additions & 64 deletions lib/secretsscanner/authorizedkeys/authorized_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (
"os/user"
"path/filepath"
"runtime"
"strings"
"sync"
"time"

Expand All @@ -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.
Expand All @@ -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)
}

Expand All @@ -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,
Comment thread
zmb3 marked this conversation as resolved.
}

return w, nil
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 35 additions & 20 deletions lib/secretsscanner/authorizedkeys/authorized_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"fmt"
"log/slog"
"os"
"os/user"
"path/filepath"
"slices"
"sync"
Expand All @@ -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
},
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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 = `
Expand Down
43 changes: 43 additions & 0 deletions lib/secretsscanner/authorizedkeys/users_list.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

package authorizedkeys

/*
#cgo CFLAGS: -D_POSIX_PTHREAD_SEMANTICS
#include <pwd.h>
*/
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),
}
}
47 changes: 47 additions & 0 deletions lib/secretsscanner/authorizedkeys/users_list_darwin.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

package authorizedkeys

/*
#cgo CFLAGS: -D_POSIX_PTHREAD_SEMANTICS
#include <pwd.h>
*/
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
}
Loading