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
135impl std::fmt::Display for Confidence {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 write!(f, "{}", self.as_str())
138 }
139}
140
141impl std::str::FromStr for Confidence {
142 type Err = ParseEnumError;
143
144 fn from_str(s: &str) -> Result<Self, Self::Err> {
145 match s.to_lowercase().as_str() {
146 "tentative" => Ok(Confidence::Tentative),
147 "firm" => Ok(Confidence::Firm),
148 "certain" => Ok(Confidence::Certain),
149 _ => Err(ParseEnumError::invalid("Confidence", s)),
150 }
151 }
152}
153
154impl Severity {
155 pub fn as_str(&self) -> &'static str {
156 match self {
157 Severity::Low => "low",
158 Severity::Medium => "medium",
159 Severity::High => "high",
160 Severity::Critical => "critical",
161 }
162 }
163}
164
165impl std::fmt::Display for Severity {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 write!(f, "{}", self.as_str().to_uppercase())
168 }
169}
170
171impl std::str::FromStr for Severity {
172 type Err = ParseEnumError;
173
174 fn from_str(s: &str) -> Result<Self, Self::Err> {
175 match s.to_lowercase().as_str() {
176 "low" => Ok(Severity::Low),
177 "medium" | "med" => Ok(Severity::Medium),
178 "high" => Ok(Severity::High),
179 "critical" | "crit" => Ok(Severity::Critical),
180 _ => Err(ParseEnumError::invalid("Severity", s)),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
186#[serde(rename_all = "lowercase")]
187pub enum Category {
188 Exfiltration,
189 PrivilegeEscalation,
190 Persistence,
191 PromptInjection,
192 Overpermission,
193 Obfuscation,
194 SupplyChain,
195 SecretLeak,
196}
197
198impl Category {
199 pub fn as_str(&self) -> &'static str {
200 match self {
201 Category::Exfiltration => "exfiltration",
202 Category::PrivilegeEscalation => "privilege_escalation",
203 Category::Persistence => "persistence",
204 Category::PromptInjection => "prompt_injection",
205 Category::Overpermission => "overpermission",
206 Category::Obfuscation => "obfuscation",
207 Category::SupplyChain => "supply_chain",
208 Category::SecretLeak => "secret_leak",
209 }
210 }
211}
212
213impl std::str::FromStr for Category {
214 type Err = ParseEnumError;
215
216 fn from_str(s: &str) -> Result<Self, Self::Err> {
217 match s.to_lowercase().replace(['_', '-'], "").as_str() {
218 "exfiltration" | "exfil" => Ok(Category::Exfiltration),
219 "privilegeescalation" | "privesc" => Ok(Category::PrivilegeEscalation),
220 "persistence" => Ok(Category::Persistence),
221 "promptinjection" => Ok(Category::PromptInjection),
222 "overpermission" => Ok(Category::Overpermission),
223 "obfuscation" => Ok(Category::Obfuscation),
224 "supplychain" => Ok(Category::SupplyChain),
225 "secretleak" => Ok(Category::SecretLeak),
226 _ => Err(ParseEnumError::invalid("Category", s)),
227 }
228 }
229}
230
231#[derive(Debug, Clone)]
232pub struct Rule {
233 pub id: &'static str,
234 pub name: &'static str,
235 pub description: &'static str,
236 pub severity: Severity,
237 pub category: Category,
238 pub confidence: Confidence,
239 pub patterns: Vec<regex::Regex>,
240 pub exclusions: Vec<regex::Regex>,
241 pub message: &'static str,
242 pub recommendation: &'static str,
243 pub fix_hint: Option<&'static str>,
245 pub cwe_ids: &'static [&'static str],
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct Location {
251 pub file: String,
252 pub line: usize,
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub column: Option<usize>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct Finding {
259 pub id: String,
260 pub severity: Severity,
261 pub category: Category,
262 pub confidence: Confidence,
263 pub name: String,
264 pub location: Location,
265 pub code: String,
266 pub message: String,
267 pub recommendation: String,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub fix_hint: Option<String>,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub cwe_ids: Vec<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub rule_severity: Option<RuleSeverity>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub client: Option<String>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub context: Option<crate::context::ContentContext>,
284}
285
286impl Finding {
287 pub fn new(rule: &Rule, location: Location, code: String) -> Self {
288 Self {
289 id: rule.id.to_string(),
290 severity: rule.severity,
291 category: rule.category,
292 confidence: rule.confidence,
293 name: rule.name.to_string(),
294 location,
295 code,
296 message: rule.message.to_string(),
297 recommendation: rule.recommendation.to_string(),
298 fix_hint: rule.fix_hint.map(|s| s.to_string()),
299 cwe_ids: rule.cwe_ids.iter().map(|s| s.to_string()).collect(),
300 rule_severity: None, client: None, context: None, }
304 }
305
306 pub fn with_context(mut self, context: crate::context::ContentContext) -> Self {
308 self.context = Some(context);
309 self
310 }
311
312 pub fn with_client(mut self, client: Option<String>) -> Self {
314 self.client = client;
315 self
316 }
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct Summary {
321 pub critical: usize,
322 pub high: usize,
323 pub medium: usize,
324 pub low: usize,
325 pub passed: bool,
326 #[serde(default)]
328 pub errors: usize,
329 #[serde(default)]
331 pub warnings: usize,
332}
333
334impl Summary {
335 pub fn from_findings(findings: &[Finding]) -> Self {
339 let (critical, high, medium, low) =
340 findings
341 .iter()
342 .fold((0, 0, 0, 0), |(c, h, m, l), f| match f.severity {
343 Severity::Critical => (c + 1, h, m, l),
344 Severity::High => (c, h + 1, m, l),
345 Severity::Medium => (c, h, m + 1, l),
346 Severity::Low => (c, h, m, l + 1),
347 });
348
349 Self {
350 critical,
351 high,
352 medium,
353 low,
354 passed: critical == 0 && high == 0,
355 errors: 0,
356 warnings: 0,
357 }
358 }
359
360 pub fn from_findings_with_rule_severity(findings: &[Finding]) -> Self {
363 let (critical, high, medium, low, errors, warnings) =
364 findings
365 .iter()
366 .fold((0, 0, 0, 0, 0, 0), |(c, h, m, l, e, w), f| {
367 let (new_c, new_h, new_m, new_l) = match f.severity {
368 Severity::Critical => (c + 1, h, m, l),
369 Severity::High => (c, h + 1, m, l),
370 Severity::Medium => (c, h, m + 1, l),
371 Severity::Low => (c, h, m, l + 1),
372 };
373 let (new_e, new_w) = match f.rule_severity {
374 Some(RuleSeverity::Error) | None => (e + 1, w), Some(RuleSeverity::Warn) => (e, w + 1),
376 };
377 (new_c, new_h, new_m, new_l, new_e, new_w)
378 });
379
380 Self {
381 critical,
382 high,
383 medium,
384 low,
385 passed: errors == 0, errors,
387 warnings,
388 }
389 }
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct ScanResult {
394 pub version: String,
395 pub scanned_at: String,
396 pub target: String,
397 pub summary: Summary,
398 pub findings: Vec<Finding>,
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub risk_score: Option<RiskScore>,
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_severity_as_str() {
409 assert_eq!(Severity::Low.as_str(), "low");
410 assert_eq!(Severity::Medium.as_str(), "medium");
411 assert_eq!(Severity::High.as_str(), "high");
412 assert_eq!(Severity::Critical.as_str(), "critical");
413 }
414
415 #[test]
416 fn test_severity_display() {
417 assert_eq!(format!("{}", Severity::Low), "LOW");
418 assert_eq!(format!("{}", Severity::Medium), "MEDIUM");
419 assert_eq!(format!("{}", Severity::High), "HIGH");
420 assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
421 }
422
423 #[test]
424 fn test_severity_ordering() {
425 assert!(Severity::Low < Severity::Medium);
426 assert!(Severity::Medium < Severity::High);
427 assert!(Severity::High < Severity::Critical);
428 }
429
430 #[test]
431 fn test_category_as_str() {
432 assert_eq!(Category::Exfiltration.as_str(), "exfiltration");
433 assert_eq!(
434 Category::PrivilegeEscalation.as_str(),
435 "privilege_escalation"
436 );
437 assert_eq!(Category::Persistence.as_str(), "persistence");
438 assert_eq!(Category::PromptInjection.as_str(), "prompt_injection");
439 assert_eq!(Category::Overpermission.as_str(), "overpermission");
440 assert_eq!(Category::Obfuscation.as_str(), "obfuscation");
441 assert_eq!(Category::SupplyChain.as_str(), "supply_chain");
442 assert_eq!(Category::SecretLeak.as_str(), "secret_leak");
443 }
444
445 #[test]
446 fn test_summary_from_empty_findings() {
447 let findings: Vec<Finding> = vec![];
448 let summary = Summary::from_findings(&findings);
449 assert_eq!(summary.critical, 0);
450 assert_eq!(summary.high, 0);
451 assert_eq!(summary.medium, 0);
452 assert_eq!(summary.low, 0);
453 assert!(summary.passed);
454 }
455
456 #[test]
457 fn test_summary_from_findings_with_critical() {
458 let findings = vec![Finding {
459 id: "EX-001".to_string(),
460 severity: Severity::Critical,
461 category: Category::Exfiltration,
462 confidence: Confidence::Certain,
463 name: "Test".to_string(),
464 location: Location {
465 file: "test.sh".to_string(),
466 line: 1,
467 column: None,
468 },
469 code: "test".to_string(),
470 message: "test".to_string(),
471 recommendation: "test".to_string(),
472 fix_hint: None,
473 cwe_ids: vec![],
474 rule_severity: None,
475 client: None,
476 context: None,
477 }];
478 let summary = Summary::from_findings(&findings);
479 assert_eq!(summary.critical, 1);
480 assert!(!summary.passed);
481 }
482
483 #[test]
484 fn test_summary_from_findings_all_severities() {
485 let findings = vec![
486 Finding {
487 id: "C-001".to_string(),
488 severity: Severity::Critical,
489 category: Category::Exfiltration,
490 confidence: Confidence::Certain,
491 name: "Critical".to_string(),
492 location: Location {
493 file: "test.sh".to_string(),
494 line: 1,
495 column: None,
496 },
497 code: "test".to_string(),
498 message: "test".to_string(),
499 recommendation: "test".to_string(),
500 fix_hint: None,
501 cwe_ids: vec![],
502 rule_severity: None,
503 client: None,
504 context: None,
505 },
506 Finding {
507 id: "H-001".to_string(),
508 severity: Severity::High,
509 category: Category::PrivilegeEscalation,
510 confidence: Confidence::Firm,
511 name: "High".to_string(),
512 location: Location {
513 file: "test.sh".to_string(),
514 line: 2,
515 column: None,
516 },
517 code: "test".to_string(),
518 message: "test".to_string(),
519 recommendation: "test".to_string(),
520 fix_hint: None,
521 cwe_ids: vec![],
522 rule_severity: None,
523 client: None,
524 context: None,
525 },
526 Finding {
527 id: "M-001".to_string(),
528 severity: Severity::Medium,
529 category: Category::Persistence,
530 confidence: Confidence::Tentative,
531 name: "Medium".to_string(),
532 location: Location {
533 file: "test.sh".to_string(),
534 line: 3,
535 column: Some(5),
536 },
537 code: "test".to_string(),
538 message: "test".to_string(),
539 recommendation: "test".to_string(),
540 fix_hint: None,
541 cwe_ids: vec![],
542 rule_severity: None,
543 client: None,
544 context: None,
545 },
546 Finding {
547 id: "L-001".to_string(),
548 severity: Severity::Low,
549 category: Category::Overpermission,
550 confidence: Confidence::Firm,
551 name: "Low".to_string(),
552 location: Location {
553 file: "test.sh".to_string(),
554 line: 4,
555 column: None,
556 },
557 code: "test".to_string(),
558 message: "test".to_string(),
559 recommendation: "test".to_string(),
560 fix_hint: None,
561 cwe_ids: vec![],
562 rule_severity: None,
563 client: None,
564 context: None,
565 },
566 ];
567 let summary = Summary::from_findings(&findings);
568 assert_eq!(summary.critical, 1);
569 assert_eq!(summary.high, 1);
570 assert_eq!(summary.medium, 1);
571 assert_eq!(summary.low, 1);
572 assert!(!summary.passed);
573 }
574
575 #[test]
576 fn test_summary_passes_with_only_medium_low() {
577 let findings = vec![
578 Finding {
579 id: "M-001".to_string(),
580 severity: Severity::Medium,
581 category: Category::Persistence,
582 confidence: Confidence::Firm,
583 name: "Medium".to_string(),
584 location: Location {
585 file: "test.sh".to_string(),
586 line: 1,
587 column: None,
588 },
589 code: "test".to_string(),
590 message: "test".to_string(),
591 recommendation: "test".to_string(),
592 fix_hint: None,
593 cwe_ids: vec![],
594 rule_severity: None,
595 client: None,
596 context: None,
597 },
598 Finding {
599 id: "L-001".to_string(),
600 severity: Severity::Low,
601 category: Category::Overpermission,
602 confidence: Confidence::Firm,
603 name: "Low".to_string(),
604 location: Location {
605 file: "test.sh".to_string(),
606 line: 2,
607 column: None,
608 },
609 code: "test".to_string(),
610 message: "test".to_string(),
611 recommendation: "test".to_string(),
612 fix_hint: None,
613 cwe_ids: vec![],
614 rule_severity: None,
615 client: None,
616 context: None,
617 },
618 ];
619 let summary = Summary::from_findings(&findings);
620 assert!(summary.passed);
621 }
622
623 #[test]
624 fn test_finding_new() {
625 let rule = Rule {
626 id: "TEST-001",
627 name: "Test Rule",
628 description: "A test rule",
629 severity: Severity::High,
630 category: Category::Exfiltration,
631 confidence: Confidence::Certain,
632 patterns: vec![],
633 exclusions: vec![],
634 message: "Test message",
635 recommendation: "Test recommendation",
636 fix_hint: Some("Test fix hint"),
637 cwe_ids: &["CWE-200", "CWE-78"],
638 };
639 let location = Location {
640 file: "test.sh".to_string(),
641 line: 42,
642 column: Some(10),
643 };
644 let finding = Finding::new(&rule, location, "test code".to_string());
645
646 assert_eq!(finding.id, "TEST-001");
647 assert_eq!(finding.name, "Test Rule");
648 assert_eq!(finding.severity, Severity::High);
649 assert_eq!(finding.category, Category::Exfiltration);
650 assert_eq!(finding.confidence, Confidence::Certain);
651 assert_eq!(finding.location.file, "test.sh");
652 assert_eq!(finding.location.line, 42);
653 assert_eq!(finding.location.column, Some(10));
654 assert_eq!(finding.code, "test code");
655 assert_eq!(finding.message, "Test message");
656 assert_eq!(finding.recommendation, "Test recommendation");
657 assert_eq!(finding.cwe_ids, vec!["CWE-200", "CWE-78"]);
658 }
659
660 #[test]
661 fn test_confidence_as_str() {
662 assert_eq!(Confidence::Tentative.as_str(), "tentative");
663 assert_eq!(Confidence::Firm.as_str(), "firm");
664 assert_eq!(Confidence::Certain.as_str(), "certain");
665 }
666
667 #[test]
668 fn test_confidence_display() {
669 assert_eq!(format!("{}", Confidence::Tentative), "tentative");
670 assert_eq!(format!("{}", Confidence::Firm), "firm");
671 assert_eq!(format!("{}", Confidence::Certain), "certain");
672 }
673
674 #[test]
675 fn test_confidence_ordering() {
676 assert!(Confidence::Tentative < Confidence::Firm);
677 assert!(Confidence::Firm < Confidence::Certain);
678 }
679
680 #[test]
681 fn test_confidence_default() {
682 assert_eq!(Confidence::default(), Confidence::Firm);
683 }
684
685 #[test]
686 fn test_confidence_serialization() {
687 let confidence = Confidence::Certain;
688 let json = serde_json::to_string(&confidence).unwrap();
689 assert_eq!(json, "\"certain\"");
690
691 let deserialized: Confidence = serde_json::from_str(&json).unwrap();
692 assert_eq!(deserialized, Confidence::Certain);
693 }
694
695 #[test]
696 fn test_severity_serialization() {
697 let severity = Severity::Critical;
698 let json = serde_json::to_string(&severity).unwrap();
699 assert_eq!(json, "\"critical\"");
700
701 let deserialized: Severity = serde_json::from_str(&json).unwrap();
702 assert_eq!(deserialized, Severity::Critical);
703 }
704
705 #[test]
706 fn test_category_serialization() {
707 let category = Category::PromptInjection;
708 let json = serde_json::to_string(&category).unwrap();
709 assert_eq!(json, "\"promptinjection\"");
710
711 let deserialized: Category = serde_json::from_str(&json).unwrap();
712 assert_eq!(deserialized, Category::PromptInjection);
713 }
714
715 #[test]
716 fn test_location_without_column_serialization() {
717 let location = Location {
718 file: "test.sh".to_string(),
719 line: 10,
720 column: None,
721 };
722 let json = serde_json::to_string(&location).unwrap();
723 assert!(!json.contains("column"));
724 }
725
726 #[test]
727 fn test_location_with_column_serialization() {
728 let location = Location {
729 file: "test.sh".to_string(),
730 line: 10,
731 column: Some(5),
732 };
733 let json = serde_json::to_string(&location).unwrap();
734 assert!(json.contains("\"column\":5"));
735 }
736
737 #[test]
740 fn test_rule_severity_default_is_error() {
741 assert_eq!(RuleSeverity::default(), RuleSeverity::Error);
742 }
743
744 #[test]
745 fn test_rule_severity_as_str() {
746 assert_eq!(RuleSeverity::Error.as_str(), "error");
747 assert_eq!(RuleSeverity::Warn.as_str(), "warn");
748 }
749
750 #[test]
751 fn test_rule_severity_display() {
752 assert_eq!(format!("{}", RuleSeverity::Error), "ERROR");
753 assert_eq!(format!("{}", RuleSeverity::Warn), "WARN");
754 }
755
756 #[test]
757 fn test_rule_severity_ordering() {
758 assert!(RuleSeverity::Warn < RuleSeverity::Error);
760 }
761
762 #[test]
763 fn test_rule_severity_serialization() {
764 let error = RuleSeverity::Error;
765 let json = serde_json::to_string(&error).unwrap();
766 assert_eq!(json, "\"error\"");
767
768 let warn = RuleSeverity::Warn;
769 let json = serde_json::to_string(&warn).unwrap();
770 assert_eq!(json, "\"warn\"");
771
772 let deserialized: RuleSeverity = serde_json::from_str("\"error\"").unwrap();
773 assert_eq!(deserialized, RuleSeverity::Error);
774
775 let deserialized: RuleSeverity = serde_json::from_str("\"warn\"").unwrap();
776 assert_eq!(deserialized, RuleSeverity::Warn);
777 }
778
779 fn create_test_finding(
782 id: &str,
783 severity: Severity,
784 rule_severity: Option<RuleSeverity>,
785 ) -> Finding {
786 Finding {
787 id: id.to_string(),
788 severity,
789 category: Category::Exfiltration,
790 confidence: Confidence::Firm,
791 name: "Test".to_string(),
792 location: Location {
793 file: "test.sh".to_string(),
794 line: 1,
795 column: None,
796 },
797 code: "test".to_string(),
798 message: "test".to_string(),
799 recommendation: "test".to_string(),
800 fix_hint: None,
801 cwe_ids: vec![],
802 rule_severity,
803 client: None,
804 context: None,
805 }
806 }
807
808 #[test]
809 fn test_summary_with_rule_severity_empty() {
810 let findings: Vec<Finding> = vec![];
811 let summary = Summary::from_findings_with_rule_severity(&findings);
812 assert_eq!(summary.errors, 0);
813 assert_eq!(summary.warnings, 0);
814 assert!(summary.passed);
815 }
816
817 #[test]
818 fn test_summary_with_rule_severity_all_errors() {
819 let findings = vec![
820 create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
821 create_test_finding("E-002", Severity::High, Some(RuleSeverity::Error)),
822 ];
823 let summary = Summary::from_findings_with_rule_severity(&findings);
824 assert_eq!(summary.errors, 2);
825 assert_eq!(summary.warnings, 0);
826 assert!(!summary.passed);
827 }
828
829 #[test]
830 fn test_summary_with_rule_severity_all_warnings() {
831 let findings = vec![
832 create_test_finding("W-001", Severity::Critical, Some(RuleSeverity::Warn)),
833 create_test_finding("W-002", Severity::High, Some(RuleSeverity::Warn)),
834 ];
835 let summary = Summary::from_findings_with_rule_severity(&findings);
836 assert_eq!(summary.errors, 0);
837 assert_eq!(summary.warnings, 2);
838 assert!(summary.passed); }
840
841 #[test]
842 fn test_summary_with_rule_severity_mixed() {
843 let findings = vec![
844 create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
845 create_test_finding("W-001", Severity::High, Some(RuleSeverity::Warn)),
846 create_test_finding("W-002", Severity::Medium, Some(RuleSeverity::Warn)),
847 ];
848 let summary = Summary::from_findings_with_rule_severity(&findings);
849 assert_eq!(summary.errors, 1);
850 assert_eq!(summary.warnings, 2);
851 assert!(!summary.passed); assert_eq!(summary.critical, 1);
854 assert_eq!(summary.high, 1);
855 assert_eq!(summary.medium, 1);
856 }
857
858 #[test]
859 fn test_summary_with_rule_severity_none_defaults_to_error() {
860 let findings = vec![
861 create_test_finding("N-001", Severity::Low, None), ];
863 let summary = Summary::from_findings_with_rule_severity(&findings);
864 assert_eq!(summary.errors, 1);
865 assert_eq!(summary.warnings, 0);
866 assert!(!summary.passed);
867 }
868
869 #[test]
870 fn test_finding_rule_severity_not_serialized_when_none() {
871 let finding = create_test_finding("TEST-001", Severity::High, None);
872 let json = serde_json::to_string(&finding).unwrap();
873 assert!(!json.contains("rule_severity"));
874 }
875
876 #[test]
877 fn test_finding_rule_severity_serialized_when_some() {
878 let finding = create_test_finding("TEST-001", Severity::High, Some(RuleSeverity::Warn));
879 let json = serde_json::to_string(&finding).unwrap();
880 assert!(json.contains("\"rule_severity\":\"warn\""));
881 }
882}