Skip to main content

taudit_core/
ignore.rs

1use serde::Deserialize;
2
3use crate::finding::{Finding, FindingCategory};
4
5/// A single ignore rule. Matches findings by category and optionally by
6/// pipeline source file path.
7#[derive(Debug, Clone, Deserialize)]
8pub struct IgnoreRule {
9    /// Required: finding category to match (snake_case, e.g. "unpinned_action").
10    pub category: FindingCategory,
11    /// Optional: glob pattern for the pipeline source file path.
12    /// If absent, the rule matches all files.
13    #[serde(default)]
14    pub path: Option<String>,
15    /// Optional: human-readable reason for suppression (documentation only).
16    #[serde(default)]
17    pub reason: Option<String>,
18}
19
20/// Top-level ignore configuration, loaded from `.tauditignore`.
21#[derive(Debug, Clone, Deserialize, Default)]
22pub struct IgnoreConfig {
23    #[serde(default)]
24    pub ignore: Vec<IgnoreRule>,
25}
26
27/// Result of applying ignore rules to a set of findings.
28pub struct IgnoreResult {
29    /// Findings that passed through (not ignored).
30    pub findings: Vec<Finding>,
31    /// Number of findings that were suppressed.
32    pub suppressed_count: usize,
33}
34
35impl IgnoreConfig {
36    /// Apply ignore rules to a set of findings, given the source file path.
37    /// Returns findings that were NOT matched by any ignore rule, plus a
38    /// count of how many were suppressed.
39    pub fn apply(&self, findings: Vec<Finding>, source_file: &str) -> IgnoreResult {
40        if self.ignore.is_empty() {
41            return IgnoreResult {
42                findings,
43                suppressed_count: 0,
44            };
45        }
46
47        let mut kept = Vec::new();
48        let mut suppressed = 0;
49
50        for finding in findings {
51            if self.matches(&finding, source_file) {
52                suppressed += 1;
53            } else {
54                kept.push(finding);
55            }
56        }
57
58        IgnoreResult {
59            findings: kept,
60            suppressed_count: suppressed,
61        }
62    }
63
64    /// Check if any ignore rule matches this finding.
65    fn matches(&self, finding: &Finding, source_file: &str) -> bool {
66        self.ignore
67            .iter()
68            .any(|rule| rule.matches(finding, source_file))
69    }
70}
71
72impl IgnoreRule {
73    /// Check if this rule matches a specific finding and source file.
74    fn matches(&self, finding: &Finding, source_file: &str) -> bool {
75        // Category must match
76        if self.category != finding.category {
77            return false;
78        }
79
80        // If path pattern is specified, it must match the source file
81        if let Some(ref pattern) = self.path {
82            return glob_match(pattern, source_file);
83        }
84
85        // Category-only rule matches all files
86        true
87    }
88}
89
90/// Match a glob pattern against a file path.
91/// Supports `*` (match any sequence of characters) and `**` (same, but
92/// `**` in the middle of a pattern naturally matches path separators too).
93/// Exported so the CLI can apply the same logic for `--exclude` patterns.
94pub fn glob_match(pattern: &str, text: &str) -> bool {
95    if pattern == "*" {
96        return true;
97    }
98
99    // Split pattern by '*' and check if all parts appear in order
100    let parts: Vec<&str> = pattern.split('*').collect();
101
102    if parts.len() == 1 {
103        // No wildcards — exact match
104        return pattern == text;
105    }
106
107    let mut pos = 0;
108
109    // First part must match at start (if non-empty)
110    if !parts[0].is_empty() {
111        if !text.starts_with(parts[0]) {
112            return false;
113        }
114        pos = parts[0].len();
115    }
116
117    // Last part must match at end (if non-empty)
118    let last = parts[parts.len() - 1];
119    let end_bound = if !last.is_empty() {
120        if !text.ends_with(last) {
121            return false;
122        }
123        text.len() - last.len()
124    } else {
125        text.len()
126    };
127
128    // Middle parts must appear in order between start and end
129    for part in &parts[1..parts.len() - 1] {
130        if part.is_empty() {
131            continue;
132        }
133        if let Some(found) = text[pos..end_bound].find(part) {
134            pos += found + part.len();
135        } else {
136            return false;
137        }
138    }
139
140    pos <= end_bound
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::finding::{Recommendation, Severity};
147
148    fn finding(category: FindingCategory) -> Finding {
149        Finding {
150            severity: Severity::High,
151            category,
152            path: None,
153            nodes_involved: vec![0],
154            message: "test".into(),
155            recommendation: Recommendation::Manual {
156                action: "fix".into(),
157            },
158        }
159    }
160
161    #[test]
162    fn category_only_rule_matches_all_files() {
163        let config = IgnoreConfig {
164            ignore: vec![IgnoreRule {
165                category: FindingCategory::UnpinnedAction,
166                path: None,
167                reason: Some("accepted".into()),
168            }],
169        };
170
171        let findings = vec![
172            finding(FindingCategory::UnpinnedAction),
173            finding(FindingCategory::AuthorityPropagation),
174        ];
175
176        let result = config.apply(findings, ".github/workflows/ci.yml");
177        assert_eq!(result.findings.len(), 1);
178        assert_eq!(result.suppressed_count, 1);
179        assert_eq!(
180            result.findings[0].category,
181            FindingCategory::AuthorityPropagation
182        );
183    }
184
185    #[test]
186    fn path_glob_filters_to_specific_file() {
187        let config = IgnoreConfig {
188            ignore: vec![IgnoreRule {
189                category: FindingCategory::UnpinnedAction,
190                path: Some(".github/workflows/legacy.yml".into()),
191                reason: None,
192            }],
193        };
194
195        // Should match legacy.yml
196        let result_legacy = config.apply(
197            vec![finding(FindingCategory::UnpinnedAction)],
198            ".github/workflows/legacy.yml",
199        );
200        assert_eq!(result_legacy.findings.len(), 0);
201        assert_eq!(result_legacy.suppressed_count, 1);
202
203        // Should NOT match ci.yml
204        let result_ci = config.apply(
205            vec![finding(FindingCategory::UnpinnedAction)],
206            ".github/workflows/ci.yml",
207        );
208        assert_eq!(result_ci.findings.len(), 1);
209        assert_eq!(result_ci.suppressed_count, 0);
210    }
211
212    #[test]
213    fn path_glob_with_wildcard() {
214        let config = IgnoreConfig {
215            ignore: vec![IgnoreRule {
216                category: FindingCategory::OverPrivilegedIdentity,
217                path: Some("*.yml".into()),
218                reason: None,
219            }],
220        };
221
222        let result = config.apply(
223            vec![finding(FindingCategory::OverPrivilegedIdentity)],
224            ".github/workflows/ci.yml",
225        );
226        assert_eq!(result.findings.len(), 0);
227        assert_eq!(result.suppressed_count, 1);
228    }
229
230    #[test]
231    fn unmatched_findings_pass_through() {
232        let config = IgnoreConfig {
233            ignore: vec![IgnoreRule {
234                category: FindingCategory::FloatingImage,
235                path: None,
236                reason: None,
237            }],
238        };
239
240        let findings = vec![
241            finding(FindingCategory::UnpinnedAction),
242            finding(FindingCategory::AuthorityPropagation),
243            finding(FindingCategory::OverPrivilegedIdentity),
244        ];
245
246        let result = config.apply(findings, "ci.yml");
247        assert_eq!(result.findings.len(), 3, "no findings should be suppressed");
248        assert_eq!(result.suppressed_count, 0);
249    }
250
251    #[test]
252    fn empty_config_passes_everything() {
253        let config = IgnoreConfig::default();
254        let findings = vec![
255            finding(FindingCategory::UnpinnedAction),
256            finding(FindingCategory::AuthorityPropagation),
257        ];
258
259        let result = config.apply(findings, "ci.yml");
260        assert_eq!(result.findings.len(), 2);
261        assert_eq!(result.suppressed_count, 0);
262    }
263
264    #[test]
265    fn multiple_rules_compose() {
266        let config = IgnoreConfig {
267            ignore: vec![
268                IgnoreRule {
269                    category: FindingCategory::UnpinnedAction,
270                    path: None,
271                    reason: None,
272                },
273                IgnoreRule {
274                    category: FindingCategory::LongLivedCredential,
275                    path: Some("*legacy*".into()),
276                    reason: Some("migrating".into()),
277                },
278            ],
279        };
280
281        let findings = vec![
282            finding(FindingCategory::UnpinnedAction),
283            finding(FindingCategory::LongLivedCredential),
284            finding(FindingCategory::AuthorityPropagation),
285        ];
286
287        // legacy file: both rules apply
288        let result = config.apply(findings, ".github/workflows/legacy-deploy.yml");
289        assert_eq!(result.findings.len(), 1);
290        assert_eq!(result.suppressed_count, 2);
291        assert_eq!(
292            result.findings[0].category,
293            FindingCategory::AuthorityPropagation
294        );
295    }
296
297    // ── glob_match unit tests ──────────────────────────────
298
299    #[test]
300    fn glob_exact_match() {
301        assert!(glob_match("foo.yml", "foo.yml"));
302        assert!(!glob_match("foo.yml", "bar.yml"));
303    }
304
305    #[test]
306    fn glob_star_suffix() {
307        assert!(glob_match("*.yml", "ci.yml"));
308        assert!(glob_match("*.yml", ".github/workflows/ci.yml"));
309        assert!(!glob_match("*.yml", "ci.yaml"));
310    }
311
312    #[test]
313    fn glob_star_prefix() {
314        assert!(glob_match("ci.*", "ci.yml"));
315        assert!(glob_match("ci.*", "ci.yaml"));
316        assert!(!glob_match("ci.*", "deploy.yml"));
317    }
318
319    #[test]
320    fn glob_star_middle() {
321        assert!(glob_match(".github/*/ci.yml", ".github/workflows/ci.yml"));
322        assert!(!glob_match(".github/*/ci.yml", ".github/ci.yml"));
323    }
324
325    #[test]
326    fn glob_wildcard_all() {
327        assert!(glob_match("*", "anything"));
328        assert!(glob_match("*", ""));
329    }
330}