1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::ir::{data_surface::TaintPath, SourceLocation};
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}
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#[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 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 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 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#[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 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 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 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}