use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Serialize, Deserialize};
use std::fs;
use std::io::Write;
use super::attestation::Attestation;
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForensicsPack {
pub version: String,
pub project_root: PathBuf,
pub env: HashMap<String, String>,
pub stdout: Vec<String>,
pub stderr: Vec<String>,
pub command: Vec<String>,
pub exit_code: Option<i32>,
pub images: HashMap<String, String>,
pub seccomp: Option<SeccompProfile>,
pub coverage: Option<CoverageData>,
pub reproducer: String,
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeccompProfile {
pub default_action: String,
pub syscalls: Vec<String>,
pub architectures: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageData {
pub files: Vec<String>,
pub total_lines: u64,
pub covered_lines: u64,
pub coverage_pct: f64,
}
impl ForensicsPack {
pub fn new(project_root: impl Into<PathBuf>) -> Self {
Self {
version: "1.0".to_string(),
project_root: project_root.into(),
env: HashMap::new(),
stdout: Vec::new(),
stderr: Vec::new(),
command: Vec::new(),
exit_code: None,
images: HashMap::new(),
seccomp: None,
coverage: None,
reproducer: String::new(),
metadata: HashMap::new(),
}
}
pub fn add_env(&mut self, key: impl Into<String>, value: impl Into<String>) {
let key = key.into();
let value = value.into();
let redacted_value = if Self::is_secret(&key) {
Self::redact(&value)
} else {
value
};
self.env.insert(key, redacted_value);
}
pub fn capture_stdout(&mut self, output: impl AsRef<str>) {
let lines = output.as_ref().lines();
for line in lines {
self.stdout.push(Self::redact_line(line));
}
}
pub fn capture_stderr(&mut self, output: impl AsRef<str>) {
let lines = output.as_ref().lines();
for line in lines {
self.stderr.push(Self::redact_line(line));
}
}
pub fn set_command(&mut self, command: Vec<String>) {
self.command = command;
}
pub fn set_exit_code(&mut self, code: i32) {
self.exit_code = Some(code);
}
pub fn add_image(&mut self, name: impl Into<String>, digest: impl Into<String>) {
self.images.insert(name.into(), digest.into());
}
pub fn set_seccomp(&mut self, profile: SeccompProfile) {
self.seccomp = Some(profile);
}
pub fn set_coverage(&mut self, coverage: CoverageData) {
self.coverage = Some(coverage);
}
pub fn add_metadata(&mut self, key: impl Into<String>, value: serde_json::Value) {
self.metadata.insert(key.into(), value);
}
pub fn generate(&self, output_path: impl AsRef<Path>, attestation: &Attestation) -> Result<()> {
let bundle = ForensicsBundle {
forensics: self.clone(),
attestation: attestation.clone(),
};
let json = serde_json::to_string_pretty(&bundle)?;
let mut file = fs::File::create(output_path.as_ref())?;
file.write_all(json.as_bytes())?;
Ok(())
}
pub fn load(path: impl AsRef<Path>) -> Result<ForensicsBundle> {
let json = fs::read_to_string(path)?;
let bundle = serde_json::from_str(&json)?;
Ok(bundle)
}
pub fn generate_reproducer(&mut self) {
let mut script = String::new();
script.push_str("#!/usr/bin/env bash\n");
script.push_str("# Auto-generated reproducer script\n\n");
script.push_str("set -e\n\n");
script.push_str("# Environment (redacted secrets)\n");
for (key, value) in &self.env {
script.push_str(&format!("export {}='{}'\n", key, value));
}
script.push_str("\n# Change to project root\n");
script.push_str(&format!("cd '{}'\n\n", self.project_root.display()));
script.push_str("# Run command\n");
script.push_str(&self.command.join(" "));
script.push('\n');
self.reproducer = script;
}
fn is_secret(key: &str) -> bool {
let key_lower = key.to_lowercase();
key_lower.contains("secret")
|| key_lower.contains("password")
|| key_lower.contains("token")
|| key_lower.contains("key")
|| key_lower.contains("api_")
|| key_lower.contains("auth")
}
fn redact(value: &str) -> String {
if value.is_empty() {
return String::new();
}
let len = value.len();
if len <= 4 {
return "*".repeat(len);
}
format!("{}...{}", &value[..2], &value[len - 2..])
}
fn redact_line(line: &str) -> String {
let mut redacted = line.to_string();
let patterns = [
(r"(sk-[a-zA-Z0-9]{48})", "sk-**REDACTED**"),
(r"(ghp_[a-zA-Z0-9]{36})", "ghp_**REDACTED**"),
(r"(AIza[a-zA-Z0-9]{35})", "AIza**REDACTED**"),
(r"([a-zA-Z0-9]{32})", "**REDACTED**"), ];
for (pattern, replacement) in &patterns {
if let Ok(re) = regex::Regex::new(pattern) {
redacted = re.replace_all(&redacted, *replacement).to_string();
}
}
redacted
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForensicsBundle {
pub forensics: ForensicsPack,
pub attestation: Attestation,
}
impl ForensicsBundle {
pub fn reproducer(&self) -> &str {
&self.forensics.reproducer
}
pub fn extract_reproducer(&self, path: impl AsRef<Path>) -> Result<()> {
fs::write(path, &self.forensics.reproducer)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(&path)?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&path, permissions)?;
}
Ok(())
}
pub fn summary(&self) -> String {
let mut summary = String::new();
summary.push_str("# Forensics Pack Summary\n\n");
summary.push_str(&format!("Policy: {}\n", self.attestation.policy));
summary.push_str(&format!("Determinism: {:.2}\n", self.attestation.determinism_score));
summary.push_str(&format!("Exit Code: {:?}\n", self.forensics.exit_code));
summary.push_str(&format!("Command: {}\n\n", self.forensics.command.join(" ")));
summary.push_str("## Stdout\n");
for line in self.forensics.stdout.iter().take(10) {
summary.push_str(&format!(" {}\n", line));
}
if self.forensics.stdout.len() > 10 {
summary.push_str(&format!(" ... {} more lines\n", self.forensics.stdout.len() - 10));
}
summary.push_str("\n## Stderr\n");
for line in self.forensics.stderr.iter().take(10) {
summary.push_str(&format!(" {}\n", line));
}
if self.forensics.stderr.len() > 10 {
summary.push_str(&format!(" ... {} more lines\n", self.forensics.stderr.len() - 10));
}
summary
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_detection() {
assert!(ForensicsPack::is_secret("SECRET_KEY"));
assert!(ForensicsPack::is_secret("API_TOKEN"));
assert!(ForensicsPack::is_secret("PASSWORD"));
assert!(!ForensicsPack::is_secret("PATH"));
assert!(!ForensicsPack::is_secret("HOME"));
}
#[test]
fn test_secret_redaction() {
assert_eq!(ForensicsPack::redact("sk-1234567890"), "sk...90");
assert_eq!(ForensicsPack::redact("short"), "*****");
}
#[test]
fn test_forensics_pack_creation() {
let mut pack = ForensicsPack::new("/tmp/test");
pack.add_env("PATH", "/usr/bin");
pack.add_env("SECRET_KEY", "sk-1234567890");
assert_eq!(pack.env.get("PATH").unwrap(), "/usr/bin");
assert!(pack.env.get("SECRET_KEY").unwrap().contains("..."));
}
#[test]
fn test_reproducer_generation() {
let mut pack = ForensicsPack::new("/tmp/test");
pack.set_command(vec!["cargo".to_string(), "test".to_string()]);
pack.generate_reproducer();
assert!(pack.reproducer.contains("#!/usr/bin/env bash"));
assert!(pack.reproducer.contains("cargo test"));
}
}