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    strict_secrets: bool,
82}
83
84impl ScannerConfig {
85    /// Creates a new ScannerConfig with default settings.
86    pub fn new() -> Self {
87        Self {
88            engine: RuleEngine::new(),
89            ignore_filter: None,
90            skip_comments: false,
91            strict_secrets: false,
92        }
93    }
94
95    /// Enables or disables comment skipping during scanning.
96    pub fn with_skip_comments(mut self, skip: bool) -> Self {
97        self.skip_comments = skip;
98        self.engine = self.engine.with_skip_comments(skip);
99        self
100    }
101
102    /// Enables or disables strict secrets mode.
103    /// When enabled, dummy key heuristics are disabled for test files.
104    pub fn with_strict_secrets(mut self, strict: bool) -> Self {
105        self.strict_secrets = strict;
106        self.engine = self.engine.with_strict_secrets(strict);
107        self
108    }
109
110    /// Sets an ignore filter for file filtering.
111    pub fn with_ignore_filter(mut self, filter: IgnoreFilter) -> Self {
112        self.ignore_filter = Some(filter);
113        self
114    }
115
116    /// Adds dynamic rules loaded from custom YAML files.
117    pub fn with_dynamic_rules(mut self, rules: Vec<DynamicRule>) -> Self {
118        self.engine = self.engine.with_dynamic_rules(rules);
119        self
120    }
121
122    /// Returns whether the given path should be ignored.
123    pub fn is_ignored(&self, path: &Path) -> bool {
124        self.ignore_filter
125            .as_ref()
126            .is_some_and(|f| f.is_ignored(path))
127    }
128
129    /// Reads a file and returns its content as a string.
130    pub fn read_file(&self, path: &Path) -> Result<String> {
131        trace!(path = %path.display(), "Reading file");
132        fs::read_to_string(path).map_err(|e| {
133            debug!(path = %path.display(), error = %e, "Failed to read file");
134            AuditError::ReadError {
135                path: path.display().to_string(),
136                source: e,
137            }
138        })
139    }
140
141    /// Checks the content against all rules and returns findings.
142    pub fn check_content(&self, content: &str, file_path: &str) -> Vec<Finding> {
143        trace!(
144            file = file_path,
145            content_len = content.len(),
146            "Checking content"
147        );
148        let findings = self.engine.check_content(content, file_path);
149        if !findings.is_empty() {
150            debug!(file = file_path, count = findings.len(), "Found issues");
151        }
152        findings
153    }
154
155    /// Checks YAML frontmatter for specific rules (e.g., OP-001).
156    pub fn check_frontmatter(&self, frontmatter: &str, file_path: &str) -> Vec<Finding> {
157        self.engine.check_frontmatter(frontmatter, file_path)
158    }
159
160    /// Returns whether skip_comments is enabled.
161    pub fn skip_comments(&self) -> bool {
162        self.skip_comments
163    }
164
165    /// Returns whether strict_secrets is enabled.
166    pub fn strict_secrets(&self) -> bool {
167        self.strict_secrets
168    }
169
170    /// Returns a reference to the underlying RuleEngine.
171    pub fn engine(&self) -> &RuleEngine {
172        &self.engine
173    }
174}
175
176impl Default for ScannerConfig {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use tempfile::TempDir;
186
187    #[test]
188    fn test_new_config() {
189        let config = ScannerConfig::new();
190        assert!(!config.skip_comments());
191    }
192
193    #[test]
194    fn test_with_skip_comments() {
195        let config = ScannerConfig::new().with_skip_comments(true);
196        assert!(config.skip_comments());
197    }
198
199    #[test]
200    fn test_default_config() {
201        let config = ScannerConfig::default();
202        assert!(!config.skip_comments());
203    }
204
205    #[test]
206    fn test_is_ignored_without_filter() {
207        let config = ScannerConfig::new();
208        assert!(!config.is_ignored(Path::new("test.rs")));
209    }
210
211    #[test]
212    fn test_read_file_success() {
213        let dir = TempDir::new().unwrap();
214        let file_path = dir.path().join("test.txt");
215        fs::write(&file_path, "test content").unwrap();
216
217        let config = ScannerConfig::new();
218        let content = config.read_file(&file_path).unwrap();
219        assert_eq!(content, "test content");
220    }
221
222    #[test]
223    fn test_read_file_not_found() {
224        let config = ScannerConfig::new();
225        let result = config.read_file(Path::new("/nonexistent/file.txt"));
226        assert!(result.is_err());
227    }
228
229    #[test]
230    fn test_check_content_detects_sudo() {
231        let config = ScannerConfig::new();
232        let findings = config.check_content("sudo rm -rf /", "test.sh");
233        assert!(findings.iter().any(|f| f.id == "PE-001"));
234    }
235
236    #[test]
237    fn test_check_content_skip_comments() {
238        let config = ScannerConfig::new().with_skip_comments(true);
239        let findings = config.check_content("# sudo rm -rf /", "test.sh");
240        assert!(findings.iter().all(|f| f.id != "PE-001"));
241    }
242
243    #[test]
244    fn test_check_frontmatter_wildcard() {
245        let config = ScannerConfig::new();
246        let findings = config.check_frontmatter("allowed-tools: *", "SKILL.md");
247        assert!(findings.iter().any(|f| f.id == "OP-001"));
248    }
249
250    #[test]
251    fn test_engine_accessor() {
252        let config = ScannerConfig::new();
253        let _engine = config.engine();
254    }
255}