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::{FindingExtras, FindingSource, 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            source: FindingSource::BuiltIn,
159            extras: FindingExtras::default(),
160        }
161    }
162
163    #[test]
164    fn category_only_rule_matches_all_files() {
165        let config = IgnoreConfig {
166            ignore: vec![IgnoreRule {
167                category: FindingCategory::UnpinnedAction,
168                path: None,
169                reason: Some("accepted".into()),
170            }],
171        };
172
173        let findings = vec![
174            finding(FindingCategory::UnpinnedAction),
175            finding(FindingCategory::AuthorityPropagation),
176        ];
177
178        let result = config.apply(findings, ".github/workflows/ci.yml");
179        assert_eq!(result.findings.len(), 1);
180        assert_eq!(result.suppressed_count, 1);
181        assert_eq!(
182            result.findings[0].category,
183            FindingCategory::AuthorityPropagation
184        );
185    }
186
187    #[test]
188    fn path_glob_filters_to_specific_file() {
189        let config = IgnoreConfig {
190            ignore: vec![IgnoreRule {
191                category: FindingCategory::UnpinnedAction,
192                path: Some(".github/workflows/legacy.yml".into()),
193                reason: None,
194            }],
195        };
196
197        // Should match legacy.yml
198        let result_legacy = config.apply(
199            vec![finding(FindingCategory::UnpinnedAction)],
200            ".github/workflows/legacy.yml",
201        );
202        assert_eq!(result_legacy.findings.len(), 0);
203        assert_eq!(result_legacy.suppressed_count, 1);
204
205        // Should NOT match ci.yml
206        let result_ci = config.apply(
207            vec![finding(FindingCategory::UnpinnedAction)],
208            ".github/workflows/ci.yml",
209        );
210        assert_eq!(result_ci.findings.len(), 1);
211        assert_eq!(result_ci.suppressed_count, 0);
212    }
213
214    #[test]
215    fn path_glob_with_wildcard() {
216        let config = IgnoreConfig {
217            ignore: vec![IgnoreRule {
218                category: FindingCategory::OverPrivilegedIdentity,
219                path: Some("*.yml".into()),
220                reason: None,
221            }],
222        };
223
224        let result = config.apply(
225            vec![finding(FindingCategory::OverPrivilegedIdentity)],
226            ".github/workflows/ci.yml",
227        );
228        assert_eq!(result.findings.len(), 0);
229        assert_eq!(result.suppressed_count, 1);
230    }
231
232    #[test]
233    fn unmatched_findings_pass_through() {
234        let config = IgnoreConfig {
235            ignore: vec![IgnoreRule {
236                category: FindingCategory::FloatingImage,
237                path: None,
238                reason: None,
239            }],
240        };
241
242        let findings = vec![
243            finding(FindingCategory::UnpinnedAction),
244            finding(FindingCategory::AuthorityPropagation),
245            finding(FindingCategory::OverPrivilegedIdentity),
246        ];
247
248        let result = config.apply(findings, "ci.yml");
249        assert_eq!(result.findings.len(), 3, "no findings should be suppressed");
250        assert_eq!(result.suppressed_count, 0);
251    }
252
253    #[test]
254    fn empty_config_passes_everything() {
255        let config = IgnoreConfig::default();
256        let findings = vec![
257            finding(FindingCategory::UnpinnedAction),
258            finding(FindingCategory::AuthorityPropagation),
259        ];
260
261        let result = config.apply(findings, "ci.yml");
262        assert_eq!(result.findings.len(), 2);
263        assert_eq!(result.suppressed_count, 0);
264    }
265
266    #[test]
267    fn multiple_rules_compose() {
268        let config = IgnoreConfig {
269            ignore: vec![
270                IgnoreRule {
271                    category: FindingCategory::UnpinnedAction,
272                    path: None,
273                    reason: None,
274                },
275                IgnoreRule {
276                    category: FindingCategory::LongLivedCredential,
277                    path: Some("*legacy*".into()),
278                    reason: Some("migrating".into()),
279                },
280            ],
281        };
282
283        let findings = vec![
284            finding(FindingCategory::UnpinnedAction),
285            finding(FindingCategory::LongLivedCredential),
286            finding(FindingCategory::AuthorityPropagation),
287        ];
288
289        // legacy file: both rules apply
290        let result = config.apply(findings, ".github/workflows/legacy-deploy.yml");
291        assert_eq!(result.findings.len(), 1);
292        assert_eq!(result.suppressed_count, 2);
293        assert_eq!(
294            result.findings[0].category,
295            FindingCategory::AuthorityPropagation
296        );
297    }
298
299    // ── glob_match unit tests ──────────────────────────────
300
301    #[test]
302    fn glob_exact_match() {
303        assert!(glob_match("foo.yml", "foo.yml"));
304        assert!(!glob_match("foo.yml", "bar.yml"));
305    }
306
307    #[test]
308    fn glob_star_suffix() {
309        assert!(glob_match("*.yml", "ci.yml"));
310        assert!(glob_match("*.yml", ".github/workflows/ci.yml"));
311        assert!(!glob_match("*.yml", "ci.yaml"));
312    }
313
314    #[test]
315    fn glob_star_prefix() {
316        assert!(glob_match("ci.*", "ci.yml"));
317        assert!(glob_match("ci.*", "ci.yaml"));
318        assert!(!glob_match("ci.*", "deploy.yml"));
319    }
320
321    #[test]
322    fn glob_star_middle() {
323        assert!(glob_match(".github/*/ci.yml", ".github/workflows/ci.yml"));
324        assert!(!glob_match(".github/*/ci.yml", ".github/ci.yml"));
325    }
326
327    #[test]
328    fn glob_wildcard_all() {
329        assert!(glob_match("*", "anything"));
330        assert!(glob_match("*", ""));
331    }
332}