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