corbel/tests/pipeline_integration.rs

444 lines
16 KiB
Rust
Executable File

// ---------------------------------------------------------------------------
// Pipeline integration tests
// ---------------------------------------------------------------------------
use std::path::PathBuf;
use corbel_purge::{Config, CleanseMode, DocumentFormat, Pipeline};
use corbel_purge::core::types::ThreatClassification;
use corbel_purge::parsers::{Dispatcher, DocumentParser};
use tempfile::tempdir;
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn fixture(name: &str) -> PathBuf {
fixtures_dir().join(name)
}
// ---------------------------------------------------------------------------
// Benign documents: no findings, no quarantine, no cleanse
// ---------------------------------------------------------------------------
#[test]
fn benign_pdf_produces_no_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("benign.pdf")).unwrap();
assert_eq!(result.scan_report.malicious_count(), 0);
assert!(result.quarantine_path.is_none());
assert!(result.cleansed_path.is_none());
}
#[test]
fn benign_epub_produces_no_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("benign.epub")).unwrap();
assert_eq!(result.scan_report.malicious_count(), 0);
assert!(result.quarantine_path.is_none());
}
#[test]
fn benign_docx_produces_no_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("benign.docx")).unwrap();
assert_eq!(result.scan_report.malicious_count(), 0);
assert!(result.quarantine_path.is_none());
}
#[test]
fn benign_markdown_produces_no_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("benign.md")).unwrap();
assert_eq!(result.scan_report.malicious_count(), 0);
assert!(result.quarantine_path.is_none());
}
// ---------------------------------------------------------------------------
// Malicious documents: findings, quarantine, and cleanse
// ---------------------------------------------------------------------------
#[test]
fn malicious_pdf_js_produces_findings_and_quarantine() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("malicious_js.pdf")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
assert!(result.quarantine_path.is_some());
assert!(result.cleansed_path.is_some());
}
#[test]
fn malicious_pdf_launch_produces_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("malicious_launch.pdf")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
assert!(result.quarantine_path.is_some());
}
#[test]
fn malicious_epub_script_produces_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("malicious.epub")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
assert!(result.quarantine_path.is_some());
}
#[test]
fn malicious_md_xss_produces_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("malicious.md")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
}
#[test]
fn malicious_docx_macro_produces_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("malicious_macro.docx")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
assert!(result.quarantine_path.is_some());
}
#[test]
fn malicious_docx_ole_produces_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("malicious_ole.docx")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
assert!(result.quarantine_path.is_some());
}
#[test]
fn malicious_docx_link_produces_findings() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("malicious_link.docx")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
}
// ---------------------------------------------------------------------------
// PreserveFormat repackage
// ---------------------------------------------------------------------------
#[test]
fn preserve_format_epub_strips_script_entry() {
let tmp = tempdir().unwrap();
let mut config = Config::with_workspace(tmp.path());
config.cleanse_mode = CleanseMode::PreserveFormat;
let pipeline = Pipeline::with_config(config);
let result = pipeline.run(fixture("malicious.epub")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
let cleansed = result.cleansed_path.expect("cleansed EPUB should exist");
let content = std::fs::read(&cleansed).unwrap();
let content_str = String::from_utf8_lossy(&content);
// The repackaged EPUB should not contain the malicious script.
assert!(
!content_str.contains("alert"),
"repackaged EPUB should not contain the script payload"
);
// Output should be a valid ZIP.
let _archive = zip::ZipArchive::new(std::io::Cursor::new(&content))
.expect("cleansed EPUB should be a valid ZIP");
}
#[test]
fn preserve_format_docx_strips_macro() {
let tmp = tempdir().unwrap();
let mut config = Config::with_workspace(tmp.path());
config.cleanse_mode = CleanseMode::PreserveFormat;
let pipeline = Pipeline::with_config(config);
let result = pipeline.run(fixture("malicious_macro.docx")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
let cleansed = result.cleansed_path.expect("cleansed DOCX should exist");
let content = std::fs::read(&cleansed).unwrap();
let content_str = String::from_utf8_lossy(&content);
assert!(
!content_str.contains("vbaProject"),
"repackaged DOCX should not contain VBA project"
);
}
// ---------------------------------------------------------------------------
// PDF PreserveFormat repackage tests (v0.3.0)
// ---------------------------------------------------------------------------
#[test]
fn preserve_format_pdf_strips_javascript() {
let tmp = tempdir().unwrap();
let mut config = Config::with_workspace(tmp.path());
config.cleanse_mode = corbel_purge::CleanseMode::PreserveFormat;
let pipeline = Pipeline::with_config(config);
let result = pipeline.run(fixture("malicious_js.pdf")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
let cleansed = result.cleansed_path.expect("cleansed PDF should exist");
let content = std::fs::read(&cleansed).unwrap();
let content_str = String::from_utf8_lossy(&content);
// The JavaScript action payload should be gone.
assert!(
!content_str.contains("app.alert"),
"repackaged PDF should not contain the JS payload, got:\n{}",
content_str
);
}
#[test]
fn preserve_format_pdf_strips_launch() {
let tmp = tempdir().unwrap();
let mut config = Config::with_workspace(tmp.path());
config.cleanse_mode = corbel_purge::CleanseMode::PreserveFormat;
let pipeline = Pipeline::with_config(config);
let result = pipeline.run(fixture("malicious_launch.pdf")).unwrap();
assert!(result.scan_report.malicious_count() > 0);
let cleansed = result.cleansed_path.expect("cleansed PDF should exist");
let content = std::fs::read(&cleansed).unwrap();
let content_str = String::from_utf8_lossy(&content);
// The /Launch action should be removed from the catalog.
assert!(
!content_str.contains("/Launch"),
"repackaged PDF should not contain /Launch action"
);
}
// ---------------------------------------------------------------------------
// Abort-on-threat
// ---------------------------------------------------------------------------
#[test]
fn abort_on_threat_returns_error_for_malicious() {
let tmp = tempdir().unwrap();
let mut config = Config::with_workspace(tmp.path());
config.abort_on_threat = true;
let pipeline = Pipeline::with_config(config);
let result = pipeline.run(fixture("malicious_js.pdf"));
assert!(result.is_err(), "abort-on-threat should return Err for malicious PDF");
let err = result.unwrap_err().to_string();
assert!(
err.contains("threat detected"),
"error should mention threat, got: {err}"
);
}
#[test]
fn abort_on_threat_succeeds_for_benign() {
let tmp = tempdir().unwrap();
let mut config = Config::with_workspace(tmp.path());
config.abort_on_threat = true;
let pipeline = Pipeline::with_config(config);
let result = pipeline.run(fixture("benign.pdf"));
assert!(result.is_ok(), "abort-on-threat should succeed for benign PDF");
}
// ---------------------------------------------------------------------------
// Educational whitelisting
// ---------------------------------------------------------------------------
#[test]
fn cve_writeup_md_whitelisted_as_educational() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("cve_writeup.md")).unwrap();
// CVE writeups should produce educational findings, not malicious.
let has_malicious = result
.scan_report
.findings
.iter()
.any(|f| matches!(f.classification, ThreatClassification::Malicious(_)));
assert!(!has_malicious, "CVE writeup should not produce malicious findings");
}
#[test]
fn cve_writeup_pdf_whitelisted_as_educational() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("cve_writeup.pdf")).unwrap();
let has_malicious = result
.scan_report
.findings
.iter()
.any(|f| matches!(f.classification, ThreatClassification::Malicious(_)));
assert!(!has_malicious, "CVE writeup PDF should not produce malicious findings");
}
// ---------------------------------------------------------------------------
// Report content verification
// ---------------------------------------------------------------------------
#[test]
fn json_report_contains_schema_version() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("malicious_js.pdf")).unwrap();
let report_path = result
.json_report_path
.expect("JSON report should exist for malicious PDF");
let report_str = std::fs::read_to_string(&report_path).unwrap();
let report: serde_json::Value = serde_json::from_str(&report_str).unwrap();
assert_eq!(
report["schema_version"], 1,
"report should have schema_version = 1"
);
assert!(
report["findings"].as_array().unwrap().len() > 0,
"report should have at least one finding"
);
assert!(
report["source"].as_object().is_some(),
"report should have a source object"
);
}
#[test]
fn quarantine_tarball_contains_original_file() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("malicious_js.pdf")).unwrap();
let tarball_path = result
.quarantine_path
.expect("quarantine tarball should exist");
let tarball_gz = std::fs::File::open(&tarball_path).unwrap();
let decoder = flate2::read::GzDecoder::new(tarball_gz);
let mut archive = tar::Archive::new(decoder);
let entries: Vec<String> = archive
.entries()
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path().unwrap().to_string_lossy().to_string())
.collect();
assert!(
entries.iter().any(|e| e.starts_with("original.")),
"quarantine tarball should contain original.pdf, entries: {entries:?}"
);
assert!(
entries.iter().any(|e| e == "report.json"),
"quarantine tarball should contain report.json, entries: {entries:?}"
);
}
// ---------------------------------------------------------------------------
// Total-archive-scan-cap (TODO #8)
// ---------------------------------------------------------------------------
#[test]
fn total_archive_cap_limits_cumulative_reads() {
// Parse an EPUB with a very low total cap.
let bytes = std::fs::read(fixture("benign.epub")).unwrap();
let mut config = Config::default();
config.total_archive_scan_cap = 100; // 100 bytes — absurdly low.
let doc = Dispatcher::parse(&bytes, DocumentFormat::Epub, None, &config).unwrap();
// Some entries should have been skipped due to budget exhaustion.
let has_budget_msg = doc
.executable_vectors
.iter()
.any(|v| {
v.decoded_preview
.as_deref()
.map(|p| p.contains("total-archive-budget exhausted"))
.unwrap_or(false)
});
assert!(
has_budget_msg,
"with 100-byte total cap, some entries should be budget-exhausted"
);
}
// ---------------------------------------------------------------------------
// Payload carving v2: .hex and .info files
// ---------------------------------------------------------------------------
#[test]
fn quarantine_produces_hex_and_info_files() {
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let _result = pipeline.run(fixture("malicious_js.pdf")).unwrap();
let q_dir = tmp.path().join("corbel_quarantine");
let hex_files: Vec<_> = std::fs::read_dir(&q_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext == "hex")
.unwrap_or(false)
})
.collect();
assert!(
!hex_files.is_empty(),
"quarantine should produce at least one .hex file"
);
// Verify the .hex file has the xxd-style format.
let first_hex = std::fs::read_to_string(hex_files[0].path()).unwrap();
assert!(
first_hex.starts_with("00000000:"),
".hex file should start with offset, got: {first_hex:?}"
);
// Verify .info files exist too.
let info_files: Vec<_> = std::fs::read_dir(&q_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext == "info")
.unwrap_or(false)
})
.collect();
assert!(
!info_files.is_empty(),
"quarantine should produce at least one .info file"
);
// Verify the .info file is valid JSON with required fields.
let first_info_str = std::fs::read_to_string(info_files[0].path()).unwrap();
let info: serde_json::Value = serde_json::from_str(&first_info_str).unwrap();
assert!(info.get("filename").is_some(), ".info should have filename");
assert!(
info.get("payload_sha256").is_some(),
".info should have payload_sha256"
);
assert!(
info.get("payload_size_bytes").is_some(),
".info should have payload_size_bytes"
);
assert!(
info.get("classification").is_some(),
".info should have classification"
);
}