1use crate::scoring::RiskScore;
2use serde::{Deserialize, Serialize};
3
4#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 PartialOrd,
13 Ord,
14 Serialize,
15 Deserialize,
16 Default,
17 clap::ValueEnum,
18)]
19#[serde(rename_all = "lowercase")]
20pub enum RuleSeverity {
21 Warn,
23 #[default]
25 Error,
26}
27
28impl RuleSeverity {
29 pub fn as_str(&self) -> &'static str {
30 match self {
31 RuleSeverity::Warn => "warn",
32 RuleSeverity::Error => "error",
33 }
34 }
35}
36
37impl std::fmt::Display for RuleSeverity {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 write!(f, "{}", self.as_str().to_uppercase())
40 }
41}
42
43#[derive(
44 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, clap::ValueEnum,
45)]
46#[serde(rename_all = "lowercase")]
47pub enum Severity {
48 Low,
49 Medium,
50 High,
51 Critical,
52}
53
54#[derive(
56 Debug,
57 Clone,
58 Copy,
59 PartialEq,
60 Eq,
61 PartialOrd,
62 Ord,
63 Serialize,
64 Deserialize,
65 Default,
66 clap::ValueEnum,
67)]
68#[serde(rename_all = "lowercase")]
69pub enum Confidence {
70 Tentative,
72 #[default]
74 Firm,
75 Certain,
77}
78
79impl Confidence {
80 pub fn as_str(&self) -> &'static str {
81 match self {
82 Confidence::Tentative => "tentative",
83 Confidence::Firm => "firm",
84 Confidence::Certain => "certain",
85 }
86 }
87}
88
89impl std::fmt::Display for Confidence {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 write!(f, "{}", self.as_str())
92 }
93}
94
95impl Severity {
96 pub fn as_str(&self) -> &'static str {
97 match self {
98 Severity::Low => "low",
99 Severity::Medium => "medium",
100 Severity::High => "high",
101 Severity::Critical => "critical",
102 }
103 }
104}
105
106impl std::fmt::Display for Severity {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 write!(f, "{}", self.as_str().to_uppercase())
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum Category {
115 Exfiltration,
116 PrivilegeEscalation,
117 Persistence,
118 PromptInjection,
119 Overpermission,
120 Obfuscation,
121 SupplyChain,
122 SecretLeak,
123}
124
125impl Category {
126 pub fn as_str(&self) -> &'static str {
127 match self {
128 Category::Exfiltration => "exfiltration",
129 Category::PrivilegeEscalation => "privilege_escalation",
130 Category::Persistence => "persistence",
131 Category::PromptInjection => "prompt_injection",
132 Category::Overpermission => "overpermission",
133 Category::Obfuscation => "obfuscation",
134 Category::SupplyChain => "supply_chain",
135 Category::SecretLeak => "secret_leak",
136 }
137 }
138}
139
140#[derive(Debug, Clone)]
141pub struct Rule {
142 pub id: &'static str,
143 pub name: &'static str,
144 pub description: &'static str,
145 pub severity: Severity,
146 pub category: Category,
147 pub confidence: Confidence,
148 pub patterns: Vec<regex::Regex>,
149 pub exclusions: Vec<regex::Regex>,
150 pub message: &'static str,
151 pub recommendation: &'static str,
152 pub fix_hint: Option<&'static str>,
154 pub cwe_ids: &'static [&'static str],
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct Location {
160 pub file: String,
161 pub line: usize,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub column: Option<usize>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Finding {
168 pub id: String,
169 pub severity: Severity,
170 pub category: Category,
171 pub confidence: Confidence,
172 pub name: String,
173 pub location: Location,
174 pub code: String,
175 pub message: String,
176 pub recommendation: String,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub fix_hint: Option<String>,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub cwe_ids: Vec<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub rule_severity: Option<RuleSeverity>,
186}
187
188impl Finding {
189 pub fn new(rule: &Rule, location: Location, code: String) -> Self {
190 Self {
191 id: rule.id.to_string(),
192 severity: rule.severity,
193 category: rule.category,
194 confidence: rule.confidence,
195 name: rule.name.to_string(),
196 location,
197 code,
198 message: rule.message.to_string(),
199 recommendation: rule.recommendation.to_string(),
200 fix_hint: rule.fix_hint.map(|s| s.to_string()),
201 cwe_ids: rule.cwe_ids.iter().map(|s| s.to_string()).collect(),
202 rule_severity: None, }
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Summary {
209 pub critical: usize,
210 pub high: usize,
211 pub medium: usize,
212 pub low: usize,
213 pub passed: bool,
214 #[serde(default)]
216 pub errors: usize,
217 #[serde(default)]
219 pub warnings: usize,
220}
221
222impl Summary {
223 pub fn from_findings(findings: &[Finding]) -> Self {
227 let (critical, high, medium, low) =
228 findings
229 .iter()
230 .fold((0, 0, 0, 0), |(c, h, m, l), f| match f.severity {
231 Severity::Critical => (c + 1, h, m, l),
232 Severity::High => (c, h + 1, m, l),
233 Severity::Medium => (c, h, m + 1, l),
234 Severity::Low => (c, h, m, l + 1),
235 });
236
237 Self {
238 critical,
239 high,
240 medium,
241 low,
242 passed: critical == 0 && high == 0,
243 errors: 0,
244 warnings: 0,
245 }
246 }
247
248 pub fn from_findings_with_rule_severity(findings: &[Finding]) -> Self {
251 let (critical, high, medium, low, errors, warnings) =
252 findings
253 .iter()
254 .fold((0, 0, 0, 0, 0, 0), |(c, h, m, l, e, w), f| {
255 let (new_c, new_h, new_m, new_l) = match f.severity {
256 Severity::Critical => (c + 1, h, m, l),
257 Severity::High => (c, h + 1, m, l),
258 Severity::Medium => (c, h, m + 1, l),
259 Severity::Low => (c, h, m, l + 1),
260 };
261 let (new_e, new_w) = match f.rule_severity {
262 Some(RuleSeverity::Error) | None => (e + 1, w), Some(RuleSeverity::Warn) => (e, w + 1),
264 };
265 (new_c, new_h, new_m, new_l, new_e, new_w)
266 });
267
268 Self {
269 critical,
270 high,
271 medium,
272 low,
273 passed: errors == 0, errors,
275 warnings,
276 }
277 }
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ScanResult {
282 pub version: String,
283 pub scanned_at: String,
284 pub target: String,
285 pub summary: Summary,
286 pub findings: Vec<Finding>,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub risk_score: Option<RiskScore>,
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_severity_as_str() {
297 assert_eq!(Severity::Low.as_str(), "low");
298 assert_eq!(Severity::Medium.as_str(), "medium");
299 assert_eq!(Severity::High.as_str(), "high");
300 assert_eq!(Severity::Critical.as_str(), "critical");
301 }
302
303 #[test]
304 fn test_severity_display() {
305 assert_eq!(format!("{}", Severity::Low), "LOW");
306 assert_eq!(format!("{}", Severity::Medium), "MEDIUM");
307 assert_eq!(format!("{}", Severity::High), "HIGH");
308 assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
309 }
310
311 #[test]
312 fn test_severity_ordering() {
313 assert!(Severity::Low < Severity::Medium);
314 assert!(Severity::Medium < Severity::High);
315 assert!(Severity::High < Severity::Critical);
316 }
317
318 #[test]
319 fn test_category_as_str() {
320 assert_eq!(Category::Exfiltration.as_str(), "exfiltration");
321 assert_eq!(
322 Category::PrivilegeEscalation.as_str(),
323 "privilege_escalation"
324 );
325 assert_eq!(Category::Persistence.as_str(), "persistence");
326 assert_eq!(Category::PromptInjection.as_str(), "prompt_injection");
327 assert_eq!(Category::Overpermission.as_str(), "overpermission");
328 assert_eq!(Category::Obfuscation.as_str(), "obfuscation");
329 assert_eq!(Category::SupplyChain.as_str(), "supply_chain");
330 assert_eq!(Category::SecretLeak.as_str(), "secret_leak");
331 }
332
333 #[test]
334 fn test_summary_from_empty_findings() {
335 let findings: Vec<Finding> = vec![];
336 let summary = Summary::from_findings(&findings);
337 assert_eq!(summary.critical, 0);
338 assert_eq!(summary.high, 0);
339 assert_eq!(summary.medium, 0);
340 assert_eq!(summary.low, 0);
341 assert!(summary.passed);
342 }
343
344 #[test]
345 fn test_summary_from_findings_with_critical() {
346 let findings = vec![Finding {
347 id: "EX-001".to_string(),
348 severity: Severity::Critical,
349 category: Category::Exfiltration,
350 confidence: Confidence::Certain,
351 name: "Test".to_string(),
352 location: Location {
353 file: "test.sh".to_string(),
354 line: 1,
355 column: None,
356 },
357 code: "test".to_string(),
358 message: "test".to_string(),
359 recommendation: "test".to_string(),
360 fix_hint: None,
361 cwe_ids: vec![],
362 rule_severity: None,
363 }];
364 let summary = Summary::from_findings(&findings);
365 assert_eq!(summary.critical, 1);
366 assert!(!summary.passed);
367 }
368
369 #[test]
370 fn test_summary_from_findings_all_severities() {
371 let findings = vec![
372 Finding {
373 id: "C-001".to_string(),
374 severity: Severity::Critical,
375 category: Category::Exfiltration,
376 confidence: Confidence::Certain,
377 name: "Critical".to_string(),
378 location: Location {
379 file: "test.sh".to_string(),
380 line: 1,
381 column: None,
382 },
383 code: "test".to_string(),
384 message: "test".to_string(),
385 recommendation: "test".to_string(),
386 fix_hint: None,
387 cwe_ids: vec![],
388 rule_severity: None,
389 },
390 Finding {
391 id: "H-001".to_string(),
392 severity: Severity::High,
393 category: Category::PrivilegeEscalation,
394 confidence: Confidence::Firm,
395 name: "High".to_string(),
396 location: Location {
397 file: "test.sh".to_string(),
398 line: 2,
399 column: None,
400 },
401 code: "test".to_string(),
402 message: "test".to_string(),
403 recommendation: "test".to_string(),
404 fix_hint: None,
405 cwe_ids: vec![],
406 rule_severity: None,
407 },
408 Finding {
409 id: "M-001".to_string(),
410 severity: Severity::Medium,
411 category: Category::Persistence,
412 confidence: Confidence::Tentative,
413 name: "Medium".to_string(),
414 location: Location {
415 file: "test.sh".to_string(),
416 line: 3,
417 column: Some(5),
418 },
419 code: "test".to_string(),
420 message: "test".to_string(),
421 recommendation: "test".to_string(),
422 fix_hint: None,
423 cwe_ids: vec![],
424 rule_severity: None,
425 },
426 Finding {
427 id: "L-001".to_string(),
428 severity: Severity::Low,
429 category: Category::Overpermission,
430 confidence: Confidence::Firm,
431 name: "Low".to_string(),
432 location: Location {
433 file: "test.sh".to_string(),
434 line: 4,
435 column: None,
436 },
437 code: "test".to_string(),
438 message: "test".to_string(),
439 recommendation: "test".to_string(),
440 fix_hint: None,
441 cwe_ids: vec![],
442 rule_severity: None,
443 },
444 ];
445 let summary = Summary::from_findings(&findings);
446 assert_eq!(summary.critical, 1);
447 assert_eq!(summary.high, 1);
448 assert_eq!(summary.medium, 1);
449 assert_eq!(summary.low, 1);
450 assert!(!summary.passed);
451 }
452
453 #[test]
454 fn test_summary_passes_with_only_medium_low() {
455 let findings = vec![
456 Finding {
457 id: "M-001".to_string(),
458 severity: Severity::Medium,
459 category: Category::Persistence,
460 confidence: Confidence::Firm,
461 name: "Medium".to_string(),
462 location: Location {
463 file: "test.sh".to_string(),
464 line: 1,
465 column: None,
466 },
467 code: "test".to_string(),
468 message: "test".to_string(),
469 recommendation: "test".to_string(),
470 fix_hint: None,
471 cwe_ids: vec![],
472 rule_severity: None,
473 },
474 Finding {
475 id: "L-001".to_string(),
476 severity: Severity::Low,
477 category: Category::Overpermission,
478 confidence: Confidence::Firm,
479 name: "Low".to_string(),
480 location: Location {
481 file: "test.sh".to_string(),
482 line: 2,
483 column: None,
484 },
485 code: "test".to_string(),
486 message: "test".to_string(),
487 recommendation: "test".to_string(),
488 fix_hint: None,
489 cwe_ids: vec![],
490 rule_severity: None,
491 },
492 ];
493 let summary = Summary::from_findings(&findings);
494 assert!(summary.passed);
495 }
496
497 #[test]
498 fn test_finding_new() {
499 let rule = Rule {
500 id: "TEST-001",
501 name: "Test Rule",
502 description: "A test rule",
503 severity: Severity::High,
504 category: Category::Exfiltration,
505 confidence: Confidence::Certain,
506 patterns: vec![],
507 exclusions: vec![],
508 message: "Test message",
509 recommendation: "Test recommendation",
510 fix_hint: Some("Test fix hint"),
511 cwe_ids: &["CWE-200", "CWE-78"],
512 };
513 let location = Location {
514 file: "test.sh".to_string(),
515 line: 42,
516 column: Some(10),
517 };
518 let finding = Finding::new(&rule, location, "test code".to_string());
519
520 assert_eq!(finding.id, "TEST-001");
521 assert_eq!(finding.name, "Test Rule");
522 assert_eq!(finding.severity, Severity::High);
523 assert_eq!(finding.category, Category::Exfiltration);
524 assert_eq!(finding.confidence, Confidence::Certain);
525 assert_eq!(finding.location.file, "test.sh");
526 assert_eq!(finding.location.line, 42);
527 assert_eq!(finding.location.column, Some(10));
528 assert_eq!(finding.code, "test code");
529 assert_eq!(finding.message, "Test message");
530 assert_eq!(finding.recommendation, "Test recommendation");
531 assert_eq!(finding.cwe_ids, vec!["CWE-200", "CWE-78"]);
532 }
533
534 #[test]
535 fn test_confidence_as_str() {
536 assert_eq!(Confidence::Tentative.as_str(), "tentative");
537 assert_eq!(Confidence::Firm.as_str(), "firm");
538 assert_eq!(Confidence::Certain.as_str(), "certain");
539 }
540
541 #[test]
542 fn test_confidence_display() {
543 assert_eq!(format!("{}", Confidence::Tentative), "tentative");
544 assert_eq!(format!("{}", Confidence::Firm), "firm");
545 assert_eq!(format!("{}", Confidence::Certain), "certain");
546 }
547
548 #[test]
549 fn test_confidence_ordering() {
550 assert!(Confidence::Tentative < Confidence::Firm);
551 assert!(Confidence::Firm < Confidence::Certain);
552 }
553
554 #[test]
555 fn test_confidence_default() {
556 assert_eq!(Confidence::default(), Confidence::Firm);
557 }
558
559 #[test]
560 fn test_confidence_serialization() {
561 let confidence = Confidence::Certain;
562 let json = serde_json::to_string(&confidence).unwrap();
563 assert_eq!(json, "\"certain\"");
564
565 let deserialized: Confidence = serde_json::from_str(&json).unwrap();
566 assert_eq!(deserialized, Confidence::Certain);
567 }
568
569 #[test]
570 fn test_severity_serialization() {
571 let severity = Severity::Critical;
572 let json = serde_json::to_string(&severity).unwrap();
573 assert_eq!(json, "\"critical\"");
574
575 let deserialized: Severity = serde_json::from_str(&json).unwrap();
576 assert_eq!(deserialized, Severity::Critical);
577 }
578
579 #[test]
580 fn test_category_serialization() {
581 let category = Category::PromptInjection;
582 let json = serde_json::to_string(&category).unwrap();
583 assert_eq!(json, "\"promptinjection\"");
584
585 let deserialized: Category = serde_json::from_str(&json).unwrap();
586 assert_eq!(deserialized, Category::PromptInjection);
587 }
588
589 #[test]
590 fn test_location_without_column_serialization() {
591 let location = Location {
592 file: "test.sh".to_string(),
593 line: 10,
594 column: None,
595 };
596 let json = serde_json::to_string(&location).unwrap();
597 assert!(!json.contains("column"));
598 }
599
600 #[test]
601 fn test_location_with_column_serialization() {
602 let location = Location {
603 file: "test.sh".to_string(),
604 line: 10,
605 column: Some(5),
606 };
607 let json = serde_json::to_string(&location).unwrap();
608 assert!(json.contains("\"column\":5"));
609 }
610
611 #[test]
614 fn test_rule_severity_default_is_error() {
615 assert_eq!(RuleSeverity::default(), RuleSeverity::Error);
616 }
617
618 #[test]
619 fn test_rule_severity_as_str() {
620 assert_eq!(RuleSeverity::Error.as_str(), "error");
621 assert_eq!(RuleSeverity::Warn.as_str(), "warn");
622 }
623
624 #[test]
625 fn test_rule_severity_display() {
626 assert_eq!(format!("{}", RuleSeverity::Error), "ERROR");
627 assert_eq!(format!("{}", RuleSeverity::Warn), "WARN");
628 }
629
630 #[test]
631 fn test_rule_severity_ordering() {
632 assert!(RuleSeverity::Warn < RuleSeverity::Error);
634 }
635
636 #[test]
637 fn test_rule_severity_serialization() {
638 let error = RuleSeverity::Error;
639 let json = serde_json::to_string(&error).unwrap();
640 assert_eq!(json, "\"error\"");
641
642 let warn = RuleSeverity::Warn;
643 let json = serde_json::to_string(&warn).unwrap();
644 assert_eq!(json, "\"warn\"");
645
646 let deserialized: RuleSeverity = serde_json::from_str("\"error\"").unwrap();
647 assert_eq!(deserialized, RuleSeverity::Error);
648
649 let deserialized: RuleSeverity = serde_json::from_str("\"warn\"").unwrap();
650 assert_eq!(deserialized, RuleSeverity::Warn);
651 }
652
653 fn create_test_finding(
656 id: &str,
657 severity: Severity,
658 rule_severity: Option<RuleSeverity>,
659 ) -> Finding {
660 Finding {
661 id: id.to_string(),
662 severity,
663 category: Category::Exfiltration,
664 confidence: Confidence::Firm,
665 name: "Test".to_string(),
666 location: Location {
667 file: "test.sh".to_string(),
668 line: 1,
669 column: None,
670 },
671 code: "test".to_string(),
672 message: "test".to_string(),
673 recommendation: "test".to_string(),
674 fix_hint: None,
675 cwe_ids: vec![],
676 rule_severity,
677 }
678 }
679
680 #[test]
681 fn test_summary_with_rule_severity_empty() {
682 let findings: Vec<Finding> = vec![];
683 let summary = Summary::from_findings_with_rule_severity(&findings);
684 assert_eq!(summary.errors, 0);
685 assert_eq!(summary.warnings, 0);
686 assert!(summary.passed);
687 }
688
689 #[test]
690 fn test_summary_with_rule_severity_all_errors() {
691 let findings = vec![
692 create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
693 create_test_finding("E-002", Severity::High, Some(RuleSeverity::Error)),
694 ];
695 let summary = Summary::from_findings_with_rule_severity(&findings);
696 assert_eq!(summary.errors, 2);
697 assert_eq!(summary.warnings, 0);
698 assert!(!summary.passed);
699 }
700
701 #[test]
702 fn test_summary_with_rule_severity_all_warnings() {
703 let findings = vec![
704 create_test_finding("W-001", Severity::Critical, Some(RuleSeverity::Warn)),
705 create_test_finding("W-002", Severity::High, Some(RuleSeverity::Warn)),
706 ];
707 let summary = Summary::from_findings_with_rule_severity(&findings);
708 assert_eq!(summary.errors, 0);
709 assert_eq!(summary.warnings, 2);
710 assert!(summary.passed); }
712
713 #[test]
714 fn test_summary_with_rule_severity_mixed() {
715 let findings = vec![
716 create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
717 create_test_finding("W-001", Severity::High, Some(RuleSeverity::Warn)),
718 create_test_finding("W-002", Severity::Medium, Some(RuleSeverity::Warn)),
719 ];
720 let summary = Summary::from_findings_with_rule_severity(&findings);
721 assert_eq!(summary.errors, 1);
722 assert_eq!(summary.warnings, 2);
723 assert!(!summary.passed); assert_eq!(summary.critical, 1);
726 assert_eq!(summary.high, 1);
727 assert_eq!(summary.medium, 1);
728 }
729
730 #[test]
731 fn test_summary_with_rule_severity_none_defaults_to_error() {
732 let findings = vec![
733 create_test_finding("N-001", Severity::Low, None), ];
735 let summary = Summary::from_findings_with_rule_severity(&findings);
736 assert_eq!(summary.errors, 1);
737 assert_eq!(summary.warnings, 0);
738 assert!(!summary.passed);
739 }
740
741 #[test]
742 fn test_finding_rule_severity_not_serialized_when_none() {
743 let finding = create_test_finding("TEST-001", Severity::High, None);
744 let json = serde_json::to_string(&finding).unwrap();
745 assert!(!json.contains("rule_severity"));
746 }
747
748 #[test]
749 fn test_finding_rule_severity_serialized_when_some() {
750 let finding = create_test_finding("TEST-001", Severity::High, Some(RuleSeverity::Warn));
751 let json = serde_json::to_string(&finding).unwrap();
752 assert!(json.contains("\"rule_severity\":\"warn\""));
753 }
754}