Skip to main content

cac_scanner/
lib.rs

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}