Skip to main content

max / makenotwork

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