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    /// Cryptographic asset findings
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub crypto_findings: Option<CryptoFindings>,
26    /// Analyst notes and annotations
27    pub analyst_notes: Vec<AnalystNote>,
28    /// Recommended actions
29    pub recommendations: Vec<Recommendation>,
30    /// Report generation timestamp
31    pub generated_at: DateTime<Utc>,
32}
33
34impl AnalystReport {
35    /// Create a new empty analyst report
36    #[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    /// Export report to JSON format
52    pub fn to_json(&self) -> Result<String, serde_json::Error> {
53        serde_json::to_string_pretty(self)
54    }
55
56    /// Export report to Markdown format
57    #[must_use]
58    pub fn to_markdown(&self) -> String {
59        // Estimate capacity: ~200 bytes per section, plus variable content
60        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        // Title
72        md.push_str("# Security Analysis Report\n\n");
73
74        // Metadata
75        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        // Executive Summary
96        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        // Vulnerability Findings
141        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        // Component Findings
184        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        // License Issues
209        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        // Cryptographic Findings
223        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            // A 0/0 readiness is vacuous, not perfect: without algorithms
233            // there is nothing to be quantum-ready about.
234            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        // Compliance Status
293        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        // Recommendations
315        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        // Analyst Notes
339        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) = &note.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        // Footer
361        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/// Report metadata
375#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376pub struct AnalystReportMetadata {
377    /// Tool name and version
378    pub tool_version: String,
379    /// Title of the analysis
380    pub title: Option<String>,
381    /// Analyst name or identifier
382    pub analyst: Option<String>,
383    /// SBOM file paths
384    pub sbom_paths: Vec<String>,
385    /// Analysis date
386    pub analysis_date: Option<DateTime<Utc>>,
387}
388
389/// Executive summary with overall risk assessment
390#[derive(Debug, Clone, Default, Serialize, Deserialize)]
391pub struct ExecutiveSummary {
392    /// Overall risk score (0-100, higher = more risk)
393    pub risk_score: u8,
394    /// Risk level label (Low, Medium, High, Critical)
395    pub risk_level: RiskLevel,
396    /// Number of critical security issues
397    pub critical_issues: usize,
398    /// Number of high severity issues
399    pub high_issues: usize,
400    /// Count of KEV (Known Exploited Vulnerabilities)
401    pub kev_count: usize,
402    /// Count of stale/unmaintained dependencies
403    pub stale_dependencies: usize,
404    /// Count of license conflicts
405    pub license_conflicts: usize,
406    /// CRA compliance percentage (0-100)
407    pub cra_compliance_score: Option<u8>,
408    /// Brief summary text
409    pub summary_text: String,
410}
411
412/// Risk level classification
413#[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    /// Calculate from risk score
424    #[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    /// Get display label
435    #[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/// Vulnerability findings section
453#[derive(Debug, Clone, Default, Serialize, Deserialize)]
454pub struct VulnerabilityFindings {
455    /// Total vulnerability count
456    pub total_count: usize,
457    /// KEV vulnerabilities (highest priority)
458    pub kev_vulnerabilities: Vec<VulnFinding>,
459    /// Critical severity vulnerabilities
460    pub critical_vulnerabilities: Vec<VulnFinding>,
461    /// High severity vulnerabilities
462    pub high_vulnerabilities: Vec<VulnFinding>,
463    /// Medium severity vulnerabilities
464    pub medium_vulnerabilities: Vec<VulnFinding>,
465    /// Low severity vulnerabilities
466    pub low_vulnerabilities: Vec<VulnFinding>,
467}
468
469impl VulnerabilityFindings {
470    /// Get all findings in priority order
471    #[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/// Individual vulnerability finding
489#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct VulnFinding {
491    /// Vulnerability ID (CVE, GHSA, etc.)
492    pub id: String,
493    /// Severity level
494    pub severity: String,
495    /// CVSS score
496    pub cvss_score: Option<f32>,
497    /// Whether in KEV catalog
498    pub is_kev: bool,
499    /// Whether used in ransomware
500    pub is_ransomware_related: bool,
501    /// KEV due date if applicable
502    pub kev_due_date: Option<DateTime<Utc>>,
503    /// Affected component name
504    pub component_name: String,
505    /// Component version
506    pub component_version: Option<String>,
507    /// Vulnerability description
508    pub description: Option<String>,
509    /// Remediation suggestion
510    pub remediation: Option<String>,
511    /// Attack paths to this vulnerability
512    pub attack_paths: Vec<String>,
513    /// Status in diff (Introduced, Resolved, Persistent)
514    pub change_status: Option<String>,
515    /// Analyst note if present
516    pub analyst_note: Option<String>,
517    /// Marked as false positive
518    pub is_false_positive: bool,
519}
520
521impl VulnFinding {
522    /// Create a new vulnerability finding
523    #[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/// Component-related findings
545#[derive(Debug, Clone, Default, Serialize, Deserialize)]
546pub struct ComponentFindings {
547    /// Total component count
548    pub total_components: usize,
549    /// Components added (in diff mode)
550    pub added_count: usize,
551    /// Components removed (in diff mode)
552    pub removed_count: usize,
553    /// Stale components (>1 year without update)
554    pub stale_components: Vec<StaleComponentFinding>,
555    /// Deprecated components
556    pub deprecated_components: Vec<DeprecatedComponentFinding>,
557    /// License issues
558    pub license_issues: Vec<LicenseIssueFinding>,
559}
560
561/// Stale component finding
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct StaleComponentFinding {
564    /// Component name
565    pub name: String,
566    /// Current version
567    pub version: Option<String>,
568    /// Days since last update
569    pub days_since_update: u32,
570    /// Last publish date
571    pub last_published: Option<DateTime<Utc>>,
572    /// Latest available version
573    pub latest_version: Option<String>,
574    /// Staleness level
575    pub staleness_level: String,
576    /// Analyst note if present
577    pub analyst_note: Option<String>,
578}
579
580/// Deprecated component finding
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct DeprecatedComponentFinding {
583    /// Component name
584    pub name: String,
585    /// Current version
586    pub version: Option<String>,
587    /// Deprecation message
588    pub deprecation_message: Option<String>,
589    /// Suggested replacement
590    pub replacement: Option<String>,
591    /// Analyst note if present
592    pub analyst_note: Option<String>,
593}
594
595/// License issue finding
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct LicenseIssueFinding {
598    /// Issue type
599    pub issue_type: LicenseIssueType,
600    /// Severity
601    pub severity: IssueSeverity,
602    /// First license involved
603    pub license_a: String,
604    /// Second license involved (for conflicts)
605    pub license_b: Option<String>,
606    /// Affected components
607    pub affected_components: Vec<String>,
608    /// Description of the issue
609    pub description: String,
610    /// Analyst note if present
611    pub analyst_note: Option<String>,
612}
613
614/// Type of license issue
615#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
616pub enum LicenseIssueType {
617    /// Incompatible licenses in same binary
618    BinaryIncompatible,
619    /// Incompatible licenses in project
620    ProjectIncompatible,
621    /// Network copyleft (AGPL) implications
622    NetworkCopyleft,
623    /// Patent clause conflict
624    PatentConflict,
625    /// Unknown or unrecognized license
626    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/// Issue severity level
642#[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/// Cryptographic asset findings
660#[derive(Debug, Clone, Default, Serialize, Deserialize)]
661pub struct CryptoFindings {
662    /// Total cryptographic components
663    pub total_crypto_assets: usize,
664    /// Algorithm count
665    pub algorithms_count: usize,
666    /// Certificate count
667    pub certificates_count: usize,
668    /// Key material count
669    pub keys_count: usize,
670    /// Protocol count
671    pub protocols_count: usize,
672    /// Quantum readiness percentage (0-100)
673    pub quantum_readiness_pct: f32,
674    /// Quantum-safe algorithm count
675    pub quantum_safe_count: usize,
676    /// Quantum-vulnerable algorithm count
677    pub quantum_vulnerable_count: usize,
678    /// Hybrid PQC combiner count
679    pub hybrid_pqc_count: usize,
680    /// Weak/broken algorithms found
681    pub weak_algorithms: Vec<CryptoAlgorithmFinding>,
682    /// Expired certificates
683    pub expired_certificates: Vec<CryptoCertFinding>,
684    /// Compromised key material
685    pub compromised_keys: Vec<CryptoKeyFinding>,
686    /// Deprecation warnings (quantum-vulnerable classical algorithms)
687    pub deprecation_warnings: Vec<String>,
688}
689
690/// Individual algorithm finding for analyst reports
691#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct CryptoAlgorithmFinding {
693    /// Algorithm name
694    pub name: String,
695    /// Algorithm family (e.g., "SHA-1", "DES")
696    pub family: Option<String>,
697    /// NIST quantum security level (0 = vulnerable)
698    pub quantum_level: Option<u8>,
699    /// Why this is flagged
700    pub reason: String,
701}
702
703/// Certificate finding for analyst reports
704#[derive(Debug, Clone, Serialize, Deserialize)]
705pub struct CryptoCertFinding {
706    /// Certificate subject or component name
707    pub name: String,
708    /// Expiry date
709    pub expires: Option<String>,
710    /// Days overdue (positive = expired)
711    pub days_overdue: Option<i64>,
712}
713
714/// Key material finding for analyst reports
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct CryptoKeyFinding {
717    /// Key component name
718    pub name: String,
719    /// Material type
720    pub material_type: String,
721    /// Current state
722    pub state: String,
723}
724
725/// Compliance status summary
726#[derive(Debug, Clone, Default, Serialize, Deserialize)]
727pub struct ComplianceStatus {
728    /// Overall compliance level
729    pub level: String,
730    /// Compliance score (0-100)
731    pub score: u8,
732    /// Total violations count
733    pub total_violations: usize,
734    /// Violations by CRA article (for CRA compliance)
735    pub violations_by_article: Vec<ArticleViolations>,
736    /// Key compliance issues
737    pub key_issues: Vec<String>,
738}
739
740/// Violations grouped by CRA article
741#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct ArticleViolations {
743    /// Article reference (e.g., "Art. 13(17)")
744    pub article: String,
745    /// Article description
746    pub description: String,
747    /// Violation count
748    pub count: usize,
749}
750
751/// Analyst note/annotation
752#[derive(Debug, Clone, Serialize, Deserialize)]
753pub struct AnalystNote {
754    /// Target type (what is being annotated)
755    pub target_type: NoteTargetType,
756    /// Target identifier (CVE ID, component name, etc.)
757    pub target_id: Option<String>,
758    /// Note content
759    pub note: String,
760    /// Whether this marks a false positive
761    pub false_positive: bool,
762    /// Severity override if applicable
763    pub severity_override: Option<String>,
764    /// Note creation timestamp
765    pub created_at: DateTime<Utc>,
766    /// Analyst identifier
767    pub analyst: Option<String>,
768}
769
770impl AnalystNote {
771    /// Create a new analyst note
772    #[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    /// Create a note for a vulnerability
786    #[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    /// Create a note for a component
800    #[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    /// Mark as false positive
814    #[must_use]
815    pub const fn mark_false_positive(mut self) -> Self {
816        self.false_positive = true;
817        self
818    }
819}
820
821/// Type of target for analyst notes
822#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
823pub enum NoteTargetType {
824    /// Note about a vulnerability
825    Vulnerability,
826    /// Note about a component
827    Component,
828    /// Note about a license
829    License,
830    /// Note about a cryptographic asset
831    Cryptography,
832    /// General note
833    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/// Recommended action
849#[derive(Debug, Clone, Serialize, Deserialize)]
850pub struct Recommendation {
851    /// Priority level
852    pub priority: RecommendationPriority,
853    /// Category of recommendation
854    pub category: RecommendationCategory,
855    /// Short title
856    pub title: String,
857    /// Detailed description
858    pub description: String,
859    /// Affected components
860    pub affected_components: Vec<String>,
861    /// Estimated effort (optional)
862    pub effort: Option<String>,
863}
864
865impl Recommendation {
866    /// Create a new recommendation
867    #[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/// Recommendation priority
886#[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/// Recommendation category
906#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
907pub enum RecommendationCategory {
908    /// Upgrade a dependency
909    Upgrade,
910    /// Replace a dependency
911    Replace,
912    /// Investigate further
913    Investigate,
914    /// Monitor for updates
915    Monitor,
916    /// Add missing information
917    AddInfo,
918    /// Fix configuration
919    Config,
920    /// Cryptographic migration or remediation
921    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    /// Zero algorithms must render as "n/a", never a vacuous 100% readiness.
976    #[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}