use crate::error::{AffidavitError, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomForensicsBundle {
pub metadata: BundleMetadata,
pub graph: Option<SupplyChainGraph>,
pub vulnerabilities: Vec<VulnerabilityRecord>,
pub compliance: Vec<ComplianceRecord>,
pub risk_propagation: Vec<RiskPropagationRecord>,
pub attestation: Option<AttestationData>,
pub redactions: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleMetadata {
pub version: String,
pub created_at: u64,
pub sbom_id: String,
pub bundle_id: String,
pub sbom_format: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupplyChainGraph {
pub component_count: usize,
pub edge_count: usize,
pub adjacency: HashMap<String, Vec<String>>,
pub components: HashMap<String, ComponentNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentNode {
pub purl: String,
pub name: String,
pub version: String,
pub supplier: Option<String>,
pub licenses: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnerabilityRecord {
pub cve_id: String,
pub affected_component: String,
pub severity: String,
pub cvss_score: Option<f64>,
pub vex_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceRecord {
pub framework: String,
pub passed: bool,
pub score: f64,
pub failures: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskPropagationRecord {
pub component: String,
pub root_cve: String,
pub propagated_to: Vec<String>,
pub blast_radius: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttestationData {
pub slsa_version: String,
pub builder: Option<String>,
pub timestamp: u64,
pub environment_hash: String,
}
pub struct SbomArtifactCollector {
#[allow(dead_code)]
work_dir: PathBuf,
redact_sensitive: bool,
}
impl SbomArtifactCollector {
pub fn new() -> Result<Self> {
let work_dir = std::env::temp_dir().join("affidavit-sbom-artifacts");
std::fs::create_dir_all(&work_dir)?;
Ok(Self {
work_dir,
redact_sensitive: true,
})
}
pub fn with_redaction(mut self, redact: bool) -> Self {
self.redact_sensitive = redact;
self
}
pub fn create_bundle(
&self,
sbom_id: &str,
sbom_format: &str,
description: Option<String>,
) -> SbomForensicsBundle {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let bundle_id = format!("sbom_bundle_{}", timestamp);
SbomForensicsBundle {
metadata: BundleMetadata {
version: "1.0".to_string(),
created_at: timestamp,
sbom_id: sbom_id.to_string(),
bundle_id,
sbom_format: sbom_format.to_string(),
description,
},
graph: None,
vulnerabilities: Vec::new(),
compliance: Vec::new(),
risk_propagation: Vec::new(),
attestation: None,
redactions: HashMap::new(),
}
}
pub fn save_bundle(&self, bundle: &SbomForensicsBundle, path: PathBuf) -> Result<()> {
let content = serde_json::to_string_pretty(bundle)?;
std::fs::write(&path, content)?;
Ok(())
}
pub fn load_bundle(&self, path: PathBuf) -> Result<SbomForensicsBundle> {
if !path.exists() {
return Err(AffidavitError::Execution(
format!("SBOM bundle file not found: {}", path.display()),
));
}
let content = std::fs::read_to_string(&path)?;
let bundle: SbomForensicsBundle = serde_json::from_str(&content)?;
Ok(bundle)
}
fn is_sensitive_value(&self, value: &str) -> bool {
let sensitive_patterns = [
"password", "secret", "token", "key", "credential",
"aws_", "github_", "gitlab_", "docker_",
"private", "api_key",
];
sensitive_patterns
.iter()
.any(|pattern| value.to_lowercase().contains(pattern))
}
pub fn redact_sensitive(&self, value: &str) -> String {
if self.redact_sensitive && self.is_sensitive_value(value) {
"[REDACTED]".to_string()
} else {
value.to_string()
}
}
}
impl Default for SbomArtifactCollector {
fn default() -> Self {
Self::new().unwrap_or_else(|_| Self {
work_dir: std::env::temp_dir().join("affidavit-sbom-artifacts"),
redact_sensitive: true,
})
}
}
pub struct SbomArtifactGuard {
bundle: Option<SbomForensicsBundle>,
path: Option<PathBuf>,
cleanup_actions: Vec<Box<dyn FnOnce() + Send + Sync>>,
}
impl SbomArtifactGuard {
pub fn new(bundle: SbomForensicsBundle) -> Self {
Self {
bundle: Some(bundle),
path: None,
cleanup_actions: Vec::new(),
}
}
pub fn add_cleanup_action<F>(mut self, action: F) -> Self
where
F: FnOnce() + Send + Sync + 'static,
{
self.cleanup_actions.push(Box::new(action));
self
}
pub fn with_path(mut self, path: PathBuf) -> Self {
self.path = Some(path);
self
}
pub fn bundle(&self) -> Option<&SbomForensicsBundle> {
self.bundle.as_ref()
}
pub fn take_bundle(mut self) -> Option<SbomForensicsBundle> {
self.bundle.take()
}
pub fn cleanup(mut self) {
for action in self.cleanup_actions.drain(..) {
action();
}
}
}
impl Drop for SbomArtifactGuard {
fn drop(&mut self) {
while let Some(action) = self.cleanup_actions.pop() {
action();
}
if let Some(ref path) = self.path {
if path.exists() {
let _ = std::fs::remove_file(path);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bundle_creation() {
let collector = SbomArtifactCollector::new().unwrap();
let bundle = collector.create_bundle(
"test-sbom",
"CycloneDX",
Some("Test bundle".to_string()),
);
assert_eq!(bundle.metadata.sbom_id, "test-sbom");
assert_eq!(bundle.metadata.sbom_format, "CycloneDX");
assert!(!bundle.metadata.bundle_id.is_empty());
}
#[test]
fn test_sensitive_redaction() {
let collector = SbomArtifactCollector::new().unwrap();
assert_eq!(collector.redact_sensitive("normal_value"), "normal_value");
assert_eq!(collector.redact_sensitive("api_key_secret"), "[REDACTED]");
assert_eq!(collector.redact_sensitive("password123"), "[REDACTED]");
}
#[test]
fn test_artifact_guard() {
let collector = SbomArtifactCollector::new().unwrap();
let bundle = collector.create_bundle("test", "SPDX", None);
let guard = SbomArtifactGuard::new(bundle).add_cleanup_action(|| {
});
assert!(guard.bundle().is_some());
}
#[test]
fn test_bundle_serialization() {
let collector = SbomArtifactCollector::new().unwrap();
let bundle = collector.create_bundle("test-sbom", "CycloneDX", None);
let json = serde_json::to_string(&bundle).unwrap();
let deserialized: SbomForensicsBundle = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.metadata.sbom_id, bundle.metadata.sbom_id);
}
}