1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt::Write;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AnalystReport {
13 pub metadata: AnalystReportMetadata,
15 pub executive_summary: ExecutiveSummary,
17 pub vulnerability_findings: VulnerabilityFindings,
19 pub component_findings: ComponentFindings,
21 pub compliance_status: ComplianceStatus,
23 pub analyst_notes: Vec<AnalystNote>,
25 pub recommendations: Vec<Recommendation>,
27 pub generated_at: DateTime<Utc>,
29}
30
31impl AnalystReport {
32 #[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 pub fn to_json(&self) -> Result<String, serde_json::Error> {
49 serde_json::to_string_pretty(self)
50 }
51
52 #[must_use]
54 pub fn to_markdown(&self) -> String {
55 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 md.push_str("# Security Analysis Report\n\n");
65
66 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 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 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 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 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 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 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 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) = ¬e.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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298pub struct AnalystReportMetadata {
299 pub tool_version: String,
301 pub title: Option<String>,
303 pub analyst: Option<String>,
305 pub sbom_paths: Vec<String>,
307 pub analysis_date: Option<DateTime<Utc>>,
309}
310
311#[derive(Debug, Clone, Default, Serialize, Deserialize)]
313pub struct ExecutiveSummary {
314 pub risk_score: u8,
316 pub risk_level: RiskLevel,
318 pub critical_issues: usize,
320 pub high_issues: usize,
322 pub kev_count: usize,
324 pub stale_dependencies: usize,
326 pub license_conflicts: usize,
328 pub cra_compliance_score: Option<u8>,
330 pub summary_text: String,
332}
333
334#[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 #[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 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376pub struct VulnerabilityFindings {
377 pub total_count: usize,
379 pub kev_vulnerabilities: Vec<VulnFinding>,
381 pub critical_vulnerabilities: Vec<VulnFinding>,
383 pub high_vulnerabilities: Vec<VulnFinding>,
385 pub medium_vulnerabilities: Vec<VulnFinding>,
387 pub low_vulnerabilities: Vec<VulnFinding>,
389}
390
391impl VulnerabilityFindings {
392 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct VulnFinding {
413 pub id: String,
415 pub severity: String,
417 pub cvss_score: Option<f32>,
419 pub is_kev: bool,
421 pub is_ransomware_related: bool,
423 pub kev_due_date: Option<DateTime<Utc>>,
425 pub component_name: String,
427 pub component_version: Option<String>,
429 pub description: Option<String>,
431 pub remediation: Option<String>,
433 pub attack_paths: Vec<String>,
435 pub change_status: Option<String>,
437 pub analyst_note: Option<String>,
439 pub is_false_positive: bool,
441}
442
443impl VulnFinding {
444 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
468pub struct ComponentFindings {
469 pub total_components: usize,
471 pub added_count: usize,
473 pub removed_count: usize,
475 pub stale_components: Vec<StaleComponentFinding>,
477 pub deprecated_components: Vec<DeprecatedComponentFinding>,
479 pub license_issues: Vec<LicenseIssueFinding>,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct StaleComponentFinding {
486 pub name: String,
488 pub version: Option<String>,
490 pub days_since_update: u32,
492 pub last_published: Option<DateTime<Utc>>,
494 pub latest_version: Option<String>,
496 pub staleness_level: String,
498 pub analyst_note: Option<String>,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct DeprecatedComponentFinding {
505 pub name: String,
507 pub version: Option<String>,
509 pub deprecation_message: Option<String>,
511 pub replacement: Option<String>,
513 pub analyst_note: Option<String>,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct LicenseIssueFinding {
520 pub issue_type: LicenseIssueType,
522 pub severity: IssueSeverity,
524 pub license_a: String,
526 pub license_b: Option<String>,
528 pub affected_components: Vec<String>,
530 pub description: String,
532 pub analyst_note: Option<String>,
534}
535
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
538pub enum LicenseIssueType {
539 BinaryIncompatible,
541 ProjectIncompatible,
543 NetworkCopyleft,
545 PatentConflict,
547 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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
583pub struct ComplianceStatus {
584 pub level: String,
586 pub score: u8,
588 pub total_violations: usize,
590 pub violations_by_article: Vec<ArticleViolations>,
592 pub key_issues: Vec<String>,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct ArticleViolations {
599 pub article: String,
601 pub description: String,
603 pub count: usize,
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize)]
609pub struct AnalystNote {
610 pub target_type: NoteTargetType,
612 pub target_id: Option<String>,
614 pub note: String,
616 pub false_positive: bool,
618 pub severity_override: Option<String>,
620 pub created_at: DateTime<Utc>,
622 pub analyst: Option<String>,
624}
625
626impl AnalystNote {
627 #[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 #[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 #[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 #[must_use]
671 pub const fn mark_false_positive(mut self) -> Self {
672 self.false_positive = true;
673 self
674 }
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
679pub enum NoteTargetType {
680 Vulnerability,
682 Component,
684 License,
686 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#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct Recommendation {
704 pub priority: RecommendationPriority,
706 pub category: RecommendationCategory,
708 pub title: String,
710 pub description: String,
712 pub affected_components: Vec<String>,
714 pub effort: Option<String>,
716}
717
718impl Recommendation {
719 #[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
760pub enum RecommendationCategory {
761 Upgrade,
763 Replace,
765 Investigate,
767 Monitor,
769 AddInfo,
771 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}