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 pub fn new() -> Self {
34 Self {
35 metadata: AnalystReportMetadata::default(),
36 executive_summary: ExecutiveSummary::default(),
37 vulnerability_findings: VulnerabilityFindings::default(),
38 component_findings: ComponentFindings::default(),
39 compliance_status: ComplianceStatus::default(),
40 analyst_notes: Vec::new(),
41 recommendations: Vec::new(),
42 generated_at: Utc::now(),
43 }
44 }
45
46 pub fn to_json(&self) -> Result<String, serde_json::Error> {
48 serde_json::to_string_pretty(self)
49 }
50
51 pub fn to_markdown(&self) -> String {
53 let estimated_size = 2000
55 + self.vulnerability_findings.kev_vulnerabilities.len() * 100
56 + self.component_findings.license_issues.len() * 150
57 + self.recommendations.len() * 300
58 + self.analyst_notes.len() * 100;
59 let mut md = String::with_capacity(estimated_size);
60
61 md.push_str("# Security Analysis Report\n\n");
63
64 if let Some(title) = &self.metadata.title {
66 let _ = writeln!(md, "**Analysis:** {}", title);
67 }
68 if let Some(analyst) = &self.metadata.analyst {
69 let _ = writeln!(md, "**Analyst:** {}", analyst);
70 }
71 let _ = writeln!(
72 md,
73 "**Generated:** {}",
74 self.generated_at.format("%Y-%m-%d %H:%M:%S UTC")
75 );
76 if !self.metadata.sbom_paths.is_empty() {
77 let _ = writeln!(
78 md,
79 "**SBOMs Analyzed:** {}",
80 self.metadata.sbom_paths.join(", ")
81 );
82 }
83 md.push_str("\n---\n\n");
84
85 md.push_str("## Executive Summary\n\n");
87 let _ = writeln!(
88 md,
89 "**Risk Score:** {} ({:?})\n",
90 self.executive_summary.risk_score, self.executive_summary.risk_level
91 );
92
93 md.push_str("| Metric | Count |\n");
94 md.push_str("|--------|-------|\n");
95 let _ = writeln!(
96 md,
97 "| Critical Issues | {} |",
98 self.executive_summary.critical_issues
99 );
100 let _ = writeln!(
101 md,
102 "| High Issues | {} |",
103 self.executive_summary.high_issues
104 );
105 let _ = writeln!(
106 md,
107 "| KEV Vulnerabilities | {} |",
108 self.executive_summary.kev_count
109 );
110 let _ = writeln!(
111 md,
112 "| Stale Dependencies | {} |",
113 self.executive_summary.stale_dependencies
114 );
115 let _ = writeln!(
116 md,
117 "| License Conflicts | {} |",
118 self.executive_summary.license_conflicts
119 );
120 if let Some(cra) = self.executive_summary.cra_compliance_score {
121 let _ = writeln!(md, "| CRA Compliance | {}% |", cra);
122 }
123 md.push('\n');
124
125 if !self.executive_summary.summary_text.is_empty() {
126 md.push_str(&self.executive_summary.summary_text);
127 md.push_str("\n\n");
128 }
129
130 md.push_str("## Vulnerability Findings\n\n");
132 let _ = writeln!(
133 md,
134 "- **Total Vulnerabilities:** {}",
135 self.vulnerability_findings.total_count
136 );
137 let _ = writeln!(
138 md,
139 "- **Critical:** {}",
140 self.vulnerability_findings.critical_vulnerabilities.len()
141 );
142 let _ = writeln!(
143 md,
144 "- **High:** {}",
145 self.vulnerability_findings.high_vulnerabilities.len()
146 );
147 let _ = writeln!(
148 md,
149 "- **Medium:** {}",
150 self.vulnerability_findings.medium_vulnerabilities.len()
151 );
152 let _ = writeln!(
153 md,
154 "- **Low:** {}",
155 self.vulnerability_findings.low_vulnerabilities.len()
156 );
157
158 if !self.vulnerability_findings.kev_vulnerabilities.is_empty() {
159 md.push_str("\n### Known Exploited Vulnerabilities (KEV)\n\n");
160 md.push_str(
161 "These vulnerabilities are actively being exploited in the wild and require immediate attention.\n\n",
162 );
163 for vuln in &self.vulnerability_findings.kev_vulnerabilities {
164 let _ = writeln!(
165 md,
166 "- **{}** ({}) - {}",
167 vuln.id, vuln.severity, vuln.component_name
168 );
169 }
170 }
171 md.push('\n');
172
173 md.push_str("## Component Findings\n\n");
175 let _ = writeln!(
176 md,
177 "- **Total Components:** {}",
178 self.component_findings.total_components
179 );
180 let _ = writeln!(
181 md,
182 "- **Added:** {}",
183 self.component_findings.added_count
184 );
185 let _ = writeln!(
186 md,
187 "- **Removed:** {}",
188 self.component_findings.removed_count
189 );
190 let _ = writeln!(
191 md,
192 "- **Stale:** {}",
193 self.component_findings.stale_components.len()
194 );
195 let _ = writeln!(
196 md,
197 "- **Deprecated:** {}",
198 self.component_findings.deprecated_components.len()
199 );
200 md.push('\n');
201
202 if !self.component_findings.license_issues.is_empty() {
204 md.push_str("### License Issues\n\n");
205 for issue in &self.component_findings.license_issues {
206 let components = issue.affected_components.join(", ");
207 let _ = writeln!(
208 md,
209 "- **{}** ({}): {} - {}",
210 issue.issue_type, issue.severity, issue.description, components
211 );
212 }
213 md.push('\n');
214 }
215
216 if self.compliance_status.score > 0 {
218 md.push_str("## Compliance Status\n\n");
219 let _ = writeln!(
220 md,
221 "**CRA Compliance:** {}%\n",
222 self.compliance_status.score
223 );
224
225 if !self.compliance_status.violations_by_article.is_empty() {
226 md.push_str("### CRA Violations\n\n");
227 for violation in &self.compliance_status.violations_by_article {
228 let _ = writeln!(
229 md,
230 "- **{}** ({} occurrences): {}",
231 violation.article, violation.count, violation.description
232 );
233 }
234 md.push('\n');
235 }
236 }
237
238 if !self.recommendations.is_empty() {
240 md.push_str("## Recommendations\n\n");
241
242 let mut sorted_recs = self.recommendations.clone();
243 sorted_recs.sort_by(|a, b| a.priority.cmp(&b.priority));
244
245 for rec in &sorted_recs {
246 let _ = writeln!(
247 md,
248 "### [{:?}] {} - {}\n",
249 rec.priority, rec.category, rec.title
250 );
251 md.push_str(&rec.description);
252 md.push_str("\n\n");
253 if !rec.affected_components.is_empty() {
254 let _ = writeln!(
255 md,
256 "**Affected:** {}\n",
257 rec.affected_components.join(", ")
258 );
259 }
260 if let Some(effort) = &rec.effort {
261 let _ = writeln!(md, "**Estimated Effort:** {}\n", effort);
262 }
263 }
264 }
265
266 if !self.analyst_notes.is_empty() {
268 md.push_str("## Analyst Notes\n\n");
269 for note in &self.analyst_notes {
270 let fp_marker = if note.false_positive {
271 " [FALSE POSITIVE]"
272 } else {
273 ""
274 };
275 if let Some(id) = ¬e.target_id {
276 let _ = writeln!(
277 md,
278 "- **{} ({}){}**: {}",
279 note.target_type, id, fp_marker, note.note
280 );
281 } else {
282 let _ = writeln!(
283 md,
284 "- **{}{}**: {}",
285 note.target_type, fp_marker, note.note
286 );
287 }
288 }
289 md.push('\n');
290 }
291
292 md.push_str("---\n\n");
294 md.push_str("*Generated by sbom-tools*\n");
295
296 md
297 }
298}
299
300impl Default for AnalystReport {
301 fn default() -> Self {
302 Self::new()
303 }
304}
305
306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct AnalystReportMetadata {
309 pub tool_version: String,
311 pub title: Option<String>,
313 pub analyst: Option<String>,
315 pub sbom_paths: Vec<String>,
317 pub analysis_date: Option<DateTime<Utc>>,
319}
320
321#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct ExecutiveSummary {
324 pub risk_score: u8,
326 pub risk_level: RiskLevel,
328 pub critical_issues: usize,
330 pub high_issues: usize,
332 pub kev_count: usize,
334 pub stale_dependencies: usize,
336 pub license_conflicts: usize,
338 pub cra_compliance_score: Option<u8>,
340 pub summary_text: String,
342}
343
344#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
346pub enum RiskLevel {
347 #[default]
348 Low,
349 Medium,
350 High,
351 Critical,
352}
353
354impl RiskLevel {
355 pub fn from_score(score: u8) -> Self {
357 match score {
358 0..=25 => RiskLevel::Low,
359 26..=50 => RiskLevel::Medium,
360 51..=75 => RiskLevel::High,
361 _ => RiskLevel::Critical,
362 }
363 }
364
365 pub fn label(&self) -> &'static str {
367 match self {
368 RiskLevel::Low => "Low",
369 RiskLevel::Medium => "Medium",
370 RiskLevel::High => "High",
371 RiskLevel::Critical => "Critical",
372 }
373 }
374}
375
376impl std::fmt::Display for RiskLevel {
377 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378 write!(f, "{}", self.label())
379 }
380}
381
382#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384pub struct VulnerabilityFindings {
385 pub total_count: usize,
387 pub kev_vulnerabilities: Vec<VulnFinding>,
389 pub critical_vulnerabilities: Vec<VulnFinding>,
391 pub high_vulnerabilities: Vec<VulnFinding>,
393 pub medium_vulnerabilities: Vec<VulnFinding>,
395 pub low_vulnerabilities: Vec<VulnFinding>,
397}
398
399impl VulnerabilityFindings {
400 pub fn all_findings(&self) -> Vec<&VulnFinding> {
402 let capacity = self.kev_vulnerabilities.len()
403 + self.critical_vulnerabilities.len()
404 + self.high_vulnerabilities.len()
405 + self.medium_vulnerabilities.len()
406 + self.low_vulnerabilities.len();
407 let mut all = Vec::with_capacity(capacity);
408 all.extend(self.kev_vulnerabilities.iter());
409 all.extend(self.critical_vulnerabilities.iter());
410 all.extend(self.high_vulnerabilities.iter());
411 all.extend(self.medium_vulnerabilities.iter());
412 all.extend(self.low_vulnerabilities.iter());
413 all
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct VulnFinding {
420 pub id: String,
422 pub severity: String,
424 pub cvss_score: Option<f32>,
426 pub is_kev: bool,
428 pub is_ransomware_related: bool,
430 pub kev_due_date: Option<DateTime<Utc>>,
432 pub component_name: String,
434 pub component_version: Option<String>,
436 pub description: Option<String>,
438 pub remediation: Option<String>,
440 pub attack_paths: Vec<String>,
442 pub change_status: Option<String>,
444 pub analyst_note: Option<String>,
446 pub is_false_positive: bool,
448}
449
450impl VulnFinding {
451 pub fn new(id: String, component_name: String) -> Self {
453 Self {
454 id,
455 severity: "Unknown".to_string(),
456 cvss_score: None,
457 is_kev: false,
458 is_ransomware_related: false,
459 kev_due_date: None,
460 component_name,
461 component_version: None,
462 description: None,
463 remediation: None,
464 attack_paths: Vec::new(),
465 change_status: None,
466 analyst_note: None,
467 is_false_positive: false,
468 }
469 }
470}
471
472#[derive(Debug, Clone, Default, Serialize, Deserialize)]
474pub struct ComponentFindings {
475 pub total_components: usize,
477 pub added_count: usize,
479 pub removed_count: usize,
481 pub stale_components: Vec<StaleComponentFinding>,
483 pub deprecated_components: Vec<DeprecatedComponentFinding>,
485 pub license_issues: Vec<LicenseIssueFinding>,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct StaleComponentFinding {
492 pub name: String,
494 pub version: Option<String>,
496 pub days_since_update: u32,
498 pub last_published: Option<DateTime<Utc>>,
500 pub latest_version: Option<String>,
502 pub staleness_level: String,
504 pub analyst_note: Option<String>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct DeprecatedComponentFinding {
511 pub name: String,
513 pub version: Option<String>,
515 pub deprecation_message: Option<String>,
517 pub replacement: Option<String>,
519 pub analyst_note: Option<String>,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct LicenseIssueFinding {
526 pub issue_type: LicenseIssueType,
528 pub severity: IssueSeverity,
530 pub license_a: String,
532 pub license_b: Option<String>,
534 pub affected_components: Vec<String>,
536 pub description: String,
538 pub analyst_note: Option<String>,
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544pub enum LicenseIssueType {
545 BinaryIncompatible,
547 ProjectIncompatible,
549 NetworkCopyleft,
551 PatentConflict,
553 UnknownLicense,
555}
556
557impl std::fmt::Display for LicenseIssueType {
558 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 match self {
560 LicenseIssueType::BinaryIncompatible => write!(f, "Binary Incompatible"),
561 LicenseIssueType::ProjectIncompatible => write!(f, "Project Incompatible"),
562 LicenseIssueType::NetworkCopyleft => write!(f, "Network Copyleft"),
563 LicenseIssueType::PatentConflict => write!(f, "Patent Conflict"),
564 LicenseIssueType::UnknownLicense => write!(f, "Unknown License"),
565 }
566 }
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
571pub enum IssueSeverity {
572 Error,
573 Warning,
574 Info,
575}
576
577impl std::fmt::Display for IssueSeverity {
578 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579 match self {
580 IssueSeverity::Error => write!(f, "Error"),
581 IssueSeverity::Warning => write!(f, "Warning"),
582 IssueSeverity::Info => write!(f, "Info"),
583 }
584 }
585}
586
587#[derive(Debug, Clone, Default, Serialize, Deserialize)]
589pub struct ComplianceStatus {
590 pub level: String,
592 pub score: u8,
594 pub total_violations: usize,
596 pub violations_by_article: Vec<ArticleViolations>,
598 pub key_issues: Vec<String>,
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct ArticleViolations {
605 pub article: String,
607 pub description: String,
609 pub count: usize,
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct AnalystNote {
616 pub target_type: NoteTargetType,
618 pub target_id: Option<String>,
620 pub note: String,
622 pub false_positive: bool,
624 pub severity_override: Option<String>,
626 pub created_at: DateTime<Utc>,
628 pub analyst: Option<String>,
630}
631
632impl AnalystNote {
633 pub fn new(target_type: NoteTargetType, note: String) -> Self {
635 Self {
636 target_type,
637 target_id: None,
638 note,
639 false_positive: false,
640 severity_override: None,
641 created_at: Utc::now(),
642 analyst: None,
643 }
644 }
645
646 pub fn for_vulnerability(vuln_id: String, note: String) -> Self {
648 Self {
649 target_type: NoteTargetType::Vulnerability,
650 target_id: Some(vuln_id),
651 note,
652 false_positive: false,
653 severity_override: None,
654 created_at: Utc::now(),
655 analyst: None,
656 }
657 }
658
659 pub fn for_component(component_name: String, note: String) -> Self {
661 Self {
662 target_type: NoteTargetType::Component,
663 target_id: Some(component_name),
664 note,
665 false_positive: false,
666 severity_override: None,
667 created_at: Utc::now(),
668 analyst: None,
669 }
670 }
671
672 pub fn mark_false_positive(mut self) -> Self {
674 self.false_positive = true;
675 self
676 }
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
681pub enum NoteTargetType {
682 Vulnerability,
684 Component,
686 License,
688 General,
690}
691
692impl std::fmt::Display for NoteTargetType {
693 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
694 match self {
695 NoteTargetType::Vulnerability => write!(f, "Vulnerability"),
696 NoteTargetType::Component => write!(f, "Component"),
697 NoteTargetType::License => write!(f, "License"),
698 NoteTargetType::General => write!(f, "General"),
699 }
700 }
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize)]
705pub struct Recommendation {
706 pub priority: RecommendationPriority,
708 pub category: RecommendationCategory,
710 pub title: String,
712 pub description: String,
714 pub affected_components: Vec<String>,
716 pub effort: Option<String>,
718}
719
720impl Recommendation {
721 pub fn new(
723 priority: RecommendationPriority,
724 category: RecommendationCategory,
725 title: String,
726 description: String,
727 ) -> Self {
728 Self {
729 priority,
730 category,
731 title,
732 description,
733 affected_components: Vec::new(),
734 effort: None,
735 }
736 }
737}
738
739#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
741pub enum RecommendationPriority {
742 Critical,
743 High,
744 Medium,
745 Low,
746}
747
748impl std::fmt::Display for RecommendationPriority {
749 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750 match self {
751 RecommendationPriority::Critical => write!(f, "Critical"),
752 RecommendationPriority::High => write!(f, "High"),
753 RecommendationPriority::Medium => write!(f, "Medium"),
754 RecommendationPriority::Low => write!(f, "Low"),
755 }
756 }
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
761pub enum RecommendationCategory {
762 Upgrade,
764 Replace,
766 Investigate,
768 Monitor,
770 AddInfo,
772 Config,
774}
775
776impl std::fmt::Display for RecommendationCategory {
777 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
778 match self {
779 RecommendationCategory::Upgrade => write!(f, "Upgrade"),
780 RecommendationCategory::Replace => write!(f, "Replace"),
781 RecommendationCategory::Investigate => write!(f, "Investigate"),
782 RecommendationCategory::Monitor => write!(f, "Monitor"),
783 RecommendationCategory::AddInfo => write!(f, "Add Information"),
784 RecommendationCategory::Config => write!(f, "Configuration"),
785 }
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn test_risk_level_from_score() {
795 assert_eq!(RiskLevel::from_score(0), RiskLevel::Low);
796 assert_eq!(RiskLevel::from_score(25), RiskLevel::Low);
797 assert_eq!(RiskLevel::from_score(26), RiskLevel::Medium);
798 assert_eq!(RiskLevel::from_score(50), RiskLevel::Medium);
799 assert_eq!(RiskLevel::from_score(51), RiskLevel::High);
800 assert_eq!(RiskLevel::from_score(75), RiskLevel::High);
801 assert_eq!(RiskLevel::from_score(76), RiskLevel::Critical);
802 assert_eq!(RiskLevel::from_score(100), RiskLevel::Critical);
803 }
804
805 #[test]
806 fn test_analyst_note_creation() {
807 let note = AnalystNote::for_vulnerability(
808 "CVE-2024-1234".to_string(),
809 "Mitigated by WAF".to_string(),
810 );
811 assert_eq!(note.target_type, NoteTargetType::Vulnerability);
812 assert_eq!(note.target_id, Some("CVE-2024-1234".to_string()));
813 assert!(!note.false_positive);
814
815 let fp_note = note.mark_false_positive();
816 assert!(fp_note.false_positive);
817 }
818
819 #[test]
820 fn test_recommendation_ordering() {
821 assert!(RecommendationPriority::Critical < RecommendationPriority::High);
822 assert!(RecommendationPriority::High < RecommendationPriority::Medium);
823 assert!(RecommendationPriority::Medium < RecommendationPriority::Low);
824 }
825}