1use serde::Deserialize;
2
3use crate::finding::{Finding, FindingCategory};
4
5const MAX_CONFIG_BYTES: u64 = 2 * 1024 * 1024;
6
7#[derive(Debug, Clone, Deserialize)]
10pub struct IgnoreRule {
11 pub category: FindingCategory,
13 #[serde(default)]
16 pub path: Option<String>,
17 #[serde(default)]
19 pub reason: Option<String>,
20}
21
22#[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
53pub struct IgnoreResult {
55 pub findings: Vec<Finding>,
57 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 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 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 fn matches(&self, finding: &Finding, source_file: &str) -> bool {
149 if self.category != finding.category {
151 return false;
152 }
153
154 if let Some(ref pattern) = self.path {
156 return glob_match(pattern, source_file);
157 }
158
159 true
161 }
162}
163
164pub fn glob_match(pattern: &str, text: &str) -> bool {
169 if pattern == "*" {
170 return true;
171 }
172
173 let parts: Vec<&str> = pattern.split('*').collect();
175
176 if parts.len() == 1 {
177 return pattern == text;
179 }
180
181 let mut pos = 0;
182
183 if !parts[0].is_empty() {
185 if !text.starts_with(parts[0]) {
186 return false;
187 }
188 pos = parts[0].len();
189 }
190
191 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 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 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 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 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 #[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}