Skip to content

Commit

Permalink
Consumer to parse sudoers files (#29)
Browse files Browse the repository at this point in the history
* Adding consumer to parse sudoers files
* Sudoers - notify for create/delete events and add test cases
* Exclude sudoers files if it belongs to exclusion list, update documentation
* Add test cases
* Ensure sudoers files are not mapped to generic consumer
* Refactor code
  • Loading branch information
ramyahasini authored Jul 2, 2020
1 parent 7ae86b7 commit 00e4e6b
Show file tree
Hide file tree
Showing 15 changed files with 445 additions and 110 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,14 @@ __Main dependencies:__

bpfink Is a set of consumers connected to file system watcher. We are currently using eBPF to watch vfs_write syscalls in the kernel.
When an event is fired the associated consumer is called, we have currently two
different consumers for three different use cases:
different consumers for four different use cases:

- User consumer, watch for the __/passwd__, __/shadow__ file to detect password changes
(password hash is not logged to avoid offline brute force on leaked logs),
it also watches for user home directory to detect ssh key injection.
- Access consumer, just watch __/access.conf__
- Generic consumer, watches for any existing or new files/directories for any given parent directory
- Sudoers consumer, watches __/sudoers__ and all files under __/etc/sudoers.d__ directory.

All consumers hold their own states to keep track of changes and diffing. If
a difference is spotted, the diff is logged to our stdout in json format.
Expand Down
1 change: 1 addition & 0 deletions cfg/agent.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ bcc = "pkg/ebpf/vfs.o"
root = "/"
access = "/access.conf"
generic = ["/etc"]
sudoers = ["/sudoers", "/etc/sudoers.d"]

[consumers.users]
root = "/"
Expand Down
127 changes: 54 additions & 73 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,16 @@ type (
Consumers struct {
Root string
Access string
Sudoers string
Sudoers []string
Users struct {
Shadow, Passwd string
}
Generic []string
Excludes []string
}
}
// GenericFile is the struct for watching generic file
GenericFile struct {
// filesToMonitor is the struct for watching files, used for generic and sudoers consumers
FileInfo struct {
File string
IsDir bool
}
Expand Down Expand Up @@ -91,21 +91,24 @@ func (c Configuration) logger() (logger zerolog.Logger) {

func (c Configuration) consumers(db *pkg.AgentDB) (consumers pkg.BaseConsumers) {
fs := afero.NewOsFs()
var existingConsumersFiles = make(map[string]bool)

if c.Consumers.Root != "" {
fs = afero.NewBasePathFs(fs, c.Consumers.Root)
}
if c.Consumers.Access != "" {
if !c.fileBelongsToExclusionList(c.Consumers.Access) {
if !c.isFileToBeExcluded(c.Consumers.Access, existingConsumersFiles) {
state := &pkg.AccessState{
AccessListener: pkg.NewAccessListener(
pkg.AccessFileOpt(fs, c.Consumers.Access, c.logger()),
),
}
consumers = append(consumers, &pkg.BaseConsumer{AgentDB: db, ParserLoader: state})
existingConsumersFiles[c.Consumers.Access] = true
}
}
if c.Consumers.Users.Shadow != "" && c.Consumers.Users.Passwd != "" {
if !c.fileBelongsToExclusionList(c.Consumers.Users.Shadow) || !c.fileBelongsToExclusionList(c.Consumers.Users.Passwd) {
if !c.isFileToBeExcluded(c.Consumers.Users.Shadow, existingConsumersFiles) || !c.isFileToBeExcluded(c.Consumers.Users.Passwd, existingConsumersFiles) {
state := &pkg.UsersState{
UsersListener: pkg.NewUsersListener(func(l *pkg.UsersListener) {
l.Passwd = c.Consumers.Users.Passwd
Expand All @@ -114,12 +117,29 @@ func (c Configuration) consumers(db *pkg.AgentDB) (consumers pkg.BaseConsumers)
}),
}
consumers = append(consumers, &pkg.BaseConsumer{AgentDB: db, ParserLoader: state})
existingConsumersFiles[c.Consumers.Users.Shadow] = true
existingConsumersFiles[c.Consumers.Users.Passwd] = true
}
}
if len(c.Consumers.Sudoers) > 0 {
//get list of files to watch
sudoersFiles := c.getListOfFiles(fs, c.Consumers.Sudoers)
for _, sudoersFile := range sudoersFiles {
if !c.isFileToBeExcluded(sudoersFile.File, existingConsumersFiles) {
state := &pkg.SudoersState{
SudoersListener: pkg.NewSudoersListener(
pkg.SudoersFileOpt(fs, sudoersFile.File, c.logger()),
),
}
consumers = append(consumers, &pkg.BaseConsumer{AgentDB: db, ParserLoader: state})
existingConsumersFiles[sudoersFile.File] = true
}
}
}
if len(c.Consumers.Generic) > 0 {
genericFiles := c.genericConsumer(fs)
genericFiles := c.getListOfFiles(fs, c.Consumers.Generic)
for _, genericFile := range genericFiles {
if !c.fileBelongsToExclusionList(genericFile.File) {
if !c.isFileToBeExcluded(genericFile.File, existingConsumersFiles) {
genericFile := genericFile
state := &pkg.GenericState{
GenericListener: pkg.NewGenericListener(func(l *pkg.GenericListener) {
Expand All @@ -137,25 +157,30 @@ func (c Configuration) consumers(db *pkg.AgentDB) (consumers pkg.BaseConsumers)
return consumers
}

/* Checks if file belongs to exclusion list
true: if file needs to be excluded and hence does not create consumer
/* Checks if file belongs to exclusion list or is already assigned to a consumer and excludes it accordingly
true: if file needs to be excluded
false: otherwise
*/
func (c Configuration) fileBelongsToExclusionList(file string) bool {
func (c Configuration) isFileToBeExcluded(file string, existingConsumersFiles map[string]bool) bool {
logger := c.logger()
isFileExcluded := false

for _, excludeFile := range c.Consumers.Excludes {
if strings.HasPrefix(file, excludeFile) {
logger.Debug().Msgf("File belongs to exclusion list, excluding from monitoring: %v", file)
return true
isFileExcluded = true
break
}
}
return false

return isFileExcluded || existingConsumersFiles[file]
}

func (c Configuration) genericConsumer(fs afero.Fs) []GenericFile {
// Gets list of files to be monitored from all files/dirs listed in the config
func (c Configuration) getListOfFiles(fs afero.Fs, pathList []string) []FileInfo {
logger := c.logger()
var genericFiles []GenericFile
for _, fullPath := range c.Consumers.Generic {
var filesToMonitor []FileInfo
for _, fullPath := range pathList {
fullPath := fullPath
pkgFile := pkg.NewFile(func(file *pkg.File) {
file.Fs, file.Path, file.Logger = fs, fullPath, logger
Expand All @@ -168,46 +193,37 @@ func (c Configuration) genericConsumer(fs afero.Fs) []GenericFile {
if PathFull == "" {
PathFull = fullPath
}
logger.Debug().Msgf("generic file to watch: %v", PathFull)
logger.Debug().Msgf("file to watch: %v", PathFull)
PathFull, fi := c.resolvePath(PathFull)
if PathFull == "" {
continue // could not resolve the file. skip for now.
}
if c.checkIgnored(PathFull, fs) {
continue // skip ignored file
}

switch mode := fi.Mode(); {
case mode.IsDir():
logger.Debug().Msg("Generic is dir")
logger.Debug().Msg("Path is a dir")
err := filepath.Walk(PathFull, func(path string, info os.FileInfo, err error) error {
if c.checkIgnored(path, fs) {
return nil // skip for now
}
walkPath, resolvedInfo := c.resolvePath(path)
if walkPath == "" {
return nil // path could not be resolved skip for now
}
isDir := resolvedInfo.IsDir()
if c.checkIgnored(walkPath, fs) {
return nil // skip for now
}

logger.Debug().Msgf("Generic Path: %v", path)
genericFiles = append(genericFiles, GenericFile{File: path, IsDir: isDir})
logger.Debug().Msgf("Path: %v", path)
filesToMonitor = append(filesToMonitor, FileInfo{File: path, IsDir: isDir})
return nil
})
if err != nil {
logger.Error().Err(err).Msgf("error walking dir: %v", PathFull)
}
case mode.IsRegular():
logger.Debug().Msg("Generic is file")
logger.Debug().Msgf("Generic Path: %v", PathFull)
genericFiles = append(genericFiles, GenericFile{File: PathFull, IsDir: false})
logger.Debug().Msg("Path is a file")
logger.Debug().Msgf("Path: %v", PathFull)
filesToMonitor = append(filesToMonitor, FileInfo{File: PathFull, IsDir: false})
default:
logger.Debug().Msg("Generic is dir")
logger.Debug().Msg("Path is a dir")
}
}
return genericFiles
return filesToMonitor
}

func (c Configuration) resolvePath(pathFull string) (string, os.FileInfo) {
Expand All @@ -217,7 +233,9 @@ func (c Configuration) resolvePath(pathFull string) (string, os.FileInfo) {
logger.Error().Err(err).Msgf("error getting file stat: %v", pathFull)
return "", nil
}

if fi.Mode()&os.ModeSocket != 0 {
return "", nil
}
logger.Debug().Msgf("is symlink: %v", fi.Mode()&os.ModeSymlink != 0)
if fi.Mode()&os.ModeSymlink != 0 {
linkPath, err := os.Readlink(pathFull)
Expand Down Expand Up @@ -252,43 +270,6 @@ func (c Configuration) resolvePath(pathFull string) (string, os.FileInfo) {
return "", nil
}

func (c Configuration) checkIgnored(path string, fs afero.Fs) bool {
logger := c.logger()
base, ok := fs.(*afero.BasePathFs)
if !ok {
logger.Error().Msg("Could not type assert")
return false
}
passwdFilePath, _ := base.RealPath(c.Consumers.Users.Passwd)
shodowFilePath, _ := base.RealPath(c.Consumers.Users.Shadow)
accessFilePath, _ := base.RealPath(c.Consumers.Access)

switch path {
case passwdFilePath:
return true
case shodowFilePath:
return true
case accessFilePath:
return true
default:
// If file belongs to exclusion list, ignore it
if c.fileBelongsToExclusionList(path) {
return true
}
// Get file stat
fi, err := os.Stat(path)
if err != nil {
logger.Error().Err(err).Msgf("error getting file stat: %v", path)
return true
}
// If file is a socket, ignore it
if fi.Mode()&os.ModeSocket != 0 {
return true
}
return false
}
}

func (c Configuration) metrics() (*pkg.Metrics, error) {
logger := c.logger()
metrics := &pkg.Metrics{
Expand Down Expand Up @@ -357,7 +338,7 @@ func (c Configuration) watcher() (*pkg.Watcher, error) {
}
}
return pkg.NewWatcher(func(w *pkg.Watcher) {
w.Logger, w.Consumers, w.FIM, w.Database, w.Key, w.Excludes = logger, consumers.Consumers(), fim, database, c.key, c.Consumers.Excludes
w.Logger, w.Consumers, w.FIM, w.Database, w.Key, w.Excludes, w.Sudoers = logger, consumers.Consumers(), fim, database, c.key, c.Consumers.Excludes, c.Consumers.Sudoers
}), nil
}

Expand Down
26 changes: 23 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,20 @@ warn level.
__Structure of the logs:__

As bpfink is trying to be smart during parsing, we are able to log a difference
of state for dedicated structures. For the moment there's only __3 types of structures/logs__:
of state for dedicated structures. For the moment there's only __4 types of structures/logs__:

- users
- access
- generic
- sudoers

For each of those the internal structure is bit different, but the way it is logged
is the same. Basically, when detecting a change bpfink will logs __3 different information__:

- what has been added, under the `add` JSON key
- what has been deleted, under the `del` JSON key
- what is the current state, which as a different key depending on the consumer:
`users`, `generic`, `access`.
`users`, `generic`, `access`, `sudoers`.

In order to avoid complex logging logic, if an internal part of a structure has
changed, this structure is logged both as `add` and `del`, the difference can
Expand Down Expand Up @@ -90,9 +91,28 @@ while `root` and `ALL` were
"generic":{
"current":"","next":"1a25723c4bbfb4ae20b83cbdcfc039e1a4d5f878e0c4b9f58db30478d6f8b6252403ba19d45ade5ea8e3bf65140a8a9b4995674626034f60cc7f405b"
},
"path":"dynamicPathFile",
"file":"dynamicPathFile",
"processName":"touch",
"user":"root"
}
```

In this example the file dynamicPathFile was created.

``` json
{
"level": "warn",
"add": {
"Sudoers": ["root ALL = (ALL:ALL) ALL"]
},
"del": {
"Sudoers": []
},
"file": "bpfink.sudoers",
"processName": "-bash ",
"user": "root",
"message": "Sudoers file modified"
}
```

In the above example the file bpfink.sudoers was modified.
28 changes: 24 additions & 4 deletions e2etests/basic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,46 @@ func TestBPfink(t *testing.T) {
world := SetUp(t)
defer world.TearDown()
t.Run("generic file create/modify/delete", world.SubTest(testCreateGenericFile))
t.Run("sudoers file create", world.SubTest(testCreateSudoersDir))

}

func testCreateGenericFile(t *testing.T, w *World) {
genericFile := path.Join(w.FS.GenericMonitoringDir, "hohoho.txt")
f := w.FS.MustCreateFile(t, genericFile)
w.BPFink.ExpectGenericEvent(t, Event{
w.BPFink.ExpectEvent(t, Event{
File: genericFile,
Message: "generic file created",
})

f.WriteString("hello world")
w.BPFink.ExpectGenericEvent(t, Event{
w.BPFink.ExpectEvent(t, Event{
File: genericFile,
Message: "generic file Modified",
})

w.FS.MustRemoveFile(t, genericFile)
w.BPFink.ExpectGenericEvent(t, Event{
w.BPFink.ExpectEvent(t, Event{
File: genericFile,
Message: "generic file deleted",
})
}

func testCreateSudoersDir(t *testing.T, w *World) {
sudoersFile := path.Join(w.FS.SudoersDir, "testSudoers")
f := w.FS.MustCreateFile(t, sudoersFile)
w.BPFink.ExpectEvent(t, Event{
File: sudoersFile,
Message: "Sudoers file created",
})
f.WriteString("root ALL=(ALL) ALL")
w.BPFink.ExpectEvent(t, Event{
File: sudoersFile,
Message: "Sudoers file modified",
})

w.FS.MustRemoveFile(t, sudoersFile)
w.BPFink.ExpectEvent(t, Event{
File: sudoersFile,
Message: "Sudoers file deleted",
})
}
Loading

0 comments on commit 00e4e6b

Please sign in to comment.