Skip to content
Draft
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
2 changes: 2 additions & 0 deletions docs/checks/commands/check_files.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Naemon Config
| ---------------------------- | -------------------------------------------------------------------------------------- |
| calculate-subdirectory-sizes | For subdirectories that are found under the search paths, calculate the subdirectory sizes based on found files. This calculation may be expensive. Default: false |
| file | Alias for path |
| follow-symlinks | Follow symlinks of files and subdirectories while traversing. The file paths will be registered originating from search path. Default: true |
| max-depth | Maximum recursion depth. Default: no limit. '0' and '1' disable recursion and only include files/directories directly under path., '2' starts to include files/directories of subdirectories with given depth. |
| path | Path in which to search for files |
| paths | A comma separated list of paths |
Expand Down Expand Up @@ -98,3 +99,4 @@ these can be used in filters and thresholds (along with the default attributes):
| sha256_checksum | SHA256 checksum of the file |
| sha384_checksum | SHA384 checksum of the file |
| sha512_checksum | SHA512 checksum of the file |
| is_symlink | The file or its parent is a symlink |
169 changes: 160 additions & 9 deletions pkg/snclient/check_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,17 @@ type FileInfoUnified struct {
Ctime time.Time // Create time
}

const (
CheckFilesDefaultFollowSymlinks = true
)

type CheckFiles struct {
paths []string
pathList CommaStringList
pattern string // constructor NewCheckFiles sets this as '*'
maxDepth int64 // constructor NewCheckFiles sets this as -1
calculateSubdirectorySizes bool // constructor NewCheckFiles sets this as false
followSymlinks bool // constructor NewCheckFiles sets this as true
}

func NewCheckFiles() CheckHandler {
Expand All @@ -43,6 +48,7 @@ func NewCheckFiles() CheckHandler {
pattern: "*",
maxDepth: int64(-1),
calculateSubdirectorySizes: false,
followSymlinks: CheckFilesDefaultFollowSymlinks,
}
}

