1use serde::Deserialize;
2
3use crate::finding::{Finding, FindingCategory};
4
5#[derive(Debug, Clone, Deserialize)]
8pub struct IgnoreRule {
9 pub category: FindingCategory,
11 #[serde(default)]
14 pub path: Option<String>,
15 #[serde(default)]
17 pub reason: Option<String>,
18}
19
20#[derive(Debug, Clone, Deserialize, Default)]
22pub struct IgnoreConfig {
23 #[serde(default)]
24 pub ignore: Vec<IgnoreRule>,
25}
26
27pub struct IgnoreResult {
29 pub findings: Vec<Finding>,
31 pub suppressed_count: usize,
33}
34
35impl IgnoreConfig {
36 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 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 fn matches(&self, finding: &Finding, source_file: &str) -> bool {
75 if self.category != finding.category {
77 return false;
78 }
79
80 if let Some(ref pattern) = self.path {
82 return glob_match(pattern, source_file);
83 }
84
85 true
87 }
88}
89
90pub fn glob_match(pattern: &str, text: &str) -> bool {
95 if pattern == "*" {
96 return true;
97 }
98
99 let parts: Vec<&str> = pattern.split('*').collect();
101
102 if parts.len() == 1 {
103 return pattern == text;
105 }
106
107 let mut pos = 0;
108
109 if !parts[0].is_empty() {
111 if !text.starts_with(parts[0]) {
112 return false;
113 }
114 pos = parts[0].len();
115 }
116
117 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 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 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 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 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 #[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}