Skip to main content

agentshield/rules/
finding.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::ir::{data_surface::TaintPath, SourceLocation};
7
8/// A security finding produced by a detector.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Finding {
11    /// Unique rule identifier (e.g., "SHIELD-001").
12    pub rule_id: String,
13    /// Human-readable rule name.
14    pub rule_name: String,
15    /// Severity level.
16    pub severity: Severity,
17    /// Confidence level (how certain we are this is a real issue).
18    pub confidence: Confidence,
19    /// MITRE ATT&CK-style category.
20    pub attack_category: AttackCategory,
21    /// Human-readable description of the finding.
22    pub message: String,
23    /// Primary source location.
24    pub location: Option<SourceLocation>,
25    /// Evidence supporting the finding.
26    pub evidence: Vec<Evidence>,
27    /// Taint path (if applicable).
28    pub taint_path: Option<TaintPath>,
29    /// Suggested remediation.
30    pub remediation: Option<String>,
31    /// CWE identifier (if applicable).
32    pub cwe_id: Option<String>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum Severity {
38    Info,
39    Low,
40    Medium,
41    High,
42    Critical,
43}
44
45impl Severity {
46    pub fn from_str_lenient(s: &str) -> Option<Self> {
47        match s.to_lowercase().as_str() {
48            "info" => Some(Self::Info),
49            "low" => Some(Self::Low),
50            "medium" | "med" => Some(Self::Medium),
51            "high" => Some(Self::High),
52            "critical" | "crit" => Some(Self::Critical),
53            _ => None,
54        }
55    }
56}
57
58impl std::fmt::Display for Severity {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::Info => write!(f, "info"),
62            Self::Low => write!(f, "low"),
63            Self::Medium => write!(f, "medium"),
64            Self::High => write!(f, "high"),
65            Self::Critical => write!(f, "critical"),
66        }
67    }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum Confidence {
73    Low,
74    Medium,
75    High,
76}
77
78impl std::fmt::Display for Confidence {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            Self::Low => write!(f, "low"),
82            Self::Medium => write!(f, "medium"),
83            Self::High => write!(f, "high"),
84        }
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum AttackCategory {
91    CommandInjection,
92    CodeInjection,
93    CredentialExfiltration,
94    Ssrf,
95    ArbitraryFileAccess,
96    SupplyChain,
97    SelfModification,
98    PromptInjectionSurface,
99    ExcessivePermissions,
100    DataExfiltration,
101}
102
103impl std::fmt::Display for AttackCategory {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::CommandInjection => write!(f, "Command Injection"),
107            Self::CodeInjection => write!(f, "Code Injection"),
108            Self::CredentialExfiltration => write!(f, "Credential Exfiltration"),
109            Self::Ssrf => write!(f, "SSRF"),
110            Self::ArbitraryFileAccess => write!(f, "Arbitrary File Access"),
111            Self::SupplyChain => write!(f, "Supply Chain"),
112            Self::SelfModification => write!(f, "Self-Modification"),
113            Self::PromptInjectionSurface => write!(f, "Prompt Injection Surface"),
114            Self::ExcessivePermissions => write!(f, "Excessive Permissions"),
115            Self::DataExfiltration => write!(f, "Data Exfiltration"),
116        }
117    }
118}
119
120/// Evidence supporting a finding.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct Evidence {
123    pub description: String,
124    pub location: Option<SourceLocation>,
125    pub snippet: Option<String>,
126}
127
128impl Finding {
129    /// Compute a stable fingerprint that survives line shifts.
130    ///
131    /// Hash of `(rule_id, relative_file_path, evidence_key, attack_category)`.
132    /// Line and column numbers are intentionally excluded so that the
133    /// fingerprint remains the same when surrounding code is edited.
134    pub fn fingerprint(&self, scan_root: &Path) -> String {
135        let mut hasher = Sha256::new();
136        hasher.update(self.rule_id.as_bytes());
137        hasher.update(b"|");
138
139        // Use relative path so fingerprint is portable across machines
140        if let Some(ref loc) = self.location {
141            let rel = loc.file.strip_prefix(scan_root).unwrap_or(&loc.file);
142            hasher.update(rel.to_string_lossy().as_bytes());
143        }
144        hasher.update(b"|");
145
146        // Use first evidence description as the "what" component
147        if let Some(ev) = self.evidence.first() {
148            hasher.update(ev.description.as_bytes());
149        }
150        hasher.update(b"|");
151
152        hasher.update(format!("{:?}", self.attack_category).as_bytes());
153
154        let result = hasher.finalize();
155        hex::encode(result)
156    }
157}
158
159/// Metadata about a detector rule, used for `list-rules` output.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct RuleMetadata {
162    pub id: String,
163    pub name: String,
164    pub description: String,
165    pub default_severity: Severity,
166    pub attack_category: AttackCategory,
167    pub cwe_id: Option<String>,
168}
169
170#[cfg(test)]
171mod tests {
172    use std::path::{Path, PathBuf};
173
174    use super::*;
175    use crate::ir::SourceLocation;
176
177    /// Helper: build a minimal finding for tests.
178    fn make_finding(
179        rule_id: &str,
180        file: &str,
181        line: usize,
182        column: usize,
183        evidence_desc: &str,
184        category: AttackCategory,
185    ) -> Finding {
186        Finding {
187            rule_id: rule_id.to_string(),
188            rule_name: "Test Rule".to_string(),
189            severity: Severity::Critical,
190            confidence: Confidence::High,
191            attack_category: category,
192            message: "test".to_string(),
193            location: Some(SourceLocation {
194                file: PathBuf::from(file),
195                line,
196                column,
197                end_line: None,
198                end_column: None,
199            }),
200            evidence: vec![Evidence {
201                description: evidence_desc.to_string(),
202                location: None,
203                snippet: None,
204            }],
205            taint_path: None,
206            remediation: None,
207            cwe_id: None,
208        }
209    }
210
211    #[test]
212    fn fingerprint_stable_across_line_shifts() {
213        let scan_root = Path::new("/project");
214
215        let finding1 = make_finding(
216            "SHIELD-001",
217            "/project/src/main.py",
218            10,
219            0,
220            "subprocess.run receives parameter",
221            AttackCategory::CommandInjection,
222        );
223
224        // Same finding but at a different line and column
225        let finding2 = make_finding(
226            "SHIELD-001",
227            "/project/src/main.py",
228            25,
229            5,
230            "subprocess.run receives parameter",
231            AttackCategory::CommandInjection,
232        );
233
234        assert_eq!(
235            finding1.fingerprint(scan_root),
236            finding2.fingerprint(scan_root),
237            "Fingerprint should be stable across line shifts"
238        );
239    }
240
241    #[test]
242    fn fingerprint_different_for_different_rules() {
243        let scan_root = Path::new("/project");
244
245        let finding1 = make_finding(
246            "SHIELD-001",
247            "/project/src/main.py",
248            10,
249            0,
250            "subprocess.run receives parameter",
251            AttackCategory::CommandInjection,
252        );
253
254        let finding2 = make_finding(
255            "SHIELD-003",
256            "/project/src/main.py",
257            10,
258            0,
259            "requests.get receives parameter",
260            AttackCategory::Ssrf,
261        );
262
263        assert_ne!(
264            finding1.fingerprint(scan_root),
265            finding2.fingerprint(scan_root),
266            "Different rules should produce different fingerprints"
267        );
268    }
269
270    #[test]
271    fn fingerprint_different_for_different_files() {
272        let scan_root = Path::new("/project");
273
274        let finding1 = make_finding(
275            "SHIELD-001",
276            "/project/src/main.py",
277            10,
278            0,
279            "subprocess.run receives parameter",
280            AttackCategory::CommandInjection,
281        );
282
283        let finding3 = make_finding(
284            "SHIELD-001",
285            "/project/src/other.py",
286            10,
287            0,
288            "subprocess.run receives parameter",
289            AttackCategory::CommandInjection,
290        );
291
292        assert_ne!(
293            finding1.fingerprint(scan_root),
294            finding3.fingerprint(scan_root),
295            "Different files should produce different fingerprints"
296        );
297    }
298
299    #[test]
300    fn fingerprint_relative_path_portability() {
301        let finding1 = make_finding(
302            "SHIELD-001",
303            "/project/src/main.py",
304            10,
305            0,
306            "subprocess.run receives parameter",
307            AttackCategory::CommandInjection,
308        );
309
310        let finding2 = make_finding(
311            "SHIELD-001",
312            "/other/src/main.py",
313            10,
314            0,
315            "subprocess.run receives parameter",
316            AttackCategory::CommandInjection,
317        );
318
319        let fp1 = finding1.fingerprint(Path::new("/project"));
320        let fp2 = finding2.fingerprint(Path::new("/other"));
321
322        assert_eq!(
323            fp1, fp2,
324            "Same relative paths from different roots should produce same fingerprint"
325        );
326    }
327
328    #[test]
329    fn fingerprint_no_location() {
330        let scan_root = Path::new("/project");
331
332        let finding = Finding {
333            rule_id: "SHIELD-009".to_string(),
334            rule_name: "No Location".to_string(),
335            severity: Severity::Medium,
336            confidence: Confidence::Medium,
337            attack_category: AttackCategory::ExcessivePermissions,
338            message: "test".to_string(),
339            location: None,
340            evidence: vec![],
341            taint_path: None,
342            remediation: None,
343            cwe_id: None,
344        };
345
346        // Should not panic and should produce a valid hex string
347        let fp = finding.fingerprint(scan_root);
348        assert_eq!(fp.len(), 64, "SHA-256 hex digest should be 64 chars");
349    }
350
351    #[test]
352    fn fingerprint_is_valid_hex() {
353        let scan_root = Path::new("/project");
354        let finding = make_finding(
355            "SHIELD-001",
356            "/project/src/main.py",
357            1,
358            0,
359            "test evidence",
360            AttackCategory::CommandInjection,
361        );
362
363        let fp = finding.fingerprint(scan_root);
364        assert_eq!(fp.len(), 64);
365        assert!(
366            fp.chars().all(|c| c.is_ascii_hexdigit()),
367            "Fingerprint should be valid hex"
368        );
369    }
370}