diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 86658de2..d1f86a6a 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -25,7 +25,7 @@ use std::{ io, ops::{Deref, DerefMut}, os::linux::fs::MetadataExt, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, sync::Arc, time::Duration, }; @@ -122,6 +122,41 @@ pub struct HostScanner { metrics: HostScannerMetrics, } +/// Return the literal directory root of a glob pattern. +/// +/// This is the run of components before the first glob metacharacter +/// (`/host/root/**` -> `/host/root`). A fully literal pattern has no +/// such boundary, so its leaf is dropped to yield the parent directory +/// (`/etc/passwd` -> `/etc`). Pure path computation, no filesystem access. +fn pattern_root(path: &Path) -> Option { + let is_glob = |c: &Component| { + c.as_os_str() + .to_string_lossy() + .contains(['*', '?', '[', '{']) + }; + + if path.components().any(|c| is_glob(&c)) { + Some(path.components().take_while(|c| !is_glob(c)).collect()) + } else { + path.parent().map(Path::to_path_buf) + } +} + +/// Return the non-glob root of a pattern when it resolves to a symlink. +/// +/// glob expansion skips the symlink root of a recursive pattern +/// (e.g. `/host/root/**` where `/host/root -> var/roothome`), leaving the +/// target directory inode untracked. Returning it here lets the scan loop +/// process it like any other symlink entry so direct children are seen +/// (ROX-36737). +fn symlink_pattern_root(path: &Path) -> Option { + let root = pattern_root(path)?; + root.symlink_metadata() + .ok() + .filter(Metadata::is_symlink) + .map(|_| root) +} + impl HostScanner { pub fn new( bpf: &mut Bpf, @@ -192,7 +227,15 @@ impl HostScanner { bail!("invalid path {}", path.display()); }; - for entry in glob::glob(glob_str)? { + // glob does not return the non-glob root of a recursive pattern, + // so seed it explicitly to cover symlink roots (ROX-36737). + let symlink_root = symlink_pattern_root(path); + + for entry in symlink_root + .map(Ok) + .into_iter() + .chain(glob::glob(glob_str)?) + { let path = match entry { Ok(p) => p, Err(e) => { @@ -701,3 +744,33 @@ You can increase this limit with: }); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pattern_root_extracts_literal_prefix() { + let cases = [ + ("/host/root/**", Some("/host/root"), "recursive wildcard"), + ("/etc/*.conf", Some("/etc"), "single-star glob"), + ("/host/ro*t/x", Some("/host"), "mid-component wildcard"), + ("/data/[abc]/x", Some("/data"), "character class"), + ("/data/{a,b}/x", Some("/data"), "brace alternation"), + ("/data/f?le", Some("/data"), "question mark"), + ("/a/b/file", Some("/a/b"), "fully literal drops leaf"), + ("/foo", Some("/"), "literal under root"), + ("foo", Some(""), "relative literal single component"), + ("*.conf", Some(""), "wildcard in first component"), + ("/", None, "root has no parent"), + ]; + + for (pattern, expected, description) in cases { + assert_eq!( + pattern_root(Path::new(pattern)).as_deref(), + expected.map(Path::new), + "Failed for {description}: {pattern}" + ); + } + } +} diff --git a/tests/test_path_symlink.py b/tests/test_path_symlink.py index 94fb1afb..0712694b 100644 --- a/tests/test_path_symlink.py +++ b/tests/test_path_symlink.py @@ -3,9 +3,12 @@ import os import re import subprocess +from pathlib import Path +from time import sleep import docker.models.containers import pytest +import yaml from event import Event, EventType, Process from server import EventServer @@ -312,6 +315,60 @@ def test_follow_symlink_to_dir_relative( ) +def test_configured_relative_symlink_root_tracks_direct_child( + tmp_path: Path, + fact: docker.models.containers.Container, + fact_config: tuple[dict, str], + server: EventServer, +): + """ + A relative symlink used as a recursive configured path should track files + created directly beneath its target. + + This models an RHCOS node, where /root is a relative symlink to + var/roothome. With /root/** configured, a containerized Fact scans + /host/root/**, which resolves into /host/var/roothome. The scan must seed + the target directory inode so that a node process creating, for example, + /root/direct-child.txt is reported with the resolved file path + /var/roothome/direct-child.txt and the configured host path + /root/direct-child.txt. Without that inode, creation of a direct child is + missed even though it is within the configured recursive path. + + This is distinct from test_follow_symlink_to_dir_relative: the symlink is + present when the host scanner reads the configuration, rather than being + created below a directory whose inode is already monitored. + """ + symlink_parent = tmp_path / 'symlink-parent' + target = tmp_path / 'target' + symlink_parent.mkdir() + target.mkdir() + + link = symlink_parent / 'watched' + link.symlink_to(os.path.relpath(target, symlink_parent)) + + config, config_file = fact_config + config['paths'] = [f'{link}/**'] + with open(config_file, 'w') as f: + yaml.dump(config, f) + fact.kill('SIGHUP') + sleep(0.5) + + file_via_link = link / 'direct-child.txt' + with open(file_via_link, 'w') as f: + f.write('This should be captured') + + server.wait_events( + [ + Event( + process=Process.from_proc(), + event_type=EventType.CREATION, + file=str(target / file_via_link.name), + host_path=str(file_via_link), + ) + ] + ) + + def test_overwrite_symlink(monitored_dir: str, server: EventServer): """ Test overwriting a symlink in a monitored directory is properly captured.