//! Layer 3: Archive / compression-bomb safety checks. //! //! Two families of check: //! 1. **ZIP archives**, inspected for excessive compression ratios, deeply //! nested archives, path-traversal entry names, and unreasonable //! uncompressed sizes. ZIPs are detected both by the offset-0 local-file //! header AND by an end-of-central-directory scan, so a prefixed / self- //! extracting ZIP (a stub prepended to the archive) can't slip past. //! 2. **Single-stream compressors**, gzip, bzip2, xz, and zstd. These are //! the common standalone decompression-bomb vectors (`.gz`, `.tar.gz`, //! `.bz2`, `.xz`, `.zst`). The stream is decompressed and the produced //! bytes are counted against the same size + ratio caps as ZIP, with an //! early exit so a bomb is rejected after ~`MAX_RATIO`× its input rather //! than fully expanded. //! //! Formats we cannot introspect in-process (7z, RAR, container formats with //! no lightweight pure-checker) are NOT bomb-checked here; they fall through to //! ClamAV. A raw (uncompressed) tar carries no decompression amplification, so //! a tar bomb only matters as `.tar.gz`, which the gzip path already covers. use std::io::{Cursor, Read}; use crate::constants; use crate::storage::FileType; use super::{ErrorPolicy, LayerResult, LayerVerdict}; /// In-process deterministic layer. Parser / decompression errors fail closed /// because they indicate either a corrupt archive or an evasion attempt, both /// warrant a human look rather than an automatic pass. pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailClosed; /// A recognized compressed/archive container we know how to inspect. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ArchiveKind { Zip, Gzip, Bzip2, Xz, Zstd, } impl ArchiveKind { fn label(self) -> &'static str { match self { ArchiveKind::Zip => "ZIP", ArchiveKind::Gzip => "gzip", ArchiveKind::Bzip2 => "bzip2", ArchiveKind::Xz => "xz", ArchiveKind::Zstd => "zstd", } } } /// Classify by leading magic bytes. ZIP is handled separately (it can be /// detected by a trailing end-of-central-directory record too), so this only /// reports the single-stream compressors plus the offset-0 ZIP fast path. fn detect_kind(magic: &[u8]) -> Option { match magic { [0x50, 0x4B, 0x03, 0x04, ..] => Some(ArchiveKind::Zip), [0x1F, 0x8B, ..] => Some(ArchiveKind::Gzip), [0x42, 0x5A, 0x68, ..] => Some(ArchiveKind::Bzip2), // "BZh" [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, ..] => Some(ArchiveKind::Xz), [0x28, 0xB5, 0x2F, 0xFD, ..] => Some(ArchiveKind::Zstd), _ => None, } } /// Recognize containers we have no pure-Rust decompression-bomb checker for /// (7z, RAR). Returns the container label so the caller can reject them where /// they aren't a legitimate download payload, rather than passing them through /// on the ClamAV (FailOpen) layer alone (ultra-fuzz Run 6 R6-Sec-L2). /// /// Scans the WHOLE buffer for the signature, not just offset 0 (Sec-S1, Run 7): /// a prefixed/polyglot container, e.g. `[PNG header][7z payload]`, sniffs as an /// image (passing the content-type layer) and would otherwise carry its 7z magic /// past offset 0, skipping this rejection and falling through to ClamAV (FailOpen) /// alone. This mirrors `has_zip_eocd`'s window scan for prefixed ZIPs. The 6-byte /// signatures (with the unusual `BC AF` / `1A 07` bytes) make a false match on /// benign image data astronomically unlikely; a false positive only costs a /// held-for-review, never a bypass. The caller gates this on `file_type` so the /// scan never runs over a large Download buffer. fn detect_unsupported_container(data: &[u8]) -> Option<&'static str> { // 7z: "7z\xBC\xAF\x27\x1C". const SEVENZ: &[u8] = &[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]; // RAR4 ("Rar!\x1a\x07\x00") and RAR5 ("Rar!\x1a\x07\x01\x00") share this prefix. const RAR: &[u8] = &[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]; if contains_window(data, SEVENZ) { Some("7z") } else if contains_window(data, RAR) { Some("RAR") } else { None } } /// True if `needle` appears anywhere in `haystack`. fn contains_window(haystack: &[u8], needle: &[u8]) -> bool { haystack.windows(needle.len()).any(|w| w == needle) } /// ZIP end-of-central-directory signature (`PK\x05\x06`). A valid ZIP always /// ends with this record (plus an optional trailing comment), even when bytes /// are prepended ahead of the first local header (self-extracting archives). /// Scanning the tail catches those prefixed ZIPs that `detect_kind` misses. fn has_zip_eocd(data: &[u8]) -> bool { const EOCD: [u8; 4] = [0x50, 0x4B, 0x05, 0x06]; // The EOCD sits within the last 22 bytes + up to 64 KiB of comment. let window = 22 + u16::MAX as usize; let start = data.len().saturating_sub(window); data[start..].windows(EOCD.len()).any(|w| w == EOCD) } /// Check a file for archive / decompression-bomb safety issues. /// Runs regardless of claimed type so a disguised archive is still inspected. pub fn check_archive_safety(data: &[u8], file_type: FileType) -> LayerResult { inspect_archive(data, file_type, None).0 } /// Outcome of buffering a decompressed entry's prefix for the interior scan. enum ContentBuf { /// Fully buffered within the per-entry ceiling and shared budget. Buffered(Vec), /// Decompressed size exceeded `INTERIOR_ENTRY_MAX`, cannot content-scan. Overflow, /// The shared interior budget was exhausted before this entry finished. BudgetExceeded, } /// Byte limit at which `tee_decompress` should stop expanding a stream for bomb /// accounting: the smaller of the 2 GB absolute cap and `compressed * MAX_RATIO`. /// A stream that blows past its ratio budget is flagged as a bomb regardless, so /// there is no reason to keep decompressing it up to the full 2 GB first. Falls /// back to the absolute cap when the compressed size is unknown (0). fn entry_bomb_stop_limit(compressed_size: u64) -> u64 { if compressed_size == 0 { return constants::SCAN_ZIP_MAX_UNCOMPRESSED; } let ratio_limit = compressed_size.saturating_mul(constants::SCAN_ZIP_MAX_RATIO as u64); constants::SCAN_ZIP_MAX_UNCOMPRESSED.min(ratio_limit) } /// Decompress `reader` to completion (or until well past the bomb cap), /// returning the FULL decompressed byte count (for bomb accounting) and, when /// `want_content` is set, the buffered prefix (≤ `INTERIOR_ENTRY_MAX`, charged /// against `budget`) for the interior content scan. A single decompression now /// serves both the bomb-defense walk and the interior scan; they previously /// decompressed every entry independently (Run #2 Performance SERIOUS). /// /// `Err(detail)` is a mid-stream decode failure: the caller decides what that /// means for each dimension (ZIP bomb accounting uses a conservative estimate /// and continues; a single-stream compressor fails closed). fn tee_decompress( reader: &mut dyn Read, bomb_abs_limit: u64, want_content: bool, budget: &mut u64, ) -> Result<(u64, Option), (u64, String)> { let mut counted: u64 = 0; let mut buf = [0u8; 8192]; let mut content = if want_content { Some(ContentBuf::Buffered(Vec::new())) } else { None }; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { counted += n as u64; if let Some(ContentBuf::Buffered(ref mut v)) = content { if v.len() + n > INTERIOR_ENTRY_MAX { content = Some(ContentBuf::Overflow); } else if (n as u64) > *budget { content = Some(ContentBuf::BudgetExceeded); } else { *budget -= n as u64; v.extend_from_slice(&buf[..n]); } } // Stop once the bomb cap is blown AND there's no more content to // buffer, reading further serves neither dimension. if counted > bomb_abs_limit && !matches!(content, Some(ContentBuf::Buffered(_))) { break; } } Err(e) => return Err((counted, format!("{e}"))), } } Ok((counted, content)) } /// Single-pass archive inspection. Decompresses each top-level entry ONCE and /// derives BOTH the bomb-defense verdict (`"archive"`) and the interior content /// verdict (`"archive_nested"`) from the same decompression. `check_archive_safety`, /// `check_archive_safety_path`, and `scan_nested_contents` all delegate here so /// the two layers can never drift apart. /// /// `yara_rules == None` still runs the interior content scan (content-type + /// structural layers); it only skips the YARA sub-check. Callers that want bomb /// defense only (`check_archive_safety`) take the first element and discard the /// second. pub fn inspect_archive( data: &[u8], file_type: FileType, yara_rules: Option<&yara_x::Rules>, ) -> (LayerResult, LayerResult) { // 7z / RAR have no pure-Rust bomb checker, so without this they would fall // through to detect_kind == None and rely on ClamAV (FailOpen) alone. Reject // them (FailClosed → held for review) anywhere they aren't a legitimate // download payload; Download keeps the ClamAV backstop (R6-Sec-L2). // Gate on file_type FIRST so the whole-buffer signature scan never runs over a // large Download buffer (Download keeps the ClamAV backstop, R6-Sec-L2). if file_type != FileType::Download && let Some(container) = detect_unsupported_container(data) { let detail = format!( "{container} archives are not accepted for this upload type (cannot be decompression-bomb inspected)" ); // Outer layer is FailClosed, so returning Error (not Skip) aligns the // nested verdict with the policy: the file was rejected before interior // scanning, not merely left unscanned. return (error(detail.clone()), nested(LayerVerdict::Error, detail)); } // A cover-disguised archive: layer 1 (content_type) handles the type // mismatch, so the bomb walk is skipped, but the interior is still scanned // (the nested layer never gated on file type). `bomb` off, `content` on. let bomb = file_type != FileType::Cover; match detect_kind(data) { Some(ArchiveKind::Zip) => walk_zip(Cursor::new(data), bomb, true, yara_rules), Some(stream) => walk_compressed( stream, data.len() as u64, Cursor::new(data), bomb, true, yara_rules, ), None if has_zip_eocd(data) => { // Prefixed / self-extracting ZIP: no offset-0 magic, but a real // central directory at the tail. ZipArchive locates it from the end. walk_zip(Cursor::new(data), bomb, true, yara_rules) } None => ( if bomb { skip("Not a recognized archive") } else { skip("Archive check skipped for cover images") }, nested( LayerVerdict::Skip, "Not an archive; no interior to scan".to_string(), ), ), } } /// Bomb-defense result to surface when the bomb dimension is disabled (covers). fn bomb_disabled_archive() -> LayerResult { skip("Archive check skipped for cover images") } /// Walk a ZIP once, computing the bomb-defense verdict (when `bomb`) and the /// interior content verdict (when `content`). Returns `(archive, archive_nested)`. fn walk_zip( reader: R, bomb: bool, content: bool, yara_rules: Option<&yara_x::Rules>, ) -> (LayerResult, LayerResult) { let mut archive = match zip::ZipArchive::new(reader) { Ok(a) => a, Err(e) => { return ( if bomb { LayerResult { layer: "archive", verdict: LayerVerdict::Error, detail: Some(format!("Failed to parse ZIP: {e}")), } } else { bomb_disabled_archive() }, if content { nested(LayerVerdict::Error, format!("cannot open nested ZIP: {e}")) } else { nested(LayerVerdict::Skip, String::new()) }, ); } }; let count = archive.len(); // `archive_res`/`nested_res` freeze the first non-clean verdict for each // dimension; the loop keeps running for the OTHER dimension so a bomb Fail // and a content Fail are both surfaced (and a later bomb can still upgrade // a merely-held file to a quarantine). let mut archive_res: Option = None; let mut nested_res: Option = None; if count > constants::SCAN_ZIP_MAX_ENTRIES { if bomb { archive_res = Some(LayerResult { layer: "archive", verdict: LayerVerdict::Fail, detail: Some(format!( "ZIP entry count {count} exceeds limit {}", constants::SCAN_ZIP_MAX_ENTRIES )), }); } if content { nested_res = Some(nested( LayerVerdict::Error, format!( "nested ZIP entry count {count} exceeds limit {}", constants::SCAN_ZIP_MAX_ENTRIES ), )); } return ( archive_res.unwrap_or_else(bomb_disabled_archive), nested_res.unwrap_or_else(|| nested(LayerVerdict::Skip, String::new())), ); } let mut total_compressed: u64 = 0; let mut total_uncompressed: u64 = 0; let mut interior_budget: u64 = constants::SCAN_ZIP_MAX_UNCOMPRESSED; for i in 0..count { // Once both dimensions are settled, nothing more to learn. let bomb_active = bomb && archive_res.is_none(); let content_active = content && nested_res.is_none(); if !bomb_active && !content_active { break; } let (name, entry_compressed, claimed_size) = match archive.by_index_raw(i) { Ok(e) => (e.name().to_string(), e.compressed_size(), e.size()), Err(e) => { if bomb_active { archive_res = Some(LayerResult { layer: "archive", verdict: LayerVerdict::Error, detail: Some(format!("Failed to read ZIP entry {i}: {e}")), }); } if content_active { nested_res = Some(nested( LayerVerdict::Error, format!("read nested ZIP entry {i}: {e}"), )); } continue; } }; // Path traversal is a bomb-dimension failure (the nested walk never // checked entry names). if bomb_active { let name_lower = name.to_ascii_lowercase(); if name.contains("../") || name.contains("..\\") || name_lower.contains("%2e%2e") || name.starts_with('/') || name.contains('\0') { archive_res = Some(LayerResult { layer: "archive", verdict: LayerVerdict::Fail, detail: Some(format!("Path traversal in entry: {name}")), }); // Bomb verdict frozen; keep going only if content still needs us. if !content_active { break; } } } // Decompress the entry exactly once, teeing the full size (bomb) and the // buffered prefix (content). Stop early once the entry blows past // `compressed * MAX_RATIO` rather than always decompressing to the 2 GB // absolute cap, a high-ratio entry is flagged below regardless, so there // is no reason to expand it fully first. let entry_stop_limit = entry_bomb_stop_limit(entry_compressed); let want_content = content && nested_res.is_none(); let (counted, content_buf, decode_err) = match archive.by_index(i) { Ok(mut entry) => match tee_decompress( &mut entry, entry_stop_limit, want_content, &mut interior_budget, ) { Ok((c, cb)) => (c, cb, None), Err((c, why)) => (c, None, Some(why)), }, // Could not open the entry to decompress: bomb uses a conservative // estimate; content is held for review. Err(e) => ( claimed_size.saturating_mul(10).max(1024 * 1024), None, Some(format!("{e}")), ), }; // Bomb accounting (skipped once the bomb verdict is frozen). if bomb && archive_res.is_none() { // A decode error mid-stream gets the conservative estimate rather // than trusting the attacker-controlled claimed size. let actual_size = if decode_err.is_some() { claimed_size.saturating_mul(10).max(1024 * 1024) } else { counted }; if actual_size > constants::SCAN_ZIP_MAX_UNCOMPRESSED { archive_res = Some(LayerResult { layer: "archive", verdict: LayerVerdict::Fail, detail: Some(format!( "Actual decompressed size exceeds {} bytes (possible ZIP bomb)", constants::SCAN_ZIP_MAX_UNCOMPRESSED )), }); } else { total_compressed += entry_compressed; total_uncompressed += actual_size; // Stop as soon as the cumulative uncompressed size blows the // budget, don't keep expanding the remaining entries just to // check the total after the loop. Sub-64-KiB entries skip the // per-entry ratio floor below, so a 100k-entry archive of tiny // ultra-compressible members could otherwise force multi-GB of // cumulative decompression before the post-loop guard trips // (Run 20 Perf). The finalizer turns this into a Fail verdict. if total_uncompressed > constants::SCAN_ZIP_MAX_UNCOMPRESSED { break; } // Per-entry ratio is a fast-path signal; the accumulation vector // (many small ultra-compressed entries) is the total-ratio guard's // job below. The size floor keeps tiny, naturally-compressible // files (text/JSON/SVG) from tripping the 100x ratio, lowered // from 1 MiB to 64 KiB so mid-size bombs are caught at the entry // level too, where a >100x ratio is already anomalous (Run 11 Sec // LOW). if entry_compressed > 0 && actual_size >= 64 * 1024 { let entry_ratio = actual_size as f64 / entry_compressed as f64; if entry_ratio > constants::SCAN_ZIP_MAX_RATIO { archive_res = Some(LayerResult { layer: "archive", verdict: LayerVerdict::Fail, detail: Some(format!( "Entry {name} compression ratio {entry_ratio:.1}x exceeds limit of {:.0}x (possible ZIP bomb)", constants::SCAN_ZIP_MAX_RATIO )), }); } } } } // Interior content scan (skipped once the nested verdict is frozen). if content && nested_res.is_none() { if let Some(why) = decode_err { nested_res = Some(nested( LayerVerdict::Error, format!("decode nested entry: {why}"), )); } else { match content_buf { Some(ContentBuf::Buffered(bytes)) => { if let Some(v) = scan_entry( &bytes, yara_rules, constants::SCAN_ZIP_MAX_DEPTH, &mut interior_budget, ) { nested_res = Some(v); } } Some(ContentBuf::Overflow) => { nested_res = Some(nested( LayerVerdict::Error, format!( "nested entry exceeds {INTERIOR_ENTRY_MAX}-byte interior scan ceiling" ), )); } Some(ContentBuf::BudgetExceeded) => { nested_res = Some(nested( LayerVerdict::Error, "nested archive content exceeds total interior scan budget".to_string(), )); } None => {} } } } } // Finalize bomb verdict from the totals if nothing tripped mid-walk. let archive_result = match archive_res { Some(r) => r, None if !bomb => bomb_disabled_archive(), None => { if total_uncompressed > constants::SCAN_ZIP_MAX_UNCOMPRESSED { LayerResult { layer: "archive", verdict: LayerVerdict::Fail, detail: Some(format!( "Total uncompressed size {total_uncompressed} bytes exceeds limit of {} bytes", constants::SCAN_ZIP_MAX_UNCOMPRESSED )), } } else if total_compressed > 0 && (total_uncompressed as f64 / total_compressed as f64) > constants::SCAN_ZIP_MAX_RATIO { LayerResult { layer: "archive", verdict: LayerVerdict::Fail, detail: Some(format!( "Compression ratio {:.1}x exceeds limit of {:.0}x (possible ZIP bomb)", total_uncompressed as f64 / total_compressed as f64, constants::SCAN_ZIP_MAX_RATIO )), } } else { LayerResult { layer: "archive", verdict: LayerVerdict::Pass, detail: Some(format!( "{count} entries, {:.1}x ratio", if total_compressed > 0 { total_uncompressed as f64 / total_compressed as f64 } else { 0.0 } )), } } } }; let nested_result = match nested_res { Some(r) => r, None if !content => nested(LayerVerdict::Skip, String::new()), None => nested( LayerVerdict::Pass, "Archive interior fully scanned; no threats found".to_string(), ), }; (archive_result, nested_result) } /// Walk a single-stream compressor (gzip/bzip2/xz/zstd) once, computing the /// bomb-defense verdict (when `bomb`) and the interior content verdict (when /// `content`). Returns `(archive, archive_nested)`. fn walk_compressed( kind: ArchiveKind, compressed_size: u64, reader: R, bomb: bool, content: bool, yara_rules: Option<&yara_x::Rules>, ) -> (LayerResult, LayerResult) { let mut decoder: Box = match kind { ArchiveKind::Gzip => Box::new(flate2::read::MultiGzDecoder::new(reader)), ArchiveKind::Bzip2 => Box::new(bzip2::read::BzDecoder::new(reader)), ArchiveKind::Xz => Box::new(xz2::read::XzDecoder::new(reader)), ArchiveKind::Zstd => match zstd::stream::read::Decoder::new(reader) { Ok(d) => Box::new(d), Err(e) => { let why = format!("zstd init failed: {e}"); return ( if bomb { error(why.clone()) } else { bomb_disabled_archive() }, if content { nested(LayerVerdict::Error, why) } else { nested(LayerVerdict::Skip, String::new()) }, ); } }, ArchiveKind::Zip => unreachable!("zip is handled by walk_zip"), }; let abs_limit = constants::SCAN_ZIP_MAX_UNCOMPRESSED; let ratio_limit = compressed_size.saturating_mul(constants::SCAN_ZIP_MAX_RATIO as u64); let mut interior_budget: u64 = constants::SCAN_ZIP_MAX_UNCOMPRESSED; // Stop expanding once the stream blows past `compressed * MAX_RATIO` rather // than always running to the 2 GB absolute cap; the ratio verdict below fires // identically on the early-stopped count. let stop_limit = entry_bomb_stop_limit(compressed_size); let (counted, content_buf, decode_err) = match tee_decompress(decoder.as_mut(), stop_limit, content, &mut interior_budget) { Ok((c, cb)) => (c, cb, None), Err((c, why)) => (c, None, Some(why)), }; // Bomb verdict. let archive_result = if !bomb { bomb_disabled_archive() } else if let Some(ref why) = decode_err { // Mid-stream decode error is suspicious; fail closed for review. error(format!("{} decode error: {why}", kind.label())) } else if counted > abs_limit { LayerResult { layer: "archive", verdict: LayerVerdict::Fail, detail: Some(format!( "{} stream decompresses past {abs_limit} bytes (possible decompression bomb)", kind.label() )), } } else if compressed_size > 0 && counted > ratio_limit { LayerResult { layer: "archive", verdict: LayerVerdict::Fail, detail: Some(format!( "{} compression ratio exceeds {:.0}x (possible decompression bomb)", kind.label(), constants::SCAN_ZIP_MAX_RATIO )), } } else { LayerResult { layer: "archive", verdict: LayerVerdict::Pass, detail: Some(format!( "{} stream, {counted} bytes uncompressed ({:.1}x)", kind.label(), if compressed_size > 0 { counted as f64 / compressed_size as f64 } else { 0.0 } )), } }; // Interior content verdict. let nested_result = if !content { nested(LayerVerdict::Skip, String::new()) } else if let Some(why) = decode_err { nested(LayerVerdict::Error, format!("decode nested entry: {why}")) } else { match content_buf { Some(ContentBuf::Buffered(bytes)) => scan_entry( &bytes, yara_rules, constants::SCAN_ZIP_MAX_DEPTH, &mut interior_budget, ) .unwrap_or_else(|| { nested( LayerVerdict::Pass, "Archive interior fully scanned; no threats found".to_string(), ) }), Some(ContentBuf::Overflow) => nested( LayerVerdict::Error, format!("nested entry exceeds {INTERIOR_ENTRY_MAX}-byte interior scan ceiling"), ), Some(ContentBuf::BudgetExceeded) => nested( LayerVerdict::Error, "nested archive content exceeds total interior scan budget".to_string(), ), None => nested( LayerVerdict::Pass, "Archive interior fully scanned; no threats found".to_string(), ), } }; (archive_result, nested_result) } /// Path-based entry. Opens the spooled file directly so we never have to /// buffer the whole archive. File-type gating happens at the call site (same /// shape as the buffered variant, caller already checked `file_type`). /// Path-based variant, retained only as a buffered-vs-path equivalence oracle in /// tests. The live pipeline scans the mmap slice via [`check_archive_safety`]; /// `#[cfg(test)]` makes wiring this into production a compile error, so the /// nested-interior scan can't be silently dropped on the spool path (ultra-fuzz N2). #[cfg(test)] pub fn check_archive_safety_path(path: &std::path::Path, file_type: FileType) -> LayerResult { use std::io::{Seek, SeekFrom}; if file_type == FileType::Cover { return skip("Archive check skipped for cover images"); } let mut file = match std::fs::File::open(path) { Ok(f) => f, Err(e) => return error(format!("open spool {}: {e}", path.display())), }; let mut magic = [0u8; 6]; let read = file.read(&mut magic).unwrap_or(0); let kind = detect_kind(&magic[..read]); if file.seek(SeekFrom::Start(0)).is_err() { return error(format!("seek spool {}", path.display())); } // Path variant is bomb-defense only (`content = false`): the interior scan // runs from the in-memory/mmap path through `inspect_archive`. Both share // `walk_zip`/`walk_compressed`, so the bomb verdict matches byte-for-byte. match kind { Some(ArchiveKind::Zip) => walk_zip(file, true, false, None).0, Some(stream) => { let compressed_size = std::fs::metadata(path).map_or(0, |m| m.len()); walk_compressed(stream, compressed_size, file, true, false, None).0 } None => { // Tail-scan for a prefixed-ZIP central directory. Read up to the // last 64 KiB + 22 bytes rather than the whole (possibly huge) file. let len = std::fs::metadata(path).map_or(0, |m| m.len()); let window = 22 + u16::MAX as u64; let start = len.saturating_sub(window); let mut tail = Vec::new(); let is_zip = file.seek(SeekFrom::Start(start)).is_ok() && file.read_to_end(&mut tail).is_ok() && has_zip_eocd(&tail); if is_zip { if file.seek(SeekFrom::Start(0)).is_err() { return error(format!("seek spool {}", path.display())); } walk_zip(file, true, false, None).0 } else { skip("Not a recognized archive") } } } } fn skip(detail: &str) -> LayerResult { LayerResult { layer: "archive", verdict: LayerVerdict::Skip, detail: Some(detail.to_string()), } } fn error(detail: String) -> LayerResult { LayerResult { layer: "archive", verdict: LayerVerdict::Error, detail: Some(detail), } } /// True if `data` is a container we know how to descend into (offset-0 magic or /// a prefixed-ZIP central directory). Gate for the recursive interior scan. pub fn is_archive(data: &[u8]) -> bool { detect_kind(data).is_some() || has_zip_eocd(data) } /// Per-entry ceiling for interior scanning. An entry whose decompressed size /// exceeds this cannot be fully buffered for the in-process layers; rather than /// blow up memory it is reported "not fully scanned" and held for review (the /// bomb-defense walk in `check_archive_safety` independently bounds total size). const INTERIOR_ENTRY_MAX: usize = constants::SCAN_MAX_MEMORY_BYTES; fn nested(verdict: LayerVerdict, detail: String) -> LayerResult { LayerResult { layer: "archive_nested", verdict, detail: Some(detail), } } /// Recursively inspect the *interior* of an archive: decompress each entry and /// re-feed its bytes through the FailClosed in-process layers (content-type, /// structural, YARA), descending into nested archives up to `SCAN_ZIP_MAX_DEPTH`. /// /// This is the coverage `check_archive_safety` deliberately does NOT provide: /// that walk counts entries and bounds decompression-bomb size; this one scans /// their *content*. The result is the `"archive_nested"` layer: /// - `Pass`, every entry at every depth cleared the scan layers. /// - `Fail`, an entry tripped a content layer (malware / disguised HTML/exe). /// - `Error`, the interior could not be fully scanned (un-openable, over the /// per-entry or total budget, OR nesting deeper than `SCAN_ZIP_MAX_DEPTH`). /// `"archive_nested"` is FailClosed, so a not-fully-scanned interior is held /// for review, never passed Clean. There is no path that descends into an /// archive without either scanning the bytes or emitting this verdict, the /// old "count nested archives but never scan them" branch is gone. pub fn scan_nested_contents(data: &[u8], yara_rules: Option<&yara_x::Rules>) -> LayerResult { // Thin wrapper: the interior verdict is the second half of the single-pass // `inspect_archive`. `Download` is non-cover, so the bomb dimension also runs // here (discarded), callers on the hot path use `inspect_archive` directly // to get both verdicts from one decompression. inspect_archive(data, FileType::Download, yara_rules).1 } /// Descend one archive level. `Some(verdict)` short-circuits with a non-clean /// result; `None` means everything at and below this level was clean. fn scan_interior( data: &[u8], yara_rules: Option<&yara_x::Rules>, depth: u32, budget: &mut u64, ) -> Option { match detect_kind(data) { Some(ArchiveKind::Zip) => scan_zip_interior(Cursor::new(data), yara_rules, depth, budget), Some(stream) => match decompress_one_bounded(stream, Cursor::new(data), budget) { Ok(bytes) => scan_entry(&bytes, yara_rules, depth, budget), Err(why) => Some(nested(LayerVerdict::Error, why)), }, None if has_zip_eocd(data) => { scan_zip_interior(Cursor::new(data), yara_rules, depth, budget) } None => None, } } fn scan_zip_interior( reader: R, yara_rules: Option<&yara_x::Rules>, depth: u32, budget: &mut u64, ) -> Option { let mut archive = match zip::ZipArchive::new(reader) { Ok(a) => a, Err(e) => { return Some(nested( LayerVerdict::Error, format!("cannot open nested ZIP: {e}"), )); } }; if archive.len() > constants::SCAN_ZIP_MAX_ENTRIES { return Some(nested( LayerVerdict::Error, format!( "nested ZIP entry count {} exceeds limit {}", archive.len(), constants::SCAN_ZIP_MAX_ENTRIES ), )); } for i in 0..archive.len() { let bytes = match read_zip_entry_bounded(&mut archive, i, budget) { Ok(b) => b, Err(why) => return Some(nested(LayerVerdict::Error, why)), }; if let Some(v) = scan_entry(&bytes, yara_rules, depth, budget) { return Some(v); } } None } /// Run an entry's decompressed bytes through the FailClosed in-process layers, /// then recurse if the entry is itself an archive. fn scan_entry( bytes: &[u8], yara_rules: Option<&yara_x::Rules>, depth: u32, budget: &mut u64, ) -> Option { let yara_result = match yara_rules { Some(rules) => super::yara::scan_with_yara(rules, bytes), None => LayerResult { layer: "yara", verdict: LayerVerdict::Skip, detail: None, }, }; // Inner bundle content is downloadable-by-a-buyer, so it is checked as a // `Download`: content-type catches disguised HTML/SVG, structural catches // executables, YARA catches signatures (EICAR, script-in-binary). let checks = [ super::content_type::verify_content_type(bytes, FileType::Download), super::structural::analyze_binary(bytes, FileType::Download), yara_result, ]; for r in checks { match r.verdict { LayerVerdict::Fail => { return Some(nested( LayerVerdict::Fail, format!( "nested archive entry flagged by {}: {}", r.layer, r.detail.unwrap_or_default() ), )); } LayerVerdict::Error if super::error_policy_for(r.layer) == ErrorPolicy::FailClosed => { return Some(nested( LayerVerdict::Error, format!( "nested archive entry: {} layer errored: {}", r.layer, r.detail.unwrap_or_default() ), )); } _ => {} } } if is_archive(bytes) { if depth == 0 { return Some(nested( LayerVerdict::Error, "nested archive exceeds maximum scan depth; held for review".to_string(), )); } return scan_interior(bytes, yara_rules, depth - 1, budget); } None } fn read_zip_entry_bounded( archive: &mut zip::ZipArchive, index: usize, budget: &mut u64, ) -> Result, String> { let mut entry = archive .by_index(index) .map_err(|e| format!("read nested ZIP entry {index}: {e}"))?; read_bounded(&mut entry, budget) } fn decompress_one_bounded( kind: ArchiveKind, reader: R, budget: &mut u64, ) -> Result, String> { let mut decoder: Box = match kind { ArchiveKind::Gzip => Box::new(flate2::read::MultiGzDecoder::new(reader)), ArchiveKind::Bzip2 => Box::new(bzip2::read::BzDecoder::new(reader)), ArchiveKind::Xz => Box::new(xz2::read::XzDecoder::new(reader)), ArchiveKind::Zstd => zstd::stream::read::Decoder::new(reader) .map(|d| Box::new(d) as Box) .map_err(|e| format!("zstd init failed: {e}"))?, ArchiveKind::Zip => return Err("zip handled separately".to_string()), }; read_bounded(decoder.as_mut(), budget) } /// Read a decompressed entry into a Vec, enforcing the per-entry ceiling and the /// shared total budget. Either overflow, or a mid-stream decode error, is a /// "not fully scanned" condition (the caller fails closed). fn read_bounded(reader: &mut dyn Read, budget: &mut u64) -> Result, String> { let mut out: Vec = Vec::new(); let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { if out.len() + n > INTERIOR_ENTRY_MAX { return Err(format!( "nested entry exceeds {INTERIOR_ENTRY_MAX}-byte interior scan ceiling" )); } if n as u64 > *budget { return Err( "nested archive content exceeds total interior scan budget".to_string() ); } *budget -= n as u64; out.extend_from_slice(&buf[..n]); } Err(e) => return Err(format!("decode nested entry: {e}")), } } Ok(out) } #[cfg(test)] mod tests { use super::*; use zip::write::SimpleFileOptions; fn make_zip(entries: &[(&str, &[u8])]) -> Vec { let buf = Vec::new(); let cursor = Cursor::new(buf); let mut writer = zip::ZipWriter::new(cursor); let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); for (name, data) in entries { writer.start_file(*name, options).unwrap(); std::io::Write::write_all(&mut writer, data).unwrap(); } writer.finish().unwrap().into_inner() } fn make_compressed_zip(entries: &[(&str, &[u8])]) -> Vec { let buf = Vec::new(); let cursor = Cursor::new(buf); let mut writer = zip::ZipWriter::new(cursor); let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); for (name, data) in entries { writer.start_file(*name, options).unwrap(); std::io::Write::write_all(&mut writer, data).unwrap(); } writer.finish().unwrap().into_inner() } // Skip behavior #[test] fn non_zip_skipped() { let result = check_archive_safety(b"not a zip file", FileType::Download); assert_eq!(result.verdict, LayerVerdict::Skip); } #[test] fn audio_non_zip_skipped() { let result = check_archive_safety(b"audio data", FileType::Audio); assert_eq!(result.verdict, LayerVerdict::Skip); } // 7z / RAR: no pure-Rust bomb checker (R6-Sec-L2) #[test] fn sevenzip_rejected_for_non_download() { let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0]; let result = check_archive_safety(&data, FileType::Cover); assert_eq!(result.verdict, LayerVerdict::Error); } #[test] fn sevenzip_allowed_for_download() { // Download keeps the ClamAV backstop; the in-process layer doesn't reject. let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0]; let result = check_archive_safety(&data, FileType::Download); assert_ne!(result.verdict, LayerVerdict::Error); } #[test] fn rar_rejected_for_non_download() { let data = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0, 0, 0]; let result = check_archive_safety(&data, FileType::MediaImage); assert_eq!(result.verdict, LayerVerdict::Error); } #[test] fn cover_zip_skipped() { // A ZIP file claimed as cover should be skipped (layer 1 handles type mismatch) let data = make_zip(&[("test.txt", b"hello")]); let result = check_archive_safety(&data, FileType::Cover); assert_eq!(result.verdict, LayerVerdict::Skip); } // Valid archives #[test] fn valid_zip_passes() { let data = make_zip(&[("test.txt", b"hello world")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Pass); } #[test] fn empty_zip_passes() { let buf = Vec::new(); let cursor = Cursor::new(buf); let writer = zip::ZipWriter::new(cursor); let data = writer.finish().unwrap().into_inner(); // Empty ZIPs may not have the PK magic at offset 0, they'd just be // an end-of-central-directory record. If it doesn't start with PK 03 04, // we'll skip it. That's fine. let result = check_archive_safety(&data, FileType::Download); // Either Skip (no local file header) or Pass (valid empty ZIP) assert!( result.verdict == LayerVerdict::Skip || result.verdict == LayerVerdict::Pass, "unexpected verdict: {:?}", result.verdict ); } #[test] fn multi_entry_zip_passes() { let data = make_zip(&[ ("file1.txt", b"content one"), ("subdir/file2.txt", b"content two"), ("readme.md", b"# hello"), ]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Pass); assert!(result.detail.unwrap().contains("3 entries")); } // Path traversal #[test] fn zip_with_forward_slash_traversal_fails() { let data = make_zip(&[("../../../etc/passwd", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } #[test] fn zip_with_backslash_traversal_fails() { let data = make_zip(&[("..\\..\\Windows\\System32\\config", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } #[test] fn zip_with_mid_path_traversal_fails() { let data = make_zip(&[("safe/../../etc/passwd", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); } #[test] fn zip_with_url_encoded_traversal_fails() { // %2e%2e is URL-encoded "..". The check is case-insensitive on the encoding. let data = make_zip(&[("%2E%2E/secrets", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } #[test] fn zip_with_absolute_path_fails() { let data = make_zip(&[("/etc/passwd", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } #[test] fn zip_with_null_byte_in_name_fails() { let data = make_zip(&[("legit.txt\0../escape", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } // Nesting detection // Nested-archive interior scanning (`scan_nested_contents`) // // The old behavior here merely *counted* nested-archive entries and failed a // ZIP with more than `SCAN_ZIP_MAX_DEPTH` of them, never inspecting their // contents, counting is not scanning, so a payload in a zip-in-a-zip passed // Clean (the run #20→#22 chronic). `check_archive_safety` no longer counts; // interior coverage is `scan_nested_contents`, exercised below with the real // rule set so an actual signature in a nested archive is caught or held. /// The compiled production YARA rules (includes the EICAR test signature). fn test_yara_rules() -> yara_x::Rules { super::super::yara::compile_rules_from_dir("yara-rules") .expect("compile yara-rules") .0 .expect("yara-rules dir has rules") } /// EICAR antivirus test string, matched by `yara-rules/mnw_test_files.yar`. const EICAR: &[u8] = br"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; #[test] fn benign_nested_zip_passes() { let inner = make_zip(&[("hello.txt", b"hello world")]); let outer = make_zip(&[("inner.zip", &inner), ("notes.txt", b"readme")]); let result = scan_nested_contents(&outer, Some(&test_yara_rules())); assert_eq!(result.verdict, LayerVerdict::Pass, "{:?}", result.detail); } #[test] fn eicar_in_zip_in_zip_is_caught() { // outer.zip -> inner.zip -> evil.txt(EICAR). The interior bytes must // traverse YARA exactly as a top-level file would: not Clean. let inner = make_zip(&[("evil.txt", EICAR)]); let outer = make_zip(&[("inner.zip", &inner)]); let result = scan_nested_contents(&outer, Some(&test_yara_rules())); assert_eq!( result.verdict, LayerVerdict::Fail, "EICAR nested two zips deep must be caught, got {:?}", result.detail ); } #[test] fn eicar_in_single_gzip_is_caught() { // A standalone gzip member is one logical entry; its decompressed bytes // must be scanned. let gz = gzip(EICAR); let result = scan_nested_contents(&gz, Some(&test_yara_rules())); assert_eq!(result.verdict, LayerVerdict::Fail, "{:?}", result.detail); } #[test] fn nesting_beyond_scan_depth_is_held_not_passed() { // SCAN_ZIP_MAX_DEPTH = 2. Wrap a benign file in enough ZIP layers that // the innermost archive sits past the descent budget; the interior is // not fully scanned, so it must fail closed (Error -> held), never Clean. let mut nested = make_zip(&[("leaf.txt", b"benign")]); for _ in 0..4 { nested = make_zip(&[("inner.zip", &nested)]); } let result = scan_nested_contents(&nested, Some(&test_yara_rules())); assert_eq!( result.verdict, LayerVerdict::Error, "a nest deeper than the scan depth must be held, got {:?}", result.detail ); assert_eq!( super::super::error_policy_for(result.layer), ErrorPolicy::FailClosed ); } #[test] fn non_archive_has_no_interior() { let result = scan_nested_contents(b"just some plain bytes", Some(&test_yara_rules())); assert_eq!(result.verdict, LayerVerdict::Skip); } #[test] fn non_archive_extensions_ignored() { let data = make_zip(&[ ("app.exe", b"binary"), ("readme.txt", b"hello"), ("image.png", b"pixels"), ]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Pass); } // Compression ratio (ZIP bomb detection) #[test] fn high_compression_ratio_fails() { // Create highly compressible data: repeating zeros compress extremely well // 1MB of zeros should compress to ~1KB with deflate, giving ratio ~1000x let zeros = vec![0u8; 1024 * 1024]; let data = make_compressed_zip(&[("bomb.bin", &zeros)]); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "Expected Fail for high compression ratio, got: {:?}", result.detail ); assert!(result.detail.unwrap().contains("ZIP bomb")); } #[test] fn normal_compression_ratio_passes() { // Random-ish data doesn't compress well, ratio should be ~1x let data_bytes: Vec = (0..10000).map(|i| (i * 37 + 13) as u8).collect(); let data = make_compressed_zip(&[("normal.bin", &data_bytes)]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Pass); } // Audio file with ZIP magic (disguised archive) #[test] fn zip_disguised_as_audio_checked() { // A ZIP file claimed as Audio should still be checked (not skipped) let data = make_zip(&[("test.txt", b"hello")]); let result = check_archive_safety(&data, FileType::Audio); assert_eq!(result.verdict, LayerVerdict::Pass); } #[test] fn zip_disguised_as_audio_with_traversal_fails() { let data = make_zip(&[("../../../etc/passwd", b"pwned")]); let result = check_archive_safety(&data, FileType::Audio); assert_eq!(result.verdict, LayerVerdict::Fail); } // Corrupted ZIP #[test] fn corrupted_zip_magic_returns_error() { // Valid ZIP magic bytes but garbage after let mut data = vec![0x50, 0x4B, 0x03, 0x04]; data.extend_from_slice(&[0xFF; 100]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Error); assert!(result.detail.unwrap().contains("Failed to parse ZIP")); } #[test] fn path_entry_matches_buffered_for_non_zip() { let data = b"not a zip at all"; let buffered = check_archive_safety(data, FileType::Download); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), data).unwrap(); let path_based = check_archive_safety_path(tmp.path(), FileType::Download); assert_eq!(buffered.verdict, path_based.verdict); assert_eq!(buffered.verdict, LayerVerdict::Skip); } #[test] fn path_entry_matches_buffered_for_cover_skip() { let mut data = vec![0x50, 0x4B, 0x03, 0x04]; data.extend_from_slice(&[0xFF; 100]); let buffered = check_archive_safety(&data, FileType::Cover); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), &data).unwrap(); let path_based = check_archive_safety_path(tmp.path(), FileType::Cover); assert_eq!(buffered.verdict, path_based.verdict); assert_eq!(buffered.verdict, LayerVerdict::Skip); } // Single-stream decompression bombs (gzip / bzip2 / xz / zstd) use std::io::Write; fn gzip(data: &[u8]) -> Vec { let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); e.write_all(data).unwrap(); e.finish().unwrap() } fn bzip2_compress(data: &[u8]) -> Vec { let mut e = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::new(9)); e.write_all(data).unwrap(); e.finish().unwrap() } fn xz(data: &[u8]) -> Vec { let mut e = xz2::write::XzEncoder::new(Vec::new(), 9); e.write_all(data).unwrap(); e.finish().unwrap() } fn zstd_compress(data: &[u8]) -> Vec { zstd::encode_all(data, 19).unwrap() } /// 8 MiB of zeros, compresses to a tiny stream at a ratio far above the /// 100x cap, the canonical decompression-bomb shape. fn bomb_payload() -> Vec { vec![0u8; 8 * 1024 * 1024] } /// Moderately-incompressible data: stays well under the ratio cap, so a /// legitimate compressed download passes. fn benign_payload() -> Vec { (0..200_000u32) .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8) .collect() } #[test] fn gzip_bomb_fails() { let data = gzip(&bomb_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); assert!(result.detail.unwrap().to_lowercase().contains("bomb")); } #[test] fn benign_gzip_passes() { let data = gzip(&benign_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Pass, "detail: {:?}", result.detail ); } #[test] fn bzip2_bomb_fails() { let data = bzip2_compress(&bomb_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn xz_bomb_fails() { let data = xz(&bomb_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn zstd_bomb_fails() { let data = zstd_compress(&bomb_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn gzip_bomb_caught_on_path_variant_too() { let data = gzip(&bomb_payload()); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), &data).unwrap(); let result = check_archive_safety_path(tmp.path(), FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn gzip_bomb_skipped_for_cover() { // Type mismatch is layer 1's job; the archive layer skips covers. let data = gzip(&bomb_payload()); let result = check_archive_safety(&data, FileType::Cover); assert_eq!(result.verdict, LayerVerdict::Skip); } // Prefixed / self-extracting ZIP (no offset-0 magic) #[test] fn prefixed_zip_is_not_silently_skipped() { // A real ZIP with arbitrary bytes prepended (the self-extracting-stub // shape). It lacks the offset-0 PK\x03\x04 magic, so the old offset-0 // gate would Skip it. The tail EOCD scan must catch it and hand it to // inspect_zip, the security property is that it is NOT Skipped. let zip = make_zip(&[("readme.txt", b"hello")]); let mut data = b"MZ\x90\x00 this is a self-extracting stub padding ".to_vec(); data.extend_from_slice(&zip); let result = check_archive_safety(&data, FileType::Download); assert_ne!( result.verdict, LayerVerdict::Skip, "prefixed ZIP must be inspected, not skipped; got {:?}", result.detail ); // And on the path variant. let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), &data).unwrap(); let path_based = check_archive_safety_path(tmp.path(), FileType::Download); assert_ne!( path_based.verdict, LayerVerdict::Skip, "detail: {:?}", path_based.detail ); } #[test] fn prefixed_zip_bomb_fails() { // Prepend a stub to a high-ratio ZIP; it must still be caught. let zeros = vec![0u8; 1024 * 1024]; let zip = make_compressed_zip(&[("bomb.bin", &zeros)]); let mut data = b"self-extracting stub ".to_vec(); data.extend_from_slice(&zip); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn prefixed_7z_polyglot_rejected_for_non_download() { // [PNG header][7z magic][junk]: sniffs as a PNG (passing the content-type // layer) but carries a 7z payload past offset 0. The offset-0-only magic // check missed this and fell through to ClamAV FailOpen (Sec-S1); the // whole-buffer window scan now rejects it for non-Download uploads. let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; data.extend_from_slice(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]); // 7z magic data.extend_from_slice(&[0u8; 64]); // Cover / image: held for review (Error), not passed to ClamAV alone. let cover = check_archive_safety(&data, FileType::Cover); assert_eq!( cover.verdict, LayerVerdict::Error, "detail: {:?}", cover.detail ); assert!(cover.detail.unwrap().contains("7z")); // Download keeps the ClamAV backstop, this layer must not reject it. let download = check_archive_safety(&data, FileType::Download); assert_ne!( download.verdict, LayerVerdict::Error, "Download must keep the ClamAV backstop, not be rejected by the container check" ); } #[test] fn prefixed_rar_polyglot_rejected_for_non_download() { // Same evasion shape with a RAR signature embedded after a JPEG header. let mut data = vec![0xFF, 0xD8, 0xFF, 0xE0]; // JPEG SOI + APP0 data.extend_from_slice(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]); // "Rar!\x1a\x07" data.extend_from_slice(&[0u8; 64]); let cover = check_archive_safety(&data, FileType::Cover); assert_eq!( cover.verdict, LayerVerdict::Error, "detail: {:?}", cover.detail ); assert!(cover.detail.unwrap().contains("RAR")); } }