corbel/src/core/config.rs

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"));
}
}