1use crate::scoring::RiskScore;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct ParseEnumError {
7 type_name: &'static str,
8 value: String,
9}
10
11impl ParseEnumError {
12 pub fn invalid(type_name: &'static str, value: &str) -> Self {
13 Self {
14 type_name,
15 value: value.to_string(),
16 }
17 }
18}
19
20impl std::fmt::Display for ParseEnumError {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 write!(f, "invalid {} value: '{}'", self.type_name, self.value)
23 }
24}
25
26impl std::error::Error for ParseEnumError {}
27
28#[derive(
31 Debug,
32 Clone,
33 Copy,
34 PartialEq,
35 Eq,
36 PartialOrd,
37 Ord,
38 Serialize,
39 Deserialize,
40 Default,
41 clap::ValueEnum,
42)]
43#[serde(rename_all = "lowercase")]
44pub enum RuleSeverity {
45 Warn,
47 #[default]
49 Error,
50}
51
52impl RuleSeverity {
53 pub fn as_str(&self) -> &'static str {
54 match self {
55 RuleSeverity::Warn => "warn",
56 RuleSeverity::Error => "error",
57 }
58 }
59}
60
61impl std::fmt::Display for RuleSeverity {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(f, "{}", self.as_str().to_uppercase())
64 }
65}
66
67impl std::str::FromStr for RuleSeverity {
68 type Err = ParseEnumError;
69
70 fn from_str(s: &str) -> Result<Self, Self::Err> {
71 match s.to_lowercase().as_str() {
72 "warn" | "warning" => Ok(RuleSeverity::Warn),
73 "error" | "err" => Ok(RuleSeverity::Error),
74 _ => Err(ParseEnumError::invalid("RuleSeverity", s)),
75 }
76 }
77}
78
79#[derive(
80 Debug,
81 Clone,
82 Copy,
83 PartialEq,
84 Eq,
85 PartialOrd,
86 Ord,
87 Hash,
88 Serialize,
89 Deserialize,
90 clap::ValueEnum,
91)]
92#[serde(rename_all = "lowercase")]
93pub enum Severity {
94 Low,
95 Medium,
96 High,
97 Critical,
98}
99
100#[derive(
102 Debug,
103 Clone,
104 Copy,
105 PartialEq,
106 Eq,
107 PartialOrd,
108 Ord,
109 Serialize,
110 Deserialize,
111 Default,
112 clap::ValueEnum,
113)]
114#[serde(rename_all = "lowercase")]
115pub enum Confidence {
116 Tentative,
118 #[default]
120 Firm,
121 Certain,
123}
124
125impl Confidence {
126 pub fn as_str(&self) -> &'static str {
127 match self {
128 Confidence::Tentative => "tentative",
129 Confidence::Firm => "firm",
130 Confidence::Certain => "certain",
131 }
132 }
133
134 pub fn downgrade(&self) -> Self {
143 match self {
144 Confidence::Certain => Confidence::Firm,
145 Confidence::Firm => Confidence::Tentative,
146 Confidence::Tentative => Confidence::Tentative,
147 }
148 }
149}
150
151impl std::fmt::Display for Confidence {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 write!(f, "{}", self.as_str())
154 }
155}
156
157impl std::str::FromStr for Confidence {
158 type Err = ParseEnumError;
159
160 fn from_str(s: &str) -> Result<Self, Self::Err> {
161 match s.to_lowercase().as_str() {
162 "tentative" => Ok(Confidence::Tentative),
163 "firm" => Ok(Confidence::Firm),
164 "certain" => Ok(Confidence::Certain),
165 _ => Err(ParseEnumError::invalid("Confidence", s)),
166 }
167 }
168}
169
170impl Severity {
171 pub fn as_str(&self) -> &'static str {
172 match self {
173 Severity::Low => "low",
174 Severity::Medium => "medium",
175 Severity::High => "high",
176 Severity::Critical => "critical",
177 }
178 }
179}
180
181impl std::fmt::Display for Severity {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 write!(f, "{}", self.as_str().to_uppercase())
184 }
185}
186
187impl std::str::FromStr for Severity {
188 type Err = ParseEnumError;
189
190 fn from_str(s: &str) -> Result<Self, Self::Err> {
191 match s.to_lowercase().as_str() {
192 "low" => Ok(Severity::Low),
193 "medium" | "med" => Ok(Severity::Medium),
194 "high" => Ok(Severity::High),
195 "critical" | "crit" => Ok(Severity::Critical),
196 _ => Err(ParseEnumError::invalid("Severity", s)),
197 }
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
202#[serde(rename_all = "lowercase")]
203pub enum Category {
204 Exfiltration,
205 PrivilegeEscalation,
206 Persistence,
207 PromptInjection,
208 Overpermission,
209 Obfuscation,
210 SupplyChain,
211 SecretLeak,
212}
213
214impl Category {
215 pub fn as_str(&self) -> &'static str {
216 match self {
217 Category::Exfiltration => "exfiltration",
218 Category::PrivilegeEscalation => "privilege_escalation",
219 Category::Persistence => "persistence",
220 Category::PromptInjection => "prompt_injection",
221 Category::Overpermission => "overpermission",
222 Category::Obfuscation => "obfuscation",
223 Category::SupplyChain => "supply_chain",
224 Category::SecretLeak => "secret_leak",
225 }
226 }
227}
228
229impl std::str::FromStr for Category {
230 type Err = ParseEnumError;
231
232 fn from_str(s: &str) -> Result<Self, Self::Err> {
233 match s.to_lowercase().replace(['_', '-'], "").as_str() {
234 "exfiltration" | "exfil" => Ok(Category::Exfiltration),
235 "privilegeescalation" | "privesc" => Ok(Category::PrivilegeEscalation),
236 "persistence" => Ok(Category::Persistence),
237 "promptinjection" => Ok(Category::PromptInjection),
238 "overpermission" => Ok(Category::Overpermission),
239 "obfuscation" => Ok(Category::Obfuscation),
240 "supplychain" => Ok(Category::SupplyChain),
241 "secretleak" => Ok(Category::SecretLeak),
242 _ => Err(ParseEnumError::invalid("Category", s)),
243 }
244 }
245}
246
247#[derive(Debug, Clone)]
248pub struct Rule {
249 pub id: &'static str,
250 pub name: &'static str,
251 pub description: &'static str,
252 pub severity: Severity,
253 pub category: Category,
254 pub confidence: Confidence,
255 pub patterns: Vec<regex::Regex>,
256 pub exclusions: Vec<regex::Regex>,
257 pub message: &'static str,
258 pub recommendation: &'static str,
259 pub fix_hint: Option<&'static str>,
261 pub cwe_ids: &'static [&'static str],
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Location {
267 pub file: String,
268 pub line: usize,
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub column: Option<usize>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct Finding {
275 pub id: String,
276 pub severity: Severity,
277 pub category: Category,
278 pub confidence: Confidence,
279 pub name: String,
280 pub location: Location,
281 pub code: String,
282 pub message: String,
283 pub recommendation: String,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub fix_hint: Option<String>,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub cwe_ids: Vec<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub rule_severity: Option<RuleSeverity>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub client: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub context: Option<crate::context::ContentContext>,
300}
301
302impl Finding {
303 pub fn new(rule: &Rule, location: Location, code: String) -> Self {
304 Self {
305 id: rule.id.to_string(),
306 severity: rule.severity,
307 category: rule.category,
308 confidence: rule.confidence,
309 name: rule.name.to_string(),
310 location,
311 code,
312 message: rule.message.to_string(),
313 recommendation: rule.recommendation.to_string(),
314 fix_hint: rule.fix_hint.map(|s| s.to_string()),
315 cwe_ids: rule.cwe_ids.iter().map(|s| s.to_string()).collect(),
316 rule_severity: None, client: None, context: None, }
320 }
321
322 pub fn with_context(mut self, context: crate::context::ContentContext) -> Self {
324 self.context = Some(context);
325 self
326 }
327
328 pub fn with_client(mut self, client: Option<String>) -> Self {
330 self.client = client;
331 self
332 }
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct Summary {
337 pub critical: usize,
338 pub high: usize,
339 pub medium: usize,
340 pub low: usize,
341 pub passed: bool,
342 #[serde(default)]
344 pub errors: usize,
345 #[serde(default)]
347 pub warnings: usize,
348}
349
350impl Summary {
351 pub fn from_findings(findings: &[Finding]) -> Self {
355 let (critical, high, medium, low) =
356 findings
357 .iter()
358 .fold((0, 0, 0, 0), |(c, h, m, l), f| match f.severity {
359 Severity::Critical => (c + 1, h, m, l),
360 Severity::High => (c, h + 1, m, l),
361 Severity::Medium => (c, h, m + 1, l),
362 Severity::Low => (c, h, m, l + 1),
363 });
364
365 Self {
366 critical,
367 high,
368 medium,
369 low,
370 passed: critical == 0 && high == 0,
371 errors: 0,
372 warnings: 0,
373 }
374 }
375
376 pub fn from_findings_with_rule_severity(findings: &[Finding]) -> Self {
379 let (critical, high, medium, low, errors, warnings) =
380 findings
381 .iter()
382 .fold((0, 0, 0, 0, 0, 0), |(c, h, m, l, e, w), f| {
383 let (new_c, new_h, new_m, new_l) = match f.severity {
384 Severity::Critical => (c + 1, h, m, l),
385 Severity::High => (c, h + 1, m, l),
386 Severity::Medium => (c, h, m + 1, l),
387 Severity::Low => (c, h, m, l + 1),
388 };
389 let (new_e, new_w) = match f.rule_severity {
390 Some(RuleSeverity::Error) | None => (e + 1, w), Some(RuleSeverity::Warn) => (e, w + 1),
392 };
393 (new_c, new_h, new_m, new_l, new_e, new_w)
394 });
395
396 Self {
397 critical,
398 high,
399 medium,
400 low,
401 passed: errors == 0, errors,
403 warnings,
404 }
405 }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ScanResult {
410 pub version: String,
411 pub scanned_at: String,
412 pub target: String,
413 pub summary: Summary,
414 pub findings: Vec<Finding>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub risk_score: Option<RiskScore>,
417 pub elapsed_ms: u64,
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_severity_as_str() {
426 assert_eq!(Severity::Low.as_str(), "low");
427 assert_eq!(Severity::Medium.as_str(), "medium");
428 assert_eq!(Severity::High.as_str(), "high");
429 assert_eq!(Severity::Critical.as_str(), "critical");
430 }
431
432 #[test]
433 fn test_severity_display() {
434 assert_eq!(format!("{}", Severity::Low), "LOW");
435 assert_eq!(format!("{}", Severity::Medium), "MEDIUM");
436 assert_eq!(format!("{}", Severity::High), "HIGH");
437 assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
438 }
439
440 #[test]
441 fn test_severity_ordering() {
442 assert!(Severity::Low < Severity::Medium);
443 assert!(Severity::Medium < Severity::High);
444 assert!(Severity::High < Severity::Critical);
445 }
446
447 #[test]
448 fn test_category_as_str() {
449 assert_eq!(Category::Exfiltration.as_str(), "exfiltration");
450 assert_eq!(
451 Category::PrivilegeEscalation.as_str(),
452 "privilege_escalation"
453 );
454 assert_eq!(Category::Persistence.as_str(), "persistence");
455 assert_eq!(Category::PromptInjection.as_str(), "prompt_injection");
456 assert_eq!(Category::Overpermission.as_str(), "overpermission");
457 assert_eq!(Category::Obfuscation.as_str(), "obfuscation");
458 assert_eq!(Category::SupplyChain.as_str(), "supply_chain");
459 assert_eq!(Category::SecretLeak.as_str(), "secret_leak");
460 }
461
462 #[test]
463 fn test_summary_from_empty_findings() {
464 let findings: Vec<Finding> = vec![];
465 let summary = Summary::from_findings(&findings);
466 assert_eq!(summary.critical, 0);
467 assert_eq!(summary.high, 0);
468 assert_eq!(summary.medium, 0);
469 assert_eq!(summary.low, 0);
470 assert!(summary.passed);
471 }
472
473 #[test]
474 fn test_summary_from_findings_with_critical() {
475 let findings = vec![Finding {
476 id: "EX-001".to_string(),
477 severity: Severity::Critical,
478 category: Category::Exfiltration,
479 confidence: Confidence::Certain,
480 name: "Test".to_string(),
481 location: Location {
482 file: "test.sh".to_string(),
483 line: 1,
484 column: None,
485 },
486 code: "test".to_string(),
487 message: "test".to_string(),
488 recommendation: "test".to_string(),
489 fix_hint: None,
490 cwe_ids: vec![],
491 rule_severity: None,
492 client: None,
493 context: None,
494 }];
495 let summary = Summary::from_findings(&findings);
496 assert_eq!(summary.critical, 1);
497 assert!(!summary.passed);
498 }
499
500 #[test]
501 fn test_summary_from_findings_all_severities() {
502 let findings = vec![
503 Finding {
504 id: "C-001".to_string(),
505 severity: Severity::Critical,
506 category: Category::Exfiltration,
507 confidence: Confidence::Certain,
508 name: "Critical".to_string(),
509 location: Location {
510 file: "test.sh".to_string(),
511 line: 1,
512 column: None,
513 },
514 code: "test".to_string(),
515 message: "test".to_string(),
516 recommendation: "test".to_string(),
517 fix_hint: None,
518 cwe_ids: vec![],
519 rule_severity: None,
520 client: None,
521 context: None,
522 },
523 Finding {
524 id: "H-001".to_string(),
525 severity: Severity::High,
526 category: Category::PrivilegeEscalation,
527 confidence: Confidence::Firm,
528 name: "High".to_string(),
529 location: Location {
530 file: "test.sh".to_string(),
531 line: 2,
532 column: None,
533 },
534 code: "test".to_string(),
535 message: "test".to_string(),
536 recommendation: "test".to_string(),
537 fix_hint: None,
538 cwe_ids: vec![],
539 rule_severity: None,
540 client: None,
541 context: None,
542 },
543 Finding {
544 id: "M-001".to_string(),
545 severity: Severity::Medium,
546 category: Category::Persistence,
547 confidence: Confidence::Tentative,
548 name: "Medium".to_string(),
549 location: Location {
550 file: "test.sh".to_string(),
551 line: 3,
552 column: Some(5),
553 },
554 code: "test".to_string(),
555 message: "test".to_string(),
556 recommendation: "test".to_string(),
557 fix_hint: None,
558 cwe_ids: vec![],
559 rule_severity: None,
560 client: None,
561 context: None,
562 },
563 Finding {
564 id: "L-001".to_string(),
565 severity: Severity::Low,
566 category: Category::Overpermission,
567 confidence: Confidence::Firm,
568 name: "Low".to_string(),
569 location: Location {
570 file: "test.sh".to_string(),
571 line: 4,
572 column: None,
573 },
574 code: "test".to_string(),
575 message: "test".to_string(),
576 recommendation: "test".to_string(),
577 fix_hint: None,
578 cwe_ids: vec![],
579 rule_severity: None,
580 client: None,
581 context: None,
582 },
583 ];
584 let summary = Summary::from_findings(&findings);
585 assert_eq!(summary.critical, 1);
586 assert_eq!(summary.high, 1);
587 assert_eq!(summary.medium, 1);
588 assert_eq!(summary.low, 1);
589 assert!(!summary.passed);
590 }
591
592 #[test]
593 fn test_summary_passes_with_only_medium_low() {
594 let findings = vec![
595 Finding {
596 id: "M-001".to_string(),
597 severity: Severity::Medium,
598 category: Category::Persistence,
599 confidence: Confidence::Firm,
600 name: "Medium".to_string(),
601 location: Location {
602 file: "test.sh".to_string(),
603 line: 1,
604 column: None,
605 },
606 code: "test".to_string(),
607 message: "test".to_string(),
608 recommendation: "test".to_string(),
609 fix_hint: None,
610 cwe_ids: vec![],
611 rule_severity: None,
612 client: None,
613 context: None,
614 },
615 Finding {
616 id: "L-001".to_string(),
617 severity: Severity::Low,
618 category: Category::Overpermission,
619 confidence: Confidence::Firm,
620 name: "Low".to_string(),
621 location: Location {
622 file: "test.sh".to_string(),
623 line: 2,
624 column: None,
625 },
626 code: "test".to_string(),
627 message: "test".to_string(),
628 recommendation: "test".to_string(),
629 fix_hint: None,
630 cwe_ids: vec![],
631 rule_severity: None,
632 client: None,
633 context: None,
634 },
635 ];
636 let summary = Summary::from_findings(&findings);
637 assert!(summary.passed);
638 }
639
640 #[test]
641 fn test_finding_new() {
642 let rule = Rule {
643 id: "TEST-001",
644 name: "Test Rule",
645 description: "A test rule",
646 severity: Severity::High,
647 category: Category::Exfiltration,
648 confidence: Confidence::Certain,
649 patterns: vec![],
650 exclusions: vec![],
651 message: "Test message",
652 recommendation: "Test recommendation",
653 fix_hint: Some("Test fix hint"),
654 cwe_ids: &["CWE-200", "CWE-78"],
655 };
656 let location = Location {
657 file: "test.sh".to_string(),
658 line: 42,
659 column: Some(10),
660 };
661 let finding = Finding::new(&rule, location, "test code".to_string());
662
663 assert_eq!(finding.id, "TEST-001");
664 assert_eq!(finding.name, "Test Rule");
665 assert_eq!(finding.severity, Severity::High);
666 assert_eq!(finding.category, Category::Exfiltration);
667 assert_eq!(finding.confidence, Confidence::Certain);
668 assert_eq!(finding.location.file, "test.sh");
669 assert_eq!(finding.location.line, 42);
670 assert_eq!(finding.location.column, Some(10));
671 assert_eq!(finding.code, "test code");
672 assert_eq!(finding.message, "Test message");
673 assert_eq!(finding.recommendation, "Test recommendation");
674 assert_eq!(finding.cwe_ids, vec!["CWE-200", "CWE-78"]);
675 }
676
677 #[test]
678 fn test_confidence_as_str() {
679 assert_eq!(Confidence::Tentative.as_str(), "tentative");
680 assert_eq!(Confidence::Firm.as_str(), "firm");
681 assert_eq!(Confidence::Certain.as_str(), "certain");
682 }
683
684 #[test]
685 fn test_confidence_display() {
686 assert_eq!(format!("{}", Confidence::Tentative), "tentative");
687 assert_eq!(format!("{}", Confidence::Firm), "firm");
688 assert_eq!(format!("{}", Confidence::Certain), "certain");
689 }
690
691 #[test]
692 fn test_confidence_downgrade() {
693 assert_eq!(Confidence::Certain.downgrade(), Confidence::Firm);
695 assert_eq!(Confidence::Firm.downgrade(), Confidence::Tentative);
697 assert_eq!(Confidence::Tentative.downgrade(), Confidence::Tentative);
699 }
700
701 #[test]
702 fn test_confidence_downgrade_twice() {
703 let confidence = Confidence::Certain;
705 let downgraded_once = confidence.downgrade();
706 let downgraded_twice = downgraded_once.downgrade();
707 assert_eq!(downgraded_twice, Confidence::Tentative);
708 }
709
710 #[test]
711 fn test_confidence_ordering() {
712 assert!(Confidence::Tentative < Confidence::Firm);
713 assert!(Confidence::Firm < Confidence::Certain);
714 }
715
716 #[test]
717 fn test_confidence_default() {
718 assert_eq!(Confidence::default(), Confidence::Firm);
719 }
720
721 #[test]
722 fn test_confidence_serialization() {
723 let confidence = Confidence::Certain;
724 let json = serde_json::to_string(&confidence).unwrap();
725 assert_eq!(json, "\"certain\"");
726
727 let deserialized: Confidence = serde_json::from_str(&json).unwrap();
728 assert_eq!(deserialized, Confidence::Certain);
729 }
730
731 #[test]
732 fn test_severity_serialization() {
733 let severity = Severity::Critical;
734 let json = serde_json::to_string(&severity).unwrap();
735 assert_eq!(json, "\"critical\"");
736
737 let deserialized: Severity = serde_json::from_str(&json).unwrap();
738 assert_eq!(deserialized, Severity::Critical);
739 }
740
741 #[test]
742 fn test_category_serialization() {
743 let category = Category::PromptInjection;
744 let json = serde_json::to_string(&category).unwrap();
745 assert_eq!(json, "\"promptinjection\"");
746
747 let deserialized: Category = serde_json::from_str(&json).unwrap();
748 assert_eq!(deserialized, Category::PromptInjection);
749 }
750
751 #[test]
752 fn test_location_without_column_serialization() {
753 let location = Location {
754 file: "test.sh".to_string(),
755 line: 10,
756 column: None,
757 };
758 let json = serde_json::to_string(&location).unwrap();
759 assert!(!json.contains("column"));
760 }
761
762 #[test]
763 fn test_location_with_column_serialization() {
764 let location = Location {
765 file: "test.sh".to_string(),
766 line: 10,
767 column: Some(5),
768 };
769 let json = serde_json::to_string(&location).unwrap();
770 assert!(json.contains("\"column\":5"));
771 }
772
773 #[test]
776 fn test_rule_severity_default_is_error() {
777 assert_eq!(RuleSeverity::default(), RuleSeverity::Error);
778 }
779
780 #[test]
781 fn test_rule_severity_as_str() {
782 assert_eq!(RuleSeverity::Error.as_str(), "error");
783 assert_eq!(RuleSeverity::Warn.as_str(), "warn");
784 }
785
786 #[test]
787 fn test_rule_severity_display() {
788 assert_eq!(format!("{}", RuleSeverity::Error), "ERROR");
789 assert_eq!(format!("{}", RuleSeverity::Warn), "WARN");
790 }
791
792 #[test]
793 fn test_rule_severity_ordering() {
794 assert!(RuleSeverity::Warn < RuleSeverity::Error);
796 }
797
798 #[test]
799 fn test_rule_severity_serialization() {
800 let error = RuleSeverity::Error;
801 let json = serde_json::to_string(&error).unwrap();
802 assert_eq!(json, "\"error\"");
803
804 let warn = RuleSeverity::Warn;
805 let json = serde_json::to_string(&warn).unwrap();
806 assert_eq!(json, "\"warn\"");
807
808 let deserialized: RuleSeverity = serde_json::from_str("\"error\"").unwrap();
809 assert_eq!(deserialized, RuleSeverity::Error);
810
811 let deserialized: RuleSeverity = serde_json::from_str("\"warn\"").unwrap();
812 assert_eq!(deserialized, RuleSeverity::Warn);
813 }
814
815 fn create_test_finding(
818 id: &str,
819 severity: Severity,
820 rule_severity: Option<RuleSeverity>,
821 ) -> Finding {
822 Finding {
823 id: id.to_string(),
824 severity,
825 category: Category::Exfiltration,
826 confidence: Confidence::Firm,
827 name: "Test".to_string(),
828 location: Location {
829 file: "test.sh".to_string(),
830 line: 1,
831 column: None,
832 },
833 code: "test".to_string(),
834 message: "test".to_string(),
835 recommendation: "test".to_string(),
836 fix_hint: None,
837 cwe_ids: vec![],
838 rule_severity,
839 client: None,
840 context: None,
841 }
842 }
843
844 #[test]
845 fn test_summary_with_rule_severity_empty() {
846 let findings: Vec<Finding> = vec![];
847 let summary = Summary::from_findings_with_rule_severity(&findings);
848 assert_eq!(summary.errors, 0);
849 assert_eq!(summary.warnings, 0);
850 assert!(summary.passed);
851 }
852
853 #[test]
854 fn test_summary_with_rule_severity_all_errors() {
855 let findings = vec![
856 create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
857 create_test_finding("E-002", Severity::High, Some(RuleSeverity::Error)),
858 ];
859 let summary = Summary::from_findings_with_rule_severity(&findings);
860 assert_eq!(summary.errors, 2);
861 assert_eq!(summary.warnings, 0);
862 assert!(!summary.passed);
863 }
864
865 #[test]
866 fn test_summary_with_rule_severity_all_warnings() {
867 let findings = vec![
868 create_test_finding("W-001", Severity::Critical, Some(RuleSeverity::Warn)),
869 create_test_finding("W-002", Severity::High, Some(RuleSeverity::Warn)),
870 ];
871 let summary = Summary::from_findings_with_rule_severity(&findings);
872 assert_eq!(summary.errors, 0);
873 assert_eq!(summary.warnings, 2);
874 assert!(summary.passed); }
876
877 #[test]
878 fn test_summary_with_rule_severity_mixed() {
879 let findings = vec![
880 create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
881 create_test_finding("W-001", Severity::High, Some(RuleSeverity::Warn)),
882 create_test_finding("W-002", Severity::Medium, Some(RuleSeverity::Warn)),
883 ];
884 let summary = Summary::from_findings_with_rule_severity(&findings);
885 assert_eq!(summary.errors, 1);
886 assert_eq!(summary.warnings, 2);
887 assert!(!summary.passed); assert_eq!(summary.critical, 1);
890 assert_eq!(summary.high, 1);
891 assert_eq!(summary.medium, 1);
892 }
893
894 #[test]
895 fn test_summary_with_rule_severity_none_defaults_to_error() {
896 let findings = vec![
897 create_test_finding("N-001", Severity::Low, None), ];
899 let summary = Summary::from_findings_with_rule_severity(&findings);
900 assert_eq!(summary.errors, 1);
901 assert_eq!(summary.warnings, 0);
902 assert!(!summary.passed);
903 }
904
905 #[test]
906 fn test_finding_rule_severity_not_serialized_when_none() {
907 let finding = create_test_finding("TEST-001", Severity::High, None);
908 let json = serde_json::to_string(&finding).unwrap();
909 assert!(!json.contains("rule_severity"));
910 }
911
912 #[test]
913 fn test_finding_rule_severity_serialized_when_some() {
914 let finding = create_test_finding("TEST-001", Severity::High, Some(RuleSeverity::Warn));
915 let json = serde_json::to_string(&finding).unwrap();
916 assert!(json.contains("\"rule_severity\":\"warn\""));
917 }
918
919 #[test]
922 fn test_parse_enum_error_invalid() {
923 let error = ParseEnumError::invalid("TestType", "bad_value");
924 assert_eq!(error.type_name, "TestType");
925 assert_eq!(error.value, "bad_value");
926 }
927
928 #[test]
929 fn test_parse_enum_error_display() {
930 let error = ParseEnumError::invalid("RuleSeverity", "unknown");
931 let display = format!("{}", error);
932 assert_eq!(display, "invalid RuleSeverity value: 'unknown'");
933 }
934
935 #[test]
936 fn test_parse_enum_error_debug() {
937 let error = ParseEnumError::invalid("TestType", "value");
938 let debug = format!("{:?}", error);
939 assert!(debug.contains("ParseEnumError"));
940 assert!(debug.contains("TestType"));
941 assert!(debug.contains("value"));
942 }
943
944 #[test]
945 fn test_parse_enum_error_is_error() {
946 let error = ParseEnumError::invalid("Test", "val");
947 let _: &dyn std::error::Error = &error;
949 }
950
951 #[test]
954 fn test_rule_severity_from_str_valid() {
955 use std::str::FromStr;
956
957 assert_eq!(RuleSeverity::from_str("warn").unwrap(), RuleSeverity::Warn);
959 assert_eq!(
960 RuleSeverity::from_str("error").unwrap(),
961 RuleSeverity::Error
962 );
963
964 assert_eq!(
966 RuleSeverity::from_str("warning").unwrap(),
967 RuleSeverity::Warn
968 );
969 assert_eq!(RuleSeverity::from_str("err").unwrap(), RuleSeverity::Error);
970
971 assert_eq!(RuleSeverity::from_str("WARN").unwrap(), RuleSeverity::Warn);
973 assert_eq!(
974 RuleSeverity::from_str("ERROR").unwrap(),
975 RuleSeverity::Error
976 );
977 assert_eq!(
978 RuleSeverity::from_str("Warning").unwrap(),
979 RuleSeverity::Warn
980 );
981 }
982
983 #[test]
984 fn test_rule_severity_from_str_invalid() {
985 use std::str::FromStr;
986
987 let result = RuleSeverity::from_str("invalid");
988 assert!(result.is_err());
989 let error = result.unwrap_err();
990 assert_eq!(error.type_name, "RuleSeverity");
991 assert_eq!(error.value, "invalid");
992
993 let result = RuleSeverity::from_str("");
995 assert!(result.is_err());
996
997 let result = RuleSeverity::from_str("critical");
999 assert!(result.is_err());
1000 }
1001
1002 #[test]
1005 fn test_severity_from_str_valid() {
1006 use std::str::FromStr;
1007
1008 assert_eq!(Severity::from_str("low").unwrap(), Severity::Low);
1009 assert_eq!(Severity::from_str("LOW").unwrap(), Severity::Low);
1010 assert_eq!(Severity::from_str("Low").unwrap(), Severity::Low);
1011
1012 assert_eq!(Severity::from_str("medium").unwrap(), Severity::Medium);
1013 assert_eq!(Severity::from_str("MEDIUM").unwrap(), Severity::Medium);
1014 assert_eq!(Severity::from_str("med").unwrap(), Severity::Medium);
1015 assert_eq!(Severity::from_str("MED").unwrap(), Severity::Medium);
1016
1017 assert_eq!(Severity::from_str("high").unwrap(), Severity::High);
1018 assert_eq!(Severity::from_str("HIGH").unwrap(), Severity::High);
1019
1020 assert_eq!(Severity::from_str("critical").unwrap(), Severity::Critical);
1021 assert_eq!(Severity::from_str("CRITICAL").unwrap(), Severity::Critical);
1022 assert_eq!(Severity::from_str("crit").unwrap(), Severity::Critical);
1023 assert_eq!(Severity::from_str("CRIT").unwrap(), Severity::Critical);
1024 }
1025
1026 #[test]
1027 fn test_severity_from_str_invalid() {
1028 use std::str::FromStr;
1029
1030 let result = Severity::from_str("invalid");
1031 assert!(result.is_err());
1032 let error = result.unwrap_err();
1033 assert_eq!(error.type_name, "Severity");
1034 assert_eq!(error.value, "invalid");
1035
1036 let result = Severity::from_str("");
1038 assert!(result.is_err());
1039 }
1040
1041 #[test]
1044 fn test_category_from_str_valid() {
1045 use std::str::FromStr;
1046
1047 assert_eq!(
1049 Category::from_str("exfiltration").unwrap(),
1050 Category::Exfiltration
1051 );
1052 assert_eq!(
1053 Category::from_str("EXFILTRATION").unwrap(),
1054 Category::Exfiltration
1055 );
1056 assert_eq!(Category::from_str("exfil").unwrap(), Category::Exfiltration);
1057 assert_eq!(Category::from_str("EXFIL").unwrap(), Category::Exfiltration);
1058
1059 assert_eq!(
1061 Category::from_str("privilege_escalation").unwrap(),
1062 Category::PrivilegeEscalation
1063 );
1064 assert_eq!(
1065 Category::from_str("privilege-escalation").unwrap(),
1066 Category::PrivilegeEscalation
1067 );
1068 assert_eq!(
1069 Category::from_str("privilegeescalation").unwrap(),
1070 Category::PrivilegeEscalation
1071 );
1072 assert_eq!(
1073 Category::from_str("privesc").unwrap(),
1074 Category::PrivilegeEscalation
1075 );
1076
1077 assert_eq!(
1079 Category::from_str("persistence").unwrap(),
1080 Category::Persistence
1081 );
1082
1083 assert_eq!(
1085 Category::from_str("prompt_injection").unwrap(),
1086 Category::PromptInjection
1087 );
1088 assert_eq!(
1089 Category::from_str("promptinjection").unwrap(),
1090 Category::PromptInjection
1091 );
1092
1093 assert_eq!(
1095 Category::from_str("overpermission").unwrap(),
1096 Category::Overpermission
1097 );
1098
1099 assert_eq!(
1101 Category::from_str("obfuscation").unwrap(),
1102 Category::Obfuscation
1103 );
1104
1105 assert_eq!(
1107 Category::from_str("supply_chain").unwrap(),
1108 Category::SupplyChain
1109 );
1110 assert_eq!(
1111 Category::from_str("supplychain").unwrap(),
1112 Category::SupplyChain
1113 );
1114
1115 assert_eq!(
1117 Category::from_str("secret_leak").unwrap(),
1118 Category::SecretLeak
1119 );
1120 assert_eq!(
1121 Category::from_str("secretleak").unwrap(),
1122 Category::SecretLeak
1123 );
1124 }
1125
1126 #[test]
1127 fn test_category_from_str_invalid() {
1128 use std::str::FromStr;
1129
1130 let result = Category::from_str("invalid");
1131 assert!(result.is_err());
1132 let error = result.unwrap_err();
1133 assert_eq!(error.type_name, "Category");
1134 assert_eq!(error.value, "invalid");
1135
1136 let result = Category::from_str("");
1138 assert!(result.is_err());
1139 }
1140
1141 #[test]
1144 fn test_finding_with_context() {
1145 use crate::context::ContentContext;
1146
1147 let finding = create_test_finding("TEST-001", Severity::High, None);
1148 assert!(finding.context.is_none());
1149
1150 let finding_with_context = finding.with_context(ContentContext::Documentation);
1151 assert_eq!(
1152 finding_with_context.context,
1153 Some(ContentContext::Documentation)
1154 );
1155 }
1156
1157 #[test]
1158 fn test_finding_with_client() {
1159 let finding = create_test_finding("TEST-001", Severity::High, None);
1160 assert!(finding.client.is_none());
1161
1162 let finding_with_client = finding.with_client(Some("claude".to_string()));
1163 assert_eq!(finding_with_client.client, Some("claude".to_string()));
1164
1165 let finding2 = create_test_finding("TEST-002", Severity::Medium, None);
1167 let finding_without_client = finding2.with_client(None);
1168 assert!(finding_without_client.client.is_none());
1169 }
1170}