Skip to main content

cc_audit/engine/
scanner.rs

1//! Scanner traits and configuration for the detection layer (L5).
2//!
3//! This module provides file-system oriented scanning interfaces:
4//! - `Scanner` trait for scanning files and directories
5//! - `ContentScanner` trait for content-based scanning
6//! - `ScannerConfig` for common scanner configuration
7
8use crate::error::{AuditError, Result};
9use crate::ignore::IgnoreFilter;
10use crate::rules::{DynamicRule, Finding, RuleEngine};
11use std::fs;
12use std::path::Path;
13use tracing::{debug, trace};
14
15/// Core trait for all security scanners.
16///
17/// Scanners implement this trait to provide file and directory scanning capabilities.
18/// The default `scan_path` implementation handles path validation and delegates to
19/// either `scan_file` or `scan_directory` based on the path type.
20pub trait Scanner {
21    /// Scan a single file and return findings.
22    fn scan_file(&self, path: &Path) -> Result<Vec<Finding>>;
23
24    /// Scan a directory and return findings.
25    fn scan_directory(&self, dir: &Path) -> Result<Vec<Finding>>;
26
27    /// Scan a path (file or directory).
28    ///
29    /// This is the main entry point for scanning. It validates the path
30    /// and delegates to either `scan_file` or `scan_directory`.
31    fn scan_path(&self, path: &Path) -> Result<Vec<Finding>> {
32        trace!(path = %path.display(), "Scanning path");
33
34        if !path.exists() {
35            debug!(path = %path.display(), "Path not found");
36            return Err(AuditError::FileNotFound(path.display().to_string()));
37        }
38
39        if path.is_file() {
40            trace!(path = %path.display(), "Scanning as file");
41            return self.scan_file(path);
42        }
43
44        if !path.is_dir() {
45            debug!(path = %path.display(), "Path is not a directory");
46            return Err(AuditError::NotADirectory(path.display().to_string()));
47        }
48
49        trace!(path = %path.display(), "Scanning as directory");
50        self.scan_directory(path)
51    }
52}
53
54/// Extended trait for scanners that support content-based scanning.
55///
56/// This trait provides a unified interface for scanning raw content strings,
57/// which is useful for testing and for scanners that parse structured files
58/// (like JSON) before applying rules.
59pub trait ContentScanner: Scanner {
60    /// Returns a reference to the scanner's configuration.
61    fn config(&self) -> &ScannerConfig;
62
63    /// Scans content and returns findings.
64    ///
65    /// Default implementation delegates to ScannerConfig::check_content.
66    /// Override this method for scanners that need custom content processing
67    /// (e.g., JSON parsing, frontmatter extraction).
68    fn scan_content(&self, content: &str, file_path: &str) -> Result<Vec<Finding>> {
69        Ok(self.config().check_content(content, file_path))
70    }
71}
72
73/// Common configuration shared by all scanners.
74///
75/// This struct provides a unified way to manage RuleEngine settings,
76/// ignore filters, and common file operations across different scanner implementations.
77pub struct ScannerConfig {
78    engine: RuleEngine,
79    ignore_filter: Option<IgnoreFilter>,
80    skip_comments: bool,
81}
82
83impl ScannerConfig {
84    /// Creates a new ScannerConfig with default settings.
85    pub fn new() -> Self {
86        Self {
87            engine: RuleEngine::new(),
88            ignore_filter: None,
89            skip_comments: false,
90        }
91    }
92
93    /// Enables or disables comment skipping during scanning.
94    pub fn with_skip_comments(mut self, skip: bool) -> Self {
95        self.skip_comments = skip;
96        self.engine = RuleEngine::new().with_skip_comments(skip);
97        self
98    }
99
100    /// Sets an ignore filter for file filtering.
101    pub fn with_ignore_filter(mut self, filter: IgnoreFilter) -> Self {
102        self.ignore_filter = Some(filter);
103        self
104    }
105
106    /// Adds dynamic rules loaded from custom YAML files.
107    pub fn with_dynamic_rules(mut self, rules: Vec<DynamicRule>) -> Self {
108        self.engine = self.engine.with_dynamic_rules(rules);
109        self
110    }
111
112    /// Returns whether the given path should be ignored.
113    pub fn is_ignored(&self, path: &Path) -> bool {
114        self.ignore_filter
115            .as_ref()
116            .is_some_and(|f| f.is_ignored(path))
117    }
118
119    /// Reads a file and returns its content as a string.
120    pub fn read_file(&self, path: &Path) -> Result<String> {
121        trace!(path = %path.display(), "Reading file");
122        fs::read_to_string(path).map_err(|e| {
123            debug!(path = %path.display(), error = %e, "Failed to read file");
124            AuditError::ReadError {
125                path: path.display().to_string(),
126                source: e,
127            }
128        })
129    }
130
131    /// Checks the content against all rules and returns findings.
132    pub fn check_content(&self, content: &str, file_path: &str) -> Vec<Finding> {
133        trace!(
134            file = file_path,
135            content_len = content.len(),
136            "Checking content"
137        );
138        let findings = self.engine.check_content(content, file_path);
139        if !findings.is_empty() {
140            debug!(file = file_path, count = findings.len(), "Found issues");
141        }
142        findings
143    }
144
145    /// Checks YAML frontmatter for specific rules (e.g., OP-001).
146    pub fn check_frontmatter(&self, frontmatter: &str, file_path: &str) -> Vec<Finding> {
147        self.engine.check_frontmatter(frontmatter, file_path)
148    }
149
150    /// Returns whether skip_comments is enabled.
151    pub fn skip_comments(&self) -> bool {
152        self.skip_comments
153    }
154
155    /// Returns a reference to the underlying RuleEngine.
156    pub fn engine(&self) -> &RuleEngine {
157        &self.engine
158    }
159}
160
161impl Default for ScannerConfig {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use tempfile::TempDir;
171
172    #[test]
173    fn test_new_config() {
174        let config = ScannerConfig::new();
175        assert!(!config.skip_comments());
176    }
177
178    #[test]
179    fn test_with_skip_comments() {
180        let config = ScannerConfig::new().with_skip_comments(true);
181        assert!(config.skip_comments());
182    }
183
184    #[test]
185    fn test_default_config() {
186        let config = ScannerConfig::default();
187        assert!(!config.skip_comments());
188    }
189
190    #[test]
191    fn test_is_ignored_without_filter() {
192        let config = ScannerConfig::new();
193        assert!(!config.is_ignored(Path::new("test.rs")));
194    }
195
196    #[test]
197    fn test_read_file_success() {
198        let dir = TempDir::new().unwrap();
199        let file_path = dir.path().join("test.txt");
200        fs::write(&file_path, "test content").unwrap();
201
202        let config = ScannerConfig::new();
203        let content = config.read_file(&file_path).unwrap();
204        assert_eq!(content, "test content");
205    }
206
207    #[test]
208    fn test_read_file_not_found() {
209        let config = ScannerConfig::new();
210        let result = config.read_file(Path::new("/nonexistent/file.txt"));
211        assert!(result.is_err());
212    }
213
214    #[test]
215    fn test_check_content_detects_sudo() {
216        let config = ScannerConfig::new();
217        let findings = config.check_content("sudo rm -rf /", "test.sh");
218        assert!(findings.iter().any(|f| f.id == "PE-001"));
219    }
220
221    #[test]
222    fn test_check_content_skip_comments() {
223        let config = ScannerConfig::new().with_skip_comments(true);
224        let findings = config.check_content("# sudo rm -rf /", "test.sh");
225        assert!(findings.iter().all(|f| f.id != "PE-001"));
226    }
227
228    #[test]
229    fn test_check_frontmatter_wildcard() {
230        let config = ScannerConfig::new();
231        let findings = config.check_frontmatter("allowed-tools: *", "SKILL.md");
232        assert!(findings.iter().any(|f| f.id == "OP-001"));
233    }
234
235    #[test]
236    fn test_engine_accessor() {
237        let config = ScannerConfig::new();
238        let _engine = config.engine();
239    }
240}