1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt::Write;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AnalystReport {
13 pub metadata: AnalystReportMetadata,
15 pub executive_summary: ExecutiveSummary,
17 pub vulnerability_findings: VulnerabilityFindings,
19 pub component_findings: ComponentFindings,
21 pub compliance_status: ComplianceStatus,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub crypto_findings: Option<CryptoFindings>,
26 pub analyst_notes: Vec<AnalystNote>,
28 pub recommendations: Vec<Recommendation>,
30 pub generated_at: DateTime<Utc>,
32}
33
34impl AnalystReport {
35 #[must_use]
37 pub fn new() -> Self {
38 Self {
39 metadata: AnalystReportMetadata::default(),
40 executive_summary: ExecutiveSummary::default(),
41 vulnerability_findings: VulnerabilityFindings::default(),
42 component_findings: ComponentFindings::default(),
43 compliance_status: ComplianceStatus::default(),
44 crypto_findings: None,
45 analyst_notes: Vec::new(),
46 recommendations: Vec::new(),
47 generated_at: Utc::now(),
48 }
49 }
50
51 pub fn to_json(&self) -> Result<String, serde_json::Error> {
53 serde_json::to_string_pretty(self)
54 }
55
56 #[must_use]
58 pub fn to_markdown(&self) -> String {
59 let crypto_size = self.crypto_findings.as_ref().map_or(0, |cf| {
61 500 + cf.weak_algorithms.len() * 100 + cf.deprecation_warnings.len() * 80
62 });
63 let estimated_size = 2000
64 + self.vulnerability_findings.kev_vulnerabilities.len() * 100
65 + self.component_findings.license_issues.len() * 150
66 + self.recommendations.len() * 300
67 + self.analyst_notes.len() * 100
68 + crypto_size;
69 let mut md = String::with_capacity(estimated_size);
70
71 md.push_str("# Security Analysis Report\n\n");
73
74 if let Some(title) = &self.metadata.title {
76 let _ = writeln!(md, "**Analysis:** {title}");
77 }
78 if let Some(analyst) = &self.metadata.analyst {
79 let _ = writeln!(md, "**Analyst:** {analyst}");
80 }
81 let _ = writeln!(
82 md,
83 "**Generated:** {}",
84 self.generated_at.format("%Y-%m-%d %H:%M:%S UTC")
85 );
86 if !self.metadata.sbom_paths.is_empty() {
87 let _ = writeln!(
88 md,
89 "**SBOMs Analyzed:** {}",
90 self.metadata.sbom_paths.join(", ")
91 );
92 }
93 md.push_str("\n---\n\n");
94
95 md.push_str("## Executive Summary\n\n");
97 let _ = writeln!(
98 md,
99 "**Risk Score:** {} ({:?})\n",
100 self.executive_summary.risk_score, self.executive_summary.risk_level
101 );
102
103 md.push_str("| Metric | Count |\n");
104 md.push_str("|--------|-------|\n");
105 let _ = writeln!(
106 md,
107 "| Critical Issues | {} |",
108 self.executive_summary.critical_issues
109 );
110 let _ = writeln!(
111 md,
112 "| High Issues | {} |",
113 self.executive_summary.high_issues
114 );
115 let _ = writeln!(
116 md,
117 "| KEV Vulnerabilities | {} |",
118 self.executive_summary.kev_count
119 );
120 let _ = writeln!(
121 md,
122 "| Stale Dependencies | {} |",
123 self.executive_summary.stale_dependencies
124 );
125 let _ = writeln!(
126 md,
127 "| License Conflicts | {} |",
128 self.executive_summary.license_conflicts
129 );
130 if let Some(cra) = self.executive_summary.cra_compliance_score {
131 let _ = writeln!(md, "| CRA Compliance | {cra}% |");
132 }
133 md.push('\n');
134
135 if !self.executive_summary.summary_text.is_empty() {
136 md.push_str(&self.executive_summary.summary_text);
137 md.push_str("\n\n");
138 }
139
140 md.push_str("## Vulnerability Findings\n\n");
142 let _ = writeln!(
143 md,
144 "- **Total Vulnerabilities:** {}",
145 self.vulnerability_findings.total_count
146 );
147 let _ = writeln!(
148 md,
149 "- **Critical:** {}",
150 self.vulnerability_findings.critical_vulnerabilities.len()
151 );
152 let _ = writeln!(
153 md,
154 "- **High:** {}",
155 self.vulnerability_findings.high_vulnerabilities.len()
156 );
157 let _ = writeln!(
158 md,
159 "- **Medium:** {}",
160 self.vulnerability_findings.medium_vulnerabilities.len()
161 );
162 let _ = writeln!(
163 md,
164 "- **Low:** {}",
165 self.vulnerability_findings.low_vulnerabilities.len()
166 );
167
168 if !self.vulnerability_findings.kev_vulnerabilities.is_empty() {
169 md.push_str("\n### Known Exploited Vulnerabilities (KEV)\n\n");
170 md.push_str(
171 "These vulnerabilities are actively being exploited in the wild and require immediate attention.\n\n",
172 );
173 for vuln in &self.vulnerability_findings.kev_vulnerabilities {
174 let _ = writeln!(
175 md,
176 "- **{}** ({}) - {}",
177 vuln.id, vuln.severity, vuln.component_name
178 );
179 }
180 }
181 md.push('\n');
182
183 md.push_str("## Component Findings\n\n");
185 let _ = writeln!(
186 md,
187 "- **Total Components:** {}",
188 self.component_findings.total_components
189 );
190 let _ = writeln!(md, "- **Added:** {}", self.component_findings.added_count);
191 let _ = writeln!(
192 md,
193 "- **Removed:** {}",
194 self.component_findings.removed_count
195 );
196 let _ = writeln!(
197 md,
198 "- **Stale:** {}",
199 self.component_findings.stale_components.len()
200 );
201 let _ = writeln!(
202 md,
203 "- **Deprecated:** {}",
204 self.component_findings.deprecated_components.len()
205 );
206 md.push('\n');
207
208 if !self.component_findings.license_issues.is_empty() {
210 md.push_str("### License Issues\n\n");
211 for issue in &self.component_findings.license_issues {
212 let components = issue.affected_components.join(", ");
213 let _ = writeln!(
214 md,
215 "- **{}** ({}): {} - {}",
216 issue.issue_type, issue.severity, issue.description, components
217 );
218 }
219 md.push('\n');
220 }
221
222 if let Some(cf) = &self.crypto_findings {
224 md.push_str("## Cryptographic Asset Findings\n\n");
225 md.push_str("| Metric | Value |\n");
226 md.push_str("|--------|-------|\n");
227 let _ = writeln!(md, "| Total Crypto Assets | {} |", cf.total_crypto_assets);
228 let _ = writeln!(md, "| Algorithms | {} |", cf.algorithms_count);
229 let _ = writeln!(md, "| Certificates | {} |", cf.certificates_count);
230 let _ = writeln!(md, "| Key Material | {} |", cf.keys_count);
231 let _ = writeln!(md, "| Protocols | {} |", cf.protocols_count);
232 if cf.algorithms_count > 0 {
235 let _ = writeln!(
236 md,
237 "| Quantum Readiness | {:.0}% ({}/{}) |",
238 cf.quantum_readiness_pct, cf.quantum_safe_count, cf.algorithms_count
239 );
240 } else {
241 md.push_str("| Quantum Readiness | n/a (no algorithms declared) |\n");
242 }
243 if cf.hybrid_pqc_count > 0 {
244 let _ = writeln!(md, "| Hybrid PQC Combiners | {} |", cf.hybrid_pqc_count);
245 }
246 md.push('\n');
247
248 if !cf.weak_algorithms.is_empty() {
249 md.push_str("### Weak/Broken Algorithms\n\n");
250 md.push_str("| Algorithm | Family | Quantum Level | Reason |\n");
251 md.push_str("|-----------|--------|---------------|--------|\n");
252 for algo in &cf.weak_algorithms {
253 let family = algo.family.as_deref().unwrap_or("-");
254 let ql = algo
255 .quantum_level
256 .map_or("-".to_string(), |l| l.to_string());
257 let _ = writeln!(md, "| {} | {family} | {ql} | {} |", algo.name, algo.reason);
258 }
259 md.push('\n');
260 }
261
262 if !cf.expired_certificates.is_empty() {
263 md.push_str("### Expired Certificates\n\n");
264 for cert in &cf.expired_certificates {
265 let expires = cert.expires.as_deref().unwrap_or("unknown");
266 let _ = writeln!(md, "- **{}** — expired {expires}", cert.name);
267 }
268 md.push('\n');
269 }
270
271 if !cf.compromised_keys.is_empty() {
272 md.push_str("### Compromised Key Material\n\n");
273 for key in &cf.compromised_keys {
274 let _ = writeln!(
275 md,
276 "- **{}** ({}) — state: {}",
277 key.name, key.material_type, key.state
278 );
279 }
280 md.push('\n');
281 }
282
283 if !cf.deprecation_warnings.is_empty() {
284 md.push_str("### Quantum Deprecation Warnings\n\n");
285 for warning in &cf.deprecation_warnings {
286 let _ = writeln!(md, "- {warning}");
287 }
288 md.push('\n');
289 }
290 }
291
292 if self.compliance_status.score > 0 {
294 md.push_str("## Compliance Status\n\n");
295 let _ = writeln!(
296 md,
297 "**CRA Compliance:** {}%\n",
298 self.compliance_status.score
299 );
300
301 if !self.compliance_status.violations_by_article.is_empty() {
302 md.push_str("### CRA Violations\n\n");
303 for violation in &self.compliance_status.violations_by_article {
304 let _ = writeln!(
305 md,
306 "- **{}** ({} occurrences): {}",
307 violation.article, violation.count, violation.description
308 );
309 }
310 md.push('\n');
311 }
312 }
313
314 if !self.recommendations.is_empty() {
316 md.push_str("## Recommendations\n\n");
317
318 let mut sorted_recs = self.recommendations.clone();
319 sorted_recs.sort_by(|a, b| a.priority.cmp(&b.priority));
320
321 for rec in &sorted_recs {
322 let _ = writeln!(
323 md,
324 "### [{:?}] {} - {}\n",
325 rec.priority, rec.category, rec.title
326 );
327 md.push_str(&rec.description);
328 md.push_str("\n\n");
329 if !rec.affected_components.is_empty() {
330 let _ = writeln!(md, "**Affected:** {}\n", rec.affected_components.join(", "));
331 }
332 if let Some(effort) = &rec.effort {
333 let _ = writeln!(md, "**Estimated Effort:** {effort}\n");
334 }
335 }
336 }
337
338 if !self.analyst_notes.is_empty() {
340 md.push_str("## Analyst Notes\n\n");
341 for note in &self.analyst_notes {
342 let fp_marker = if note.false_positive {
343 " [FALSE POSITIVE]"
344 } else {
345 ""
346 };
347 if let Some(id) = ¬e.target_id {
348 let _ = writeln!(
349 md,
350 "- **{} ({}){}**: {}",
351 note.target_type, id, fp_marker, note.note
352 );
353 } else {
354 let _ = writeln!(md, "- **{}{}**: {}", note.target_type, fp_marker, note.note);
355 }
356 }
357 md.push('\n');
358 }
359
360 md.push_str("---\n\n");
362 md.push_str("*Generated by sbom-tools*\n");
363
364 md
365 }
366}
367
368impl Default for AnalystReport {
369 fn default() -> Self {
370 Self::new()
371 }
372}
373
374#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376pub struct AnalystReportMetadata {
377 pub tool_version: String,
379 pub title: Option<String>,
381 pub analyst: Option<String>,
383 pub sbom_paths: Vec<String>,
385 pub analysis_date: Option<DateTime<Utc>>,
387}
388
389#[derive(Debug, Clone, Default, Serialize, Deserialize)]
391pub struct ExecutiveSummary {
392 pub risk_score: u8,
394 pub risk_level: RiskLevel,
396 pub critical_issues: usize,
398 pub high_issues: usize,
400 pub kev_count: usize,
402 pub stale_dependencies: usize,
404 pub license_conflicts: usize,
406 pub cra_compliance_score: Option<u8>,
408 pub summary_text: String,
410}
411
412#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
414pub enum RiskLevel {
415 #[default]
416 Low,
417 Medium,
418 High,
419 Critical,
420}
421
422impl RiskLevel {
423 #[must_use]
425 pub const fn from_score(score: u8) -> Self {
426 match score {
427 0..=25 => Self::Low,
428 26..=50 => Self::Medium,
429 51..=75 => Self::High,
430 _ => Self::Critical,
431 }
432 }
433
434 #[must_use]
436 pub const fn label(&self) -> &'static str {
437 match self {
438 Self::Low => "Low",
439 Self::Medium => "Medium",
440 Self::High => "High",
441 Self::Critical => "Critical",
442 }
443 }
444}
445
446impl std::fmt::Display for RiskLevel {
447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 write!(f, "{}", self.label())
449 }
450}
451
452#[derive(Debug, Clone, Default, Serialize, Deserialize)]
454pub struct VulnerabilityFindings {
455 pub total_count: usize,
457 pub kev_vulnerabilities: Vec<VulnFinding>,
459 pub critical_vulnerabilities: Vec<VulnFinding>,
461 pub high_vulnerabilities: Vec<VulnFinding>,
463 pub medium_vulnerabilities: Vec<VulnFinding>,
465 pub low_vulnerabilities: Vec<VulnFinding>,
467}
468
469impl VulnerabilityFindings {
470 #[must_use]
472 pub fn all_findings(&self) -> Vec<&VulnFinding> {
473 let capacity = self.kev_vulnerabilities.len()
474 + self.critical_vulnerabilities.len()
475 + self.high_vulnerabilities.len()
476 + self.medium_vulnerabilities.len()
477 + self.low_vulnerabilities.len();
478 let mut all = Vec::with_capacity(capacity);
479 all.extend(self.kev_vulnerabilities.iter());
480 all.extend(self.critical_vulnerabilities.iter());
481 all.extend(self.high_vulnerabilities.iter());
482 all.extend(self.medium_vulnerabilities.iter());
483 all.extend(self.low_vulnerabilities.iter());
484 all
485 }
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct VulnFinding {
491 pub id: String,
493 pub severity: String,
495 pub cvss_score: Option<f32>,
497 pub is_kev: bool,
499 pub is_ransomware_related: bool,
501 pub kev_due_date: Option<DateTime<Utc>>,
503 pub component_name: String,
505 pub component_version: Option<String>,
507 pub description: Option<String>,
509 pub remediation: Option<String>,
511 pub attack_paths: Vec<String>,
513 pub change_status: Option<String>,
515 pub analyst_note: Option<String>,
517 pub is_false_positive: bool,
519}
520
521impl VulnFinding {
522 #[must_use]
524 pub fn new(id: String, component_name: String) -> Self {
525 Self {
526 id,
527 severity: "Unknown".to_string(),
528 cvss_score: None,
529 is_kev: false,
530 is_ransomware_related: false,
531 kev_due_date: None,
532 component_name,
533 component_version: None,
534 description: None,
535 remediation: None,
536 attack_paths: Vec::new(),
537 change_status: None,
538 analyst_note: None,
539 is_false_positive: false,
540 }
541 }
542}
543
544#[derive(Debug, Clone, Default, Serialize, Deserialize)]
546pub struct ComponentFindings {
547 pub total_components: usize,
549 pub added_count: usize,
551 pub removed_count: usize,
553 pub stale_components: Vec<StaleComponentFinding>,
555 pub deprecated_components: Vec<DeprecatedComponentFinding>,
557 pub license_issues: Vec<LicenseIssueFinding>,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct StaleComponentFinding {
564 pub name: String,
566 pub version: Option<String>,
568 pub days_since_update: u32,
570 pub last_published: Option<DateTime<Utc>>,
572 pub latest_version: Option<String>,
574 pub staleness_level: String,
576 pub analyst_note: Option<String>,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct DeprecatedComponentFinding {
583 pub name: String,
585 pub version: Option<String>,
587 pub deprecation_message: Option<String>,
589 pub replacement: Option<String>,
591 pub analyst_note: Option<String>,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct LicenseIssueFinding {
598 pub issue_type: LicenseIssueType,
600 pub severity: IssueSeverity,
602 pub license_a: String,
604 pub license_b: Option<String>,
606 pub affected_components: Vec<String>,
608 pub description: String,
610 pub analyst_note: Option<String>,
612}
613
614#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
616pub enum LicenseIssueType {
617 BinaryIncompatible,
619 ProjectIncompatible,
621 NetworkCopyleft,
623 PatentConflict,
625 UnknownLicense,
627}
628
629impl std::fmt::Display for LicenseIssueType {
630 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
631 match self {
632 Self::BinaryIncompatible => write!(f, "Binary Incompatible"),
633 Self::ProjectIncompatible => write!(f, "Project Incompatible"),
634 Self::NetworkCopyleft => write!(f, "Network Copyleft"),
635 Self::PatentConflict => write!(f, "Patent Conflict"),
636 Self::UnknownLicense => write!(f, "Unknown License"),
637 }
638 }
639}
640
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
643pub enum IssueSeverity {
644 Error,
645 Warning,
646 Info,
647}
648
649impl std::fmt::Display for IssueSeverity {
650 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651 match self {
652 Self::Error => write!(f, "Error"),
653 Self::Warning => write!(f, "Warning"),
654 Self::Info => write!(f, "Info"),
655 }
656 }
657}
658
659#[derive(Debug, Clone, Default, Serialize, Deserialize)]
661pub struct CryptoFindings {
662 pub total_crypto_assets: usize,
664 pub algorithms_count: usize,
666 pub certificates_count: usize,
668 pub keys_count: usize,
670 pub protocols_count: usize,
672 pub quantum_readiness_pct: f32,
674 pub quantum_safe_count: usize,
676 pub quantum_vulnerable_count: usize,
678 pub hybrid_pqc_count: usize,
680 pub weak_algorithms: Vec<CryptoAlgorithmFinding>,
682 pub expired_certificates: Vec<CryptoCertFinding>,
684 pub compromised_keys: Vec<CryptoKeyFinding>,
686 pub deprecation_warnings: Vec<String>,
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct CryptoAlgorithmFinding {
693 pub name: String,
695 pub family: Option<String>,
697 pub quantum_level: Option<u8>,
699 pub reason: String,
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize)]
705pub struct CryptoCertFinding {
706 pub name: String,
708 pub expires: Option<String>,
710 pub days_overdue: Option<i64>,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct CryptoKeyFinding {
717 pub name: String,
719 pub material_type: String,
721 pub state: String,
723}
724
725#[derive(Debug, Clone, Default, Serialize, Deserialize)]
727pub struct ComplianceStatus {
728 pub level: String,
730 pub score: u8,
732 pub total_violations: usize,
734 pub violations_by_article: Vec<ArticleViolations>,
736 pub key_issues: Vec<String>,
738}
739
740#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct ArticleViolations {
743 pub article: String,
745 pub description: String,
747 pub count: usize,
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize)]
753pub struct AnalystNote {
754 pub target_type: NoteTargetType,
756 pub target_id: Option<String>,
758 pub note: String,
760 pub false_positive: bool,
762 pub severity_override: Option<String>,
764 pub created_at: DateTime<Utc>,
766 pub analyst: Option<String>,
768}
769
770impl AnalystNote {
771 #[must_use]
773 pub fn new(target_type: NoteTargetType, note: String) -> Self {
774 Self {
775 target_type,
776 target_id: None,
777 note,
778 false_positive: false,
779 severity_override: None,
780 created_at: Utc::now(),
781 analyst: None,
782 }
783 }
784
785 #[must_use]
787 pub fn for_vulnerability(vuln_id: String, note: String) -> Self {
788 Self {
789 target_type: NoteTargetType::Vulnerability,
790 target_id: Some(vuln_id),
791 note,
792 false_positive: false,
793 severity_override: None,
794 created_at: Utc::now(),
795 analyst: None,
796 }
797 }
798
799 #[must_use]
801 pub fn for_component(component_name: String, note: String) -> Self {
802 Self {
803 target_type: NoteTargetType::Component,
804 target_id: Some(component_name),
805 note,
806 false_positive: false,
807 severity_override: None,
808 created_at: Utc::now(),
809 analyst: None,
810 }
811 }
812
813 #[must_use]
815 pub const fn mark_false_positive(mut self) -> Self {
816 self.false_positive = true;
817 self
818 }
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
823pub enum NoteTargetType {
824 Vulnerability,
826 Component,
828 License,
830 Cryptography,
832 General,
834}
835
836impl std::fmt::Display for NoteTargetType {
837 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
838 match self {
839 Self::Vulnerability => write!(f, "Vulnerability"),
840 Self::Component => write!(f, "Component"),
841 Self::License => write!(f, "License"),
842 Self::Cryptography => write!(f, "Cryptography"),
843 Self::General => write!(f, "General"),
844 }
845 }
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize)]
850pub struct Recommendation {
851 pub priority: RecommendationPriority,
853 pub category: RecommendationCategory,
855 pub title: String,
857 pub description: String,
859 pub affected_components: Vec<String>,
861 pub effort: Option<String>,
863}
864
865impl Recommendation {
866 #[must_use]
868 pub const fn new(
869 priority: RecommendationPriority,
870 category: RecommendationCategory,
871 title: String,
872 description: String,
873 ) -> Self {
874 Self {
875 priority,
876 category,
877 title,
878 description,
879 affected_components: Vec::new(),
880 effort: None,
881 }
882 }
883}
884
885#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
887pub enum RecommendationPriority {
888 Critical,
889 High,
890 Medium,
891 Low,
892}
893
894impl std::fmt::Display for RecommendationPriority {
895 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
896 match self {
897 Self::Critical => write!(f, "Critical"),
898 Self::High => write!(f, "High"),
899 Self::Medium => write!(f, "Medium"),
900 Self::Low => write!(f, "Low"),
901 }
902 }
903}
904
905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
907pub enum RecommendationCategory {
908 Upgrade,
910 Replace,
912 Investigate,
914 Monitor,
916 AddInfo,
918 Config,
920 Cryptography,
922}
923
924impl std::fmt::Display for RecommendationCategory {
925 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
926 match self {
927 Self::Upgrade => write!(f, "Upgrade"),
928 Self::Replace => write!(f, "Replace"),
929 Self::Investigate => write!(f, "Investigate"),
930 Self::Monitor => write!(f, "Monitor"),
931 Self::AddInfo => write!(f, "Add Information"),
932 Self::Config => write!(f, "Configuration"),
933 Self::Cryptography => write!(f, "Cryptography"),
934 }
935 }
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 #[test]
943 fn test_risk_level_from_score() {
944 assert_eq!(RiskLevel::from_score(0), RiskLevel::Low);
945 assert_eq!(RiskLevel::from_score(25), RiskLevel::Low);
946 assert_eq!(RiskLevel::from_score(26), RiskLevel::Medium);
947 assert_eq!(RiskLevel::from_score(50), RiskLevel::Medium);
948 assert_eq!(RiskLevel::from_score(51), RiskLevel::High);
949 assert_eq!(RiskLevel::from_score(75), RiskLevel::High);
950 assert_eq!(RiskLevel::from_score(76), RiskLevel::Critical);
951 assert_eq!(RiskLevel::from_score(100), RiskLevel::Critical);
952 }
953
954 #[test]
955 fn test_analyst_note_creation() {
956 let note = AnalystNote::for_vulnerability(
957 "CVE-2024-1234".to_string(),
958 "Mitigated by WAF".to_string(),
959 );
960 assert_eq!(note.target_type, NoteTargetType::Vulnerability);
961 assert_eq!(note.target_id, Some("CVE-2024-1234".to_string()));
962 assert!(!note.false_positive);
963
964 let fp_note = note.mark_false_positive();
965 assert!(fp_note.false_positive);
966 }
967
968 #[test]
969 fn test_recommendation_ordering() {
970 assert!(RecommendationPriority::Critical < RecommendationPriority::High);
971 assert!(RecommendationPriority::High < RecommendationPriority::Medium);
972 assert!(RecommendationPriority::Medium < RecommendationPriority::Low);
973 }
974
975 #[test]
977 fn test_crypto_findings_zero_algorithms_render_na() {
978 let mut report = AnalystReport::new();
979 report.crypto_findings = Some(CryptoFindings {
980 total_crypto_assets: 2,
981 certificates_count: 2,
982 ..Default::default()
983 });
984 let md = report.to_markdown();
985 assert!(md.contains("| Quantum Readiness | n/a (no algorithms declared) |"));
986 assert!(!md.contains("Quantum Readiness | 100%"));
987 }
988}