corbel/src/quarantine/mod.rs

250 lines
9.4 KiB
Rust
Executable File

//! 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 `<workspace>/corbel_quarantine/quarantine_<ts>_<sha256_prefix>.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_<ts>_<sha256_prefix>.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<PathBuf>,
/// Extracted payload files (one per malicious finding), keyed by
/// a sanitized filename derived from the finding location.
pub extracted_payload_paths: Vec<PathBuf>,
}
/// 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<QuarantineOutcome> {
// 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<String> {
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.<ext>`.
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(())
}