1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::ir::{SourceLocation, data_surface::TaintPath};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Finding {
11 pub rule_id: String,
13 pub rule_name: String,
15 pub severity: Severity,
17 pub confidence: Confidence,
19 pub attack_category: AttackCategory,
21 pub message: String,
23 pub location: Option<SourceLocation>,
25 pub evidence: Vec<Evidence>,
27 pub taint_path: Option<TaintPath>,
29 pub remediation: Option<String>,
31 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#[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 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 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 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub owasp_mcp: Option<OwaspMcp>,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
181pub enum OwaspMcp {
182 #[serde(rename = "MCP01")]
184 TokenMismanagement,
185 #[serde(rename = "MCP02")]
187 ExcessiveScope,
188 #[serde(rename = "MCP03")]
190 ToolPoisoning,
191 #[serde(rename = "MCP04")]
193 PromptInjection,
194 #[serde(rename = "MCP05")]
196 CommandExecution,
197 #[serde(rename = "MCP06")]
199 DataExfiltration,
200 #[serde(rename = "MCP07")]
202 SupplyChain,
203 #[serde(rename = "MCP08")]
205 InsecureCommunication,
206 #[serde(rename = "MCP09")]
208 MaliciousUpdate,
209 #[serde(rename = "MCP10")]
211 InsufficientLogging,
212}
213
214impl OwaspMcp {
215 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 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 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 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 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 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 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}