250 lines
9.4 KiB
Rust
Executable File
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(())
|
|
}
|