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 let _ = writeln!(
233 md,
234 "| Quantum Readiness | {:.0}% ({}/{}) |",
235 cf.quantum_readiness_pct, cf.quantum_safe_count, cf.algorithms_count
236 );
237 if cf.hybrid_pqc_count > 0 {
238 let _ = writeln!(md, "| Hybrid PQC Combiners | {} |", cf.hybrid_pqc_count);
239 }
240 md.push('\n');
241
242 if !cf.weak_algorithms.is_empty() {
243 md.push_str("### Weak/Broken Algorithms\n\n");
244 md.push_str("| Algorithm | Family | Quantum Level | Reason |\n");
245 md.push_str("|-----------|--------|---------------|--------|\n");
246 for algo in &cf.weak_algorithms {
247 let family = algo.family.as_deref().unwrap_or("-");
248 let ql = algo
249 .quantum_level
250 .map_or("-".to_string(), |l| l.to_string());
251 let _ = writeln!(md, "| {} | {family} | {ql} | {} |", algo.name, algo.reason);
252 }
253 md.push('\n');
254 }
255
256 if !cf.expired_certificates.is_empty() {
257 md.push_str("### Expired Certificates\n\n");
258 for cert in &cf.expired_certificates {
259 let expires = cert.expires.as_deref().unwrap_or("unknown");
260 let _ = writeln!(md, "- **{}** — expired {expires}", cert.name);
261 }
262 md.push('\n');
263 }
264
265 if !cf.compromised_keys.is_empty() {
266 md.push_str("### Compromised Key Material\n\n");
267 for key in &cf.compromised_keys {
268 let _ = writeln!(
269 md,
270 "- **{}** ({}) — state: {}",
271 key.name, key.material_type, key.state
272 );
273 }
274 md.push('\n');
275 }
276
277 if !cf.deprecation_warnings.is_empty() {
278 md.push_str("### Quantum Deprecation Warnings\n\n");
279 for warning in &cf.deprecation_warnings {
280 let _ = writeln!(md, "- {warning}");
281 }
282 md.push('\n');
283 }
284 }
285
286 if self.compliance_status.score > 0 {
288 md.push_str("## Compliance Status\n\n");
289 let _ = writeln!(
290 md,
291 "**CRA Compliance:** {}%\n",
292 self.compliance_status.score
293 );
294
295 if !self.compliance_status.violations_by_article.is_empty() {
296 md.push_str("### CRA Violations\n\n");
297 for violation in &self.compliance_status.violations_by_article {
298 let _ = writeln!(
299 md,
300 "- **{}** ({} occurrences): {}",
301 violation.article, violation.count, violation.description
302 );
303 }
304 md.push('\n');
305 }
306 }
307
308 if !self.recommendations.is_empty() {
310 md.push_str("## Recommendations\n\n");
311
312 let mut sorted_recs = self.recommendations.clone();
313 sorted_recs.sort_by(|a, b| a.priority.cmp(&b.priority));
314
315 for rec in &sorted_recs {
316 let _ = writeln!(
317 md,
318 "### [{:?}] {} - {}\n",
319 rec.priority, rec.category, rec.title
320 );
321 md.push_str(&rec.description);
322 md.push_str("\n\n");
323 if !rec.affected_components.is_empty() {
324 let _ = writeln!(md, "**Affected:** {}\n", rec.affected_components.join(", "));
325 }
326 if let Some(effort) = &rec.effort {
327 let _ = writeln!(md, "**Estimated Effort:** {effort}\n");
328 }
329 }
330 }
331
332 if !self.analyst_notes.is_empty() {
334 md.push_str("## Analyst Notes\n\n");
335 for note in &self.analyst_notes {
336 let fp_marker = if note.false_positive {
337 " [FALSE POSITIVE]"
338 } else {
339 ""
340 };
341 if let Some(id) = ¬e.target_id {
342 let _ = writeln!(
343 md,
344 "- **{} ({}){}**: {}",
345 note.target_type, id, fp_marker, note.note
346 );
347 } else {
348 let _ = writeln!(md, "- **{}{}**: {}", note.target_type, fp_marker, note.note);
349 }
350 }
351 md.push('\n');
352 }
353
354 md.push_str("---\n\n");
356 md.push_str("*Generated by sbom-tools*\n");
357
358 md
359 }
360}
361
362impl Default for AnalystReport {
363 fn default() -> Self {
364 Self::new()
365 }
366}
367
368#[derive(Debug, Clone, Default, Serialize, Deserialize)]
370pub struct AnalystReportMetadata {
371 pub tool_version: String,
373 pub title: Option<String>,
375 pub analyst: Option<String>,
377 pub sbom_paths: Vec<String>,
379 pub analysis_date: Option<DateTime<Utc>>,
381}
382
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
385pub struct ExecutiveSummary {
386 pub risk_score: u8,
388 pub risk_level: RiskLevel,
390 pub critical_issues: usize,
392 pub high_issues: usize,
394 pub kev_count: usize,
396 pub stale_dependencies: usize,
398 pub license_conflicts: usize,
400 pub cra_compliance_score: Option<u8>,
402 pub summary_text: String,
404}
405
406#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
408pub enum RiskLevel {
409 #[default]
410 Low,
411 Medium,
412 High,
413 Critical,
414}
415
416impl RiskLevel {
417 #[must_use]
419 pub const fn from_score(score: u8) -> Self {
420 match score {
421 0..=25 => Self::Low,
422 26..=50 => Self::Medium,
423 51..=75 => Self::High,
424 _ => Self::Critical,
425 }
426 }
427
428 #[must_use]
430 pub const fn label(&self) -> &'static str {
431 match self {
432 Self::Low => "Low",
433 Self::Medium => "Medium",
434 Self::High => "High",
435 Self::Critical => "Critical",
436 }
437 }
438}
439
440impl std::fmt::Display for RiskLevel {
441 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442 write!(f, "{}", self.label())
443 }
444}
445
446#[derive(Debug, Clone, Default, Serialize, Deserialize)]
448pub struct VulnerabilityFindings {
449 pub total_count: usize,
451 pub kev_vulnerabilities: Vec<VulnFinding>,
453 pub critical_vulnerabilities: Vec<VulnFinding>,
455 pub high_vulnerabilities: Vec<VulnFinding>,
457 pub medium_vulnerabilities: Vec<VulnFinding>,
459 pub low_vulnerabilities: Vec<VulnFinding>,
461}
462
463impl VulnerabilityFindings {
464 #[must_use]
466 pub fn all_findings(&self) -> Vec<&VulnFinding> {
467 let capacity = self.kev_vulnerabilities.len()
468 + self.critical_vulnerabilities.len()
469 + self.high_vulnerabilities.len()
470 + self.medium_vulnerabilities.len()
471 + self.low_vulnerabilities.len();
472 let mut all = Vec::with_capacity(capacity);
473 all.extend(self.kev_vulnerabilities.iter());
474 all.extend(self.critical_vulnerabilities.iter());
475 all.extend(self.high_vulnerabilities.iter());
476 all.extend(self.medium_vulnerabilities.iter());
477 all.extend(self.low_vulnerabilities.iter());
478 all
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct VulnFinding {
485 pub id: String,
487 pub severity: String,
489 pub cvss_score: Option<f32>,
491 pub is_kev: bool,
493 pub is_ransomware_related: bool,
495 pub kev_due_date: Option<DateTime<Utc>>,
497 pub component_name: String,
499 pub component_version: Option<String>,
501 pub description: Option<String>,
503 pub remediation: Option<String>,
505 pub attack_paths: Vec<String>,
507 pub change_status: Option<String>,
509 pub analyst_note: Option<String>,
511 pub is_false_positive: bool,
513}
514
515impl VulnFinding {
516 #[must_use]
518 pub fn new(id: String, component_name: String) -> Self {
519 Self {
520 id,
521 severity: "Unknown".to_string(),
522 cvss_score: None,
523 is_kev: false,
524 is_ransomware_related: false,
525 kev_due_date: None,
526 component_name,
527 component_version: None,
528 description: None,
529 remediation: None,
530 attack_paths: Vec::new(),
531 change_status: None,
532 analyst_note: None,
533 is_false_positive: false,
534 }
535 }
536}
537
538#[derive(Debug, Clone, Default, Serialize, Deserialize)]
540pub struct ComponentFindings {
541 pub total_components: usize,
543 pub added_count: usize,
545 pub removed_count: usize,
547 pub stale_components: Vec<StaleComponentFinding>,
549 pub deprecated_components: Vec<DeprecatedComponentFinding>,
551 pub license_issues: Vec<LicenseIssueFinding>,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct StaleComponentFinding {
558 pub name: String,
560 pub version: Option<String>,
562 pub days_since_update: u32,
564 pub last_published: Option<DateTime<Utc>>,
566 pub latest_version: Option<String>,
568 pub staleness_level: String,
570 pub analyst_note: Option<String>,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct DeprecatedComponentFinding {
577 pub name: String,
579 pub version: Option<String>,
581 pub deprecation_message: Option<String>,
583 pub replacement: Option<String>,
585 pub analyst_note: Option<String>,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct LicenseIssueFinding {
592 pub issue_type: LicenseIssueType,
594 pub severity: IssueSeverity,
596 pub license_a: String,
598 pub license_b: Option<String>,
600 pub affected_components: Vec<String>,
602 pub description: String,
604 pub analyst_note: Option<String>,
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
610pub enum LicenseIssueType {
611 BinaryIncompatible,
613 ProjectIncompatible,
615 NetworkCopyleft,
617 PatentConflict,
619 UnknownLicense,
621}
622
623impl std::fmt::Display for LicenseIssueType {
624 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
625 match self {
626 Self::BinaryIncompatible => write!(f, "Binary Incompatible"),
627 Self::ProjectIncompatible => write!(f, "Project Incompatible"),
628 Self::NetworkCopyleft => write!(f, "Network Copyleft"),
629 Self::PatentConflict => write!(f, "Patent Conflict"),
630 Self::UnknownLicense => write!(f, "Unknown License"),
631 }
632 }
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
637pub enum IssueSeverity {
638 Error,
639 Warning,
640 Info,
641}
642
643impl std::fmt::Display for IssueSeverity {
644 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645 match self {
646 Self::Error => write!(f, "Error"),
647 Self::Warning => write!(f, "Warning"),
648 Self::Info => write!(f, "Info"),
649 }
650 }
651}
652
653#[derive(Debug, Clone, Default, Serialize, Deserialize)]
655pub struct CryptoFindings {
656 pub total_crypto_assets: usize,
658 pub algorithms_count: usize,
660 pub certificates_count: usize,
662 pub keys_count: usize,
664 pub protocols_count: usize,
666 pub quantum_readiness_pct: f32,
668 pub quantum_safe_count: usize,
670 pub quantum_vulnerable_count: usize,
672 pub hybrid_pqc_count: usize,
674 pub weak_algorithms: Vec<CryptoAlgorithmFinding>,
676 pub expired_certificates: Vec<CryptoCertFinding>,
678 pub compromised_keys: Vec<CryptoKeyFinding>,
680 pub deprecation_warnings: Vec<String>,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
686pub struct CryptoAlgorithmFinding {
687 pub name: String,
689 pub family: Option<String>,
691 pub quantum_level: Option<u8>,
693 pub reason: String,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct CryptoCertFinding {
700 pub name: String,
702 pub expires: Option<String>,
704 pub days_overdue: Option<i64>,
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct CryptoKeyFinding {
711 pub name: String,
713 pub material_type: String,
715 pub state: String,
717}
718
719#[derive(Debug, Clone, Default, Serialize, Deserialize)]
721pub struct ComplianceStatus {
722 pub level: String,
724 pub score: u8,
726 pub total_violations: usize,
728 pub violations_by_article: Vec<ArticleViolations>,
730 pub key_issues: Vec<String>,
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize)]
736pub struct ArticleViolations {
737 pub article: String,
739 pub description: String,
741 pub count: usize,
743}
744
745#[derive(Debug, Clone, Serialize, Deserialize)]
747pub struct AnalystNote {
748 pub target_type: NoteTargetType,
750 pub target_id: Option<String>,
752 pub note: String,
754 pub false_positive: bool,
756 pub severity_override: Option<String>,
758 pub created_at: DateTime<Utc>,
760 pub analyst: Option<String>,
762}
763
764impl AnalystNote {
765 #[must_use]
767 pub fn new(target_type: NoteTargetType, note: String) -> Self {
768 Self {
769 target_type,
770 target_id: None,
771 note,
772 false_positive: false,
773 severity_override: None,
774 created_at: Utc::now(),
775 analyst: None,
776 }
777 }
778
779 #[must_use]
781 pub fn for_vulnerability(vuln_id: String, note: String) -> Self {
782 Self {
783 target_type: NoteTargetType::Vulnerability,
784 target_id: Some(vuln_id),
785 note,
786 false_positive: false,
787 severity_override: None,
788 created_at: Utc::now(),
789 analyst: None,
790 }
791 }
792
793 #[must_use]
795 pub fn for_component(component_name: String, note: String) -> Self {
796 Self {
797 target_type: NoteTargetType::Component,
798 target_id: Some(component_name),
799 note,
800 false_positive: false,
801 severity_override: None,
802 created_at: Utc::now(),
803 analyst: None,
804 }
805 }
806
807 #[must_use]
809 pub const fn mark_false_positive(mut self) -> Self {
810 self.false_positive = true;
811 self
812 }
813}
814
815#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
817pub enum NoteTargetType {
818 Vulnerability,
820 Component,
822 License,
824 Cryptography,
826 General,
828}
829
830impl std::fmt::Display for NoteTargetType {
831 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
832 match self {
833 Self::Vulnerability => write!(f, "Vulnerability"),
834 Self::Component => write!(f, "Component"),
835 Self::License => write!(f, "License"),
836 Self::Cryptography => write!(f, "Cryptography"),
837 Self::General => write!(f, "General"),
838 }
839 }
840}
841
842#[derive(Debug, Clone, Serialize, Deserialize)]
844pub struct Recommendation {
845 pub priority: RecommendationPriority,
847 pub category: RecommendationCategory,
849 pub title: String,
851 pub description: String,
853 pub affected_components: Vec<String>,
855 pub effort: Option<String>,
857}
858
859impl Recommendation {
860 #[must_use]
862 pub const fn new(
863 priority: RecommendationPriority,
864 category: RecommendationCategory,
865 title: String,
866 description: String,
867 ) -> Self {
868 Self {
869 priority,
870 category,
871 title,
872 description,
873 affected_components: Vec::new(),
874 effort: None,
875 }
876 }
877}
878
879#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
881pub enum RecommendationPriority {
882 Critical,
883 High,
884 Medium,
885 Low,
886}
887
888impl std::fmt::Display for RecommendationPriority {
889 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
890 match self {
891 Self::Critical => write!(f, "Critical"),
892 Self::High => write!(f, "High"),
893 Self::Medium => write!(f, "Medium"),
894 Self::Low => write!(f, "Low"),
895 }
896 }
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
901pub enum RecommendationCategory {
902 Upgrade,
904 Replace,
906 Investigate,
908 Monitor,
910 AddInfo,
912 Config,
914 Cryptography,
916}
917
918impl std::fmt::Display for RecommendationCategory {
919 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
920 match self {
921 Self::Upgrade => write!(f, "Upgrade"),
922 Self::Replace => write!(f, "Replace"),
923 Self::Investigate => write!(f, "Investigate"),
924 Self::Monitor => write!(f, "Monitor"),
925 Self::AddInfo => write!(f, "Add Information"),
926 Self::Config => write!(f, "Configuration"),
927 Self::Cryptography => write!(f, "Cryptography"),
928 }
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935
936 #[test]
937 fn test_risk_level_from_score() {
938 assert_eq!(RiskLevel::from_score(0), RiskLevel::Low);
939 assert_eq!(RiskLevel::from_score(25), RiskLevel::Low);
940 assert_eq!(RiskLevel::from_score(26), RiskLevel::Medium);
941 assert_eq!(RiskLevel::from_score(50), RiskLevel::Medium);
942 assert_eq!(RiskLevel::from_score(51), RiskLevel::High);
943 assert_eq!(RiskLevel::from_score(75), RiskLevel::High);
944 assert_eq!(RiskLevel::from_score(76), RiskLevel::Critical);
945 assert_eq!(RiskLevel::from_score(100), RiskLevel::Critical);
946 }
947
948 #[test]
949 fn test_analyst_note_creation() {
950 let note = AnalystNote::for_vulnerability(
951 "CVE-2024-1234".to_string(),
952 "Mitigated by WAF".to_string(),
953 );
954 assert_eq!(note.target_type, NoteTargetType::Vulnerability);
955 assert_eq!(note.target_id, Some("CVE-2024-1234".to_string()));
956 assert!(!note.false_positive);
957
958 let fp_note = note.mark_false_positive();
959 assert!(fp_note.false_positive);
960 }
961
962 #[test]
963 fn test_recommendation_ordering() {
964 assert!(RecommendationPriority::Critical < RecommendationPriority::High);
965 assert!(RecommendationPriority::High < RecommendationPriority::Medium);
966 assert!(RecommendationPriority::Medium < RecommendationPriority::Low);
967 }
968}