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::{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 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 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 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 #[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}