1use crate::scoring::RiskScore;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
5#[serde(rename_all = "lowercase")]
6pub enum Severity {
7 Low,
8 Medium,
9 High,
10 Critical,
11}
12
13#[derive(
15 Debug,
16 Clone,
17 Copy,
18 PartialEq,
19 Eq,
20 PartialOrd,
21 Ord,
22 Serialize,
23 Deserialize,
24 Default,
25 clap::ValueEnum,
26)]
27#[serde(rename_all = "lowercase")]
28pub enum Confidence {
29 Tentative,
31 #[default]
33 Firm,
34 Certain,
36}
37
38impl Confidence {
39 pub fn as_str(&self) -> &'static str {
40 match self {
41 Confidence::Tentative => "tentative",
42 Confidence::Firm => "firm",
43 Confidence::Certain => "certain",
44 }
45 }
46}
47
48impl std::fmt::Display for Confidence {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 write!(f, "{}", self.as_str())
51 }
52}
53
54impl Severity {
55 pub fn as_str(&self) -> &'static str {
56 match self {
57 Severity::Low => "low",
58 Severity::Medium => "medium",
59 Severity::High => "high",
60 Severity::Critical => "critical",
61 }
62 }
63}
64
65impl std::fmt::Display for Severity {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 write!(f, "{}", self.as_str().to_uppercase())
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "lowercase")]
73pub enum Category {
74 Exfiltration,
75 PrivilegeEscalation,
76 Persistence,
77 PromptInjection,
78 Overpermission,
79 Obfuscation,
80 SupplyChain,
81 SecretLeak,
82}
83
84impl Category {
85 pub fn as_str(&self) -> &'static str {
86 match self {
87 Category::Exfiltration => "exfiltration",
88 Category::PrivilegeEscalation => "privilege_escalation",
89 Category::Persistence => "persistence",
90 Category::PromptInjection => "prompt_injection",
91 Category::Overpermission => "overpermission",
92 Category::Obfuscation => "obfuscation",
93 Category::SupplyChain => "supply_chain",
94 Category::SecretLeak => "secret_leak",
95 }
96 }
97}
98
99#[derive(Debug, Clone)]
100pub struct Rule {
101 pub id: &'static str,
102 pub name: &'static str,
103 pub description: &'static str,
104 pub severity: Severity,
105 pub category: Category,
106 pub confidence: Confidence,
107 pub patterns: Vec<regex::Regex>,
108 pub exclusions: Vec<regex::Regex>,
109 pub message: &'static str,
110 pub recommendation: &'static str,
111 pub fix_hint: Option<&'static str>,
113 pub cwe_ids: &'static [&'static str],
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Location {
119 pub file: String,
120 pub line: usize,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub column: Option<usize>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Finding {
127 pub id: String,
128 pub severity: Severity,
129 pub category: Category,
130 pub confidence: Confidence,
131 pub name: String,
132 pub location: Location,
133 pub code: String,
134 pub message: String,
135 pub recommendation: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub fix_hint: Option<String>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub cwe_ids: Vec<String>,
141}
142
143impl Finding {
144 pub fn new(rule: &Rule, location: Location, code: String) -> Self {
145 Self {
146 id: rule.id.to_string(),
147 severity: rule.severity,
148 category: rule.category,
149 confidence: rule.confidence,
150 name: rule.name.to_string(),
151 location,
152 code,
153 message: rule.message.to_string(),
154 recommendation: rule.recommendation.to_string(),
155 fix_hint: rule.fix_hint.map(|s| s.to_string()),
156 cwe_ids: rule.cwe_ids.iter().map(|s| s.to_string()).collect(),
157 }
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Summary {
163 pub critical: usize,
164 pub high: usize,
165 pub medium: usize,
166 pub low: usize,
167 pub passed: bool,
168}
169
170impl Summary {
171 pub fn from_findings(findings: &[Finding]) -> Self {
172 let (critical, high, medium, low) =
173 findings
174 .iter()
175 .fold((0, 0, 0, 0), |(c, h, m, l), f| match f.severity {
176 Severity::Critical => (c + 1, h, m, l),
177 Severity::High => (c, h + 1, m, l),
178 Severity::Medium => (c, h, m + 1, l),
179 Severity::Low => (c, h, m, l + 1),
180 });
181
182 Self {
183 critical,
184 high,
185 medium,
186 low,
187 passed: critical == 0 && high == 0,
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct ScanResult {
194 pub version: String,
195 pub scanned_at: String,
196 pub target: String,
197 pub summary: Summary,
198 pub findings: Vec<Finding>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub risk_score: Option<RiskScore>,
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn test_severity_as_str() {
209 assert_eq!(Severity::Low.as_str(), "low");
210 assert_eq!(Severity::Medium.as_str(), "medium");
211 assert_eq!(Severity::High.as_str(), "high");
212 assert_eq!(Severity::Critical.as_str(), "critical");
213 }
214
215 #[test]
216 fn test_severity_display() {
217 assert_eq!(format!("{}", Severity::Low), "LOW");
218 assert_eq!(format!("{}", Severity::Medium), "MEDIUM");
219 assert_eq!(format!("{}", Severity::High), "HIGH");
220 assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
221 }
222
223 #[test]
224 fn test_severity_ordering() {
225 assert!(Severity::Low < Severity::Medium);
226 assert!(Severity::Medium < Severity::High);
227 assert!(Severity::High < Severity::Critical);
228 }
229
230 #[test]
231 fn test_category_as_str() {
232 assert_eq!(Category::Exfiltration.as_str(), "exfiltration");
233 assert_eq!(
234 Category::PrivilegeEscalation.as_str(),
235 "privilege_escalation"
236 );
237 assert_eq!(Category::Persistence.as_str(), "persistence");
238 assert_eq!(Category::PromptInjection.as_str(), "prompt_injection");
239 assert_eq!(Category::Overpermission.as_str(), "overpermission");
240 assert_eq!(Category::Obfuscation.as_str(), "obfuscation");
241 assert_eq!(Category::SupplyChain.as_str(), "supply_chain");
242 assert_eq!(Category::SecretLeak.as_str(), "secret_leak");
243 }
244
245 #[test]
246 fn test_summary_from_empty_findings() {
247 let findings: Vec<Finding> = vec![];
248 let summary = Summary::from_findings(&findings);
249 assert_eq!(summary.critical, 0);
250 assert_eq!(summary.high, 0);
251 assert_eq!(summary.medium, 0);
252 assert_eq!(summary.low, 0);
253 assert!(summary.passed);
254 }
255
256 #[test]
257 fn test_summary_from_findings_with_critical() {
258 let findings = vec![Finding {
259 id: "EX-001".to_string(),
260 severity: Severity::Critical,
261 category: Category::Exfiltration,
262 confidence: Confidence::Certain,
263 name: "Test".to_string(),
264 location: Location {
265 file: "test.sh".to_string(),
266 line: 1,
267 column: None,
268 },
269 code: "test".to_string(),
270 message: "test".to_string(),
271 recommendation: "test".to_string(),
272 fix_hint: None,
273 cwe_ids: vec![],
274 }];
275 let summary = Summary::from_findings(&findings);
276 assert_eq!(summary.critical, 1);
277 assert!(!summary.passed);
278 }
279
280 #[test]
281 fn test_summary_from_findings_all_severities() {
282 let findings = vec![
283 Finding {
284 id: "C-001".to_string(),
285 severity: Severity::Critical,
286 category: Category::Exfiltration,
287 confidence: Confidence::Certain,
288 name: "Critical".to_string(),
289 location: Location {
290 file: "test.sh".to_string(),
291 line: 1,
292 column: None,
293 },
294 code: "test".to_string(),
295 message: "test".to_string(),
296 recommendation: "test".to_string(),
297 fix_hint: None,
298 cwe_ids: vec![],
299 },
300 Finding {
301 id: "H-001".to_string(),
302 severity: Severity::High,
303 category: Category::PrivilegeEscalation,
304 confidence: Confidence::Firm,
305 name: "High".to_string(),
306 location: Location {
307 file: "test.sh".to_string(),
308 line: 2,
309 column: None,
310 },
311 code: "test".to_string(),
312 message: "test".to_string(),
313 recommendation: "test".to_string(),
314 fix_hint: None,
315 cwe_ids: vec![],
316 },
317 Finding {
318 id: "M-001".to_string(),
319 severity: Severity::Medium,
320 category: Category::Persistence,
321 confidence: Confidence::Tentative,
322 name: "Medium".to_string(),
323 location: Location {
324 file: "test.sh".to_string(),
325 line: 3,
326 column: Some(5),
327 },
328 code: "test".to_string(),
329 message: "test".to_string(),
330 recommendation: "test".to_string(),
331 fix_hint: None,
332 cwe_ids: vec![],
333 },
334 Finding {
335 id: "L-001".to_string(),
336 severity: Severity::Low,
337 category: Category::Overpermission,
338 confidence: Confidence::Firm,
339 name: "Low".to_string(),
340 location: Location {
341 file: "test.sh".to_string(),
342 line: 4,
343 column: None,
344 },
345 code: "test".to_string(),
346 message: "test".to_string(),
347 recommendation: "test".to_string(),
348 fix_hint: None,
349 cwe_ids: vec![],
350 },
351 ];
352 let summary = Summary::from_findings(&findings);
353 assert_eq!(summary.critical, 1);
354 assert_eq!(summary.high, 1);
355 assert_eq!(summary.medium, 1);
356 assert_eq!(summary.low, 1);
357 assert!(!summary.passed);
358 }
359
360 #[test]
361 fn test_summary_passes_with_only_medium_low() {
362 let findings = vec![
363 Finding {
364 id: "M-001".to_string(),
365 severity: Severity::Medium,
366 category: Category::Persistence,
367 confidence: Confidence::Firm,
368 name: "Medium".to_string(),
369 location: Location {
370 file: "test.sh".to_string(),
371 line: 1,
372 column: None,
373 },
374 code: "test".to_string(),
375 message: "test".to_string(),
376 recommendation: "test".to_string(),
377 fix_hint: None,
378 cwe_ids: vec![],
379 },
380 Finding {
381 id: "L-001".to_string(),
382 severity: Severity::Low,
383 category: Category::Overpermission,
384 confidence: Confidence::Firm,
385 name: "Low".to_string(),
386 location: Location {
387 file: "test.sh".to_string(),
388 line: 2,
389 column: None,
390 },
391 code: "test".to_string(),
392 message: "test".to_string(),
393 recommendation: "test".to_string(),
394 fix_hint: None,
395 cwe_ids: vec![],
396 },
397 ];
398 let summary = Summary::from_findings(&findings);
399 assert!(summary.passed);
400 }
401
402 #[test]
403 fn test_finding_new() {
404 let rule = Rule {
405 id: "TEST-001",
406 name: "Test Rule",
407 description: "A test rule",
408 severity: Severity::High,
409 category: Category::Exfiltration,
410 confidence: Confidence::Certain,
411 patterns: vec![],
412 exclusions: vec![],
413 message: "Test message",
414 recommendation: "Test recommendation",
415 fix_hint: Some("Test fix hint"),
416 cwe_ids: &["CWE-200", "CWE-78"],
417 };
418 let location = Location {
419 file: "test.sh".to_string(),
420 line: 42,
421 column: Some(10),
422 };
423 let finding = Finding::new(&rule, location, "test code".to_string());
424
425 assert_eq!(finding.id, "TEST-001");
426 assert_eq!(finding.name, "Test Rule");
427 assert_eq!(finding.severity, Severity::High);
428 assert_eq!(finding.category, Category::Exfiltration);
429 assert_eq!(finding.confidence, Confidence::Certain);
430 assert_eq!(finding.location.file, "test.sh");
431 assert_eq!(finding.location.line, 42);
432 assert_eq!(finding.location.column, Some(10));
433 assert_eq!(finding.code, "test code");
434 assert_eq!(finding.message, "Test message");
435 assert_eq!(finding.recommendation, "Test recommendation");
436 assert_eq!(finding.cwe_ids, vec!["CWE-200", "CWE-78"]);
437 }
438
439 #[test]
440 fn test_confidence_as_str() {
441 assert_eq!(Confidence::Tentative.as_str(), "tentative");
442 assert_eq!(Confidence::Firm.as_str(), "firm");
443 assert_eq!(Confidence::Certain.as_str(), "certain");
444 }
445
446 #[test]
447 fn test_confidence_display() {
448 assert_eq!(format!("{}", Confidence::Tentative), "tentative");
449 assert_eq!(format!("{}", Confidence::Firm), "firm");
450 assert_eq!(format!("{}", Confidence::Certain), "certain");
451 }
452
453 #[test]
454 fn test_confidence_ordering() {
455 assert!(Confidence::Tentative < Confidence::Firm);
456 assert!(Confidence::Firm < Confidence::Certain);
457 }
458
459 #[test]
460 fn test_confidence_default() {
461 assert_eq!(Confidence::default(), Confidence::Firm);
462 }
463
464 #[test]
465 fn test_confidence_serialization() {
466 let confidence = Confidence::Certain;
467 let json = serde_json::to_string(&confidence).unwrap();
468 assert_eq!(json, "\"certain\"");
469
470 let deserialized: Confidence = serde_json::from_str(&json).unwrap();
471 assert_eq!(deserialized, Confidence::Certain);
472 }
473
474 #[test]
475 fn test_severity_serialization() {
476 let severity = Severity::Critical;
477 let json = serde_json::to_string(&severity).unwrap();
478 assert_eq!(json, "\"critical\"");
479
480 let deserialized: Severity = serde_json::from_str(&json).unwrap();
481 assert_eq!(deserialized, Severity::Critical);
482 }
483
484 #[test]
485 fn test_category_serialization() {
486 let category = Category::PromptInjection;
487 let json = serde_json::to_string(&category).unwrap();
488 assert_eq!(json, "\"promptinjection\"");
489
490 let deserialized: Category = serde_json::from_str(&json).unwrap();
491 assert_eq!(deserialized, Category::PromptInjection);
492 }
493
494 #[test]
495 fn test_location_without_column_serialization() {
496 let location = Location {
497 file: "test.sh".to_string(),
498 line: 10,
499 column: None,
500 };
501 let json = serde_json::to_string(&location).unwrap();
502 assert!(!json.contains("column"));
503 }
504
505 #[test]
506 fn test_location_with_column_serialization() {
507 let location = Location {
508 file: "test.sh".to_string(),
509 line: 10,
510 column: Some(5),
511 };
512 let json = serde_json::to_string(&location).unwrap();
513 assert!(json.contains("\"column\":5"));
514 }
515}