Expand All @@ -62,6 +68,8 @@ func (l *CheckFiles) Build() *CheckData {
"max-depth": {value: &l.maxDepth, description: "Maximum recursion depth. Default: no limit. '0' and '1' disable recursion and only include files/directories directly under path." +
", '2' starts to include files/directories of subdirectories with given depth. "},
"timezone": {description: "Sets the timezone for time metrics (default is local time)"},
"follow-symlinks": {value: &l.followSymlinks, description: fmt.Sprintf("Follow symlinks of files and subdirectories while traversing. "+
"The file paths will be registered originating from search path. Default: %t", CheckFilesDefaultFollowSymlinks)},
"calculate-subdirectory-sizes": {value: &l.calculateSubdirectorySizes, description: "For subdirectories that are found under the search paths, " +
"calculate the subdirectory sizes based on found files. This calculation may be expensive. Default: false"},
},
Expand Down Expand Up @@ -93,6 +101,7 @@ func (l *CheckFiles) Build() *CheckData {
{name: "sha256_checksum", description: "SHA256 checksum of the file"},
{name: "sha384_checksum", description: "SHA384 checksum of the file"},
{name: "sha512_checksum", description: "SHA512 checksum of the file"},
{name: "is_symlink", description: "The file or its parent is a symlink", unit: UBool},
},
exampleDefault: `
Alert if there are logs older than 1 hour in /tmp:
Expand All @@ -119,10 +128,22 @@ func (l *CheckFiles) Check(_ context.Context, _ *Agent, check *CheckData, _ []Ar
checkPath = l.normalizePath(checkPath)
log.Tracef("normalized checkPath: %s", checkPath)

err := filepath.WalkDir(checkPath, func(path string, dirEntry fs.DirEntry, err error) error {
return l.addFile(check, path, checkPath, dirEntry, err)
})
if err != nil {
walker := &fileWalker{cf: l, check: check, checkPath: checkPath}

realRoot := checkPath
rootIsSymlink := false
if l.followSymlinks {
if lstat, lerr := os.Lstat(checkPath); lerr == nil && isLink(lstat) {
rootIsSymlink = true
}
if resolved, evalErr := resolveLink(checkPath); evalErr == nil {
realRoot = resolved
} else if resolved, evalErr := filepath.EvalSymlinks(checkPath); evalErr == nil {
realRoot = resolved
}
}

if err := walker.walk(realRoot, checkPath, rootIsSymlink); err != nil {
return nil, fmt.Errorf("error walking directory %s: %s", checkPath, err.Error())
}
}
Expand Down Expand Up @@ -153,7 +174,128 @@ func (l *CheckFiles) Check(_ context.Context, _ *Agent, check *CheckData, _ []Ar
return check.Finalize()
}

func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, err error) error {
// fileWalker walks one check path while following symlinks.
// it registers new paths under the checkPath, even if the links take it outside of it
// the depth is calculated with the checkpath being the root
type fileWalker struct {
cf *CheckFiles
check *CheckData
checkPath string // original check path this walk started from
visited []string // real directories already walked , normally or via a symlink
}

// walk walks realRoot on disk but registers paths under displayRoot
// realRoot is the canonical path on the filesystem
// isSymlink marks whether realRoot was reached via a symlink.
//
//nolint:wrapcheck // filepath walker functions need to return an error, wrapping and appending a header to each call would return a large error message
func (w *fileWalker) walk(realRoot, displayRoot string, usedSymlink bool) error {
return filepath.WalkDir(realRoot, func(path string, dirEntry fs.DirEntry, err error) error {
// map the current root under display root
displayPath := path
if rel, relErr := filepath.Rel(realRoot, path); relErr == nil {
displayPath = filepath.Join(displayRoot, rel)
}

if w.cf.followSymlinks && dirEntry.IsDir() {
log.Tracef("adding into walked paths of the filewalker: %s", path)
w.visited = append(w.visited, path)
}

// at the real root, isSymlink is not determined through recursion.
// it is passed as usedSymlink before calling walk() for the first time
isSymlink := false
entryIsLink := false

if path == realRoot {
isSymlink = usedSymlink
// when entered via a symlink, the link itself was already
// recorded by followSymlink, skip re-adding the root
if usedSymlink {
return nil
}
} else if dirEntry != nil {
if fi, fie := dirEntry.Info(); fie == nil && isLink(fi) {
isSymlink = true
entryIsLink = true
}
}

if err != nil {
return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err)
}

if entryIsLink {
if w.cf.followSymlinks {
linkDepth := w.cf.getDepth(displayPath, w.checkPath)
if w.cf.maxDepth == -1 || linkDepth < w.cf.maxDepth {
return w.followSymlink(path, displayPath)
}
}

// always add links, but only follow them if followSymlinks is on
return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, nil)
}

return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err)
})
}

// resolveLink resolves a symlink or junction to its canonical target path.
// This resolves junctions on Windows, filepath.EvalSymlinks can fail to do so
func resolveLink(linkPath string) (string, error) {
target, err := os.Readlink(linkPath)
if err != nil {
return "", fmt.Errorf("error when reading link details for path: %s , error: %w", linkPath, err)
}
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(linkPath), target)
}

return filepath.Clean(target), nil
}

// followSymlink resolves the symlink at linkPath
// if its a file, add it as a file entry
// if its a directory, record it and let WalkDir descend normally
// keep a list of visited directories to prevent infinite recursion
func (w *fileWalker) followSymlink(linkPath, displayPath string) error {
// os.Stat follows the link and therefore also resolves relative targets correctly
info, err := os.Stat(linkPath)
if err != nil {
// broken symlink (dangling target, loop, ...): record it as an errored file
return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, true, err)
}

if !info.IsDir() {
return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil)
}

resolvedSymlink, err := resolveLink(linkPath)
if err != nil {
// fallback to filepath.EvalSymlinks
resolvedSymlink, err = filepath.EvalSymlinks(linkPath)
}
if err != nil {
return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, true, err)
}

if slices.Contains(w.visited, resolvedSymlink) {
log.Tracef("not descending into symlink %s, target %s already visited", linkPath, resolvedSymlink)
} else {
w.visited = append(w.visited, resolvedSymlink)
}

// record the symlink/junction itself, let WalkDir descend into it naturally
addErr := w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil)
if addErr != nil && !errors.Is(addErr, filepath.SkipDir) {
return addErr
}

return nil
}

func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, _, isSymlink bool, err error) error {
// if the search path is a directory e.g '/usr/bin' , the program assumes you are looking for files/subdirectories under it
// therefore it does not add the search path directory to the entry list
// if it is a file like /usr/bin/bash however, it will add that
Expand All @@ -171,6 +313,7 @@ func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry
"fullname": path,
"type": "file",
"check_path": checkPath,
"is_symlink": fmt.Sprintf("%t", isSymlink),
}

matchAndAdd := func() {
Expand Down Expand Up @@ -255,6 +398,18 @@ func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry
return nil
}

if err := l.populateEntryDetails(check, entry, fileInfo, path); err != nil {
return err
}

if err := checkSlowFileOperations(check, entry, path); err != nil {
return err
}

return nil
}

func (l *CheckFiles) populateEntryDetails(check *CheckData, entry map[string]string, fileInfo fs.FileInfo, path string) error {
fileInfoSys, err := getCheckFileTimes(fileInfo)
if err != nil {
return fmt.Errorf("type assertion for fileInfo.Sys() failed")
Expand All @@ -276,10 +431,6 @@ func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry
entry["version"] = version
}

if err := checkSlowFileOperations(check, entry, path); err != nil {
return err
}

return nil
}

Expand Down
12 changes: 12 additions & 0 deletions pkg/snclient/check_files_helper_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,15 @@
func getFileVersion(path string) (string, error) {
return "0.0.0.0", fmt.Errorf("file version not supported: %s", path)
}

func isLink(fi fs.FileInfo) bool {
return fi.Mode()&fs.ModeSymlink != 0
}

func getHardLinkCount(fi fs.FileInfo) uint32 {

Check failure on line 31 in pkg/snclient/check_files_helper_linux.go

View workflow job for this annotation

GitHub Actions / test-linux

func getHardLinkCount is unused (unused)

Check failure on line 31 in pkg/snclient/check_files_helper_linux.go

View workflow job for this annotation

GitHub Actions / test-linux

func getHardLinkCount is unused (unused)
if stat, ok := fi.Sys().(*syscall.Stat_t); ok {
return uint32(stat.Nlink)

Check failure on line 33 in pkg/snclient/check_files_helper_linux.go

View workflow job for this annotation

GitHub Actions / test-linux

G115: integer overflow conversion uint64 -> uint32 (gosec)

Check failure on line 33 in pkg/snclient/check_files_helper_linux.go

View workflow job for this annotation

GitHub Actions / test-linux

G115: integer overflow conversion uint64 -> uint32 (gosec)
}

return 0
}
12 changes: 12 additions & 0 deletions pkg/snclient/check_files_helper_osx_bsd.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,15 @@ func getCheckFileTimes(fileInfo fs.FileInfo) (*FileInfoUnified, error) {
func getFileVersion(path string) (string, error) {
return "0.0.0.0", fmt.Errorf("file version not supported: %s", path)
}

func isLink(fi fs.FileInfo) bool {
return fi.Mode()&fs.ModeSymlink != 0
}

func getHardLinkCount(fi fs.FileInfo) uint32 {
if stat, ok := fi.Sys().(*syscall.Stat_t); ok {
return uint32(stat.Nlink)
}

return 0
}
12 changes: 12 additions & 0 deletions pkg/snclient/check_files_helper_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,15 @@ func getFileVersion(path string) (string, error) {

return f.FixedInfo().FileVersion.String(), nil
}

func isLink(fi fs.FileInfo) bool {
if data, ok := fi.Sys().(*syscall.Win32FileAttributeData); ok {
return data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0
}

return fi.Mode()&fs.ModeSymlink != 0
}

func getHardLinkCount(_ fs.FileInfo) uint32 {
return 0
}
92 changes: 92 additions & 0 deletions pkg/snclient/check_files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,95 @@

StopTestAgent(t, snc)
}

func TestCheckFilesFilesystemLinks(t *testing.T) {
testDir, _ := os.Getwd()
scriptsDir := filepath.Join(testDir, "t", "scripts")
scriptName := "check_files_filesystem_links"
scriptFilename := scriptName

switch runtime.GOOS {
case "windows":
scriptFilename += ".ps1"
case "linux":
scriptFilename += ".sh"
case "darwin":
// t.Skip("Skipping on darwin as it does not 'date' command does not work in the script")

Check failure on line 539 in pkg/snclient/check_files_test.go

View workflow job for this annotation

GitHub Actions / test-linux

commentedOutCode: may want to remove commented-out code (gocritic)

Check failure on line 539 in pkg/snclient/check_files_test.go

View workflow job for this annotation

GitHub Actions / test-linux

commentedOutCode: may want to remove commented-out code (gocritic)
scriptFilename += ".sh"
default:
t.Skipf("Test is not intended to be run on %s", runtime.GOOS)
}

config := checkFilesTestConfigWithScript(t, scriptsDir, scriptName, scriptFilename)
snc := StartTestAgent(t, config)

geneartionDirectory := t.TempDir()

res := snc.RunCheck(scriptName, []string{geneartionDirectory})
assert.Equalf(t, CheckExitOK, res.State, "script return state check is correct")
outputString := string(res.BuildPluginOutput())

assert.Containsf(t, outputString, "ok - Generated 3 files for testing", "output matches")
assert.Containsf(t, outputString, "ok - Generated 3 directories for testing", "output matches")

// check_files_filesystem_links script generates a structure using the filesystem links

// On Windows it looks like this
// tree /f 'C:\Users\sorus\repositories\snclient\pkg\snclient\t\link_test'
// C:\USERS\SORUS\REPOSITORIES\SNCLIENT\PKG\SNCLIENT\T\LINK_TEST
// │ file1_hardlink1.txt
// │ file1_symlink1.txt
// │
// ├───dir1
// │ file1.txt
// │
// ├───dir1_junction1
// │ file1.txt
// │
// └───dir1_symlink1
// file1.txt

switch runtime.GOOS {
case "windows":
assert.Containsf(t, outputString, `symbolic link created for dir1_symlink1 <<===>> dir1`, "output matches")
assert.Containsf(t, outputString, `symbolic link created for file1_symlink1.txt <<===>> dir1\file1.txt`, "output matches")
assert.Containsf(t, outputString, `Hardlink created for file1_hardlink1.txt <<===>> dir1\file1.txt`, "output matches")
assert.Containsf(t, outputString, `Junction created for dir1_junction1 <<===>> dir1`, "output matches")
case "linux":
case "darwin":
}

// On Windows, junctions are reported as files
res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file", "show-all"})
assert.Equalf(t, CheckExitOK, res.State, "state OK")
outputString = string(res.BuildPluginOutput())
assert.Containsf(t, outputString, "file1.txt", "output has file")
assert.Containsf(t, outputString, "file1_symlink1.txt", "output does have symlink file")
assert.Containsf(t, outputString, "file1_hardlink1.txt", "output does have hardlink file")
assert.NotContainsf(t, outputString, "dir1", "output does not have directory")
assert.NotContainsf(t, outputString, "dir1_junction1", "output does not have junction directory")

res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file and is_symlink eq true", "show-all"})
assert.Equalf(t, CheckExitOK, res.State, "state OK")
outputString = string(res.BuildPluginOutput())
assert.Containsf(t, outputString, "file1_symlink1.txt", "output does have symlink file")
assert.NotContainsf(t, outputString, "file1_hardlink1.txt", "output does not have hardlink file")
assert.NotContainsf(t, outputString, "dir1", "output does not have directory")
assert.NotContainsf(t, outputString, "dir1_junction1", "output does not have junction directory")

res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir", "show-all"})
assert.Equalf(t, CheckExitOK, res.State, "state OK")
outputString = string(res.BuildPluginOutput())
assert.Containsf(t, outputString, "dir1", "output has directory")
assert.Containsf(t, outputString, "dir1_junction1", "output has directory")
assert.Containsf(t, outputString, "dir1_symlink1", "output has directory")

res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir and is_symlink eq true", "show-all"})
assert.Equalf(t, CheckExitOK, res.State, "state OK")
outputString = string(res.BuildPluginOutput())
assert.Containsf(t, outputString, "dir1_junction1", "output has directory")
assert.Containsf(t, outputString, "dir1_symlink1", "output has directory")
assert.NotContainsf(t, strings.FieldsFunc(outputString, func(r rune) bool { return r == ' ' || r == ',' }), "dir1", "output does not have directory")

StopTestAgent(t, snc)
}
Loading
Loading