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}
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#[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 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 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 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub owasp_mcp: Option<OwaspMcp>,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
179pub enum OwaspMcp {
180 #[serde(rename = "MCP01")]
182 TokenMismanagement,
183 #[serde(rename = "MCP02")]
185 ExcessiveScope,
186 #[serde(rename = "MCP03")]
188 ToolPoisoning,
189 #[serde(rename = "MCP04")]
191 PromptInjection,
192 #[serde(rename = "MCP05")]
194 CommandExecution,
195 #[serde(rename = "MCP06")]
197 DataExfiltration,
198 #[serde(rename = "MCP07")]
200 SupplyChain,
201 #[serde(rename = "MCP08")]
203 InsecureCommunication,
204 #[serde(rename = "MCP09")]
206 MaliciousUpdate,
207 #[serde(rename = "MCP10")]
209 InsufficientLogging,
210}
211
212impl OwaspMcp {
213 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 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 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 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 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 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 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}