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::{SourceLocation, data_surface::TaintPath};
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    CapabilityMismatch,
102}
103
104impl std::fmt::Display for AttackCategory {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            Self::CommandInjection => write!(f, "Command Injection"),
108            Self::CodeInjection => write!(f, "Code Injection"),
109            Self::CredentialExfiltration => write!(f, "Credential Exfiltration"),
110            Self::Ssrf => write!(f, "SSRF"),
111            Self::ArbitraryFileAccess => write!(f, "Arbitrary File Access"),
112            Self::SupplyChain => write!(f, "Supply Chain"),
113            Self::SelfModification => write!(f, "Self-Modification"),
114            Self::PromptInjectionSurface => write!(f, "Prompt Injection Surface"),
115            Self::ExcessivePermissions => write!(f, "Excessive Permissions"),
116            Self::DataExfiltration => write!(f, "Data Exfiltration"),
117            Self::CapabilityMismatch => write!(f, "Capability Mismatch"),
118        }
119    }
120}
121
122/// Evidence supporting a finding.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Evidence {
125    pub description: String,
126    pub location: Option<SourceLocation>,
127    pub snippet: Option<String>,
128}
129
130impl Finding {
131    /// Compute a stable fingerprint that survives line shifts.
132    ///
133    /// Hash of `(rule_id, relative_file_path, evidence_key, attack_category)`.
134    /// Line and column numbers are intentionally excluded so that the
135    /// fingerprint remains the same when surrounding code is edited.
136    pub fn fingerprint(&self, scan_root: &Path) -> String {
137        let mut hasher = Sha256::new();
138        hasher.update(self.rule_id.as_bytes());
139        hasher.update(b"|");
140
141        // Use relative path so fingerprint is portable across machines
142        if let Some(ref loc) = self.location {
143            let rel = loc.file.strip_prefix(scan_root).unwrap_or(&loc.file);
144            hasher.update(rel.to_string_lossy().as_bytes());
145        }
146        hasher.update(b"|");
147
148        // Use first evidence description as the "what" component
149        if let Some(ev) = self.evidence.first() {
150            hasher.update(ev.description.as_bytes());
151        }
152        hasher.update(b"|");
153
154        hasher.update(format!("{:?}", self.attack_category).as_bytes());
155
156        let result = hasher.finalize();
157        hex::encode(result)
158    }
159}
160
161/// Metadata about a detector rule, used for `list-rules` output.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct RuleMetadata {
164    pub id: String,
165    pub name: String,
166    pub description: String,
167    pub default_severity: Severity,
168    pub attack_category: AttackCategory,
169    pub cwe_id: Option<String>,
170    /// OWASP MCP Top 10 category, if applicable.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub owasp_mcp: Option<OwaspMcp>,
173}
174
175/// OWASP MCP Top 10 (2025) categories.
176///
177/// Serialized as the short code (e.g. `"MCP05"`) for stable machine use.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
179pub enum OwaspMcp {
180    /// MCP01 — Token Mismanagement & Session Hijacking
181    #[serde(rename = "MCP01")]
182    TokenMismanagement,
183    /// MCP02 — Unauthorized / Excessive Scope & Privilege Escalation
184    #[serde(rename = "MCP02")]
185    ExcessiveScope,
186    /// MCP03 — Tool Poisoning & Malicious Tool Descriptions
187    #[serde(rename = "MCP03")]
188    ToolPoisoning,
189    /// MCP04 — Prompt Injection via Tool Metadata & Content
190    #[serde(rename = "MCP04")]
191    PromptInjection,
192    /// MCP05 — Command Injection & Arbitrary Code Execution
193    #[serde(rename = "MCP05")]
194    CommandExecution,
195    /// MCP06 — Data Exfiltration & Sensitive Information Disclosure
196    #[serde(rename = "MCP06")]
197    DataExfiltration,
198    /// MCP07 — Supply Chain & Dependency Compromise
199    #[serde(rename = "MCP07")]
200    SupplyChain,
201    /// MCP08 — Insecure Server-to-Server Communication
202    #[serde(rename = "MCP08")]
203    InsecureCommunication,
204    /// MCP09 — Malicious Updates / Rug Pulls
205    #[serde(rename = "MCP09")]
206    MaliciousUpdate,
207    /// MCP10 — Insufficient Logging, Monitoring & Auditability
208    #[serde(rename = "MCP10")]
209    InsufficientLogging,
210}
211
212impl OwaspMcp {
213    /// Short taxonomy code, e.g. "MCP05".
214    pub fn code(self) -> &'static str {
215        match self {
216            Self::TokenMismanagement => "MCP01",
217            Self::ExcessiveScope => "MCP02",
218            Self::ToolPoisoning => "MCP03",
219            Self::PromptInjection => "MCP04",
220            Self::CommandExecution => "MCP05",
221            Self::DataExfiltration => "MCP06",
222            Self::SupplyChain => "MCP07",
223            Self::InsecureCommunication => "MCP08",
224            Self::MaliciousUpdate => "MCP09",
225            Self::InsufficientLogging => "MCP10",
226        }
227    }
228
229    /// Human-readable taxonomy name.
230    pub fn name(self) -> &'static str {
231        match self {
232            Self::TokenMismanagement => "Token Mismanagement & Session Hijacking",
233            Self::ExcessiveScope => "Unauthorized / Excessive Scope & Privilege Escalation",
234            Self::ToolPoisoning => "Tool Poisoning & Malicious Tool Descriptions",
235            Self::PromptInjection => "Prompt Injection via Tool Metadata & Content",
236            Self::CommandExecution => "Command Injection & Arbitrary Code Execution",
237            Self::DataExfiltration => "Data Exfiltration & Sensitive Information Disclosure",
238            Self::SupplyChain => "Supply Chain & Dependency Compromise",
239            Self::InsecureCommunication => "Insecure Server-to-Server Communication",
240            Self::MaliciousUpdate => "Malicious Updates / Rug Pulls",
241            Self::InsufficientLogging => "Insufficient Logging, Monitoring & Auditability",
242        }
243    }
244
245    /// All categories, for SARIF taxonomy emission.
246    pub fn all() -> &'static [OwaspMcp] {
247        &[
248            Self::TokenMismanagement,
249            Self::ExcessiveScope,
250            Self::ToolPoisoning,
251            Self::PromptInjection,
252            Self::CommandExecution,
253            Self::DataExfiltration,
254            Self::SupplyChain,
255            Self::InsecureCommunication,
256            Self::MaliciousUpdate,
257            Self::InsufficientLogging,
258        ]
259    }
260}
261
262impl std::fmt::Display for OwaspMcp {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        write!(f, "{}", self.code())
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use std::path::{Path, PathBuf};
271
272    use super::*;
273    use crate::ir::SourceLocation;
274
275    /// Helper: build a minimal finding for tests.
276    fn make_finding(
277        rule_id: &str,
278        file: &str,
279        line: usize,
280        column: usize,
281        evidence_desc: &str,
282        category: AttackCategory,
283    ) -> Finding {
284        Finding {
285            rule_id: rule_id.to_string(),
286            rule_name: "Test Rule".to_string(),
287            severity: Severity::Critical,
288            confidence: Confidence::High,
289            attack_category: category,
290            message: "test".to_string(),
291            location: Some(SourceLocation {
292                file: PathBuf::from(file),
293                line,
294                column,
295                end_line: None,
296                end_column: None,
297            }),
298            evidence: vec![Evidence {
299                description: evidence_desc.to_string(),
300                location: None,
301                snippet: None,
302            }],
303            taint_path: None,
304            remediation: None,
305            cwe_id: None,
306        }
307    }
308
309    #[test]
310    fn fingerprint_stable_across_line_shifts() {
311        let scan_root = Path::new("/project");
312
313        let finding1 = make_finding(
314            "SHIELD-001",
315            "/project/src/main.py",
316            10,
317            0,
318            "subprocess.run receives parameter",
319            AttackCategory::CommandInjection,
320        );
321
322        // Same finding but at a different line and column
323        let finding2 = make_finding(
324            "SHIELD-001",
325            "/project/src/main.py",
326            25,
327            5,
328            "subprocess.run receives parameter",
329            AttackCategory::CommandInjection,
330        );
331
332        assert_eq!(
333            finding1.fingerprint(scan_root),
334            finding2.fingerprint(scan_root),
335            "Fingerprint should be stable across line shifts"
336        );
337    }
338
339    #[test]
340    fn fingerprint_different_for_different_rules() {
341        let scan_root = Path::new("/project");
342
343        let finding1 = make_finding(
344            "SHIELD-001",
345            "/project/src/main.py",
346            10,
347            0,
348            "subprocess.run receives parameter",
349            AttackCategory::CommandInjection,
350        );
351
352        let finding2 = make_finding(
353            "SHIELD-003",
354            "/project/src/main.py",
355            10,
356            0,
357            "requests.get receives parameter",
358            AttackCategory::Ssrf,
359        );
360
361        assert_ne!(
362            finding1.fingerprint(scan_root),
363            finding2.fingerprint(scan_root),
364            "Different rules should produce different fingerprints"
365        );
366    }
367
368    #[test]
369    fn fingerprint_different_for_different_files() {
370        let scan_root = Path::new("/project");
371
372        let finding1 = make_finding(
373            "SHIELD-001",
374            "/project/src/main.py",
375            10,
376            0,
377            "subprocess.run receives parameter",
378            AttackCategory::CommandInjection,
379        );
380
381        let finding3 = make_finding(
382            "SHIELD-001",
383            "/project/src/other.py",
384            10,
385            0,
386            "subprocess.run receives parameter",
387            AttackCategory::CommandInjection,
388        );
389
390        assert_ne!(
391            finding1.fingerprint(scan_root),
392            finding3.fingerprint(scan_root),
393            "Different files should produce different fingerprints"
394        );
395    }
396
397    #[test]
398    fn fingerprint_relative_path_portability() {
399        let finding1 = make_finding(
400            "SHIELD-001",
401            "/project/src/main.py",
402            10,
403            0,
404            "subprocess.run receives parameter",
405            AttackCategory::CommandInjection,
406        );
407
408        let finding2 = make_finding(
409            "SHIELD-001",
410            "/other/src/main.py",
411            10,
412            0,
413            "subprocess.run receives parameter",
414            AttackCategory::CommandInjection,
415        );
416
417        let fp1 = finding1.fingerprint(Path::new("/project"));
418        let fp2 = finding2.fingerprint(Path::new("/other"));
419
420        assert_eq!(
421            fp1, fp2,
422            "Same relative paths from different roots should produce same fingerprint"
423        );
424    }
425
426    #[test]
427    fn fingerprint_no_location() {
428        let scan_root = Path::new("/project");
429
430        let finding = Finding {
431            rule_id: "SHIELD-009".to_string(),
432            rule_name: "No Location".to_string(),
433            severity: Severity::Medium,
434            confidence: Confidence::Medium,
435            attack_category: AttackCategory::ExcessivePermissions,
436            message: "test".to_string(),
437            location: None,
438            evidence: vec![],
439            taint_path: None,
440            remediation: None,
441            cwe_id: None,
442        };
443
444        // Should not panic and should produce a valid hex string
445        let fp = finding.fingerprint(scan_root);
446        assert_eq!(fp.len(), 64, "SHA-256 hex digest should be 64 chars");
447    }
448
449    #[test]
450    fn fingerprint_is_valid_hex() {
451        let scan_root = Path::new("/project");
452        let finding = make_finding(
453            "SHIELD-001",
454            "/project/src/main.py",
455            1,
456            0,
457            "test evidence",
458            AttackCategory::CommandInjection,
459        );
460
461        let fp = finding.fingerprint(scan_root);
462        assert_eq!(fp.len(), 64);
463        assert!(
464            fp.chars().all(|c| c.is_ascii_hexdigit()),
465            "Fingerprint should be valid hex"
466        );
467    }
468
469    #[test]
470    fn rule_metadata_owasp_serialization_roundtrip() {
471        let meta = RuleMetadata {
472            id: "SHIELD-001".into(),
473            name: "Command Injection".into(),
474            description: "desc".into(),
475            default_severity: Severity::Critical,
476            attack_category: AttackCategory::CommandInjection,
477            cwe_id: Some("CWE-78".into()),
478            owasp_mcp: Some(OwaspMcp::CommandExecution),
479        };
480        let json = serde_json::to_string(&meta).unwrap();
481        assert!(json.contains("\"owasp_mcp\":\"MCP05\""));
482        let back: RuleMetadata = serde_json::from_str(&json).unwrap();
483        assert_eq!(back.owasp_mcp, Some(OwaspMcp::CommandExecution));
484    }
485
486    #[test]
487    fn rule_metadata_owasp_none_omits_key() {
488        let meta = RuleMetadata {
489            id: "SHIELD-999".into(),
490            name: "Future Rule".into(),
491            description: "desc".into(),
492            default_severity: Severity::Info,
493            attack_category: AttackCategory::SupplyChain,
494            cwe_id: None,
495            owasp_mcp: None,
496        };
497        let json = serde_json::to_string(&meta).unwrap();
498        assert!(!json.contains("owasp_mcp"));
499        // Deserialize missing key defaults to None
500        let back: RuleMetadata = serde_json::from_str(&json).unwrap();
501        assert_eq!(back.owasp_mcp, None);
502    }
503
504    #[test]
505    fn owasp_codes_and_names_complete() {
506        assert_eq!(OwaspMcp::all().len(), 10);
507        assert_eq!(OwaspMcp::CommandExecution.code(), "MCP05");
508        assert_eq!(OwaspMcp::CommandExecution.to_string(), "MCP05");
509        assert!(OwaspMcp::SupplyChain.name().contains("Supply Chain"));
510    }
511}