1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6
7use super::{Finding, Severity};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PolicyVerdict {
13 pub pass: bool,
14 pub total_findings: usize,
15 pub effective_findings: usize,
16 pub highest_severity: Option<Severity>,
17 pub fail_threshold: Severity,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Suppression {
23 pub fingerprint: String,
25 pub reason: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub expires: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub created_at: Option<String>,
33}
34
35impl Suppression {
36 pub fn is_expired(&self) -> bool {
38 if let Some(ref date_str) = self.expires {
39 if let Ok(expires_date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
40 return expires_date < Utc::now().date_naive();
41 }
42 }
43 false
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Policy {
50 #[serde(default = "default_fail_on")]
52 pub fail_on: Severity,
53 #[serde(default)]
55 pub ignore_rules: HashSet<String>,
56 #[serde(default)]
58 pub overrides: HashMap<String, Severity>,
59 #[serde(default)]
61 pub suppressions: Vec<Suppression>,
62}
63
64fn default_fail_on() -> Severity {
65 Severity::High
66}
67
68impl Default for Policy {
69 fn default() -> Self {
70 Self {
71 fail_on: Severity::High,
72 ignore_rules: HashSet::new(),
73 overrides: HashMap::new(),
74 suppressions: Vec::new(),
75 }
76 }
77}
78
79impl Policy {
80 pub fn evaluate(&self, findings: &[Finding]) -> PolicyVerdict {
82 let effective: Vec<Severity> = findings
83 .iter()
84 .filter(|f| !self.ignore_rules.contains(&f.rule_id))
85 .map(|f| {
86 self.overrides
87 .get(&f.rule_id)
88 .copied()
89 .unwrap_or(f.severity)
90 })
91 .collect();
92
93 let highest = effective.iter().copied().max();
94 let failed = effective.iter().any(|&sev| sev >= self.fail_on);
95
96 PolicyVerdict {
97 pass: !failed,
98 total_findings: findings.len(),
99 effective_findings: effective.len(),
100 highest_severity: highest,
101 fail_threshold: self.fail_on,
102 }
103 }
104
105 fn active_suppressions(&self) -> HashSet<&str> {
108 let mut active = HashSet::new();
109 for s in &self.suppressions {
110 if s.is_expired() {
111 let expires = s.expires.as_deref().unwrap_or("unknown");
112 tracing::warn!(
113 fingerprint = %s.fingerprint,
114 expires = %expires,
115 "suppression for fingerprint has expired"
116 );
117 eprintln!(
118 "warning: suppression for fingerprint {} has expired (expires: {})",
119 s.fingerprint, expires,
120 );
121 } else {
122 active.insert(s.fingerprint.as_str());
123 }
124 }
125 active
126 }
127
128 pub fn apply(&self, findings: &[Finding], scan_root: &Path) -> Vec<Finding> {
131 let suppressed = self.active_suppressions();
132
133 findings
134 .iter()
135 .filter(|f| !self.ignore_rules.contains(&f.rule_id))
136 .filter(|f| {
137 if suppressed.is_empty() {
138 return true;
139 }
140 let fp = f.fingerprint(scan_root);
141 !suppressed.contains(fp.as_str())
142 })
143 .map(|f| {
144 let mut f = f.clone();
145 if let Some(&override_sev) = self.overrides.get(&f.rule_id) {
146 f.severity = override_sev;
147 }
148 f
149 })
150 .collect()
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use std::path::PathBuf;
157
158 use super::*;
159 use crate::ir::SourceLocation;
160 use crate::rules::{AttackCategory, Confidence, Evidence};
161
162 fn make_finding(rule_id: &str, severity: Severity) -> Finding {
163 Finding {
164 rule_id: rule_id.into(),
165 rule_name: "Test".into(),
166 severity,
167 confidence: Confidence::High,
168 attack_category: AttackCategory::CommandInjection,
169 message: "test".into(),
170 location: None,
171 evidence: vec![],
172 taint_path: None,
173 remediation: None,
174 cwe_id: None,
175 }
176 }
177
178 fn make_finding_with_location(
179 rule_id: &str,
180 severity: Severity,
181 file: &str,
182 evidence_desc: &str,
183 ) -> Finding {
184 Finding {
185 rule_id: rule_id.into(),
186 rule_name: "Test".into(),
187 severity,
188 confidence: Confidence::High,
189 attack_category: AttackCategory::CommandInjection,
190 message: "test".into(),
191 location: Some(SourceLocation {
192 file: PathBuf::from(file),
193 line: 10,
194 column: 0,
195 end_line: None,
196 end_column: None,
197 }),
198 evidence: vec![Evidence {
199 description: evidence_desc.into(),
200 location: None,
201 snippet: None,
202 }],
203 taint_path: None,
204 remediation: None,
205 cwe_id: None,
206 }
207 }
208
209 #[test]
210 fn default_policy_fails_on_high() {
211 let policy = Policy::default();
212 let findings = vec![make_finding("SHIELD-001", Severity::High)];
213 let verdict = policy.evaluate(&findings);
214 assert!(!verdict.pass);
215 }
216
217 #[test]
218 fn default_policy_passes_on_medium() {
219 let policy = Policy::default();
220 let findings = vec![make_finding("SHIELD-009", Severity::Medium)];
221 let verdict = policy.evaluate(&findings);
222 assert!(verdict.pass);
223 }
224
225 #[test]
226 fn ignore_rule_removes_finding() {
227 let mut policy = Policy::default();
228 policy.ignore_rules.insert("SHIELD-001".into());
229 let findings = vec![make_finding("SHIELD-001", Severity::Critical)];
230 let verdict = policy.evaluate(&findings);
231 assert!(verdict.pass);
232 assert_eq!(verdict.effective_findings, 0);
233 }
234
235 #[test]
236 fn override_downgrades_severity() {
237 let mut policy = Policy::default();
238 policy.overrides.insert("SHIELD-001".into(), Severity::Info);
239 let findings = vec![make_finding("SHIELD-001", Severity::Critical)];
240 let verdict = policy.evaluate(&findings);
241 assert!(verdict.pass);
242 }
243
244 #[test]
245 fn suppression_filters_matching_finding() {
246 let scan_root = Path::new("/project");
247 let finding = make_finding_with_location(
248 "SHIELD-001",
249 Severity::Critical,
250 "/project/src/main.py",
251 "subprocess.run receives parameter",
252 );
253 let fp = finding.fingerprint(scan_root);
254
255 let mut policy = Policy::default();
256 policy.suppressions.push(Suppression {
257 fingerprint: fp,
258 reason: "False positive: validated by middleware".into(),
259 expires: None,
260 created_at: None,
261 });
262
263 let result = policy.apply(&[finding], scan_root);
264 assert!(
265 result.is_empty(),
266 "Suppressed finding should be filtered out"
267 );
268 }
269
270 #[test]
271 fn expired_suppression_does_not_filter() {
272 let scan_root = Path::new("/project");
273 let finding = make_finding_with_location(
274 "SHIELD-001",
275 Severity::Critical,
276 "/project/src/main.py",
277 "subprocess.run receives parameter",
278 );
279 let fp = finding.fingerprint(scan_root);
280
281 let mut policy = Policy::default();
282 policy.suppressions.push(Suppression {
283 fingerprint: fp,
284 reason: "Was a false positive".into(),
285 expires: Some("2020-01-01".into()),
286 created_at: None,
287 });
288
289 let result = policy.apply(&[finding], scan_root);
290 assert_eq!(
291 result.len(),
292 1,
293 "Expired suppression should not filter the finding"
294 );
295 }
296
297 #[test]
298 fn unexpired_suppression_filters() {
299 let scan_root = Path::new("/project");
300 let finding = make_finding_with_location(
301 "SHIELD-001",
302 Severity::Critical,
303 "/project/src/main.py",
304 "subprocess.run receives parameter",
305 );
306 let fp = finding.fingerprint(scan_root);
307
308 let mut policy = Policy::default();
309 policy.suppressions.push(Suppression {
310 fingerprint: fp,
311 reason: "Accepted risk: internal tool".into(),
312 expires: Some("2099-12-31".into()),
313 created_at: None,
314 });
315
316 let result = policy.apply(&[finding], scan_root);
317 assert!(
318 result.is_empty(),
319 "Unexpired suppression should filter the finding"
320 );
321 }
322
323 #[test]
324 fn suppression_no_expiry_always_filters() {
325 let scan_root = Path::new("/project");
326 let finding = make_finding_with_location(
327 "SHIELD-001",
328 Severity::Critical,
329 "/project/src/main.py",
330 "subprocess.run receives parameter",
331 );
332 let fp = finding.fingerprint(scan_root);
333
334 let mut policy = Policy::default();
335 policy.suppressions.push(Suppression {
336 fingerprint: fp,
337 reason: "Permanent suppression".into(),
338 expires: None,
339 created_at: None,
340 });
341
342 let result = policy.apply(&[finding], scan_root);
343 assert!(
344 result.is_empty(),
345 "Suppression without expiry should always filter"
346 );
347 }
348
349 #[test]
350 fn suppression_without_reason_rejected() {
351 let toml_str = r#"
352[policy]
353fail_on = "high"
354
355[[policy.suppressions]]
356fingerprint = "abc123"
357reason = " "
358"#;
359 let config: crate::config::Config = toml::from_str(toml_str).unwrap();
360 let result = config.validate_for_test();
361 assert!(
362 result.is_err(),
363 "Suppression with whitespace-only reason should be rejected"
364 );
365 }
366
367 #[test]
368 fn is_expired_with_past_date() {
369 let s = Suppression {
370 fingerprint: "abc".into(),
371 reason: "test".into(),
372 expires: Some("2020-01-01".into()),
373 created_at: None,
374 };
375 assert!(s.is_expired());
376 }
377
378 #[test]
379 fn is_expired_with_future_date() {
380 let s = Suppression {
381 fingerprint: "abc".into(),
382 reason: "test".into(),
383 expires: Some("2099-12-31".into()),
384 created_at: None,
385 };
386 assert!(!s.is_expired());
387 }
388
389 #[test]
390 fn is_expired_with_no_date() {
391 let s = Suppression {
392 fingerprint: "abc".into(),
393 reason: "test".into(),
394 expires: None,
395 created_at: None,
396 };
397 assert!(!s.is_expired());
398 }
399}