Skip to main content

max / makenotwork

59.2 KB · 1531 lines History Blame Raw
1 //! Layer 3: Archive / compression-bomb safety checks.
2 //!
3 //! Two families of check:
4 //! 1. **ZIP archives**, inspected for excessive compression ratios, deeply
5 //! nested archives, path-traversal entry names, and unreasonable
6 //! uncompressed sizes. ZIPs are detected both by the offset-0 local-file
7 //! header AND by an end-of-central-directory scan, so a prefixed / self-
8 //! extracting ZIP (a stub prepended to the archive) can't slip past.
9 //! 2. **Single-stream compressors**, gzip, bzip2, xz, and zstd. These are
10 //! the common standalone decompression-bomb vectors (`.gz`, `.tar.gz`,
11 //! `.bz2`, `.xz`, `.zst`). The stream is decompressed and the produced
12 //! bytes are counted against the same size + ratio caps as ZIP, with an
13 //! early exit so a bomb is rejected after ~`MAX_RATIO`× its input rather
14 //! than fully expanded.
15 //!
16 //! Formats we cannot introspect in-process (7z, RAR, container formats with
17 //! no lightweight pure-checker) are NOT bomb-checked here; they fall through to
18 //! ClamAV. A raw (uncompressed) tar carries no decompression amplification, so
19 //! a tar bomb only matters as `.tar.gz`, which the gzip path already covers.
20
21 use std::io::{Cursor, Read};
22
23 use crate::constants;
24 use crate::storage::FileType;
25
26 use super::{ErrorPolicy, LayerResult, LayerVerdict};
27
28 /// In-process deterministic layer. Parser / decompression errors fail closed
29 /// because they indicate either a corrupt archive or an evasion attempt, both
30 /// warrant a human look rather than an automatic pass.
31 pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailClosed;
32
33 /// A recognized compressed/archive container we know how to inspect.
34 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
35 enum ArchiveKind {
36 Zip,
37 Gzip,
38 Bzip2,
39 Xz,
40 Zstd,
41 }
42
43 impl ArchiveKind {
44 fn label(self) -> &'static str {
45 match self {
46 ArchiveKind::Zip => "ZIP",
47 ArchiveKind::Gzip => "gzip",
48 ArchiveKind::Bzip2 => "bzip2",
49 ArchiveKind::Xz => "xz",
50 ArchiveKind::Zstd => "zstd",
51 }
52 }
53 }
54
55 /// Classify by leading magic bytes. ZIP is handled separately (it can be
56 /// detected by a trailing end-of-central-directory record too), so this only
57 /// reports the single-stream compressors plus the offset-0 ZIP fast path.
58 fn detect_kind(magic: &[u8]) -> Option<ArchiveKind> {
59 match magic {
60 [0x50, 0x4B, 0x03, 0x04, ..] => Some(ArchiveKind::Zip),
61 [0x1F, 0x8B, ..] => Some(ArchiveKind::Gzip),
62 [0x42, 0x5A, 0x68, ..] => Some(ArchiveKind::Bzip2), // "BZh"
63 [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, ..] => Some(ArchiveKind::Xz),
64 [0x28, 0xB5, 0x2F, 0xFD, ..] => Some(ArchiveKind::Zstd),
65 _ => None,
66 }
67 }
68
69 /// Recognize containers we have no pure-Rust decompression-bomb checker for
70 /// (7z, RAR). Returns the container label so the caller can reject them where
71 /// they aren't a legitimate download payload, rather than passing them through
72 /// on the ClamAV (FailOpen) layer alone.
73 ///
74 /// Scans the WHOLE buffer for the signature, not just offset 0:
75 /// a prefixed/polyglot container, e.g. `[PNG header][7z payload]`, sniffs as an
76 /// image (passing the content-type layer) and would otherwise carry its 7z magic
77 /// past offset 0, skipping this rejection and falling through to ClamAV (FailOpen)
78 /// alone. This mirrors `has_zip_eocd`'s window scan for prefixed ZIPs. The 6-byte
79 /// signatures (with the unusual `BC AF` / `1A 07` bytes) make a false match on
80 /// benign image data astronomically unlikely; a false positive only costs a
81 /// held-for-review, never a bypass. The caller gates this on `file_type` so the
82 /// scan never runs over a large Download buffer.
83 fn detect_unsupported_container(data: &[u8]) -> Option<&'static str> {
84 // 7z: "7z\xBC\xAF\x27\x1C".
85 const SEVENZ: &[u8] = &[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C];
86 // RAR4 ("Rar!\x1a\x07\x00") and RAR5 ("Rar!\x1a\x07\x01\x00") share this prefix.
87 const RAR: &[u8] = &[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07];
88 if contains_window(data, SEVENZ) {
89 Some("7z")
90 } else if contains_window(data, RAR) {
91 Some("RAR")
92 } else {
93 None
94 }
95 }
96
97 /// True if `needle` appears anywhere in `haystack`.
98 fn contains_window(haystack: &[u8], needle: &[u8]) -> bool {
99 haystack.windows(needle.len()).any(|w| w == needle)
100 }
101
102 /// ZIP end-of-central-directory signature (`PK\x05\x06`). A valid ZIP always
103 /// ends with this record (plus an optional trailing comment), even when bytes
104 /// are prepended ahead of the first local header (self-extracting archives).
105 /// Scanning the tail catches those prefixed ZIPs that `detect_kind` misses.
106 fn has_zip_eocd(data: &[u8]) -> bool {
107 const EOCD: [u8; 4] = [0x50, 0x4B, 0x05, 0x06];
108 // The EOCD sits within the last 22 bytes + up to 64 KiB of comment.
109 let window = 22 + u16::MAX as usize;
110 let start = data.len().saturating_sub(window);
111 data[start..].windows(EOCD.len()).any(|w| w == EOCD)
112 }
113
114 /// Check a file for archive / decompression-bomb safety issues.
115 /// Runs regardless of claimed type so a disguised archive is still inspected.
116 pub fn check_archive_safety(data: &[u8], file_type: FileType) -> LayerResult {
117 inspect_archive(data, file_type, None).0
118 }
119
120 /// Outcome of buffering a decompressed entry's prefix for the interior scan.
121 enum ContentBuf {
122 /// Fully buffered within the per-entry ceiling and shared budget.
123 Buffered(Vec<u8>),
124 /// Decompressed size exceeded `INTERIOR_ENTRY_MAX`, cannot content-scan.
125 Overflow,
126 /// The shared interior budget was exhausted before this entry finished.
127 BudgetExceeded,
128 }
129
130 /// Byte limit at which `tee_decompress` should stop expanding a stream for bomb
131 /// accounting: the smaller of the 2 GB absolute cap and `compressed * MAX_RATIO`.
132 /// A stream that blows past its ratio budget is flagged as a bomb regardless, so
133 /// there is no reason to keep decompressing it up to the full 2 GB first. Falls
134 /// back to the absolute cap when the compressed size is unknown (0).
135 fn entry_bomb_stop_limit(compressed_size: u64) -> u64 {
136 if compressed_size == 0 {
137 return constants::SCAN_ZIP_MAX_UNCOMPRESSED;
138 }
139 let ratio_limit = compressed_size.saturating_mul(constants::SCAN_ZIP_MAX_RATIO as u64);
140 constants::SCAN_ZIP_MAX_UNCOMPRESSED.min(ratio_limit)
141 }
142
143 /// Decompress `reader` to completion (or until well past the bomb cap),
144 /// returning the FULL decompressed byte count (for bomb accounting) and, when
145 /// `want_content` is set, the buffered prefix (≤ `INTERIOR_ENTRY_MAX`, charged
146 /// against `budget`) for the interior content scan. A single decompression
147 /// serves both the bomb-defense walk and the interior scan.
148 ///
149 /// `Err(detail)` is a mid-stream decode failure: the caller decides what that
150 /// means for each dimension (ZIP bomb accounting uses a conservative estimate
151 /// and continues; a single-stream compressor fails closed).
152 fn tee_decompress(
153 reader: &mut dyn Read,
154 bomb_abs_limit: u64,
155 want_content: bool,
156 budget: &mut u64,
157 ) -> Result<(u64, Option<ContentBuf>), (u64, String)> {
158 let mut counted: u64 = 0;
159 let mut buf = [0u8; 8192];
160 let mut content = if want_content {
161 Some(ContentBuf::Buffered(Vec::new()))
162 } else {
163 None
164 };
165 loop {
166 match reader.read(&mut buf) {
167 Ok(0) => break,
168 Ok(n) => {
169 counted += n as u64;
170 if let Some(ContentBuf::Buffered(ref mut v)) = content {
171 if v.len() + n > INTERIOR_ENTRY_MAX {
172 content = Some(ContentBuf::Overflow);
173 } else if (n as u64) > *budget {
174 content = Some(ContentBuf::BudgetExceeded);
175 } else {
176 *budget -= n as u64;
177 v.extend_from_slice(&buf[..n]);
178 }
179 }
180 // Stop once the bomb cap is blown AND there's no more content to
181 // buffer, reading further serves neither dimension.
182 if counted > bomb_abs_limit && !matches!(content, Some(ContentBuf::Buffered(_))) {
183 break;
184 }
185 }
186 Err(e) => return Err((counted, format!("{e}"))),
187 }
188 }
189 Ok((counted, content))
190 }
191
192 /// Single-pass archive inspection. Decompresses each top-level entry ONCE and
193 /// derives BOTH the bomb-defense verdict (`"archive"`) and the interior content
194 /// verdict (`"archive_nested"`) from the same decompression. `check_archive_safety`,
195 /// `check_archive_safety_path`, and `scan_nested_contents` all delegate here so
196 /// the two layers can never drift apart.
197 ///
198 /// `yara_rules == None` still runs the interior content scan (content-type +
199 /// structural layers); it only skips the YARA sub-check. Callers that want bomb
200 /// defense only (`check_archive_safety`) take the first element and discard the
201 /// second.
202 pub fn inspect_archive(
203 data: &[u8],
204 file_type: FileType,
205 yara_rules: Option<&yara_x::Rules>,
206 ) -> (LayerResult, LayerResult) {
207 // 7z / RAR have no pure-Rust bomb checker, so without this they would fall
208 // through to detect_kind == None and rely on ClamAV (FailOpen) alone. Reject
209 // them (FailClosed → held for review) anywhere they aren't a legitimate
210 // download payload; Download keeps the ClamAV backstop (R6-Sec-L2).
211 // Gate on file_type FIRST so the whole-buffer signature scan never runs over a
212 // large Download buffer (Download keeps the ClamAV backstop, R6-Sec-L2).
213 if file_type != FileType::Download
214 && let Some(container) = detect_unsupported_container(data)
215 {
216 let detail = format!(
217 "{container} archives are not accepted for this upload type (cannot be decompression-bomb inspected)"
218 );
219 // Outer layer is FailClosed, so returning Error (not Skip) aligns the
220 // nested verdict with the policy: the file was rejected before interior
221 // scanning, not merely left unscanned.
222 return (error(detail.clone()), nested(LayerVerdict::Error, detail));
223 }
224
225 // A cover-disguised archive: layer 1 (content_type) handles the type
226 // mismatch, so the bomb walk is skipped, but the interior is still scanned
227 // (the nested layer never gated on file type). `bomb` off, `content` on.
228 let bomb = file_type != FileType::Cover;
229
230 match detect_kind(data) {
231 Some(ArchiveKind::Zip) => walk_zip(Cursor::new(data), bomb, true, yara_rules),
232 Some(stream) => walk_compressed(
233 stream,
234 data.len() as u64,
235 Cursor::new(data),
236 bomb,
237 true,
238 yara_rules,
239 ),
240 None if has_zip_eocd(data) => {
241 // Prefixed / self-extracting ZIP: no offset-0 magic, but a real
242 // central directory at the tail. ZipArchive locates it from the end.
243 walk_zip(Cursor::new(data), bomb, true, yara_rules)
244 }
245 None => (
246 if bomb {
247 skip("Not a recognized archive")
248 } else {
249 skip("Archive check skipped for cover images")
250 },
251 nested(
252 LayerVerdict::Skip,
253 "Not an archive; no interior to scan".to_string(),
254 ),
255 ),
256 }
257 }
258
259 /// Bomb-defense result to surface when the bomb dimension is disabled (covers).
260 fn bomb_disabled_archive() -> LayerResult {
261 skip("Archive check skipped for cover images")
262 }
263
264 /// Walk a ZIP once, computing the bomb-defense verdict (when `bomb`) and the
265 /// interior content verdict (when `content`). Returns `(archive, archive_nested)`.
266 fn walk_zip<R: std::io::Read + std::io::Seek>(
267 reader: R,
268 bomb: bool,
269 content: bool,
270 yara_rules: Option<&yara_x::Rules>,
271 ) -> (LayerResult, LayerResult) {
272 let mut archive = match zip::ZipArchive::new(reader) {
273 Ok(a) => a,
274 Err(e) => {
275 return (
276 if bomb {
277 LayerResult {
278 layer: "archive",
279 verdict: LayerVerdict::Error,
280 detail: Some(format!("Failed to parse ZIP: {e}")),
281 }
282 } else {
283 bomb_disabled_archive()
284 },
285 if content {
286 nested(LayerVerdict::Error, format!("cannot open nested ZIP: {e}"))
287 } else {
288 nested(LayerVerdict::Skip, String::new())
289 },
290 );
291 }
292 };
293
294 let count = archive.len();
295
296 // `archive_res`/`nested_res` freeze the first non-clean verdict for each
297 // dimension; the loop keeps running for the OTHER dimension so a bomb Fail
298 // and a content Fail are both surfaced (and a later bomb can still upgrade
299 // a merely-held file to a quarantine).
300 let mut archive_res: Option<LayerResult> = None;
301 let mut nested_res: Option<LayerResult> = None;
302
303 if count > constants::SCAN_ZIP_MAX_ENTRIES {
304 if bomb {
305 archive_res = Some(LayerResult {
306 layer: "archive",
307 verdict: LayerVerdict::Fail,
308 detail: Some(format!(
309 "ZIP entry count {count} exceeds limit {}",
310 constants::SCAN_ZIP_MAX_ENTRIES
311 )),
312 });
313 }
314 if content {
315 nested_res = Some(nested(
316 LayerVerdict::Error,
317 format!(
318 "nested ZIP entry count {count} exceeds limit {}",
319 constants::SCAN_ZIP_MAX_ENTRIES
320 ),
321 ));
322 }
323 return (
324 archive_res.unwrap_or_else(bomb_disabled_archive),
325 nested_res.unwrap_or_else(|| nested(LayerVerdict::Skip, String::new())),
326 );
327 }
328
329 let mut total_compressed: u64 = 0;
330 let mut total_uncompressed: u64 = 0;
331 let mut interior_budget: u64 = constants::SCAN_ZIP_MAX_UNCOMPRESSED;
332
333 for i in 0..count {
334 // Once both dimensions are settled, nothing more to learn.
335 let bomb_active = bomb && archive_res.is_none();
336 let content_active = content && nested_res.is_none();
337 if !bomb_active && !content_active {
338 break;
339 }
340
341 let (name, entry_compressed, claimed_size) = match archive.by_index_raw(i) {
342 Ok(e) => (e.name().to_string(), e.compressed_size(), e.size()),
343 Err(e) => {
344 if bomb_active {
345 archive_res = Some(LayerResult {
346 layer: "archive",
347 verdict: LayerVerdict::Error,
348 detail: Some(format!("Failed to read ZIP entry {i}: {e}")),
349 });
350 }
351 if content_active {
352 nested_res = Some(nested(
353 LayerVerdict::Error,
354 format!("read nested ZIP entry {i}: {e}"),
355 ));
356 }
357 continue;
358 }
359 };
360
361 // Path traversal is a bomb-dimension failure (the nested walk never
362 // checked entry names).
363 if bomb_active {
364 let name_lower = name.to_ascii_lowercase();
365 if name.contains("../")
366 || name.contains("..\\")
367 || name_lower.contains("%2e%2e")
368 || name.starts_with('/')
369 || name.contains('\0')
370 {
371 archive_res = Some(LayerResult {
372 layer: "archive",
373 verdict: LayerVerdict::Fail,
374 detail: Some(format!("Path traversal in entry: {name}")),
375 });
376 // Bomb verdict frozen; keep going only if content still needs us.
377 if !content_active {
378 break;
379 }
380 }
381 }
382
383 // Decompress the entry exactly once, teeing the full size (bomb) and the
384 // buffered prefix (content). Stop early once the entry blows past
385 // `compressed * MAX_RATIO` rather than always decompressing to the 2 GB
386 // absolute cap, a high-ratio entry is flagged below regardless, so there
387 // is no reason to expand it fully first.
388 let entry_stop_limit = entry_bomb_stop_limit(entry_compressed);
389 let want_content = content && nested_res.is_none();
390 let (counted, content_buf, decode_err) = match archive.by_index(i) {
391 Ok(mut entry) => match tee_decompress(
392 &mut entry,
393 entry_stop_limit,
394 want_content,
395 &mut interior_budget,
396 ) {
397 Ok((c, cb)) => (c, cb, None),
398 Err((c, why)) => (c, None, Some(why)),
399 },
400 // Could not open the entry to decompress: bomb uses a conservative
401 // estimate; content is held for review.
402 Err(e) => (
403 claimed_size.saturating_mul(10).max(1024 * 1024),
404 None,
405 Some(format!("{e}")),
406 ),
407 };
408
409 // Bomb accounting (skipped once the bomb verdict is frozen).
410 if bomb && archive_res.is_none() {
411 // A decode error mid-stream gets the conservative estimate rather
412 // than trusting the attacker-controlled claimed size.
413 let actual_size = if decode_err.is_some() {
414 claimed_size.saturating_mul(10).max(1024 * 1024)
415 } else {
416 counted
417 };
418 if actual_size > constants::SCAN_ZIP_MAX_UNCOMPRESSED {
419 archive_res = Some(LayerResult {
420 layer: "archive",
421 verdict: LayerVerdict::Fail,
422 detail: Some(format!(
423 "Actual decompressed size exceeds {} bytes (possible ZIP bomb)",
424 constants::SCAN_ZIP_MAX_UNCOMPRESSED
425 )),
426 });
427 } else {
428 total_compressed += entry_compressed;
429 total_uncompressed += actual_size;
430 // Stop as soon as the cumulative uncompressed size blows the
431 // budget, don't keep expanding the remaining entries just to
432 // check the total after the loop. Sub-64-KiB entries skip the
433 // per-entry ratio floor below, so a 100k-entry archive of tiny
434 // ultra-compressible members could otherwise force multi-GB of
435 // cumulative decompression before the post-loop guard trips
436 // (Run 20 Perf). The finalizer turns this into a Fail verdict.
437 if total_uncompressed > constants::SCAN_ZIP_MAX_UNCOMPRESSED {
438 break;
439 }
440 // Per-entry ratio is a fast-path signal; the accumulation vector
441 // (many small ultra-compressed entries) is the total-ratio guard's
442 // job below. The size floor keeps tiny, naturally-compressible
443 // files (text/JSON/SVG) from tripping the 100x ratio, lowered
444 // from 1 MiB to 64 KiB so mid-size bombs are caught at the entry
445 // level too, where a >100x ratio is already anomalous (Run 11 Sec
446 // LOW).
447 if entry_compressed > 0 && actual_size >= 64 * 1024 {
448 let entry_ratio = actual_size as f64 / entry_compressed as f64;
449 if entry_ratio > constants::SCAN_ZIP_MAX_RATIO {
450 archive_res = Some(LayerResult {
451 layer: "archive",
452 verdict: LayerVerdict::Fail,
453 detail: Some(format!(
454 "Entry {name} compression ratio {entry_ratio:.1}x exceeds limit of {:.0}x (possible ZIP bomb)",
455 constants::SCAN_ZIP_MAX_RATIO
456 )),
457 });
458 }
459 }
460 }
461 }
462
463 // Interior content scan (skipped once the nested verdict is frozen).
464 if content && nested_res.is_none() {
465 if let Some(why) = decode_err {
466 nested_res = Some(nested(
467 LayerVerdict::Error,
468 format!("decode nested entry: {why}"),
469 ));
470 } else {
471 match content_buf {
472 Some(ContentBuf::Buffered(bytes)) => {
473 if let Some(v) = scan_entry(
474 &bytes,
475 yara_rules,
476 constants::SCAN_ZIP_MAX_DEPTH,
477 &mut interior_budget,
478 ) {
479 nested_res = Some(v);
480 }
481 }
482 Some(ContentBuf::Overflow) => {
483 nested_res = Some(nested(
484 LayerVerdict::Error,
485 format!(
486 "nested entry exceeds {INTERIOR_ENTRY_MAX}-byte interior scan ceiling"
487 ),
488 ));
489 }
490 Some(ContentBuf::BudgetExceeded) => {
491 nested_res = Some(nested(
492 LayerVerdict::Error,
493 "nested archive content exceeds total interior scan budget".to_string(),
494 ));
495 }
496 None => {}
497 }
498 }
499 }
500 }
501
502 // Finalize bomb verdict from the totals if nothing tripped mid-walk.
503 let archive_result = match archive_res {
504 Some(r) => r,
505 None if !bomb => bomb_disabled_archive(),
506 None => {
507 if total_uncompressed > constants::SCAN_ZIP_MAX_UNCOMPRESSED {
508 LayerResult {
509 layer: "archive",
510 verdict: LayerVerdict::Fail,
511 detail: Some(format!(
512 "Total uncompressed size {total_uncompressed} bytes exceeds limit of {} bytes",
513 constants::SCAN_ZIP_MAX_UNCOMPRESSED
514 )),
515 }
516 } else if total_compressed > 0
517 && (total_uncompressed as f64 / total_compressed as f64)
518 > constants::SCAN_ZIP_MAX_RATIO
519 {
520 LayerResult {
521 layer: "archive",
522 verdict: LayerVerdict::Fail,
523 detail: Some(format!(
524 "Compression ratio {:.1}x exceeds limit of {:.0}x (possible ZIP bomb)",
525 total_uncompressed as f64 / total_compressed as f64,
526 constants::SCAN_ZIP_MAX_RATIO
527 )),
528 }
529 } else {
530 LayerResult {
531 layer: "archive",
532 verdict: LayerVerdict::Pass,
533 detail: Some(format!(
534 "{count} entries, {:.1}x ratio",
535 if total_compressed > 0 {
536 total_uncompressed as f64 / total_compressed as f64
537 } else {
538 0.0
539 }
540 )),
541 }
542 }
543 }
544 };
545
546 let nested_result = match nested_res {
547 Some(r) => r,
548 None if !content => nested(LayerVerdict::Skip, String::new()),
549 None => nested(
550 LayerVerdict::Pass,
551 "Archive interior fully scanned; no threats found".to_string(),
552 ),
553 };
554
555 (archive_result, nested_result)
556 }
557
558 /// Walk a single-stream compressor (gzip/bzip2/xz/zstd) once, computing the
559 /// bomb-defense verdict (when `bomb`) and the interior content verdict (when
560 /// `content`). Returns `(archive, archive_nested)`.
561 fn walk_compressed<R: Read>(
562 kind: ArchiveKind,
563 compressed_size: u64,
564 reader: R,
565 bomb: bool,
566 content: bool,
567 yara_rules: Option<&yara_x::Rules>,
568 ) -> (LayerResult, LayerResult) {
569 let mut decoder: Box<dyn Read> = match kind {
570 ArchiveKind::Gzip => Box::new(flate2::read::MultiGzDecoder::new(reader)),
571 ArchiveKind::Bzip2 => Box::new(bzip2::read::BzDecoder::new(reader)),
572 ArchiveKind::Xz => Box::new(xz2::read::XzDecoder::new(reader)),
573 ArchiveKind::Zstd => match zstd::stream::read::Decoder::new(reader) {
574 Ok(d) => Box::new(d),
575 Err(e) => {
576 let why = format!("zstd init failed: {e}");
577 return (
578 if bomb {
579 error(why.clone())
580 } else {
581 bomb_disabled_archive()
582 },
583 if content {
584 nested(LayerVerdict::Error, why)
585 } else {
586 nested(LayerVerdict::Skip, String::new())
587 },
588 );
589 }
590 },
591 ArchiveKind::Zip => unreachable!("zip is handled by walk_zip"),
592 };
593
594 let abs_limit = constants::SCAN_ZIP_MAX_UNCOMPRESSED;
595 let ratio_limit = compressed_size.saturating_mul(constants::SCAN_ZIP_MAX_RATIO as u64);
596 let mut interior_budget: u64 = constants::SCAN_ZIP_MAX_UNCOMPRESSED;
597
598 // Stop expanding once the stream blows past `compressed * MAX_RATIO` rather
599 // than always running to the 2 GB absolute cap; the ratio verdict below fires
600 // identically on the early-stopped count.
601 let stop_limit = entry_bomb_stop_limit(compressed_size);
602 let (counted, content_buf, decode_err) =
603 match tee_decompress(decoder.as_mut(), stop_limit, content, &mut interior_budget) {
604 Ok((c, cb)) => (c, cb, None),
605 Err((c, why)) => (c, None, Some(why)),
606 };
607
608 // Bomb verdict.
609 let archive_result = if !bomb {
610 bomb_disabled_archive()
611 } else if let Some(ref why) = decode_err {
612 // Mid-stream decode error is suspicious; fail closed for review.
613 error(format!("{} decode error: {why}", kind.label()))
614 } else if counted > abs_limit {
615 LayerResult {
616 layer: "archive",
617 verdict: LayerVerdict::Fail,
618 detail: Some(format!(
619 "{} stream decompresses past {abs_limit} bytes (possible decompression bomb)",
620 kind.label()
621 )),
622 }
623 } else if compressed_size > 0 && counted > ratio_limit {
624 LayerResult {
625 layer: "archive",
626 verdict: LayerVerdict::Fail,
627 detail: Some(format!(
628 "{} compression ratio exceeds {:.0}x (possible decompression bomb)",
629 kind.label(),
630 constants::SCAN_ZIP_MAX_RATIO
631 )),
632 }
633 } else {
634 LayerResult {
635 layer: "archive",
636 verdict: LayerVerdict::Pass,
637 detail: Some(format!(
638 "{} stream, {counted} bytes uncompressed ({:.1}x)",
639 kind.label(),
640 if compressed_size > 0 {
641 counted as f64 / compressed_size as f64
642 } else {
643 0.0
644 }
645 )),
646 }
647 };
648
649 // Interior content verdict.
650 let nested_result = if !content {
651 nested(LayerVerdict::Skip, String::new())
652 } else if let Some(why) = decode_err {
653 nested(LayerVerdict::Error, format!("decode nested entry: {why}"))
654 } else {
655 match content_buf {
656 Some(ContentBuf::Buffered(bytes)) => scan_entry(
657 &bytes,
658 yara_rules,
659 constants::SCAN_ZIP_MAX_DEPTH,
660 &mut interior_budget,
661 )
662 .unwrap_or_else(|| {
663 nested(
664 LayerVerdict::Pass,
665 "Archive interior fully scanned; no threats found".to_string(),
666 )
667 }),
668 Some(ContentBuf::Overflow) => nested(
669 LayerVerdict::Error,
670 format!("nested entry exceeds {INTERIOR_ENTRY_MAX}-byte interior scan ceiling"),
671 ),
672 Some(ContentBuf::BudgetExceeded) => nested(
673 LayerVerdict::Error,
674 "nested archive content exceeds total interior scan budget".to_string(),
675 ),
676 None => nested(
677 LayerVerdict::Pass,
678 "Archive interior fully scanned; no threats found".to_string(),
679 ),
680 }
681 };
682
683 (archive_result, nested_result)
684 }
685
686 /// Path-based entry. Opens the spooled file directly so we never have to
687 /// buffer the whole archive. File-type gating happens at the call site (same
688 /// shape as the buffered variant, caller already checked `file_type`).
689 /// Path-based variant, retained only as a buffered-vs-path equivalence oracle in
690 /// tests. The live pipeline scans the mmap slice via [`check_archive_safety`];
691 /// `#[cfg(test)]` makes wiring this into production a compile error, so the
692 /// nested-interior scan can't be silently dropped on the spool path.
693 #[cfg(test)]
694 pub fn check_archive_safety_path(path: &std::path::Path, file_type: FileType) -> LayerResult {
695 use std::io::{Seek, SeekFrom};
696
697 if file_type == FileType::Cover {
698 return skip("Archive check skipped for cover images");
699 }
700
701 let mut file = match std::fs::File::open(path) {
702 Ok(f) => f,
703 Err(e) => return error(format!("open spool {}: {e}", path.display())),
704 };
705
706 let mut magic = [0u8; 6];
707 let read = file.read(&mut magic).unwrap_or(0);
708 let kind = detect_kind(&magic[..read]);
709
710 if file.seek(SeekFrom::Start(0)).is_err() {
711 return error(format!("seek spool {}", path.display()));
712 }
713
714 // Path variant is bomb-defense only (`content = false`): the interior scan
715 // runs from the in-memory/mmap path through `inspect_archive`. Both share
716 // `walk_zip`/`walk_compressed`, so the bomb verdict matches byte-for-byte.
717 match kind {
718 Some(ArchiveKind::Zip) => walk_zip(file, true, false, None).0,
719 Some(stream) => {
720 let compressed_size = std::fs::metadata(path).map_or(0, |m| m.len());
721 walk_compressed(stream, compressed_size, file, true, false, None).0
722 }
723 None => {
724 // Tail-scan for a prefixed-ZIP central directory. Read up to the
725 // last 64 KiB + 22 bytes rather than the whole (possibly huge) file.
726 let len = std::fs::metadata(path).map_or(0, |m| m.len());
727 let window = 22 + u16::MAX as u64;
728 let start = len.saturating_sub(window);
729 let mut tail = Vec::new();
730 let is_zip = file.seek(SeekFrom::Start(start)).is_ok()
731 && file.read_to_end(&mut tail).is_ok()
732 && has_zip_eocd(&tail);
733 if is_zip {
734 if file.seek(SeekFrom::Start(0)).is_err() {
735 return error(format!("seek spool {}", path.display()));
736 }
737 walk_zip(file, true, false, None).0
738 } else {
739 skip("Not a recognized archive")
740 }
741 }
742 }
743 }
744
745 fn skip(detail: &str) -> LayerResult {
746 LayerResult {
747 layer: "archive",
748 verdict: LayerVerdict::Skip,
749 detail: Some(detail.to_string()),
750 }
751 }
752
753 fn error(detail: String) -> LayerResult {
754 LayerResult {
755 layer: "archive",
756 verdict: LayerVerdict::Error,
757 detail: Some(detail),
758 }
759 }
760
761 /// True if `data` is a container we know how to descend into (offset-0 magic or
762 /// a prefixed-ZIP central directory). Gate for the recursive interior scan.
763 pub fn is_archive(data: &[u8]) -> bool {
764 detect_kind(data).is_some() || has_zip_eocd(data)
765 }
766
767 /// Per-entry ceiling for interior scanning. An entry whose decompressed size
768 /// exceeds this cannot be fully buffered for the in-process layers; rather than
769 /// blow up memory it is reported "not fully scanned" and held for review (the
770 /// bomb-defense walk in `check_archive_safety` independently bounds total size).
771 const INTERIOR_ENTRY_MAX: usize = constants::SCAN_MAX_MEMORY_BYTES;
772
773 fn nested(verdict: LayerVerdict, detail: String) -> LayerResult {
774 LayerResult {
775 layer: "archive_nested",
776 verdict,
777 detail: Some(detail),
778 }
779 }
780
781 /// Recursively inspect the *interior* of an archive: decompress each entry and
782 /// re-feed its bytes through the FailClosed in-process layers (content-type,
783 /// structural, YARA), descending into nested archives up to `SCAN_ZIP_MAX_DEPTH`.
784 ///
785 /// This is the coverage `check_archive_safety` deliberately does NOT provide:
786 /// that walk counts entries and bounds decompression-bomb size; this one scans
787 /// their *content*. The result is the `"archive_nested"` layer:
788 /// - `Pass`, every entry at every depth cleared the scan layers.
789 /// - `Fail`, an entry tripped a content layer (malware / disguised HTML/exe).
790 /// - `Error`, the interior could not be fully scanned (un-openable, over the
791 /// per-entry or total budget, OR nesting deeper than `SCAN_ZIP_MAX_DEPTH`).
792 /// `"archive_nested"` is FailClosed, so a not-fully-scanned interior is held
793 /// for review, never passed Clean. There is no path that descends into an
794 /// archive without either scanning the bytes or emitting this verdict, the
795 /// old "count nested archives but never scan them" branch is gone.
796 pub fn scan_nested_contents(data: &[u8], yara_rules: Option<&yara_x::Rules>) -> LayerResult {
797 // Thin wrapper: the interior verdict is the second half of the single-pass
798 // `inspect_archive`. `Download` is non-cover, so the bomb dimension also runs
799 // here (discarded), callers on the hot path use `inspect_archive` directly
800 // to get both verdicts from one decompression.
801 inspect_archive(data, FileType::Download, yara_rules).1
802 }
803
804 /// Descend one archive level. `Some(verdict)` short-circuits with a non-clean
805 /// result; `None` means everything at and below this level was clean.
806 fn scan_interior(
807 data: &[u8],
808 yara_rules: Option<&yara_x::Rules>,
809 depth: u32,
810 budget: &mut u64,
811 ) -> Option<LayerResult> {
812 match detect_kind(data) {
813 Some(ArchiveKind::Zip) => scan_zip_interior(Cursor::new(data), yara_rules, depth, budget),
814 Some(stream) => match decompress_one_bounded(stream, Cursor::new(data), budget) {
815 Ok(bytes) => scan_entry(&bytes, yara_rules, depth, budget),
816 Err(why) => Some(nested(LayerVerdict::Error, why)),
817 },
818 None if has_zip_eocd(data) => {
819 scan_zip_interior(Cursor::new(data), yara_rules, depth, budget)
820 }
821 None => None,
822 }
823 }
824
825 fn scan_zip_interior<R: Read + std::io::Seek>(
826 reader: R,
827 yara_rules: Option<&yara_x::Rules>,
828 depth: u32,
829 budget: &mut u64,
830 ) -> Option<LayerResult> {
831 let mut archive = match zip::ZipArchive::new(reader) {
832 Ok(a) => a,
833 Err(e) => {
834 return Some(nested(
835 LayerVerdict::Error,
836 format!("cannot open nested ZIP: {e}"),
837 ));
838 }
839 };
840 if archive.len() > constants::SCAN_ZIP_MAX_ENTRIES {
841 return Some(nested(
842 LayerVerdict::Error,
843 format!(
844 "nested ZIP entry count {} exceeds limit {}",
845 archive.len(),
846 constants::SCAN_ZIP_MAX_ENTRIES
847 ),
848 ));
849 }
850 for i in 0..archive.len() {
851 let bytes = match read_zip_entry_bounded(&mut archive, i, budget) {
852 Ok(b) => b,
853 Err(why) => return Some(nested(LayerVerdict::Error, why)),
854 };
855 if let Some(v) = scan_entry(&bytes, yara_rules, depth, budget) {
856 return Some(v);
857 }
858 }
859 None
860 }
861
862 /// Run an entry's decompressed bytes through the FailClosed in-process layers,
863 /// then recurse if the entry is itself an archive.
864 fn scan_entry(
865 bytes: &[u8],
866 yara_rules: Option<&yara_x::Rules>,
867 depth: u32,
868 budget: &mut u64,
869 ) -> Option<LayerResult> {
870 let yara_result = match yara_rules {
871 Some(rules) => super::yara::scan_with_yara(rules, bytes),
872 None => LayerResult {
873 layer: "yara",
874 verdict: LayerVerdict::Skip,
875 detail: None,
876 },
877 };
878 // Inner bundle content is downloadable-by-a-buyer, so it is checked as a
879 // `Download`: content-type catches disguised HTML/SVG, structural catches
880 // executables, YARA catches signatures (EICAR, script-in-binary).
881 let checks = [
882 super::content_type::verify_content_type(bytes, FileType::Download),
883 super::structural::analyze_binary(bytes, FileType::Download),
884 yara_result,
885 ];
886 for r in checks {
887 match r.verdict {
888 LayerVerdict::Fail => {
889 return Some(nested(
890 LayerVerdict::Fail,
891 format!(
892 "nested archive entry flagged by {}: {}",
893 r.layer,
894 r.detail.unwrap_or_default()
895 ),
896 ));
897 }
898 LayerVerdict::Error if super::error_policy_for(r.layer) == ErrorPolicy::FailClosed => {
899 return Some(nested(
900 LayerVerdict::Error,
901 format!(
902 "nested archive entry: {} layer errored: {}",
903 r.layer,
904 r.detail.unwrap_or_default()
905 ),
906 ));
907 }
908 _ => {}
909 }
910 }
911 if is_archive(bytes) {
912 if depth == 0 {
913 return Some(nested(
914 LayerVerdict::Error,
915 "nested archive exceeds maximum scan depth; held for review".to_string(),
916 ));
917 }
918 return scan_interior(bytes, yara_rules, depth - 1, budget);
919 }
920 None
921 }
922
923 fn read_zip_entry_bounded<R: Read + std::io::Seek>(
924 archive: &mut zip::ZipArchive<R>,
925 index: usize,
926 budget: &mut u64,
927 ) -> Result<Vec<u8>, String> {
928 let mut entry = archive
929 .by_index(index)
930 .map_err(|e| format!("read nested ZIP entry {index}: {e}"))?;
931 read_bounded(&mut entry, budget)
932 }
933
934 fn decompress_one_bounded<R: Read>(
935 kind: ArchiveKind,
936 reader: R,
937 budget: &mut u64,
938 ) -> Result<Vec<u8>, String> {
939 let mut decoder: Box<dyn Read> = match kind {
940 ArchiveKind::Gzip => Box::new(flate2::read::MultiGzDecoder::new(reader)),
941 ArchiveKind::Bzip2 => Box::new(bzip2::read::BzDecoder::new(reader)),
942 ArchiveKind::Xz => Box::new(xz2::read::XzDecoder::new(reader)),
943 ArchiveKind::Zstd => zstd::stream::read::Decoder::new(reader)
944 .map(|d| Box::new(d) as Box<dyn Read>)
945 .map_err(|e| format!("zstd init failed: {e}"))?,
946 ArchiveKind::Zip => return Err("zip handled separately".to_string()),
947 };
948 read_bounded(decoder.as_mut(), budget)
949 }
950
951 /// Read a decompressed entry into a Vec, enforcing the per-entry ceiling and the
952 /// shared total budget. Either overflow, or a mid-stream decode error, is a
953 /// "not fully scanned" condition (the caller fails closed).
954 fn read_bounded(reader: &mut dyn Read, budget: &mut u64) -> Result<Vec<u8>, String> {
955 let mut out: Vec<u8> = Vec::new();
956 let mut buf = [0u8; 8192];
957 loop {
958 match reader.read(&mut buf) {
959 Ok(0) => break,
960 Ok(n) => {
961 if out.len() + n > INTERIOR_ENTRY_MAX {
962 return Err(format!(
963 "nested entry exceeds {INTERIOR_ENTRY_MAX}-byte interior scan ceiling"
964 ));
965 }
966 if n as u64 > *budget {
967 return Err(
968 "nested archive content exceeds total interior scan budget".to_string()
969 );
970 }
971 *budget -= n as u64;
972 out.extend_from_slice(&buf[..n]);
973 }
974 Err(e) => return Err(format!("decode nested entry: {e}")),
975 }
976 }
977 Ok(out)
978 }
979
980 #[cfg(test)]
981 mod tests {
982 use super::*;
983 use zip::write::SimpleFileOptions;
984
985 fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
986 let buf = Vec::new();
987 let cursor = Cursor::new(buf);
988 let mut writer = zip::ZipWriter::new(cursor);
989 let options =
990 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
991 for (name, data) in entries {
992 writer.start_file(*name, options).unwrap();
993 std::io::Write::write_all(&mut writer, data).unwrap();
994 }
995 writer.finish().unwrap().into_inner()
996 }
997
998 fn make_compressed_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
999 let buf = Vec::new();
1000 let cursor = Cursor::new(buf);
1001 let mut writer = zip::ZipWriter::new(cursor);
1002 let options =
1003 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1004 for (name, data) in entries {
1005 writer.start_file(*name, options).unwrap();
1006 std::io::Write::write_all(&mut writer, data).unwrap();
1007 }
1008 writer.finish().unwrap().into_inner()
1009 }
1010
1011 // Skip behavior
1012
1013 #[test]
1014 fn non_zip_skipped() {
1015 let result = check_archive_safety(b"not a zip file", FileType::Download);
1016 assert_eq!(result.verdict, LayerVerdict::Skip);
1017 }
1018
1019 #[test]
1020 fn audio_non_zip_skipped() {
1021 let result = check_archive_safety(b"audio data", FileType::Audio);
1022 assert_eq!(result.verdict, LayerVerdict::Skip);
1023 }
1024
1025 // 7z / RAR: no pure-Rust bomb checker (R6-Sec-L2)
1026
1027 #[test]
1028 fn sevenzip_rejected_for_non_download() {
1029 let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0];
1030 let result = check_archive_safety(&data, FileType::Cover);
1031 assert_eq!(result.verdict, LayerVerdict::Error);
1032 }
1033
1034 #[test]
1035 fn sevenzip_allowed_for_download() {
1036 // Download keeps the ClamAV backstop; the in-process layer doesn't reject.
1037 let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0];
1038 let result = check_archive_safety(&data, FileType::Download);
1039 assert_ne!(result.verdict, LayerVerdict::Error);
1040 }
1041
1042 #[test]
1043 fn rar_rejected_for_non_download() {
1044 let data = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0, 0, 0];
1045 let result = check_archive_safety(&data, FileType::MediaImage);
1046 assert_eq!(result.verdict, LayerVerdict::Error);
1047 }
1048
1049 #[test]
1050 fn cover_zip_skipped() {
1051 // A ZIP file claimed as cover should be skipped (layer 1 handles type mismatch)
1052 let data = make_zip(&[("test.txt", b"hello")]);
1053 let result = check_archive_safety(&data, FileType::Cover);
1054 assert_eq!(result.verdict, LayerVerdict::Skip);
1055 }
1056
1057 // Valid archives
1058
1059 #[test]
1060 fn valid_zip_passes() {
1061 let data = make_zip(&[("test.txt", b"hello world")]);
1062 let result = check_archive_safety(&data, FileType::Download);
1063 assert_eq!(result.verdict, LayerVerdict::Pass);
1064 }
1065
1066 #[test]
1067 fn empty_zip_passes() {
1068 let buf = Vec::new();
1069 let cursor = Cursor::new(buf);
1070 let writer = zip::ZipWriter::new(cursor);
1071 let data = writer.finish().unwrap().into_inner();
1072 // Empty ZIPs may not have the PK magic at offset 0, they'd just be
1073 // an end-of-central-directory record. If it doesn't start with PK 03 04,
1074 // we'll skip it. That's fine.
1075 let result = check_archive_safety(&data, FileType::Download);
1076 // Either Skip (no local file header) or Pass (valid empty ZIP)
1077 assert!(
1078 result.verdict == LayerVerdict::Skip || result.verdict == LayerVerdict::Pass,
1079 "unexpected verdict: {:?}",
1080 result.verdict
1081 );
1082 }
1083
1084 #[test]
1085 fn multi_entry_zip_passes() {
1086 let data = make_zip(&[
1087 ("file1.txt", b"content one"),
1088 ("subdir/file2.txt", b"content two"),
1089 ("readme.md", b"# hello"),
1090 ]);
1091 let result = check_archive_safety(&data, FileType::Download);
1092 assert_eq!(result.verdict, LayerVerdict::Pass);
1093 assert!(result.detail.unwrap().contains("3 entries"));
1094 }
1095
1096 // Path traversal
1097
1098 #[test]
1099 fn zip_with_forward_slash_traversal_fails() {
1100 let data = make_zip(&[("../../../etc/passwd", b"pwned")]);
1101 let result = check_archive_safety(&data, FileType::Download);
1102 assert_eq!(result.verdict, LayerVerdict::Fail);
1103 assert!(result.detail.unwrap().contains("Path traversal"));
1104 }
1105
1106 #[test]
1107 fn zip_with_backslash_traversal_fails() {
1108 let data = make_zip(&[("..\\..\\Windows\\System32\\config", b"pwned")]);
1109 let result = check_archive_safety(&data, FileType::Download);
1110 assert_eq!(result.verdict, LayerVerdict::Fail);
1111 assert!(result.detail.unwrap().contains("Path traversal"));
1112 }
1113
1114 #[test]
1115 fn zip_with_mid_path_traversal_fails() {
1116 let data = make_zip(&[("safe/../../etc/passwd", b"pwned")]);
1117 let result = check_archive_safety(&data, FileType::Download);
1118 assert_eq!(result.verdict, LayerVerdict::Fail);
1119 }
1120
1121 #[test]
1122 fn zip_with_url_encoded_traversal_fails() {
1123 // %2e%2e is URL-encoded "..". The check is case-insensitive on the encoding.
1124 let data = make_zip(&[("%2E%2E/secrets", b"pwned")]);
1125 let result = check_archive_safety(&data, FileType::Download);
1126 assert_eq!(result.verdict, LayerVerdict::Fail);
1127 assert!(result.detail.unwrap().contains("Path traversal"));
1128 }
1129
1130 #[test]
1131 fn zip_with_absolute_path_fails() {
1132 let data = make_zip(&[("/etc/passwd", b"pwned")]);
1133 let result = check_archive_safety(&data, FileType::Download);
1134 assert_eq!(result.verdict, LayerVerdict::Fail);
1135 assert!(result.detail.unwrap().contains("Path traversal"));
1136 }
1137
1138 #[test]
1139 fn zip_with_null_byte_in_name_fails() {
1140 let data = make_zip(&[("legit.txt\0../escape", b"pwned")]);
1141 let result = check_archive_safety(&data, FileType::Download);
1142 assert_eq!(result.verdict, LayerVerdict::Fail);
1143 assert!(result.detail.unwrap().contains("Path traversal"));
1144 }
1145
1146 // Nesting detection
1147
1148 // Nested-archive interior scanning (`scan_nested_contents`)
1149 //
1150 // The old behavior here merely *counted* nested-archive entries and failed a
1151 // ZIP with more than `SCAN_ZIP_MAX_DEPTH` of them, never inspecting their
1152 // contents, counting is not scanning, so a payload in a zip-in-a-zip passed
1153 // Clean (the run #20→#22 chronic). `check_archive_safety` no longer counts;
1154 // interior coverage is `scan_nested_contents`, exercised below with the real
1155 // rule set so an actual signature in a nested archive is caught or held.
1156
1157 /// The compiled production YARA rules (includes the EICAR test signature).
1158 fn test_yara_rules() -> yara_x::Rules {
1159 super::super::yara::compile_rules_from_dir("yara-rules")
1160 .expect("compile yara-rules")
1161 .0
1162 .expect("yara-rules dir has rules")
1163 }
1164
1165 /// EICAR antivirus test string, matched by `yara-rules/mnw_test_files.yar`.
1166 const EICAR: &[u8] = br"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
1167
1168 #[test]
1169 fn benign_nested_zip_passes() {
1170 let inner = make_zip(&[("hello.txt", b"hello world")]);
1171 let outer = make_zip(&[("inner.zip", &inner), ("notes.txt", b"readme")]);
1172 let result = scan_nested_contents(&outer, Some(&test_yara_rules()));
1173 assert_eq!(result.verdict, LayerVerdict::Pass, "{:?}", result.detail);
1174 }
1175
1176 #[test]
1177 fn eicar_in_zip_in_zip_is_caught() {
1178 // outer.zip -> inner.zip -> evil.txt(EICAR). The interior bytes must
1179 // traverse YARA exactly as a top-level file would: not Clean.
1180 let inner = make_zip(&[("evil.txt", EICAR)]);
1181 let outer = make_zip(&[("inner.zip", &inner)]);
1182 let result = scan_nested_contents(&outer, Some(&test_yara_rules()));
1183 assert_eq!(
1184 result.verdict,
1185 LayerVerdict::Fail,
1186 "EICAR nested two zips deep must be caught, got {:?}",
1187 result.detail
1188 );
1189 }
1190
1191 #[test]
1192 fn eicar_in_single_gzip_is_caught() {
1193 // A standalone gzip member is one logical entry; its decompressed bytes
1194 // must be scanned.
1195 let gz = gzip(EICAR);
1196 let result = scan_nested_contents(&gz, Some(&test_yara_rules()));
1197 assert_eq!(result.verdict, LayerVerdict::Fail, "{:?}", result.detail);
1198 }
1199
1200 #[test]
1201 fn nesting_beyond_scan_depth_is_held_not_passed() {
1202 // SCAN_ZIP_MAX_DEPTH = 2. Wrap a benign file in enough ZIP layers that
1203 // the innermost archive sits past the descent budget; the interior is
1204 // not fully scanned, so it must fail closed (Error -> held), never Clean.
1205 let mut nested = make_zip(&[("leaf.txt", b"benign")]);
1206 for _ in 0..4 {
1207 nested = make_zip(&[("inner.zip", &nested)]);
1208 }
1209 let result = scan_nested_contents(&nested, Some(&test_yara_rules()));
1210 assert_eq!(
1211 result.verdict,
1212 LayerVerdict::Error,
1213 "a nest deeper than the scan depth must be held, got {:?}",
1214 result.detail
1215 );
1216 assert_eq!(
1217 super::super::error_policy_for(result.layer),
1218 ErrorPolicy::FailClosed
1219 );
1220 }
1221
1222 #[test]
1223 fn non_archive_has_no_interior() {
1224 let result = scan_nested_contents(b"just some plain bytes", Some(&test_yara_rules()));
1225 assert_eq!(result.verdict, LayerVerdict::Skip);
1226 }
1227
1228 #[test]
1229 fn non_archive_extensions_ignored() {
1230 let data = make_zip(&[
1231 ("app.exe", b"binary"),
1232 ("readme.txt", b"hello"),
1233 ("image.png", b"pixels"),
1234 ]);
1235 let result = check_archive_safety(&data, FileType::Download);
1236 assert_eq!(result.verdict, LayerVerdict::Pass);
1237 }
1238
1239 // Compression ratio (ZIP bomb detection)
1240
1241 #[test]
1242 fn high_compression_ratio_fails() {
1243 // Create highly compressible data: repeating zeros compress extremely well
1244 // 1MB of zeros should compress to ~1KB with deflate, giving ratio ~1000x
1245 let zeros = vec![0u8; 1024 * 1024];
1246 let data = make_compressed_zip(&[("bomb.bin", &zeros)]);
1247 let result = check_archive_safety(&data, FileType::Download);
1248 assert_eq!(
1249 result.verdict,
1250 LayerVerdict::Fail,
1251 "Expected Fail for high compression ratio, got: {:?}",
1252 result.detail
1253 );
1254 assert!(result.detail.unwrap().contains("ZIP bomb"));
1255 }
1256
1257 #[test]
1258 fn normal_compression_ratio_passes() {
1259 // Random-ish data doesn't compress well, ratio should be ~1x
1260 let data_bytes: Vec<u8> = (0..10000).map(|i| (i * 37 + 13) as u8).collect();
1261 let data = make_compressed_zip(&[("normal.bin", &data_bytes)]);
1262 let result = check_archive_safety(&data, FileType::Download);
1263 assert_eq!(result.verdict, LayerVerdict::Pass);
1264 }
1265
1266 // Audio file with ZIP magic (disguised archive)
1267
1268 #[test]
1269 fn zip_disguised_as_audio_checked() {
1270 // A ZIP file claimed as Audio should still be checked (not skipped)
1271 let data = make_zip(&[("test.txt", b"hello")]);
1272 let result = check_archive_safety(&data, FileType::Audio);
1273 assert_eq!(result.verdict, LayerVerdict::Pass);
1274 }
1275
1276 #[test]
1277 fn zip_disguised_as_audio_with_traversal_fails() {
1278 let data = make_zip(&[("../../../etc/passwd", b"pwned")]);
1279 let result = check_archive_safety(&data, FileType::Audio);
1280 assert_eq!(result.verdict, LayerVerdict::Fail);
1281 }
1282
1283 // Corrupted ZIP
1284
1285 #[test]
1286 fn corrupted_zip_magic_returns_error() {
1287 // Valid ZIP magic bytes but garbage after
1288 let mut data = vec![0x50, 0x4B, 0x03, 0x04];
1289 data.extend_from_slice(&[0xFF; 100]);
1290 let result = check_archive_safety(&data, FileType::Download);
1291 assert_eq!(result.verdict, LayerVerdict::Error);
1292 assert!(result.detail.unwrap().contains("Failed to parse ZIP"));
1293 }
1294
1295 #[test]
1296 fn path_entry_matches_buffered_for_non_zip() {
1297 let data = b"not a zip at all";
1298 let buffered = check_archive_safety(data, FileType::Download);
1299 let tmp = tempfile::NamedTempFile::new().unwrap();
1300 std::fs::write(tmp.path(), data).unwrap();
1301 let path_based = check_archive_safety_path(tmp.path(), FileType::Download);
1302 assert_eq!(buffered.verdict, path_based.verdict);
1303 assert_eq!(buffered.verdict, LayerVerdict::Skip);
1304 }
1305
1306 #[test]
1307 fn path_entry_matches_buffered_for_cover_skip() {
1308 let mut data = vec![0x50, 0x4B, 0x03, 0x04];
1309 data.extend_from_slice(&[0xFF; 100]);
1310 let buffered = check_archive_safety(&data, FileType::Cover);
1311 let tmp = tempfile::NamedTempFile::new().unwrap();
1312 std::fs::write(tmp.path(), &data).unwrap();
1313 let path_based = check_archive_safety_path(tmp.path(), FileType::Cover);
1314 assert_eq!(buffered.verdict, path_based.verdict);
1315 assert_eq!(buffered.verdict, LayerVerdict::Skip);
1316 }
1317
1318 // Single-stream decompression bombs (gzip / bzip2 / xz / zstd)
1319
1320 use std::io::Write;
1321
1322 fn gzip(data: &[u8]) -> Vec<u8> {
1323 let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
1324 e.write_all(data).unwrap();
1325 e.finish().unwrap()
1326 }
1327 fn bzip2_compress(data: &[u8]) -> Vec<u8> {
1328 let mut e = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::new(9));
1329 e.write_all(data).unwrap();
1330 e.finish().unwrap()
1331 }
1332 fn xz(data: &[u8]) -> Vec<u8> {
1333 let mut e = xz2::write::XzEncoder::new(Vec::new(), 9);
1334 e.write_all(data).unwrap();
1335 e.finish().unwrap()
1336 }
1337 fn zstd_compress(data: &[u8]) -> Vec<u8> {
1338 zstd::encode_all(data, 19).unwrap()
1339 }
1340
1341 /// 8 MiB of zeros, compresses to a tiny stream at a ratio far above the
1342 /// 100x cap, the canonical decompression-bomb shape.
1343 fn bomb_payload() -> Vec<u8> {
1344 vec![0u8; 8 * 1024 * 1024]
1345 }
1346
1347 /// Moderately-incompressible data: stays well under the ratio cap, so a
1348 /// legitimate compressed download passes.
1349 fn benign_payload() -> Vec<u8> {
1350 (0..200_000u32)
1351 .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
1352 .collect()
1353 }
1354
1355 #[test]
1356 fn gzip_bomb_fails() {
1357 let data = gzip(&bomb_payload());
1358 let result = check_archive_safety(&data, FileType::Download);
1359 assert_eq!(
1360 result.verdict,
1361 LayerVerdict::Fail,
1362 "detail: {:?}",
1363 result.detail
1364 );
1365 assert!(result.detail.unwrap().to_lowercase().contains("bomb"));
1366 }
1367
1368 #[test]
1369 fn benign_gzip_passes() {
1370 let data = gzip(&benign_payload());
1371 let result = check_archive_safety(&data, FileType::Download);
1372 assert_eq!(
1373 result.verdict,
1374 LayerVerdict::Pass,
1375 "detail: {:?}",
1376 result.detail
1377 );
1378 }
1379
1380 #[test]
1381 fn bzip2_bomb_fails() {
1382 let data = bzip2_compress(&bomb_payload());
1383 let result = check_archive_safety(&data, FileType::Download);
1384 assert_eq!(
1385 result.verdict,
1386 LayerVerdict::Fail,
1387 "detail: {:?}",
1388 result.detail
1389 );
1390 }
1391
1392 #[test]
1393 fn xz_bomb_fails() {
1394 let data = xz(&bomb_payload());
1395 let result = check_archive_safety(&data, FileType::Download);
1396 assert_eq!(
1397 result.verdict,
1398 LayerVerdict::Fail,
1399 "detail: {:?}",
1400 result.detail
1401 );
1402 }
1403
1404 #[test]
1405 fn zstd_bomb_fails() {
1406 let data = zstd_compress(&bomb_payload());
1407 let result = check_archive_safety(&data, FileType::Download);
1408 assert_eq!(
1409 result.verdict,
1410 LayerVerdict::Fail,
1411 "detail: {:?}",
1412 result.detail
1413 );
1414 }
1415
1416 #[test]
1417 fn gzip_bomb_caught_on_path_variant_too() {
1418 let data = gzip(&bomb_payload());
1419 let tmp = tempfile::NamedTempFile::new().unwrap();
1420 std::fs::write(tmp.path(), &data).unwrap();
1421 let result = check_archive_safety_path(tmp.path(), FileType::Download);
1422 assert_eq!(
1423 result.verdict,
1424 LayerVerdict::Fail,
1425 "detail: {:?}",
1426 result.detail
1427 );
1428 }
1429
1430 #[test]
1431 fn gzip_bomb_skipped_for_cover() {
1432 // Type mismatch is layer 1's job; the archive layer skips covers.
1433 let data = gzip(&bomb_payload());
1434 let result = check_archive_safety(&data, FileType::Cover);
1435 assert_eq!(result.verdict, LayerVerdict::Skip);
1436 }
1437
1438 // Prefixed / self-extracting ZIP (no offset-0 magic)
1439
1440 #[test]
1441 fn prefixed_zip_is_not_silently_skipped() {
1442 // A real ZIP with arbitrary bytes prepended (the self-extracting-stub
1443 // shape). It lacks the offset-0 PK\x03\x04 magic, so the old offset-0
1444 // gate would Skip it. The tail EOCD scan must catch it and hand it to
1445 // inspect_zip, the security property is that it is NOT Skipped.
1446 let zip = make_zip(&[("readme.txt", b"hello")]);
1447 let mut data = b"MZ\x90\x00 this is a self-extracting stub padding ".to_vec();
1448 data.extend_from_slice(&zip);
1449
1450 let result = check_archive_safety(&data, FileType::Download);
1451 assert_ne!(
1452 result.verdict,
1453 LayerVerdict::Skip,
1454 "prefixed ZIP must be inspected, not skipped; got {:?}",
1455 result.detail
1456 );
1457
1458 // And on the path variant.
1459 let tmp = tempfile::NamedTempFile::new().unwrap();
1460 std::fs::write(tmp.path(), &data).unwrap();
1461 let path_based = check_archive_safety_path(tmp.path(), FileType::Download);
1462 assert_ne!(
1463 path_based.verdict,
1464 LayerVerdict::Skip,
1465 "detail: {:?}",
1466 path_based.detail
1467 );
1468 }
1469
1470 #[test]
1471 fn prefixed_zip_bomb_fails() {
1472 // Prepend a stub to a high-ratio ZIP; it must still be caught.
1473 let zeros = vec![0u8; 1024 * 1024];
1474 let zip = make_compressed_zip(&[("bomb.bin", &zeros)]);
1475 let mut data = b"self-extracting stub ".to_vec();
1476 data.extend_from_slice(&zip);
1477 let result = check_archive_safety(&data, FileType::Download);
1478 assert_eq!(
1479 result.verdict,
1480 LayerVerdict::Fail,
1481 "detail: {:?}",
1482 result.detail
1483 );
1484 }
1485
1486 #[test]
1487 fn prefixed_7z_polyglot_rejected_for_non_download() {
1488 // [PNG header][7z magic][junk]: sniffs as a PNG (passing the content-type
1489 // layer) but carries a 7z payload past offset 0. The offset-0-only magic
1490 // check missed this and fell through to ClamAV FailOpen (Sec-S1); the
1491 // whole-buffer window scan now rejects it for non-Download uploads.
1492 let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
1493 data.extend_from_slice(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]); // 7z magic
1494 data.extend_from_slice(&[0u8; 64]);
1495
1496 // Cover / image: held for review (Error), not passed to ClamAV alone.
1497 let cover = check_archive_safety(&data, FileType::Cover);
1498 assert_eq!(
1499 cover.verdict,
1500 LayerVerdict::Error,
1501 "detail: {:?}",
1502 cover.detail
1503 );
1504 assert!(cover.detail.unwrap().contains("7z"));
1505
1506 // Download keeps the ClamAV backstop, this layer must not reject it.
1507 let download = check_archive_safety(&data, FileType::Download);
1508 assert_ne!(
1509 download.verdict,
1510 LayerVerdict::Error,
1511 "Download must keep the ClamAV backstop, not be rejected by the container check"
1512 );
1513 }
1514
1515 #[test]
1516 fn prefixed_rar_polyglot_rejected_for_non_download() {
1517 // Same evasion shape with a RAR signature embedded after a JPEG header.
1518 let mut data = vec![0xFF, 0xD8, 0xFF, 0xE0]; // JPEG SOI + APP0
1519 data.extend_from_slice(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]); // "Rar!\x1a\x07"
1520 data.extend_from_slice(&[0u8; 64]);
1521 let cover = check_archive_safety(&data, FileType::Cover);
1522 assert_eq!(
1523 cover.verdict,
1524 LayerVerdict::Error,
1525 "detail: {:?}",
1526 cover.detail
1527 );
1528 assert!(cover.detail.unwrap().contains("RAR"));
1529 }
1530 }
1531