use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt::Write;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalystReport {
pub metadata: AnalystReportMetadata,
pub executive_summary: ExecutiveSummary,
pub vulnerability_findings: VulnerabilityFindings,
pub component_findings: ComponentFindings,
pub compliance_status: ComplianceStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crypto_findings: Option<CryptoFindings>,
pub analyst_notes: Vec<AnalystNote>,
pub recommendations: Vec<Recommendation>,
pub generated_at: DateTime<Utc>,
}
impl AnalystReport {
#[must_use]
pub fn new() -> Self {
Self {
metadata: AnalystReportMetadata::default(),
executive_summary: ExecutiveSummary::default(),
vulnerability_findings: VulnerabilityFindings::default(),
component_findings: ComponentFindings::default(),
compliance_status: ComplianceStatus::default(),
crypto_findings: None,
analyst_notes: Vec::new(),
recommendations: Vec::new(),
generated_at: Utc::now(),
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
#[must_use]
pub fn to_markdown(&self) -> String {
let crypto_size = self.crypto_findings.as_ref().map_or(0, |cf| {
500 + cf.weak_algorithms.len() * 100 + cf.deprecation_warnings.len() * 80
});
let estimated_size = 2000
+ self.vulnerability_findings.kev_vulnerabilities.len() * 100
+ self.component_findings.license_issues.len() * 150
+ self.recommendations.len() * 300
+ self.analyst_notes.len() * 100
+ crypto_size;
let mut md = String::with_capacity(estimated_size);
md.push_str("# Security Analysis Report\n\n");
if let Some(title) = &self.metadata.title {
let _ = writeln!(md, "**Analysis:** {title}");
}
if let Some(analyst) = &self.metadata.analyst {
let _ = writeln!(md, "**Analyst:** {analyst}");
}
let _ = writeln!(
md,
"**Generated:** {}",
self.generated_at.format("%Y-%m-%d %H:%M:%S UTC")
);
if !self.metadata.sbom_paths.is_empty() {
let _ = writeln!(
md,
"**SBOMs Analyzed:** {}",
self.metadata.sbom_paths.join(", ")
);
}
md.push_str("\n---\n\n");
md.push_str("## Executive Summary\n\n");
let _ = writeln!(
md,
"**Risk Score:** {} ({:?})\n",
self.executive_summary.risk_score, self.executive_summary.risk_level
);
md.push_str("| Metric | Count |\n");
md.push_str("|--------|-------|\n");
let _ = writeln!(
md,
"| Critical Issues | {} |",
self.executive_summary.critical_issues
);
let _ = writeln!(
md,
"| High Issues | {} |",
self.executive_summary.high_issues
);
let _ = writeln!(
md,
"| KEV Vulnerabilities | {} |",
self.executive_summary.kev_count
);
let _ = writeln!(
md,
"| Stale Dependencies | {} |",
self.executive_summary.stale_dependencies
);
let _ = writeln!(
md,
"| License Conflicts | {} |",
self.executive_summary.license_conflicts
);
if let Some(cra) = self.executive_summary.cra_compliance_score {
let _ = writeln!(md, "| CRA Compliance | {cra}% |");
}
md.push('\n');
if !self.executive_summary.summary_text.is_empty() {
md.push_str(&self.executive_summary.summary_text);
md.push_str("\n\n");
}
md.push_str("## Vulnerability Findings\n\n");
let _ = writeln!(
md,
"- **Total Vulnerabilities:** {}",
self.vulnerability_findings.total_count
);
let _ = writeln!(
md,
"- **Critical:** {}",
self.vulnerability_findings.critical_vulnerabilities.len()
);
let _ = writeln!(
md,
"- **High:** {}",
self.vulnerability_findings.high_vulnerabilities.len()
);
let _ = writeln!(
md,
"- **Medium:** {}",
self.vulnerability_findings.medium_vulnerabilities.len()
);
let _ = writeln!(
md,
"- **Low:** {}",
self.vulnerability_findings.low_vulnerabilities.len()
);
if !self.vulnerability_findings.kev_vulnerabilities.is_empty() {
md.push_str("\n### Known Exploited Vulnerabilities (KEV)\n\n");
md.push_str(
"These vulnerabilities are actively being exploited in the wild and require immediate attention.\n\n",
);
for vuln in &self.vulnerability_findings.kev_vulnerabilities {
let _ = writeln!(
md,
"- **{}** ({}) - {}",
vuln.id, vuln.severity, vuln.component_name
);
}
}
md.push('\n');
md.push_str("## Component Findings\n\n");
let _ = writeln!(
md,
"- **Total Components:** {}",
self.component_findings.total_components
);
let _ = writeln!(md, "- **Added:** {}", self.component_findings.added_count);
let _ = writeln!(
md,
"- **Removed:** {}",
self.component_findings.removed_count
);
let _ = writeln!(
md,
"- **Stale:** {}",
self.component_findings.stale_components.len()
);
let _ = writeln!(
md,
"- **Deprecated:** {}",
self.component_findings.deprecated_components.len()
);
md.push('\n');
if !self.component_findings.license_issues.is_empty() {
md.push_str("### License Issues\n\n");
for issue in &self.component_findings.license_issues {
let components = issue.affected_components.join(", ");
let _ = writeln!(
md,
"- **{}** ({}): {} - {}",
issue.issue_type, issue.severity, issue.description, components
);
}
md.push('\n');
}
if let Some(cf) = &self.crypto_findings {
md.push_str("## Cryptographic Asset Findings\n\n");
md.push_str("| Metric | Value |\n");
md.push_str("|--------|-------|\n");
let _ = writeln!(md, "| Total Crypto Assets | {} |", cf.total_crypto_assets);
let _ = writeln!(md, "| Algorithms | {} |", cf.algorithms_count);
let _ = writeln!(md, "| Certificates | {} |", cf.certificates_count);
let _ = writeln!(md, "| Key Material | {} |", cf.keys_count);
let _ = writeln!(md, "| Protocols | {} |", cf.protocols_count);
let _ = writeln!(
md,
"| Quantum Readiness | {:.0}% ({}/{}) |",
cf.quantum_readiness_pct, cf.quantum_safe_count, cf.algorithms_count
);
if cf.hybrid_pqc_count > 0 {
let _ = writeln!(md, "| Hybrid PQC Combiners | {} |", cf.hybrid_pqc_count);
}
md.push('\n');
if !cf.weak_algorithms.is_empty() {
md.push_str("### Weak/Broken Algorithms\n\n");
md.push_str("| Algorithm | Family | Quantum Level | Reason |\n");
md.push_str("|-----------|--------|---------------|--------|\n");
for algo in &cf.weak_algorithms {
let family = algo.family.as_deref().unwrap_or("-");
let ql = algo
.quantum_level
.map_or("-".to_string(), |l| l.to_string());
let _ = writeln!(md, "| {} | {family} | {ql} | {} |", algo.name, algo.reason);
}
md.push('\n');
}
if !cf.expired_certificates.is_empty() {
md.push_str("### Expired Certificates\n\n");
for cert in &cf.expired_certificates {
let expires = cert.expires.as_deref().unwrap_or("unknown");
let _ = writeln!(md, "- **{}** — expired {expires}", cert.name);
}
md.push('\n');
}
if !cf.compromised_keys.is_empty() {
md.push_str("### Compromised Key Material\n\n");
for key in &cf.compromised_keys {
let _ = writeln!(
md,
"- **{}** ({}) — state: {}",
key.name, key.material_type, key.state
);
}
md.push('\n');
}
if !cf.deprecation_warnings.is_empty() {
md.push_str("### Quantum Deprecation Warnings\n\n");
for warning in &cf.deprecation_warnings {
let _ = writeln!(md, "- {warning}");
}
md.push('\n');
}
}
if self.compliance_status.score > 0 {
md.push_str("## Compliance Status\n\n");
let _ = writeln!(
md,
"**CRA Compliance:** {}%\n",
self.compliance_status.score
);
if !self.compliance_status.violations_by_article.is_empty() {
md.push_str("### CRA Violations\n\n");
for violation in &self.compliance_status.violations_by_article {
let _ = writeln!(
md,
"- **{}** ({} occurrences): {}",
violation.article, violation.count, violation.description
);
}
md.push('\n');
}
}
if !self.recommendations.is_empty() {
md.push_str("## Recommendations\n\n");
let mut sorted_recs = self.recommendations.clone();
sorted_recs.sort_by(|a, b| a.priority.cmp(&b.priority));
for rec in &sorted_recs {
let _ = writeln!(
md,
"### [{:?}] {} - {}\n",
rec.priority, rec.category, rec.title
);
md.push_str(&rec.description);
md.push_str("\n\n");
if !rec.affected_components.is_empty() {
let _ = writeln!(md, "**Affected:** {}\n", rec.affected_components.join(", "));
}
if let Some(effort) = &rec.effort {
let _ = writeln!(md, "**Estimated Effort:** {effort}\n");
}
}
}
if !self.analyst_notes.is_empty() {
md.push_str("## Analyst Notes\n\n");
for note in &self.analyst_notes {
let fp_marker = if note.false_positive {
" [FALSE POSITIVE]"
} else {
""
};
if let Some(id) = ¬e.target_id {
let _ = writeln!(
md,
"- **{} ({}){}**: {}",
note.target_type, id, fp_marker, note.note
);
} else {
let _ = writeln!(md, "- **{}{}**: {}", note.target_type, fp_marker, note.note);
}
}
md.push('\n');
}
md.push_str("---\n\n");
md.push_str("*Generated by sbom-tools*\n");
md
}
}
impl Default for AnalystReport {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnalystReportMetadata {
pub tool_version: String,
pub title: Option<String>,
pub analyst: Option<String>,
pub sbom_paths: Vec<String>,
pub analysis_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExecutiveSummary {
pub risk_score: u8,
pub risk_level: RiskLevel,
pub critical_issues: usize,
pub high_issues: usize,
pub kev_count: usize,
pub stale_dependencies: usize,
pub license_conflicts: usize,
pub cra_compliance_score: Option<u8>,
pub summary_text: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum RiskLevel {
#[default]
Low,
Medium,
High,
Critical,
}
impl RiskLevel {
#[must_use]
pub const fn from_score(score: u8) -> Self {
match score {
0..=25 => Self::Low,
26..=50 => Self::Medium,
51..=75 => Self::High,
_ => Self::Critical,
}
}
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Low => "Low",
Self::Medium => "Medium",
Self::High => "High",
Self::Critical => "Critical",
}
}
}
impl std::fmt::Display for RiskLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.label())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VulnerabilityFindings {
pub total_count: usize,
pub kev_vulnerabilities: Vec<VulnFinding>,
pub critical_vulnerabilities: Vec<VulnFinding>,
pub high_vulnerabilities: Vec<VulnFinding>,
pub medium_vulnerabilities: Vec<VulnFinding>,
pub low_vulnerabilities: Vec<VulnFinding>,
}
impl VulnerabilityFindings {
#[must_use]
pub fn all_findings(&self) -> Vec<&VulnFinding> {
let capacity = self.kev_vulnerabilities.len()
+ self.critical_vulnerabilities.len()
+ self.high_vulnerabilities.len()
+ self.medium_vulnerabilities.len()
+ self.low_vulnerabilities.len();
let mut all = Vec::with_capacity(capacity);
all.extend(self.kev_vulnerabilities.iter());
all.extend(self.critical_vulnerabilities.iter());
all.extend(self.high_vulnerabilities.iter());
all.extend(self.medium_vulnerabilities.iter());
all.extend(self.low_vulnerabilities.iter());
all
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnFinding {
pub id: String,
pub severity: String,
pub cvss_score: Option<f32>,
pub is_kev: bool,
pub is_ransomware_related: bool,
pub kev_due_date: Option<DateTime<Utc>>,
pub component_name: String,
pub component_version: Option<String>,
pub description: Option<String>,
pub remediation: Option<String>,
pub attack_paths: Vec<String>,
pub change_status: Option<String>,
pub analyst_note: Option<String>,
pub is_false_positive: bool,
}
impl VulnFinding {
#[must_use]
pub fn new(id: String, component_name: String) -> Self {
Self {
id,
severity: "Unknown".to_string(),
cvss_score: None,
is_kev: false,
is_ransomware_related: false,
kev_due_date: None,
component_name,
component_version: None,
description: None,
remediation: None,
attack_paths: Vec::new(),
change_status: None,
analyst_note: None,
is_false_positive: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComponentFindings {
pub total_components: usize,
pub added_count: usize,
pub removed_count: usize,
pub stale_components: Vec<StaleComponentFinding>,
pub deprecated_components: Vec<DeprecatedComponentFinding>,
pub license_issues: Vec<LicenseIssueFinding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaleComponentFinding {
pub name: String,
pub version: Option<String>,
pub days_since_update: u32,
pub last_published: Option<DateTime<Utc>>,
pub latest_version: Option<String>,
pub staleness_level: String,
pub analyst_note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeprecatedComponentFinding {
pub name: String,
pub version: Option<String>,
pub deprecation_message: Option<String>,
pub replacement: Option<String>,
pub analyst_note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseIssueFinding {
pub issue_type: LicenseIssueType,
pub severity: IssueSeverity,
pub license_a: String,
pub license_b: Option<String>,
pub affected_components: Vec<String>,
pub description: String,
pub analyst_note: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LicenseIssueType {
BinaryIncompatible,
ProjectIncompatible,
NetworkCopyleft,
PatentConflict,
UnknownLicense,
}
impl std::fmt::Display for LicenseIssueType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BinaryIncompatible => write!(f, "Binary Incompatible"),
Self::ProjectIncompatible => write!(f, "Project Incompatible"),
Self::NetworkCopyleft => write!(f, "Network Copyleft"),
Self::PatentConflict => write!(f, "Patent Conflict"),
Self::UnknownLicense => write!(f, "Unknown License"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum IssueSeverity {
Error,
Warning,
Info,
}
impl std::fmt::Display for IssueSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Error => write!(f, "Error"),
Self::Warning => write!(f, "Warning"),
Self::Info => write!(f, "Info"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CryptoFindings {
pub total_crypto_assets: usize,
pub algorithms_count: usize,
pub certificates_count: usize,
pub keys_count: usize,
pub protocols_count: usize,
pub quantum_readiness_pct: f32,
pub quantum_safe_count: usize,
pub quantum_vulnerable_count: usize,
pub hybrid_pqc_count: usize,
pub weak_algorithms: Vec<CryptoAlgorithmFinding>,
pub expired_certificates: Vec<CryptoCertFinding>,
pub compromised_keys: Vec<CryptoKeyFinding>,
pub deprecation_warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CryptoAlgorithmFinding {
pub name: String,
pub family: Option<String>,
pub quantum_level: Option<u8>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CryptoCertFinding {
pub name: String,
pub expires: Option<String>,
pub days_overdue: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CryptoKeyFinding {
pub name: String,
pub material_type: String,
pub state: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComplianceStatus {
pub level: String,
pub score: u8,
pub total_violations: usize,
pub violations_by_article: Vec<ArticleViolations>,
pub key_issues: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArticleViolations {
pub article: String,
pub description: String,
pub count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalystNote {
pub target_type: NoteTargetType,
pub target_id: Option<String>,
pub note: String,
pub false_positive: bool,
pub severity_override: Option<String>,
pub created_at: DateTime<Utc>,
pub analyst: Option<String>,
}
impl AnalystNote {
#[must_use]
pub fn new(target_type: NoteTargetType, note: String) -> Self {
Self {
target_type,
target_id: None,
note,
false_positive: false,
severity_override: None,
created_at: Utc::now(),
analyst: None,
}
}
#[must_use]
pub fn for_vulnerability(vuln_id: String, note: String) -> Self {
Self {
target_type: NoteTargetType::Vulnerability,
target_id: Some(vuln_id),
note,
false_positive: false,
severity_override: None,
created_at: Utc::now(),
analyst: None,
}
}
#[must_use]
pub fn for_component(component_name: String, note: String) -> Self {
Self {
target_type: NoteTargetType::Component,
target_id: Some(component_name),
note,
false_positive: false,
severity_override: None,
created_at: Utc::now(),
analyst: None,
}
}
#[must_use]
pub const fn mark_false_positive(mut self) -> Self {
self.false_positive = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NoteTargetType {
Vulnerability,
Component,
License,
Cryptography,
General,
}
impl std::fmt::Display for NoteTargetType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Vulnerability => write!(f, "Vulnerability"),
Self::Component => write!(f, "Component"),
Self::License => write!(f, "License"),
Self::Cryptography => write!(f, "Cryptography"),
Self::General => write!(f, "General"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendation {
pub priority: RecommendationPriority,
pub category: RecommendationCategory,
pub title: String,
pub description: String,
pub affected_components: Vec<String>,
pub effort: Option<String>,
}
impl Recommendation {
#[must_use]
pub const fn new(
priority: RecommendationPriority,
category: RecommendationCategory,
title: String,
description: String,
) -> Self {
Self {
priority,
category,
title,
description,
affected_components: Vec::new(),
effort: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum RecommendationPriority {
Critical,
High,
Medium,
Low,
}
impl std::fmt::Display for RecommendationPriority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Critical => write!(f, "Critical"),
Self::High => write!(f, "High"),
Self::Medium => write!(f, "Medium"),
Self::Low => write!(f, "Low"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RecommendationCategory {
Upgrade,
Replace,
Investigate,
Monitor,
AddInfo,
Config,
Cryptography,
}
impl std::fmt::Display for RecommendationCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Upgrade => write!(f, "Upgrade"),
Self::Replace => write!(f, "Replace"),
Self::Investigate => write!(f, "Investigate"),
Self::Monitor => write!(f, "Monitor"),
Self::AddInfo => write!(f, "Add Information"),
Self::Config => write!(f, "Configuration"),
Self::Cryptography => write!(f, "Cryptography"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_risk_level_from_score() {
assert_eq!(RiskLevel::from_score(0), RiskLevel::Low);
assert_eq!(RiskLevel::from_score(25), RiskLevel::Low);
assert_eq!(RiskLevel::from_score(26), RiskLevel::Medium);
assert_eq!(RiskLevel::from_score(50), RiskLevel::Medium);
assert_eq!(RiskLevel::from_score(51), RiskLevel::High);
assert_eq!(RiskLevel::from_score(75), RiskLevel::High);
assert_eq!(RiskLevel::from_score(76), RiskLevel::Critical);
assert_eq!(RiskLevel::from_score(100), RiskLevel::Critical);
}
#[test]
fn test_analyst_note_creation() {
let note = AnalystNote::for_vulnerability(
"CVE-2024-1234".to_string(),
"Mitigated by WAF".to_string(),
);
assert_eq!(note.target_type, NoteTargetType::Vulnerability);
assert_eq!(note.target_id, Some("CVE-2024-1234".to_string()));
assert!(!note.false_positive);
let fp_note = note.mark_false_positive();
assert!(fp_note.false_positive);
}
#[test]
fn test_recommendation_ordering() {
assert!(RecommendationPriority::Critical < RecommendationPriority::High);
assert!(RecommendationPriority::High < RecommendationPriority::Medium);
assert!(RecommendationPriority::Medium < RecommendationPriority::Low);
}
}