//! Quarantine: isolation & packaging manager. //! //! When the scanner identifies a malicious finding, the quarantine //! module: //! //! 1. Carves the offending payload out of the document structure //! ([`extractor`]). //! 2. Generates a comprehensive forensic report ([`reporter`]). //! 3. Bundles payload + report into a compressed `tar.gz` archive //! at `/corbel_quarantine/quarantine__.tar.gz`. pub mod extractor; pub mod hexdump; pub mod reporter; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::core::config::Config; use crate::core::types::{Document, ScanReport}; use crate::CorbelResult; /// The outcome of a successful quarantine operation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuarantineOutcome { /// Path to the generated `quarantine__.tar.gz` file. pub tarball_path: PathBuf, /// Path to the JSON forensic report. pub json_report_path: PathBuf, /// Path to the Markdown forensic report (if `emit_markdown_report` was set). pub markdown_report_path: Option, /// Extracted payload files (one per malicious finding), keyed by /// a sanitized filename derived from the finding location. pub extracted_payload_paths: Vec, } /// Top-level quarantine entrypoint. Called by the pipeline when the /// scanner finds at least one malicious finding. pub fn handle( document: &Document, scan_report: &ScanReport, config: &Config, ) -> CorbelResult { // 1. Carve payloads out of the document. // // We pass `config` through so that `payload_path` is rooted at the // caller's quarantine_dir. The no-config `extract_payloads` helper // would fall back to a default Config whose `quarantine_dir` is the // relative path `corbel_quarantine/` — fine in production (where // CWD == workspace) but broken in tests (where the workspace is a // tempdir) and any other embedded use case. let extracted = extractor::extract_payloads_with_config(document, scan_report, config); // 2. Generate reports. let json_report = reporter::build_json_report(document, scan_report, &extracted); let markdown_report = if config.emit_markdown_report { Some(reporter::build_markdown_report(document, scan_report, &extracted)) } else { None }; // 3. Compose the quarantine tarball name. let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S"); let sha_prefix = &document.sha256[..8.min(document.sha256.len())]; let tarball_name = format!("quarantine_{timestamp}_{sha_prefix}.tar.gz"); let tarball_path = config.quarantine_dir.join(&tarball_name); let json_report_name = format!("report_{timestamp}_{sha_prefix}.json"); let json_report_path = config.quarantine_dir.join(&json_report_name); let markdown_report_path = if markdown_report.is_some() { Some(config.quarantine_dir.join(format!( "report_{timestamp}_{sha_prefix}.md" ))) } else { None }; // 4. Write the tarball. write_tarball( &tarball_path, document, &json_report, markdown_report.as_deref(), &extracted, )?; // 5. Write the JSON report as a standalone file (for easy programmatic access). std::fs::write(&json_report_path, serde_json::to_string_pretty(&json_report)?)?; // 6. Write the Markdown report as a standalone file too. if let Some(md) = &markdown_report { if let Some(md_path) = &markdown_report_path { std::fs::write(md_path, md)?; } } // 7. Write standalone payload carving v2 files (.hex + .info). for payload in &extracted { // .hex — annotated hex dump let hex_content = hexdump::hex_dump(&payload.bytes); let hex_path = payload.payload_path.with_extension("hex"); std::fs::write(&hex_path, hex_content)?; // .info — JSON metadata let finding = scan_report .findings .get(payload.source_finding_index); let (classification_str, recommendation_str, context_notes, vector_type_str, cve_tag_str) = if let Some(f) = finding { ( match &f.classification { crate::core::types::ThreatClassification::Benign => "benign".to_string(), crate::core::types::ThreatClassification::EducationalContent => "educational".to_string(), crate::core::types::ThreatClassification::Suspicious => "suspicious".to_string(), crate::core::types::ThreatClassification::Malicious(t) => format!("malicious:{t}"), }, match f.recommendation { crate::core::types::Recommendation::Allow => "allow".to_string(), crate::core::types::Recommendation::WhitelistAsEducational => "whitelist-as-educational".to_string(), crate::core::types::Recommendation::Quarantine => "quarantine".to_string(), crate::core::types::Recommendation::QuarantineAndCleanse => "quarantine-and-cleanse".to_string(), }, f.context_notes.clone(), f.vector_type.map(|v| v.to_string()), extract_cve_tag_from_notes(&f.context_notes), ) } else { (String::new(), String::new(), String::new(), None, None) }; let file_sig = crate::scanner::signatures::match_file_signature(&payload.bytes) .map(|s| s.to_string()); let info = hexdump::build_payload_info( &payload.filename, payload.source_finding_index, vector_type_str.as_deref(), &payload.location_str(), &classification_str, &recommendation_str, &context_notes, &crate::sha256_hex(&payload.bytes), payload.bytes.len(), file_sig.as_deref(), cve_tag_str.as_deref(), ); let info_path = payload.payload_path.with_extension("info"); std::fs::write(&info_path, serde_json::to_string_pretty(&info)?)?; } Ok(QuarantineOutcome { tarball_path, json_report_path, markdown_report_path, extracted_payload_paths: extracted .iter() .map(|p| p.payload_path.clone()) .collect(), }) } /// Extract a CVE tag (e.g. `[CVE-2017-11882: Equation Editor RCE]`) /// from a finding's context_notes string, if present. fn extract_cve_tag_from_notes(notes: &str) -> Option { let start = notes.find('[')?; let end = notes.find(']')?; if start < end { Some(notes[start + 1..end].to_string()) } else { None } } /// Write the quarantine tarball containing: /// - the forensic report (JSON + optional Markdown) /// - each extracted payload /// - a copy of the original file (for chain-of-custody) fn write_tarball( tarball_path: &std::path::Path, document: &Document, json_report: &serde_json::Value, markdown_report: Option<&str>, extracted: &[extractor::ExtractedPayload], ) -> CorbelResult<()> { use std::io::Write; let tar_gz = std::fs::File::create(tarball_path)?; let enc = flate2::write::GzEncoder::new(tar_gz, flate2::Compression::default()); let mut tar = tar::Builder::new(enc); // Add the original file under `original.`. let ext = match document.format { crate::core::types::DocumentFormat::Pdf => "pdf", crate::core::types::DocumentFormat::Epub => "epub", crate::core::types::DocumentFormat::Markdown => "md", crate::core::types::DocumentFormat::Docx => "docx", }; let original_name = format!("original.{ext}"); let mut header = tar::Header::new_gnu(); header.set_size(document.raw_bytes.len() as u64); header.set_mode(0o644); header.set_cksum(); tar.append_data(&mut header, &original_name, std::io::Cursor::new(&document.raw_bytes))?; // Add the JSON report. let json_bytes = serde_json::to_vec_pretty(json_report)?; let mut header = tar::Header::new_gnu(); header.set_size(json_bytes.len() as u64); header.set_mode(0o644); header.set_cksum(); tar.append_data(&mut header, "report.json", std::io::Cursor::new(&json_bytes))?; // Add the Markdown report if present. if let Some(md) = markdown_report { let md_bytes = md.as_bytes(); let mut header = tar::Header::new_gnu(); header.set_size(md_bytes.len() as u64); header.set_mode(0o644); header.set_cksum(); tar.append_data(&mut header, "report.md", std::io::Cursor::new(md_bytes))?; } // Add each extracted payload. for payload in extracted { let bytes = &payload.bytes; let mut header = tar::Header::new_gnu(); header.set_size(bytes.len() as u64); header.set_mode(0o644); header.set_cksum(); // payload.payload_path is the full path under quarantine dir; // we want just the filename inside the tarball. let name = payload .payload_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("payload.bin"); tar.append_data(&mut header, name, std::io::Cursor::new(bytes))?; } // Finalize: flush the tar + gzip encoder. let enc = tar.into_inner()?; let mut file = enc.finish()?; file.flush()?; Ok(()) }