1mod rules;
2
3use cac_core::{
4 policy::{CompiledRule, PolicyPack, RuleKind},
5 violation::{ScanReport, Violation},
6};
7use chrono::Utc;
8use glob::Pattern;
9use std::path::PathBuf;
10use thiserror::Error;
11use walkdir::WalkDir;
12
13pub use rules::DEFAULT_SKIP_DIRS;
14
15#[derive(Debug, Error)]
16pub enum ScanError {
17 #[error("policy error: {0}")]
18 Policy(#[from] cac_core::policy::PolicyError),
19 #[error("io error: {0}")]
20 Io(#[from] std::io::Error),
21}
22
23#[derive(Debug, Clone)]
24pub struct ScanConfig {
25 pub root: PathBuf,
26 pub policy_dir: PathBuf,
27 pub max_file_size: u64,
28}
29
30impl ScanConfig {
31 pub fn new(root: impl Into<PathBuf>, policy_dir: impl Into<PathBuf>) -> Self {
32 Self {
33 root: root.into(),
34 policy_dir: policy_dir.into(),
35 max_file_size: 512 * 1024,
36 }
37 }
38}
39
40pub struct Scanner {
41 config: ScanConfig,
42 rules: Vec<CompiledRule>,
43}
44
45impl Scanner {
46 pub fn from_config(config: ScanConfig) -> Result<Self, ScanError> {
47 let pack = PolicyPack::load_dir(&config.policy_dir)?;
48 let rules = pack.compile_rules()?;
49 Ok(Self { config, rules })
50 }
51
52 pub fn scan(&self) -> Result<ScanReport, ScanError> {
53 let mut violations = Vec::new();
54 let mut files_scanned = 0usize;
55
56 for entry in WalkDir::new(&self.config.root)
57 .follow_links(false)
58 .into_iter()
59 .filter_entry(|e| !rules::should_skip_entry(e.path(), &self.config.root))
60 {
61 let entry = match entry {
62 Ok(e) => e,
63 Err(_) => continue,
64 };
65 if !entry.file_type().is_file() {
66 continue;
67 }
68 let path = entry.path();
69 if rules::is_binary_or_large(path, self.config.max_file_size) {
70 continue;
71 }
72 let content = match std::fs::read_to_string(path) {
73 Ok(c) => c,
74 Err(_) => continue,
75 };
76 files_scanned += 1;
77 let rel = path
78 .strip_prefix(&self.config.root)
79 .unwrap_or(path)
80 .display()
81 .to_string();
82
83 for rule in &self.rules {
84 violations.extend(evaluate_rule(rule, &rel, &content));
85 }
86 }
87
88 Ok(ScanReport {
89 scanned_at: Utc::now().to_rfc3339(),
90 root: self.config.root.display().to_string(),
91 files_scanned,
92 violations,
93 })
94 }
95}
96
97fn evaluate_rule(rule: &CompiledRule, file_path: &str, content: &str) -> Vec<Violation> {
98 match rule.rule.kind {
99 RuleKind::SecretPattern => evaluate_regex_matches(rule, file_path, content, true),
100 RuleKind::CustomRegex => evaluate_regex_matches(rule, file_path, content, false),
101 RuleKind::ForbiddenFile => evaluate_forbidden_file(rule, file_path),
102 RuleKind::RequiredAnnotation => evaluate_required_annotation(rule, file_path, content),
103 RuleKind::RequiredCall => evaluate_required_call(rule, file_path, content),
104 }
105}
106
107fn evaluate_regex_matches(
108 rule: &CompiledRule,
109 file_path: &str,
110 content: &str,
111 auto_fixable: bool,
112) -> Vec<Violation> {
113 let Some(regex) = &rule.regex else {
114 return Vec::new();
115 };
116
117 if let Some(glob) = &rule.rule.file_glob {
118 if !glob_matches(glob, file_path) {
119 return Vec::new();
120 }
121 }
122
123 let mut violations = Vec::new();
124 for (line_idx, line) in content.lines().enumerate() {
125 for mat in regex.find_iter(line) {
126 if rules::is_likely_false_positive(line, mat.as_str()) {
127 continue;
128 }
129 violations.push(Violation {
130 rule_id: rule.rule.id.clone(),
131 policy_id: rule.policy_id.clone(),
132 policy_name: rule.policy_name.clone(),
133 framework: rule.framework.clone(),
134 severity: rule.rule.severity.clone(),
135 file_path: file_path.to_string(),
136 line: (line_idx + 1) as u32,
137 column: (mat.start() + 1) as u32,
138 snippet: line.trim().to_string(),
139 message: rule.violation_message(),
140 auto_fixable,
141 });
142 }
143 }
144 violations
145}
146
147fn evaluate_forbidden_file(rule: &CompiledRule, file_path: &str) -> Vec<Violation> {
148 let pattern = rule
149 .rule
150 .file_glob
151 .as_deref()
152 .or(rule.rule.pattern.as_deref());
153 let Some(pattern) = pattern else {
154 return Vec::new();
155 };
156 if !glob_matches(pattern, file_path) {
157 return Vec::new();
158 }
159 vec![Violation {
160 rule_id: rule.rule.id.clone(),
161 policy_id: rule.policy_id.clone(),
162 policy_name: rule.policy_name.clone(),
163 framework: rule.framework.clone(),
164 severity: rule.rule.severity.clone(),
165 file_path: file_path.to_string(),
166 line: 1,
167 column: 1,
168 snippet: file_path.to_string(),
169 message: rule.violation_message(),
170 auto_fixable: false,
171 }]
172}
173
174fn evaluate_required_annotation(
175 rule: &CompiledRule,
176 file_path: &str,
177 content: &str,
178) -> Vec<Violation> {
179 let Some(pii_regex) = &rule.regex else {
180 return Vec::new();
181 };
182 let annotation = rule.rule.annotation.as_deref().unwrap_or("@gdpr");
183 let mut violations = Vec::new();
184
185 for (line_idx, line) in content.lines().enumerate() {
186 if !pii_regex.is_match(line) {
187 continue;
188 }
189 let window_start = line_idx.saturating_sub(3);
190 let window_end = (line_idx + 4).min(content.lines().count());
191 let context: String = content
192 .lines()
193 .skip(window_start)
194 .take(window_end - window_start)
195 .collect::<Vec<_>>()
196 .join("\n");
197 if context.contains(annotation) {
198 continue;
199 }
200 violations.push(Violation {
201 rule_id: rule.rule.id.clone(),
202 policy_id: rule.policy_id.clone(),
203 policy_name: rule.policy_name.clone(),
204 framework: rule.framework.clone(),
205 severity: rule.rule.severity.clone(),
206 file_path: file_path.to_string(),
207 line: (line_idx + 1) as u32,
208 column: 1,
209 snippet: line.trim().to_string(),
210 message: rule.violation_message(),
211 auto_fixable: true,
212 });
213 }
214 violations
215}
216
217fn evaluate_required_call(rule: &CompiledRule, file_path: &str, content: &str) -> Vec<Violation> {
218 let sensitive = rule.regex.as_ref();
219 let required = rule.rule.required_call.as_deref().unwrap_or("audit_log");
220 if let Some(sensitive_re) = sensitive {
221 if !sensitive_re.is_match(content) {
222 return Vec::new();
223 }
224 }
225 if content.contains(required) {
226 return Vec::new();
227 }
228 vec![Violation {
229 rule_id: rule.rule.id.clone(),
230 policy_id: rule.policy_id.clone(),
231 policy_name: rule.policy_name.clone(),
232 framework: rule.framework.clone(),
233 severity: rule.rule.severity.clone(),
234 file_path: file_path.to_string(),
235 line: 1,
236 column: 1,
237 snippet: format!("missing required call: {required}"),
238 message: rule.violation_message(),
239 auto_fixable: true,
240 }]
241}
242
243fn glob_matches(glob: &str, path: &str) -> bool {
244 Pattern::new(glob)
245 .map(|p| p.matches(path))
246 .unwrap_or(false)
247}