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