Skip to main content

sbom_tools/reports/
analyst.rs

1//! Analyst report data structures for security analysis exports.
2//!
3//! This module provides structures for generating comprehensive security
4//! analysis reports that can be exported to Markdown or JSON format.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt::Write;
9
10/// Complete analyst report structure
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AnalystReport {
13    /// Report metadata
14    pub metadata: AnalystReportMetadata,
15    /// Executive summary with risk score
16    pub executive_summary: ExecutiveSummary,
17    /// Vulnerability findings
18    pub vulnerability_findings: VulnerabilityFindings,
19    /// Component-related findings
20    pub component_findings: ComponentFindings,
21    /// Compliance status summary
22    pub compliance_status: ComplianceStatus,
23    /// Analyst notes and annotations
24    pub analyst_notes: Vec<AnalystNote>,
25    /// Recommended actions
26    pub recommendations: Vec<Recommendation>,
27    /// Report generation timestamp
28    pub generated_at: DateTime<Utc>,
29}
30
31impl AnalystReport {
32    /// Create a new empty analyst report
33    pub fn new() -> Self {
34        Self {
35            metadata: AnalystReportMetadata::default(),
36            executive_summary: ExecutiveSummary::default(),
37            vulnerability_findings: VulnerabilityFindings::default(),
38            component_findings: ComponentFindings::default(),
39            compliance_status: ComplianceStatus::default(),
40            analyst_notes: Vec::new(),
41            recommendations: Vec::new(),
42            generated_at: Utc::now(),
43        }
44    }
45
46    /// Export report to JSON format
47    pub fn to_json(&self) -> Result<String, serde_json::Error> {
48        serde_json::to_string_pretty(self)
49    }
50
51    /// Export report to Markdown format
52    pub fn to_markdown(&self) -> String {
53        // Estimate capacity: ~200 bytes per section, plus variable content
54        let estimated_size = 2000
55            + self.vulnerability_findings.kev_vulnerabilities.len() * 100
56            + self.component_findings.license_issues.len() * 150
57            + self.recommendations.len() * 300
58            + self.analyst_notes.len() * 100;
59        let mut md = String::with_capacity(estimated_size);
60
61        // Title
62        md.push_str("# Security Analysis Report\n\n");
63
64        // Metadata
65        if let Some(title) = &self.metadata.title {
66            let _ = writeln!(md, "**Analysis:** {}", title);
67        }
68        if let Some(analyst) = &self.metadata.analyst {
69            let _ = writeln!(md, "**Analyst:** {}", analyst);
70        }
71        let _ = writeln!(
72            md,
73            "**Generated:** {}",
74            self.generated_at.format("%Y-%m-%d %H:%M:%S UTC")
75        );
76        if !self.metadata.sbom_paths.is_empty() {
77            let _ = writeln!(
78                md,
79                "**SBOMs Analyzed:** {}",
80                self.metadata.sbom_paths.join(", ")
81            );
82        }
83        md.push_str("\n---\n\n");
84
85        // Executive Summary
86        md.push_str("## Executive Summary\n\n");
87        let _ = writeln!(
88            md,
89            "**Risk Score:** {} ({:?})\n",
90            self.executive_summary.risk_score, self.executive_summary.risk_level
91        );
92
93        md.push_str("| Metric | Count |\n");
94        md.push_str("|--------|-------|\n");
95        let _ = writeln!(
96            md,
97            "| Critical Issues | {} |",
98            self.executive_summary.critical_issues
99        );
100        let _ = writeln!(
101            md,
102            "| High Issues | {} |",
103            self.executive_summary.high_issues
104        );
105        let _ = writeln!(
106            md,
107            "| KEV Vulnerabilities | {} |",
108            self.executive_summary.kev_count
109        );
110        let _ = writeln!(
111            md,
112            "| Stale Dependencies | {} |",
113            self.executive_summary.stale_dependencies
114        );
115        let _ = writeln!(
116            md,
117            "| License Conflicts | {} |",
118            self.executive_summary.license_conflicts
119        );
120        if let Some(cra) = self.executive_summary.cra_compliance_score {
121            let _ = writeln!(md, "| CRA Compliance | {}% |", cra);
122        }
123        md.push('\n');
124
125        if !self.executive_summary.summary_text.is_empty() {
126            md.push_str(&self.executive_summary.summary_text);
127            md.push_str("\n\n");
128        }
129
130        // Vulnerability Findings
131        md.push_str("## Vulnerability Findings\n\n");
132        let _ = writeln!(
133            md,
134            "- **Total Vulnerabilities:** {}",
135            self.vulnerability_findings.total_count
136        );
137        let _ = writeln!(
138            md,
139            "- **Critical:** {}",
140            self.vulnerability_findings.critical_vulnerabilities.len()
141        );
142        let _ = writeln!(
143            md,
144            "- **High:** {}",
145            self.vulnerability_findings.high_vulnerabilities.len()
146        );
147        let _ = writeln!(
148            md,
149            "- **Medium:** {}",
150            self.vulnerability_findings.medium_vulnerabilities.len()
151        );
152        let _ = writeln!(
153            md,
154            "- **Low:** {}",
155            self.vulnerability_findings.low_vulnerabilities.len()
156        );
157
158        if !self.vulnerability_findings.kev_vulnerabilities.is_empty() {
159            md.push_str("\n### Known Exploited Vulnerabilities (KEV)\n\n");
160            md.push_str(
161                "These vulnerabilities are actively being exploited in the wild and require immediate attention.\n\n",
162            );
163            for vuln in &self.vulnerability_findings.kev_vulnerabilities {
164                let _ = writeln!(
165                    md,
166                    "- **{}** ({}) - {}",
167                    vuln.id, vuln.severity, vuln.component_name
168                );
169            }
170        }
171        md.push('\n');
172
173        // Component Findings
174        md.push_str("## Component Findings\n\n");
175        let _ = writeln!(
176            md,
177            "- **Total Components:** {}",
178            self.component_findings.total_components
179        );
180        let _ = writeln!(
181            md,
182            "- **Added:** {}",
183            self.component_findings.added_count
184        );
185        let _ = writeln!(
186            md,
187            "- **Removed:** {}",
188            self.component_findings.removed_count
189        );
190        let _ = writeln!(
191            md,
192            "- **Stale:** {}",
193            self.component_findings.stale_components.len()
194        );
195        let _ = writeln!(
196            md,
197            "- **Deprecated:** {}",
198            self.component_findings.deprecated_components.len()
199        );
200        md.push('\n');
201
202        // License Issues
203        if !self.component_findings.license_issues.is_empty() {
204            md.push_str("### License Issues\n\n");
205            for issue in &self.component_findings.license_issues {
206                let components = issue.affected_components.join(", ");
207                let _ = writeln!(
208                    md,
209                    "- **{}** ({}): {} - {}",
210                    issue.issue_type, issue.severity, issue.description, components
211                );
212            }
213            md.push('\n');
214        }
215
216        // Compliance Status
217        if self.compliance_status.score > 0 {
218            md.push_str("## Compliance Status\n\n");
219            let _ = writeln!(
220                md,
221                "**CRA Compliance:** {}%\n",
222                self.compliance_status.score
223            );
224
225            if !self.compliance_status.violations_by_article.is_empty() {
226                md.push_str("### CRA Violations\n\n");
227                for violation in &self.compliance_status.violations_by_article {
228                    let _ = writeln!(
229                        md,
230                        "- **{}** ({} occurrences): {}",
231                        violation.article, violation.count, violation.description
232                    );
233                }
234                md.push('\n');
235            }
236        }
237
238        // Recommendations
239        if !self.recommendations.is_empty() {
240            md.push_str("## Recommendations\n\n");
241
242            let mut sorted_recs = self.recommendations.clone();
243            sorted_recs.sort_by(|a, b| a.priority.cmp(&b.priority));
244
245            for rec in &sorted_recs {
246                let _ = writeln!(
247                    md,
248                    "### [{:?}] {} - {}\n",
249                    rec.priority, rec.category, rec.title
250                );
251                md.push_str(&rec.description);
252                md.push_str("\n\n");
253                if !rec.affected_components.is_empty() {
254                    let _ = writeln!(
255                        md,
256                        "**Affected:** {}\n",
257                        rec.affected_components.join(", ")
258                    );
259                }
260                if let Some(effort) = &rec.effort {
261                    let _ = writeln!(md, "**Estimated Effort:** {}\n", effort);
262                }
263            }
264        }
265
266        // Analyst Notes
267        if !self.analyst_notes.is_empty() {
268            md.push_str("## Analyst Notes\n\n");
269            for note in &self.analyst_notes {
270                let fp_marker = if note.false_positive {
271                    " [FALSE POSITIVE]"
272                } else {
273                    ""
274                };
275                if let Some(id) = &note.target_id {
276                    let _ = writeln!(
277                        md,
278                        "- **{} ({}){}**: {}",
279                        note.target_type, id, fp_marker, note.note
280                    );
281                } else {
282                    let _ = writeln!(
283                        md,
284                        "- **{}{}**: {}",
285                        note.target_type, fp_marker, note.note
286                    );
287                }
288            }
289            md.push('\n');
290        }
291
292        // Footer
293        md.push_str("---\n\n");
294        md.push_str("*Generated by sbom-tools*\n");
295
296        md
297    }
298}
299
300impl Default for AnalystReport {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306/// Report metadata
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct AnalystReportMetadata {
309    /// Tool name and version
310    pub tool_version: String,
311    /// Title of the analysis
312    pub title: Option<String>,
313    /// Analyst name or identifier
314    pub analyst: Option<String>,
315    /// SBOM file paths
316    pub sbom_paths: Vec<String>,
317    /// Analysis date
318    pub analysis_date: Option<DateTime<Utc>>,
319}
320
321/// Executive summary with overall risk assessment
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct ExecutiveSummary {
324    /// Overall risk score (0-100, higher = more risk)
325    pub risk_score: u8,
326    /// Risk level label (Low, Medium, High, Critical)
327    pub risk_level: RiskLevel,
328    /// Number of critical security issues
329    pub critical_issues: usize,
330    /// Number of high severity issues
331    pub high_issues: usize,
332    /// Count of KEV (Known Exploited Vulnerabilities)
333    pub kev_count: usize,
334    /// Count of stale/unmaintained dependencies
335    pub stale_dependencies: usize,
336    /// Count of license conflicts
337    pub license_conflicts: usize,
338    /// CRA compliance percentage (0-100)
339    pub cra_compliance_score: Option<u8>,
340    /// Brief summary text
341    pub summary_text: String,
342}
343
344/// Risk level classification
345#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
346pub enum RiskLevel {
347    #[default]
348    Low,
349    Medium,
350    High,
351    Critical,
352}
353
354impl RiskLevel {
355    /// Calculate from risk score
356    pub fn from_score(score: u8) -> Self {
357        match score {
358            0..=25 => RiskLevel::Low,
359            26..=50 => RiskLevel::Medium,
360            51..=75 => RiskLevel::High,
361            _ => RiskLevel::Critical,
362        }
363    }
364
365    /// Get display label
366    pub fn label(&self) -> &'static str {
367        match self {
368            RiskLevel::Low => "Low",
369            RiskLevel::Medium => "Medium",
370            RiskLevel::High => "High",
371            RiskLevel::Critical => "Critical",
372        }
373    }
374}
375
376impl std::fmt::Display for RiskLevel {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        write!(f, "{}", self.label())
379    }
380}
381
382/// Vulnerability findings section
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384pub struct VulnerabilityFindings {
385    /// Total vulnerability count
386    pub total_count: usize,
387    /// KEV vulnerabilities (highest priority)
388    pub kev_vulnerabilities: Vec<VulnFinding>,
389    /// Critical severity vulnerabilities
390    pub critical_vulnerabilities: Vec<VulnFinding>,
391    /// High severity vulnerabilities
392    pub high_vulnerabilities: Vec<VulnFinding>,
393    /// Medium severity vulnerabilities
394    pub medium_vulnerabilities: Vec<VulnFinding>,
395    /// Low severity vulnerabilities
396    pub low_vulnerabilities: Vec<VulnFinding>,
397}
398
399impl VulnerabilityFindings {
400    /// Get all findings in priority order
401    pub fn all_findings(&self) -> Vec<&VulnFinding> {
402        let capacity = self.kev_vulnerabilities.len()
403            + self.critical_vulnerabilities.len()
404            + self.high_vulnerabilities.len()
405            + self.medium_vulnerabilities.len()
406            + self.low_vulnerabilities.len();
407        let mut all = Vec::with_capacity(capacity);
408        all.extend(self.kev_vulnerabilities.iter());
409        all.extend(self.critical_vulnerabilities.iter());
410        all.extend(self.high_vulnerabilities.iter());
411        all.extend(self.medium_vulnerabilities.iter());
412        all.extend(self.low_vulnerabilities.iter());
413        all
414    }
415}
416
417/// Individual vulnerability finding
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct VulnFinding {
420    /// Vulnerability ID (CVE, GHSA, etc.)
421    pub id: String,
422    /// Severity level
423    pub severity: String,
424    /// CVSS score
425    pub cvss_score: Option<f32>,
426    /// Whether in KEV catalog
427    pub is_kev: bool,
428    /// Whether used in ransomware
429    pub is_ransomware_related: bool,
430    /// KEV due date if applicable
431    pub kev_due_date: Option<DateTime<Utc>>,
432    /// Affected component name
433    pub component_name: String,
434    /// Component version
435    pub component_version: Option<String>,
436    /// Vulnerability description
437    pub description: Option<String>,
438    /// Remediation suggestion
439    pub remediation: Option<String>,
440    /// Attack paths to this vulnerability
441    pub attack_paths: Vec<String>,
442    /// Status in diff (Introduced, Resolved, Persistent)
443    pub change_status: Option<String>,
444    /// Analyst note if present
445    pub analyst_note: Option<String>,
446    /// Marked as false positive
447    pub is_false_positive: bool,
448}
449
450impl VulnFinding {
451    /// Create a new vulnerability finding
452    pub fn new(id: String, component_name: String) -> Self {
453        Self {
454            id,
455            severity: "Unknown".to_string(),
456            cvss_score: None,
457            is_kev: false,
458            is_ransomware_related: false,
459            kev_due_date: None,
460            component_name,
461            component_version: None,
462            description: None,
463            remediation: None,
464            attack_paths: Vec::new(),
465            change_status: None,
466            analyst_note: None,
467            is_false_positive: false,
468        }
469    }
470}
471
472/// Component-related findings
473#[derive(Debug, Clone, Default, Serialize, Deserialize)]
474pub struct ComponentFindings {
475    /// Total component count
476    pub total_components: usize,
477    /// Components added (in diff mode)
478    pub added_count: usize,
479    /// Components removed (in diff mode)
480    pub removed_count: usize,
481    /// Stale components (>1 year without update)
482    pub stale_components: Vec<StaleComponentFinding>,
483    /// Deprecated components
484    pub deprecated_components: Vec<DeprecatedComponentFinding>,
485    /// License issues
486    pub license_issues: Vec<LicenseIssueFinding>,
487}
488
489/// Stale component finding
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct StaleComponentFinding {
492    /// Component name
493    pub name: String,
494    /// Current version
495    pub version: Option<String>,
496    /// Days since last update
497    pub days_since_update: u32,
498    /// Last publish date
499    pub last_published: Option<DateTime<Utc>>,
500    /// Latest available version
501    pub latest_version: Option<String>,
502    /// Staleness level
503    pub staleness_level: String,
504    /// Analyst note if present
505    pub analyst_note: Option<String>,
506}
507
508/// Deprecated component finding
509#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct DeprecatedComponentFinding {
511    /// Component name
512    pub name: String,
513    /// Current version
514    pub version: Option<String>,
515    /// Deprecation message
516    pub deprecation_message: Option<String>,
517    /// Suggested replacement
518    pub replacement: Option<String>,
519    /// Analyst note if present
520    pub analyst_note: Option<String>,
521}
522
523/// License issue finding
524#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct LicenseIssueFinding {
526    /// Issue type
527    pub issue_type: LicenseIssueType,
528    /// Severity
529    pub severity: IssueSeverity,
530    /// First license involved
531    pub license_a: String,
532    /// Second license involved (for conflicts)
533    pub license_b: Option<String>,
534    /// Affected components
535    pub affected_components: Vec<String>,
536    /// Description of the issue
537    pub description: String,
538    /// Analyst note if present
539    pub analyst_note: Option<String>,
540}
541
542/// Type of license issue
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544pub enum LicenseIssueType {
545    /// Incompatible licenses in same binary
546    BinaryIncompatible,
547    /// Incompatible licenses in project
548    ProjectIncompatible,
549    /// Network copyleft (AGPL) implications
550    NetworkCopyleft,
551    /// Patent clause conflict
552    PatentConflict,
553    /// Unknown or unrecognized license
554    UnknownLicense,
555}
556
557impl std::fmt::Display for LicenseIssueType {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        match self {
560            LicenseIssueType::BinaryIncompatible => write!(f, "Binary Incompatible"),
561            LicenseIssueType::ProjectIncompatible => write!(f, "Project Incompatible"),
562            LicenseIssueType::NetworkCopyleft => write!(f, "Network Copyleft"),
563            LicenseIssueType::PatentConflict => write!(f, "Patent Conflict"),
564            LicenseIssueType::UnknownLicense => write!(f, "Unknown License"),
565        }
566    }
567}
568
569/// Issue severity level
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
571pub enum IssueSeverity {
572    Error,
573    Warning,
574    Info,
575}
576
577impl std::fmt::Display for IssueSeverity {
578    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579        match self {
580            IssueSeverity::Error => write!(f, "Error"),
581            IssueSeverity::Warning => write!(f, "Warning"),
582            IssueSeverity::Info => write!(f, "Info"),
583        }
584    }
585}
586
587/// Compliance status summary
588#[derive(Debug, Clone, Default, Serialize, Deserialize)]
589pub struct ComplianceStatus {
590    /// Overall compliance level
591    pub level: String,
592    /// Compliance score (0-100)
593    pub score: u8,
594    /// Total violations count
595    pub total_violations: usize,
596    /// Violations by CRA article (for CRA compliance)
597    pub violations_by_article: Vec<ArticleViolations>,
598    /// Key compliance issues
599    pub key_issues: Vec<String>,
600}
601
602/// Violations grouped by CRA article
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct ArticleViolations {
605    /// Article reference (e.g., "Art. 13(6)")
606    pub article: String,
607    /// Article description
608    pub description: String,
609    /// Violation count
610    pub count: usize,
611}
612
613/// Analyst note/annotation
614#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct AnalystNote {
616    /// Target type (what is being annotated)
617    pub target_type: NoteTargetType,
618    /// Target identifier (CVE ID, component name, etc.)
619    pub target_id: Option<String>,
620    /// Note content
621    pub note: String,
622    /// Whether this marks a false positive
623    pub false_positive: bool,
624    /// Severity override if applicable
625    pub severity_override: Option<String>,
626    /// Note creation timestamp
627    pub created_at: DateTime<Utc>,
628    /// Analyst identifier
629    pub analyst: Option<String>,
630}
631
632impl AnalystNote {
633    /// Create a new analyst note
634    pub fn new(target_type: NoteTargetType, note: String) -> Self {
635        Self {
636            target_type,
637            target_id: None,
638            note,
639            false_positive: false,
640            severity_override: None,
641            created_at: Utc::now(),
642            analyst: None,
643        }
644    }
645
646    /// Create a note for a vulnerability
647    pub fn for_vulnerability(vuln_id: String, note: String) -> Self {
648        Self {
649            target_type: NoteTargetType::Vulnerability,
650            target_id: Some(vuln_id),
651            note,
652            false_positive: false,
653            severity_override: None,
654            created_at: Utc::now(),
655            analyst: None,
656        }
657    }
658
659    /// Create a note for a component
660    pub fn for_component(component_name: String, note: String) -> Self {
661        Self {
662            target_type: NoteTargetType::Component,
663            target_id: Some(component_name),
664            note,
665            false_positive: false,
666            severity_override: None,
667            created_at: Utc::now(),
668            analyst: None,
669        }
670    }
671
672    /// Mark as false positive
673    pub fn mark_false_positive(mut self) -> Self {
674        self.false_positive = true;
675        self
676    }
677}
678
679/// Type of target for analyst notes
680#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
681pub enum NoteTargetType {
682    /// Note about a vulnerability
683    Vulnerability,
684    /// Note about a component
685    Component,
686    /// Note about a license
687    License,
688    /// General note
689    General,
690}
691
692impl std::fmt::Display for NoteTargetType {
693    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
694        match self {
695            NoteTargetType::Vulnerability => write!(f, "Vulnerability"),
696            NoteTargetType::Component => write!(f, "Component"),
697            NoteTargetType::License => write!(f, "License"),
698            NoteTargetType::General => write!(f, "General"),
699        }
700    }
701}
702
703/// Recommended action
704#[derive(Debug, Clone, Serialize, Deserialize)]
705pub struct Recommendation {
706    /// Priority level
707    pub priority: RecommendationPriority,
708    /// Category of recommendation
709    pub category: RecommendationCategory,
710    /// Short title
711    pub title: String,
712    /// Detailed description
713    pub description: String,
714    /// Affected components
715    pub affected_components: Vec<String>,
716    /// Estimated effort (optional)
717    pub effort: Option<String>,
718}
719
720impl Recommendation {
721    /// Create a new recommendation
722    pub fn new(
723        priority: RecommendationPriority,
724        category: RecommendationCategory,
725        title: String,
726        description: String,
727    ) -> Self {
728        Self {
729            priority,
730            category,
731            title,
732            description,
733            affected_components: Vec::new(),
734            effort: None,
735        }
736    }
737}
738
739/// Recommendation priority
740#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
741pub enum RecommendationPriority {
742    Critical,
743    High,
744    Medium,
745    Low,
746}
747
748impl std::fmt::Display for RecommendationPriority {
749    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750        match self {
751            RecommendationPriority::Critical => write!(f, "Critical"),
752            RecommendationPriority::High => write!(f, "High"),
753            RecommendationPriority::Medium => write!(f, "Medium"),
754            RecommendationPriority::Low => write!(f, "Low"),
755        }
756    }
757}
758
759/// Recommendation category
760#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
761pub enum RecommendationCategory {
762    /// Upgrade a dependency
763    Upgrade,
764    /// Replace a dependency
765    Replace,
766    /// Investigate further
767    Investigate,
768    /// Monitor for updates
769    Monitor,
770    /// Add missing information
771    AddInfo,
772    /// Fix configuration
773    Config,
774}
775
776impl std::fmt::Display for RecommendationCategory {
777    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
778        match self {
779            RecommendationCategory::Upgrade => write!(f, "Upgrade"),
780            RecommendationCategory::Replace => write!(f, "Replace"),
781            RecommendationCategory::Investigate => write!(f, "Investigate"),
782            RecommendationCategory::Monitor => write!(f, "Monitor"),
783            RecommendationCategory::AddInfo => write!(f, "Add Information"),
784            RecommendationCategory::Config => write!(f, "Configuration"),
785        }
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn test_risk_level_from_score() {
795        assert_eq!(RiskLevel::from_score(0), RiskLevel::Low);
796        assert_eq!(RiskLevel::from_score(25), RiskLevel::Low);
797        assert_eq!(RiskLevel::from_score(26), RiskLevel::Medium);
798        assert_eq!(RiskLevel::from_score(50), RiskLevel::Medium);
799        assert_eq!(RiskLevel::from_score(51), RiskLevel::High);
800        assert_eq!(RiskLevel::from_score(75), RiskLevel::High);
801        assert_eq!(RiskLevel::from_score(76), RiskLevel::Critical);
802        assert_eq!(RiskLevel::from_score(100), RiskLevel::Critical);
803    }
804
805    #[test]
806    fn test_analyst_note_creation() {
807        let note = AnalystNote::for_vulnerability(
808            "CVE-2024-1234".to_string(),
809            "Mitigated by WAF".to_string(),
810        );
811        assert_eq!(note.target_type, NoteTargetType::Vulnerability);
812        assert_eq!(note.target_id, Some("CVE-2024-1234".to_string()));
813        assert!(!note.false_positive);
814
815        let fp_note = note.mark_false_positive();
816        assert!(fp_note.false_positive);
817    }
818
819    #[test]
820    fn test_recommendation_ordering() {
821        assert!(RecommendationPriority::Critical < RecommendationPriority::High);
822        assert!(RecommendationPriority::High < RecommendationPriority::Medium);
823        assert!(RecommendationPriority::Medium < RecommendationPriority::Low);
824    }
825}