corbel/tests/pipeline_integration.rs

538 lines
20 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());
// Clean documents now produce a "clean" output file in the
// cleanse_dir (the original file copied under
// `clean_<timestamp>_<sha>.pdf`). This makes the scanner a
// proper pipeline stage. The cleansed_path field is overloaded:
// it holds either the cleansed derivative (malicious case) or
// the clean-output copy (clean case).
assert!(
result.cleansed_path.is_some(),
"clean PDF should produce a clean-output file in the cleanse_dir"
);
let clean_path = result.cleansed_path.unwrap();
assert!(
clean_path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("clean_") && n.ends_with(".pdf"))
.unwrap_or(false),
"clean-output file should be named clean_<ts>_<sha>.pdf, got: {clean_path:?}"
);
// The clean-output file should have the same contents as the original.
let original = std::fs::read(fixture("benign.pdf")).unwrap();
let clean_bytes = std::fs::read(&clean_path).unwrap();
assert_eq!(
original, clean_bytes,
"clean-output file should be a byte-for-byte copy of the original"
);
}
#[test]
fn benign_realworld_pdf_produces_zero_findings() {
// This is the corpus-based regression test for the false-positive
// redesign. The fixture (`benign_realworld.pdf`) is a multi-page
// PDF that mimics the structure of a technical book like *Linux
// from Scratch* — it contains 13 hyperlinks covering every URL
// pattern that USED TO produce a false positive:
//
// - https://www.linuxfromscratch.org/
// - https://lists.linuxfromscratch.org/listinfo/lfs-support (keyword "support")
// - http://ftp.osuosl.org/pub/lfs/ (http: scheme)
// - https://github.com/LFS-project/build-scripts (github.com host)
// - mailto:lfs-support@linuxfromscratch.org (mailto: + @ in path)
// - https://www.kernel.org/pub/linux/kernel/
// - tel:+1-555-123-4567 (tel: scheme)
// - https://ftp.ru.debian.org/debian/ (.ru TLD)
// - https://en.wikipedia.org/wiki/Microsoft_Windows (brand in path)
// - https://github.com/microsoft/vscode (brand in path)
// - https://www.ietf.org/rfc/rfc2616.txt
// - https://example.com/account/verify (keywords in path)
// - https://example.com/login (keyword in path)
//
// Plus prose containing: wget, exploit, payload, /bin/sh, PowerShell.
//
// The assertion is the theorem: a clean technical PDF produces ZERO
// findings (not "fewer than N", not "0 malicious but maybe some
// suspicious" — literally zero findings in the report). If any
// finding appears, the detector that produced it is wrong by
// construction, not the document.
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
let result = pipeline.run(fixture("benign_realworld.pdf")).unwrap();
assert_eq!(
result.scan_report.findings.len(),
0,
"benign real-world PDF must produce ZERO findings, got {}: {:#?}",
result.scan_report.findings.len(),
result.scan_report.findings.iter().map(|f| (
f.classification.clone(),
f.context_notes.clone(),
f.payload_preview.chars().take(80).collect::<String>(),
)).collect::<Vec<_>>(),
);
assert_eq!(result.scan_report.malicious_count(), 0);
assert!(result.quarantine_path.is_none());
// The clean-output file should exist (the pipeline-stage behavior).
assert!(
result.cleansed_path.is_some(),
"clean real-world PDF should produce a clean-output file"
);
let clean_path = result.cleansed_path.unwrap();
assert!(
clean_path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("clean_") && n.ends_with(".pdf"))
.unwrap_or(false),
"clean-output file should be named clean_<ts>_<sha>.pdf, got: {clean_path:?}"
);
// The clean-output file should be a byte-for-byte copy of the original.
let original = std::fs::read(fixture("benign_realworld.pdf")).unwrap();
let clean_bytes = std::fs::read(&clean_path).unwrap();
assert_eq!(
original, clean_bytes,
"clean-output file should be a byte-for-byte copy of the original"
);
}
#[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"
);
}