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            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        // Compliance Status
287        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        // Recommendations
309        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        // Analyst Notes
333        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) = &note.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        // Footer
355        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/// Report metadata
369#[derive(Debug, Clone, Default, Serialize, Deserialize)]
370pub struct AnalystReportMetadata {
371    /// Tool name and version
372    pub tool_version: String,
373    /// Title of the analysis
374    pub title: Option<String>,
375    /// Analyst name or identifier
376    pub analyst: Option<String>,
377    /// SBOM file paths
378    pub sbom_paths: Vec<String>,
379    /// Analysis date
380    pub analysis_date: Option<DateTime<Utc>>,
381}
382
383/// Executive summary with overall risk assessment
384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
385pub struct ExecutiveSummary {
386    /// Overall risk score (0-100, higher = more risk)
387    pub risk_score: u8,
388    /// Risk level label (Low, Medium, High, Critical)
389    pub risk_level: RiskLevel,
390    /// Number of critical security issues
391    pub critical_issues: usize,
392    /// Number of high severity issues
393    pub high_issues: usize,
394    /// Count of KEV (Known Exploited Vulnerabilities)
395    pub kev_count: usize,
396    /// Count of stale/unmaintained dependencies
397    pub stale_dependencies: usize,
398    /// Count of license conflicts
399    pub license_conflicts: usize,
400    /// CRA compliance percentage (0-100)
401    pub cra_compliance_score: Option<u8>,
402    /// Brief summary text
403    pub summary_text: String,
404}
405
406/// Risk level classification
407#[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    /// Calculate from risk score
418    #[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    /// Get display label
429    #[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/// Vulnerability findings section
447#[derive(Debug, Clone, Default, Serialize, Deserialize)]
448pub struct VulnerabilityFindings {
449    /// Total vulnerability count
450    pub total_count: usize,
451    /// KEV vulnerabilities (highest priority)
452    pub kev_vulnerabilities: Vec<VulnFinding>,
453    /// Critical severity vulnerabilities
454    pub critical_vulnerabilities: Vec<VulnFinding>,
455    /// High severity vulnerabilities
456    pub high_vulnerabilities: Vec<VulnFinding>,
457    /// Medium severity vulnerabilities
458    pub medium_vulnerabilities: Vec<VulnFinding>,
459    /// Low severity vulnerabilities
460    pub low_vulnerabilities: Vec<VulnFinding>,
461}
462
463impl VulnerabilityFindings {
464    /// Get all findings in priority order
465    #[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/// Individual vulnerability finding
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct VulnFinding {
485    /// Vulnerability ID (CVE, GHSA, etc.)
486    pub id: String,
487    /// Severity level
488    pub severity: String,
489    /// CVSS score
490    pub cvss_score: Option<f32>,
491    /// Whether in KEV catalog
492    pub is_kev: bool,
493    /// Whether used in ransomware
494    pub is_ransomware_related: bool,
495    /// KEV due date if applicable
496    pub kev_due_date: Option<DateTime<Utc>>,
497    /// Affected component name
498    pub component_name: String,
499    /// Component version
500    pub component_version: Option<String>,
501    /// Vulnerability description
502    pub description: Option<String>,
503    /// Remediation suggestion
504    pub remediation: Option<String>,
505    /// Attack paths to this vulnerability
506    pub attack_paths: Vec<String>,
507    /// Status in diff (Introduced, Resolved, Persistent)
508    pub change_status: Option<String>,
509    /// Analyst note if present
510    pub analyst_note: Option<String>,
511    /// Marked as false positive
512    pub is_false_positive: bool,
513}
514
515impl VulnFinding {
516    /// Create a new vulnerability finding
517    #[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/// Component-related findings
539#[derive(Debug, Clone, Default, Serialize, Deserialize)]
540pub struct ComponentFindings {
541    /// Total component count
542    pub total_components: usize,
543    /// Components added (in diff mode)
544    pub added_count: usize,
545    /// Components removed (in diff mode)
546    pub removed_count: usize,
547    /// Stale components (>1 year without update)
548    pub stale_components: Vec<StaleComponentFinding>,
549    /// Deprecated components
550    pub deprecated_components: Vec<DeprecatedComponentFinding>,
551    /// License issues
552    pub license_issues: Vec<LicenseIssueFinding>,
553}
554
555/// Stale component finding
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct StaleComponentFinding {
558    /// Component name
559    pub name: String,
560    /// Current version
561    pub version: Option<String>,
562    /// Days since last update
563    pub days_since_update: u32,
564    /// Last publish date
565    pub last_published: Option<DateTime<Utc>>,
566    /// Latest available version
567    pub latest_version: Option<String>,
568    /// Staleness level
569    pub staleness_level: String,
570    /// Analyst note if present
571    pub analyst_note: Option<String>,
572}
573
574/// Deprecated component finding
575#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct DeprecatedComponentFinding {
577    /// Component name
578    pub name: String,
579    /// Current version
580    pub version: Option<String>,
581    /// Deprecation message
582    pub deprecation_message: Option<String>,
583    /// Suggested replacement
584    pub replacement: Option<String>,
585    /// Analyst note if present
586    pub analyst_note: Option<String>,
587}
588
589/// License issue finding
590#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct LicenseIssueFinding {
592    /// Issue type
593    pub issue_type: LicenseIssueType,
594    /// Severity
595    pub severity: IssueSeverity,
596    /// First license involved
597    pub license_a: String,
598    /// Second license involved (for conflicts)
599    pub license_b: Option<String>,
600    /// Affected components
601    pub affected_components: Vec<String>,
602    /// Description of the issue
603    pub description: String,
604    /// Analyst note if present
605    pub analyst_note: Option<String>,
606}
607
608/// Type of license issue
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
610pub enum LicenseIssueType {
611    /// Incompatible licenses in same binary
612    BinaryIncompatible,
613    /// Incompatible licenses in project
614    ProjectIncompatible,
615    /// Network copyleft (AGPL) implications
616    NetworkCopyleft,
617    /// Patent clause conflict
618    PatentConflict,
619    /// Unknown or unrecognized license
620    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/// Issue severity level
636#[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/// Cryptographic asset findings
654#[derive(Debug, Clone, Default, Serialize, Deserialize)]
655pub struct CryptoFindings {
656    /// Total cryptographic components
657    pub total_crypto_assets: usize,
658    /// Algorithm count
659    pub algorithms_count: usize,
660    /// Certificate count
661    pub certificates_count: usize,
662    /// Key material count
663    pub keys_count: usize,
664    /// Protocol count
665    pub protocols_count: usize,
666    /// Quantum readiness percentage (0-100)
667    pub quantum_readiness_pct: f32,
668    /// Quantum-safe algorithm count
669    pub quantum_safe_count: usize,
670    /// Quantum-vulnerable algorithm count
671    pub quantum_vulnerable_count: usize,
672    /// Hybrid PQC combiner count
673    pub hybrid_pqc_count: usize,
674    /// Weak/broken algorithms found
675    pub weak_algorithms: Vec<CryptoAlgorithmFinding>,
676    /// Expired certificates
677    pub expired_certificates: Vec<CryptoCertFinding>,
678    /// Compromised key material
679    pub compromised_keys: Vec<CryptoKeyFinding>,
680    /// Deprecation warnings (quantum-vulnerable classical algorithms)
681    pub deprecation_warnings: Vec<String>,
682}
683
684/// Individual algorithm finding for analyst reports
685#[derive(Debug, Clone, Serialize, Deserialize)]
686pub struct CryptoAlgorithmFinding {
687    /// Algorithm name
688    pub name: String,
689    /// Algorithm family (e.g., "SHA-1", "DES")
690    pub family: Option<String>,
691    /// NIST quantum security level (0 = vulnerable)
692    pub quantum_level: Option<u8>,
693    /// Why this is flagged
694    pub reason: String,
695}
696
697/// Certificate finding for analyst reports
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct CryptoCertFinding {
700    /// Certificate subject or component name
701    pub name: String,
702    /// Expiry date
703    pub expires: Option<String>,
704    /// Days overdue (positive = expired)
705    pub days_overdue: Option<i64>,
706}
707
708/// Key material finding for analyst reports
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct CryptoKeyFinding {
711    /// Key component name
712    pub name: String,
713    /// Material type
714    pub material_type: String,
715    /// Current state
716    pub state: String,
717}
718
719/// Compliance status summary
720#[derive(Debug, Clone, Default, Serialize, Deserialize)]
721pub struct ComplianceStatus {
722    /// Overall compliance level
723    pub level: String,
724    /// Compliance score (0-100)
725    pub score: u8,
726    /// Total violations count
727    pub total_violations: usize,
728    /// Violations by CRA article (for CRA compliance)
729    pub violations_by_article: Vec<ArticleViolations>,
730    /// Key compliance issues
731    pub key_issues: Vec<String>,
732}
733
734/// Violations grouped by CRA article
735#[derive(Debug, Clone, Serialize, Deserialize)]
736pub struct ArticleViolations {
737    /// Article reference (e.g., "Art. 13(6)")
738    pub article: String,
739    /// Article description
740    pub description: String,
741    /// Violation count
742    pub count: usize,
743}
744
745/// Analyst note/annotation
746#[derive(Debug, Clone, Serialize, Deserialize)]
747pub struct AnalystNote {
748    /// Target type (what is being annotated)
749    pub target_type: NoteTargetType,
750    /// Target identifier (CVE ID, component name, etc.)
751    pub target_id: Option<String>,
752    /// Note content
753    pub note: String,
754    /// Whether this marks a false positive
755    pub false_positive: bool,
756    /// Severity override if applicable
757    pub severity_override: Option<String>,
758    /// Note creation timestamp
759    pub created_at: DateTime<Utc>,
760    /// Analyst identifier
761    pub analyst: Option<String>,
762}
763
764impl AnalystNote {
765    /// Create a new analyst note
766    #[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    /// Create a note for a vulnerability
780    #[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    /// Create a note for a component
794    #[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    /// Mark as false positive
808    #[must_use]
809    pub const fn mark_false_positive(mut self) -> Self {
810        self.false_positive = true;
811        self
812    }
813}
814
815/// Type of target for analyst notes
816#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
817pub enum NoteTargetType {
818    /// Note about a vulnerability
819    Vulnerability,
820    /// Note about a component
821    Component,
822    /// Note about a license
823    License,
824    /// Note about a cryptographic asset
825    Cryptography,
826    /// General note
827    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/// Recommended action
843#[derive(Debug, Clone, Serialize, Deserialize)]
844pub struct Recommendation {
845    /// Priority level
846    pub priority: RecommendationPriority,
847    /// Category of recommendation
848    pub category: RecommendationCategory,
849    /// Short title
850    pub title: String,
851    /// Detailed description
852    pub description: String,
853    /// Affected components
854    pub affected_components: Vec<String>,
855    /// Estimated effort (optional)
856    pub effort: Option<String>,
857}
858
859impl Recommendation {
860    /// Create a new recommendation
861    #[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/// Recommendation priority
880#[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/// Recommendation category
900#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
901pub enum RecommendationCategory {
902    /// Upgrade a dependency
903    Upgrade,
904    /// Replace a dependency
905    Replace,
906    /// Investigate further
907    Investigate,
908    /// Monitor for updates
909    Monitor,
910    /// Add missing information
911    AddInfo,
912    /// Fix configuration
913    Config,
914    /// Cryptographic migration or remediation
915    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}