From ebbd61d7a645bbc5a57fd55c650950da7b05bbce Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Tue, 30 Jun 2026 10:09:30 +0200 Subject: [PATCH 1/2] check_files: add symlink support add new argument followSymlinks and new attribute is_symlink add a new file walker, it can resolve file links, and keeps track of visited directories to prevent infinite recursion --- docs/checks/commands/check_files.md | 2 + pkg/snclient/check_files.go | 107 ++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/docs/checks/commands/check_files.md b/docs/checks/commands/check_files.md index 09d3300d..da464e79 100644 --- a/docs/checks/commands/check_files.md +++ b/docs/checks/commands/check_files.md @@ -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. | | 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 | @@ -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 | diff --git a/pkg/snclient/check_files.go b/pkg/snclient/check_files.go index 9cf05948..3bce8ec4 100644 --- a/pkg/snclient/check_files.go +++ b/pkg/snclient/check_files.go @@ -35,6 +35,7 @@ type CheckFiles struct { 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 { @@ -43,6 +44,7 @@ func NewCheckFiles() CheckHandler { pattern: "*", maxDepth: int64(-1), calculateSubdirectorySizes: false, + followSymlinks: true, } } @@ -62,6 +64,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: "Follow symlinks of files and subdirectories while traversing. " + + "The file paths will be registered originating from search path."}, "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"}, }, @@ -93,6 +97,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"}, }, exampleDefault: ` Alert if there are logs older than 1 hour in /tmp: @@ -119,10 +124,20 @@ 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 && lstat.Mode()&fs.ModeSymlink != 0 { + rootIsSymlink = true + } + 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()) } } @@ -153,7 +168,88 @@ 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, isSymlink 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 err != nil { + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err) + } + + // filepath.WalkDir does not follow symlinks, handle it manually + if dirEntry.Type()&fs.ModeSymlink != 0 { + if w.cf.followSymlinks { + return w.followSymlink(path, displayPath) + } + + // if we do not follow symlinks, record the symlink as a plain entry, but do not proceed + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, true, nil) + } + + // remember every real directory we walk into. + // a symlink that later resolves back into already walked territory is recoded as well + // this prevents loops and duplicated subtrees. + if w.cf.followSymlinks && dirEntry.IsDir() { + w.visited = append(w.visited, path) + } + + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err) + }) +} + +// followSymlink resolves the symlink at linkPath +// if its a file, add it as a file entry +// if its a directory, descend into it and continue the search +// 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, err) + } + + if !info.IsDir() { + return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, nil) + } + + // resolve to the canonical real path to detect loops + resolvedSymlink, err := filepath.EvalSymlinks(linkPath) + if err != nil { + return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, err) + } + + if slices.Contains(w.visited, resolvedSymlink) { + log.Tracef("not descending into symlink %s, target %s already visited", linkPath, resolvedSymlink) + + // record the symlink itself, but do not walk into it + return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, nil) + } + + // the resolved target is registered by walkFollowingLinks once it is entered + return w.walk(resolvedSymlink, displayPath, true) +} + +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 @@ -171,6 +267,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() { From aac401c31ddd353a65051eb6ad0df553f2c8c06b Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Tue, 30 Jun 2026 13:22:29 +0200 Subject: [PATCH 2/2] check_files: more fixes relating to symlinks Add default value to the followSymlinks argument definition Change is_symlink behavior: it is only toggled on if the file/directory itself is a symlink change the unit of is_symlink to UBool, its filters can now parse bool values --- docs/checks/commands/check_files.md | 2 +- pkg/snclient/check_files.go | 39 +++++++++++++++++++---------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/checks/commands/check_files.md b/docs/checks/commands/check_files.md index da464e79..38f0d852 100644 --- a/docs/checks/commands/check_files.md +++ b/docs/checks/commands/check_files.md @@ -62,7 +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. | +| 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 | diff --git a/pkg/snclient/check_files.go b/pkg/snclient/check_files.go index 3bce8ec4..2af4e791 100644 --- a/pkg/snclient/check_files.go +++ b/pkg/snclient/check_files.go @@ -29,6 +29,10 @@ type FileInfoUnified struct { Ctime time.Time // Create time } +const ( + CheckFilesDefaultFollowSymlinks = true +) + type CheckFiles struct { paths []string pathList CommaStringList @@ -44,7 +48,7 @@ func NewCheckFiles() CheckHandler { pattern: "*", maxDepth: int64(-1), calculateSubdirectorySizes: false, - followSymlinks: true, + followSymlinks: CheckFilesDefaultFollowSymlinks, } } @@ -64,8 +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: "Follow symlinks of files and subdirectories while traversing. " + - "The file paths will be registered originating from search path."}, + "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"}, }, @@ -97,7 +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"}, + {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: @@ -183,7 +187,7 @@ type fileWalker struct { // 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, isSymlink bool) error { +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 @@ -191,8 +195,17 @@ func (w *fileWalker) walk(realRoot, displayRoot string, isSymlink bool) error { displayPath = filepath.Join(displayRoot, rel) } + // at the real root, isSymlink is not determined through recursion. + // it is passed as usedSymlink before calling walk() for the first time + isSymlink := false + if path == realRoot { + isSymlink = usedSymlink + } else if dirEntry != nil && dirEntry.Type()&fs.ModeSymlink != 0 { + isSymlink = true + } + if err != nil { - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err) } // filepath.WalkDir does not follow symlinks, handle it manually @@ -202,7 +215,7 @@ func (w *fileWalker) walk(realRoot, displayRoot string, isSymlink bool) error { } // if we do not follow symlinks, record the symlink as a plain entry, but do not proceed - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, true, nil) + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, nil) } // remember every real directory we walk into. @@ -212,7 +225,7 @@ func (w *fileWalker) walk(realRoot, displayRoot string, isSymlink bool) error { w.visited = append(w.visited, path) } - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err) }) } @@ -225,31 +238,31 @@ func (w *fileWalker) followSymlink(linkPath, displayPath string) error { 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, err) + 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, nil) + return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil) } // resolve to the canonical real path to detect loops resolvedSymlink, err := filepath.EvalSymlinks(linkPath) if err != nil { - return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, err) + 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) // record the symlink itself, but do not walk into it - return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, nil) + return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil) } // the resolved target is registered by walkFollowingLinks once it is entered return w.walk(resolvedSymlink, displayPath, true) } -func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, isSymlink bool, err error) error { +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