Skip to main content

agentshield/rules/
policy.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6
7use super::{Finding, Severity};
8
9/// Policy verdict — the final pass/fail decision after applying
10/// ignore list and severity overrides to raw findings.
11#[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/// A suppression entry that silences a specific finding by fingerprint.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Suppression {
23    /// SHA-256 fingerprint of the finding to suppress.
24    pub fingerprint: String,
25    /// Mandatory reason explaining why this finding is suppressed.
26    pub reason: String,
27    /// Optional ISO-8601 date (YYYY-MM-DD) after which the suppression expires.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub expires: Option<String>,
30    /// Optional ISO-8601 date when the suppression was created.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub created_at: Option<String>,
33}
34
35impl Suppression {
36    /// Returns `true` if this suppression has passed its expiration date.
37    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/// Policy configuration loaded from `.agentshield.toml`.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Policy {
50    /// Minimum severity to fail the scan.
51    #[serde(default = "default_fail_on")]
52    pub fail_on: Severity,
53    /// Rule IDs to ignore entirely.
54    #[serde(default)]
55    pub ignore_rules: HashSet<String>,
56    /// Per-rule severity overrides.
57    #[serde(default)]
58    pub overrides: HashMap<String, Severity>,
59    /// Per-finding suppressions by fingerprint.
60    #[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    /// Evaluate findings against this policy and produce a verdict.
81    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    /// Build a set of active (non-expired) suppression fingerprints.
106    /// Logs a warning to stderr for each expired suppression.
107    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    /// Filter findings: remove ignored rules, apply overrides,
129    /// and filter out suppressed findings.
130    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}