238 lines
9.7 KiB
Rust
Executable File
238 lines
9.7 KiB
Rust
Executable File
//! Configuration: security thresholds and workspace paths.
|
|
//!
|
|
//! All tunable parameters live here so that operators can override them
|
|
//! at runtime via [`Config::override_from_env`] or by constructing a
|
|
//! custom [`Config`] and passing it to [`crate::core::pipeline::Pipeline`].
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Default maximum size for a payload preview string in the threat report.
|
|
const DEFAULT_MAX_PREVIEW_LEN: usize = 512;
|
|
|
|
/// Default maximum number of bytes of a ZIP-container entry (EPUB or
|
|
/// DOCX) to load into memory. Enforced via [`crate::util::read_with_cap`]
|
|
/// which counts **actual decompressed bytes** — not the size declared
|
|
/// in the ZIP central directory — so it defends against lying-size
|
|
/// zip bombs as well as honest-but-oversized entries.
|
|
const DEFAULT_EPUB_ENTRY_SCAN_CAP: usize = 8 * 1024 * 1024; // 8 MiB
|
|
|
|
/// Default total memory budget for all ZIP-container entries combined.
|
|
/// Prevents the "many small entries" attack where an archive with
|
|
/// thousands of entries each under the per-entry cap still exhausts memory.
|
|
const DEFAULT_TOTAL_ARCHIVE_SCAN_CAP: usize = 256 * 1024 * 1024; // 256 MiB
|
|
|
|
/// Default quarantine directory, relative to the current working dir.
|
|
const DEFAULT_QUARANTINE_DIR: &str = "corbel_quarantine";
|
|
|
|
/// Default cleansed-output directory, relative to the current working dir.
|
|
const DEFAULT_CLEANSE_DIR: &str = "corbel_clean";
|
|
|
|
/// How the cleansed document should be produced.
|
|
///
|
|
/// The default (`Markdown`) is the safest option — a Markdown file
|
|
/// cannot carry executable content by definition. `PreserveFormat`
|
|
/// repackages the document in its original format (PDF/EPUB/DOCX)
|
|
/// with the malicious entries stripped, which is more useful when
|
|
/// the document itself is the artifact of interest (e.g. a book
|
|
/// the researcher wants to actually read after cleaning).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub enum CleanseMode {
|
|
/// Produce a sanitized Markdown derivative (default, always safe).
|
|
#[default]
|
|
Markdown,
|
|
/// Repackage the document in its original format with malicious
|
|
/// entries stripped. The output is structurally similar to the
|
|
/// input but with all executable vectors removed.
|
|
PreserveFormat,
|
|
}
|
|
|
|
/// Top-level configuration for the CorbelPurge pipeline.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Config {
|
|
/// Where to write quarantine tarballs.
|
|
pub quarantine_dir: PathBuf,
|
|
/// Where to write cleansed document derivatives.
|
|
pub cleanse_dir: PathBuf,
|
|
/// Maximum length (in chars) of any payload preview in the report.
|
|
pub max_payload_preview_len: usize,
|
|
/// Maximum size (in bytes) of a single ZIP-container entry (EPUB
|
|
/// or DOCX) that will be loaded into memory. Entries that exceed
|
|
/// this cap are truncated mid-stream by [`crate::util::read_with_cap`]
|
|
/// and emitted as `UnknownPayload` vectors — the partial bytes are
|
|
/// still inspected for file signatures (PE, ELF, OLE2, etc.).
|
|
///
|
|
/// **This cap counts actual decompressed bytes, not the size
|
|
/// declared in the ZIP header.** A malicious archive that
|
|
/// declares `size = 100` but actually decompresses to gigabytes
|
|
/// is detected and truncated at this cap.
|
|
pub epub_entry_scan_cap: usize,
|
|
/// Total memory budget (in bytes) for all ZIP-container entries combined.
|
|
/// The parser tracks cumulative decompressed bytes across all entries
|
|
/// and stops processing entries once this budget is exhausted.
|
|
/// This prevents the "many small entries" attack where thousands of
|
|
/// entries each under `epub_entry_scan_cap` still exhaust memory.
|
|
pub total_archive_scan_cap: usize,
|
|
/// If `true`, the pipeline aborts with [`crate::CorbelError::ThreatDetected`]
|
|
/// the moment a malicious finding is produced, instead of proceeding
|
|
/// to quarantine + cleanse. Useful for automated CI gates.
|
|
pub abort_on_threat: bool,
|
|
/// If `true`, the scanner will emit `Suspicious` findings for any
|
|
/// executable vector it cannot confidently classify. If `false`,
|
|
/// only confidently-malicious vectors are reported.
|
|
pub emit_suspicious: bool,
|
|
/// List of URI schemes that are considered safe for hyperlink
|
|
/// navigation (e.g. `https`, `mailto`). Anything else is flagged.
|
|
pub allowed_uri_schemes: Vec<String>,
|
|
/// If `true`, generate a Markdown report alongside the JSON report.
|
|
pub emit_markdown_report: bool,
|
|
/// Path to an external YARA rules file. If set, the scanner loads
|
|
/// additional signature rules from this file at startup.
|
|
pub external_rules_path: Option<PathBuf>,
|
|
/// Path to an external CVE signature database file (JSON array).
|
|
pub external_cve_db_path: Option<PathBuf>,
|
|
/// How to produce the cleansed document derivative.
|
|
pub cleanse_mode: CleanseMode,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
Self {
|
|
quarantine_dir: PathBuf::from(DEFAULT_QUARANTINE_DIR),
|
|
cleanse_dir: PathBuf::from(DEFAULT_CLEANSE_DIR),
|
|
max_payload_preview_len: DEFAULT_MAX_PREVIEW_LEN,
|
|
epub_entry_scan_cap: DEFAULT_EPUB_ENTRY_SCAN_CAP,
|
|
total_archive_scan_cap: DEFAULT_TOTAL_ARCHIVE_SCAN_CAP,
|
|
abort_on_threat: false,
|
|
emit_suspicious: true,
|
|
allowed_uri_schemes: vec![
|
|
"https".to_string(),
|
|
"mailto".to_string(),
|
|
"ftp".to_string(),
|
|
],
|
|
emit_markdown_report: true,
|
|
external_rules_path: None,
|
|
external_cve_db_path: None,
|
|
cleanse_mode: CleanseMode::Markdown,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
/// Construct a new config with default values, but with the
|
|
/// quarantine and cleanse directories rooted at `base`.
|
|
#[must_use]
|
|
pub fn with_workspace(base: impl AsRef<Path>) -> Self {
|
|
let base = base.as_ref();
|
|
Self {
|
|
quarantine_dir: base.join(DEFAULT_QUARANTINE_DIR),
|
|
cleanse_dir: base.join(DEFAULT_CLEANSE_DIR),
|
|
..Self::default()
|
|
}
|
|
}
|
|
|
|
/// Override select fields from environment variables.
|
|
///
|
|
/// Recognized variables:
|
|
/// - `CORBEL_QUARANTINE_DIR`
|
|
/// - `CORBEL_CLEANSE_DIR`
|
|
/// - `CORBEL_ABORT_ON_THREAT` (`1`/`true`/`yes` → true)
|
|
/// - `CORBEL_EMIT_SUSPICIOUS`
|
|
/// - `CORBEL_EMIT_MARKDOWN_REPORT`
|
|
/// - `CORBEL_EXTERNAL_RULES` (path to external YARA rules JSON)
|
|
/// - `CORBEL_EXTERNAL_CVE_DB` (path to external CVE DB JSON)
|
|
pub fn override_from_env(mut self) -> Self {
|
|
if let Ok(v) = std::env::var("CORBEL_QUARANTINE_DIR") {
|
|
self.quarantine_dir = PathBuf::from(v);
|
|
}
|
|
if let Ok(v) = std::env::var("CORBEL_CLEANSE_DIR") {
|
|
self.cleanse_dir = PathBuf::from(v);
|
|
}
|
|
if let Ok(v) = std::env::var("CORBEL_ABORT_ON_THREAT") {
|
|
self.abort_on_threat = truthy(&v);
|
|
}
|
|
if let Ok(v) = std::env::var("CORBEL_EMIT_SUSPICIOUS") {
|
|
self.emit_suspicious = truthy(&v);
|
|
}
|
|
if let Ok(v) = std::env::var("CORBEL_EMIT_MARKDOWN_REPORT") {
|
|
self.emit_markdown_report = truthy(&v);
|
|
}
|
|
if let Ok(v) = std::env::var("CORBEL_TOTAL_ARCHIVE_SCAN_CAP") {
|
|
if let Ok(cap) = v.parse::<usize>() {
|
|
self.total_archive_scan_cap = cap;
|
|
}
|
|
}
|
|
if let Ok(v) = std::env::var("CORBEL_EXTERNAL_RULES") {
|
|
if !v.is_empty() {
|
|
self.external_rules_path = Some(PathBuf::from(v));
|
|
}
|
|
}
|
|
if let Ok(v) = std::env::var("CORBEL_EXTERNAL_CVE_DB") {
|
|
if !v.is_empty() {
|
|
self.external_cve_db_path = Some(PathBuf::from(v));
|
|
}
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Ensure the workspace directories exist.
|
|
///
|
|
/// Called automatically by [`crate::core::pipeline::Pipeline::run`],
|
|
/// but exposed publicly for callers that want to pre-create them.
|
|
pub fn ensure_workspace(&self) -> std::io::Result<()> {
|
|
std::fs::create_dir_all(&self.quarantine_dir)?;
|
|
std::fs::create_dir_all(&self.cleanse_dir)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn truthy(v: &str) -> bool {
|
|
matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn defaults_are_sane() {
|
|
let c = Config::default();
|
|
assert!(c.quarantine_dir.ends_with(DEFAULT_QUARANTINE_DIR));
|
|
assert!(c.cleanse_dir.ends_with(DEFAULT_CLEANSE_DIR));
|
|
assert!(!c.abort_on_threat);
|
|
assert!(c.emit_suspicious);
|
|
assert!(c.allowed_uri_schemes.contains(&"https".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn env_override_works() {
|
|
// Temporarily set env vars, build config, restore.
|
|
let old_q = std::env::var("CORBEL_QUARANTINE_DIR").ok();
|
|
let old_a = std::env::var("CORBEL_ABORT_ON_THREAT").ok();
|
|
|
|
std::env::set_var("CORBEL_QUARANTINE_DIR", "/tmp/corbel_q_test");
|
|
std::env::set_var("CORBEL_ABORT_ON_THREAT", "yes");
|
|
|
|
let c = Config::default().override_from_env();
|
|
assert_eq!(c.quarantine_dir, PathBuf::from("/tmp/corbel_q_test"));
|
|
assert!(c.abort_on_threat);
|
|
|
|
match old_q {
|
|
Some(v) => std::env::set_var("CORBEL_QUARANTINE_DIR", v),
|
|
None => std::env::remove_var("CORBEL_QUARANTINE_DIR"),
|
|
}
|
|
match old_a {
|
|
Some(v) => std::env::set_var("CORBEL_ABORT_ON_THREAT", v),
|
|
None => std::env::remove_var("CORBEL_ABORT_ON_THREAT"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_with_base() {
|
|
let c = Config::with_workspace("/tmp/corbel_ws_test");
|
|
assert_eq!(c.quarantine_dir, PathBuf::from("/tmp/corbel_ws_test/corbel_quarantine"));
|
|
assert_eq!(c.cleanse_dir, PathBuf::from("/tmp/corbel_ws_test/corbel_clean"));
|
|
}
|
|
}
|