Skip to main content

taudit_core/
ignore.rs

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