| 265 |
265 |
|
|
| 266 |
266 |
|
let mut total_compressed: u64 = 0;
|
| 267 |
267 |
|
let mut total_uncompressed: u64 = 0;
|
| 268 |
|
- |
let mut nested_archives: u32 = 0;
|
| 269 |
268 |
|
|
| 270 |
269 |
|
for i in 0..archive.len() {
|
| 271 |
270 |
|
let entry = match archive.by_index_raw(i) {
|
| 300 |
299 |
|
drop(entry);
|
| 301 |
300 |
|
|
| 302 |
301 |
|
// Use actual decompressed byte count instead of trusting the claimed size
|
| 303 |
|
- |
// from the ZIP central directory (which is attacker-controlled).
|
| 304 |
|
- |
// Also capture the first 8 bytes for nested archive magic detection,
|
| 305 |
|
- |
// avoiding a second decompression pass.
|
| 306 |
|
- |
let mut magic_bytes = [0u8; 8];
|
| 307 |
|
- |
let mut magic_len = 0usize;
|
|
302 |
+ |
// from the ZIP central directory (which is attacker-controlled). Inner
|
|
303 |
+ |
// content (including nested archives) is inspected separately by
|
|
304 |
+ |
// `scan_nested_contents`, which re-feeds each entry's decompressed bytes
|
|
305 |
+ |
// through the FailClosed scan layers — this loop is purely the
|
|
306 |
+ |
// compression-bomb / size accounting.
|
| 308 |
307 |
|
let actual_size = match archive.by_index(i) {
|
| 309 |
308 |
|
Ok(mut reader) => {
|
| 310 |
309 |
|
let mut counted: u64 = 0;
|
| 314 |
313 |
|
match std::io::Read::read(&mut reader, &mut buf) {
|
| 315 |
314 |
|
Ok(0) => break,
|
| 316 |
315 |
|
Ok(n) => {
|
| 317 |
|
- |
// Capture first 8 bytes for magic detection
|
| 318 |
|
- |
if magic_len < 8 {
|
| 319 |
|
- |
let copy = n.min(8 - magic_len);
|
| 320 |
|
- |
magic_bytes[magic_len..magic_len + copy].copy_from_slice(&buf[..copy]);
|
| 321 |
|
- |
magic_len += copy;
|
| 322 |
|
- |
}
|
| 323 |
316 |
|
counted += n as u64;
|
| 324 |
317 |
|
if counted > limit {
|
| 325 |
318 |
|
return LayerResult {
|
| 369 |
362 |
|
};
|
| 370 |
363 |
|
}
|
| 371 |
364 |
|
}
|
| 372 |
|
- |
|
| 373 |
|
- |
// Check for nested archives — extension check first, then magic bytes
|
| 374 |
|
- |
// from the first decompression pass (no re-read needed).
|
| 375 |
|
- |
let lower_name = name.to_lowercase();
|
| 376 |
|
- |
let ext_match = lower_name.ends_with(".zip")
|
| 377 |
|
- |
|| lower_name.ends_with(".tar.gz")
|
| 378 |
|
- |
|| lower_name.ends_with(".tgz")
|
| 379 |
|
- |
|| lower_name.ends_with(".7z")
|
| 380 |
|
- |
|| lower_name.ends_with(".rar")
|
| 381 |
|
- |
|| lower_name.ends_with(".tar");
|
| 382 |
|
- |
if ext_match {
|
| 383 |
|
- |
nested_archives += 1;
|
| 384 |
|
- |
} else if actual_size > 0 && magic_len >= 4 {
|
| 385 |
|
- |
let is_nested = matches!(
|
| 386 |
|
- |
magic_bytes,
|
| 387 |
|
- |
[0x50, 0x4B, 0x03, 0x04, ..] // ZIP
|
| 388 |
|
- |
| [0x1F, 0x8B, ..] // gzip (tar.gz)
|
| 389 |
|
- |
| [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, ..] // 7z
|
| 390 |
|
- |
| [0x52, 0x61, 0x72, 0x21, ..] // RAR
|
| 391 |
|
- |
);
|
| 392 |
|
- |
if is_nested {
|
| 393 |
|
- |
nested_archives += 1;
|
| 394 |
|
- |
}
|
| 395 |
|
- |
}
|
| 396 |
365 |
|
}
|
| 397 |
366 |
|
|
| 398 |
367 |
|
// Check total uncompressed size
|
| 424 |
393 |
|
}
|
| 425 |
394 |
|
}
|
| 426 |
395 |
|
|
| 427 |
|
- |
// Check nesting depth
|
| 428 |
|
- |
if nested_archives > constants::SCAN_ZIP_MAX_DEPTH {
|
| 429 |
|
- |
return LayerResult {
|
| 430 |
|
- |
layer: "archive",
|
| 431 |
|
- |
verdict: LayerVerdict::Fail,
|
| 432 |
|
- |
detail: Some(format!(
|
| 433 |
|
- |
"Contains {} nested archives (limit: {})",
|
| 434 |
|
- |
nested_archives,
|
| 435 |
|
- |
constants::SCAN_ZIP_MAX_DEPTH
|
| 436 |
|
- |
)),
|
| 437 |
|
- |
};
|
| 438 |
|
- |
}
|
| 439 |
|
- |
|
| 440 |
396 |
|
LayerResult {
|
| 441 |
397 |
|
layer: "archive",
|
| 442 |
398 |
|
verdict: LayerVerdict::Pass,
|
| 452 |
408 |
|
}
|
| 453 |
409 |
|
}
|
| 454 |
410 |
|
|
|
411 |
+ |
/// True if `data` is a container we know how to descend into (offset-0 magic or
|
|
412 |
+ |
/// a prefixed-ZIP central directory). Gate for the recursive interior scan.
|
|
413 |
+ |
pub fn is_archive(data: &[u8]) -> bool {
|
|
414 |
+ |
detect_kind(data).is_some() || has_zip_eocd(data)
|
|
415 |
+ |
}
|
|
416 |
+ |
|
|
417 |
+ |
/// Per-entry ceiling for interior scanning. An entry whose decompressed size
|
|
418 |
+ |
/// exceeds this cannot be fully buffered for the in-process layers; rather than
|
|
419 |
+ |
/// blow up memory it is reported "not fully scanned" and held for review (the
|
|
420 |
+ |
/// bomb-defense walk in `check_archive_safety` independently bounds total size).
|
|
421 |
+ |
const INTERIOR_ENTRY_MAX: usize = constants::SCAN_MAX_MEMORY_BYTES;
|
|
422 |
+ |
|
|
423 |
+ |
fn nested(verdict: LayerVerdict, detail: String) -> LayerResult {
|
|
424 |
+ |
LayerResult { layer: "archive_nested", verdict, detail: Some(detail) }
|
|
425 |
+ |
}
|
|
426 |
+ |
|
|
427 |
+ |
/// Recursively inspect the *interior* of an archive: decompress each entry and
|
|
428 |
+ |
/// re-feed its bytes through the FailClosed in-process layers (content-type,
|
|
429 |
+ |
/// structural, YARA), descending into nested archives up to `SCAN_ZIP_MAX_DEPTH`.
|
|
430 |
+ |
///
|
|
431 |
+ |
/// This is the coverage `check_archive_safety` deliberately does NOT provide:
|
|
432 |
+ |
/// that walk counts entries and bounds decompression-bomb size; this one scans
|
|
433 |
+ |
/// their *content*. The result is the `"archive_nested"` layer:
|
|
434 |
+ |
/// - `Pass` — every entry at every depth cleared the scan layers.
|
|
435 |
+ |
/// - `Fail` — an entry tripped a content layer (malware / disguised HTML/exe).
|
|
436 |
+ |
/// - `Error` — the interior could not be fully scanned (un-openable, over the
|
|
437 |
+ |
/// per-entry or total budget, OR nesting deeper than `SCAN_ZIP_MAX_DEPTH`).
|
|
438 |
+ |
/// `"archive_nested"` is FailClosed, so a not-fully-scanned interior is held
|
|
439 |
+ |
/// for review, never passed Clean. There is no path that descends into an
|
|
440 |
+ |
/// archive without either scanning the bytes or emitting this verdict — the
|
|
441 |
+ |
/// old "count nested archives but never scan them" branch is gone.
|
|
442 |
+ |
pub fn scan_nested_contents(data: &[u8], yara_rules: Option<&yara_x::Rules>) -> LayerResult {
|
|
443 |
+ |
if !is_archive(data) {
|
|
444 |
+ |
return nested(LayerVerdict::Skip, "Not an archive; no interior to scan".to_string());
|
|
445 |
+ |
}
|
|
446 |
+ |
let mut budget: u64 = constants::SCAN_ZIP_MAX_UNCOMPRESSED;
|
|
447 |
+ |
match scan_interior(data, yara_rules, constants::SCAN_ZIP_MAX_DEPTH, &mut budget) {
|
|
448 |
+ |
Some(result) => result,
|
|
449 |
+ |
None => nested(
|
|
450 |
+ |
LayerVerdict::Pass,
|
|
451 |
+ |
"Archive interior fully scanned; no threats found".to_string(),
|
|
452 |
+ |
),
|
|
453 |
+ |
}
|
|
454 |
+ |
}
|
|
455 |
+ |
|
|
456 |
+ |
/// Descend one archive level. `Some(verdict)` short-circuits with a non-clean
|
|
457 |
+ |
/// result; `None` means everything at and below this level was clean.
|
|
458 |
+ |
fn scan_interior(
|
|
459 |
+ |
data: &[u8],
|
|
460 |
+ |
yara_rules: Option<&yara_x::Rules>,
|
|
461 |
+ |
depth: u32,
|
|
462 |
+ |
budget: &mut u64,
|
|
463 |
+ |
) -> Option<LayerResult> {
|
|
464 |
+ |
match detect_kind(data) {
|
|
465 |
+ |
Some(ArchiveKind::Zip) => scan_zip_interior(Cursor::new(data), yara_rules, depth, budget),
|
|
466 |
+ |
Some(stream) => match decompress_one_bounded(stream, Cursor::new(data), budget) {
|
|
467 |
+ |
Ok(bytes) => scan_entry(&bytes, yara_rules, depth, budget),
|
|
468 |
+ |
Err(why) => Some(nested(LayerVerdict::Error, why)),
|
|
469 |
+ |
},
|
|
470 |
+ |
None if has_zip_eocd(data) => {
|
|
471 |
+ |
scan_zip_interior(Cursor::new(data), yara_rules, depth, budget)
|
|
472 |
+ |
}
|
|
473 |
+ |
None => None,
|
|
474 |
+ |
}
|
|
475 |
+ |
}
|
|
476 |
+ |
|
|
477 |
+ |
fn scan_zip_interior<R: Read + std::io::Seek>(
|
|
478 |
+ |
reader: R,
|
|
479 |
+ |
yara_rules: Option<&yara_x::Rules>,
|
|
480 |
+ |
depth: u32,
|
|
481 |
+ |
budget: &mut u64,
|
|
482 |
+ |
) -> Option<LayerResult> {
|
|
483 |
+ |
let mut archive = match zip::ZipArchive::new(reader) {
|
|
484 |
+ |
Ok(a) => a,
|
|
485 |
+ |
Err(e) => return Some(nested(LayerVerdict::Error, format!("cannot open nested ZIP: {e}"))),
|
|
486 |
+ |
};
|
|
487 |
+ |
if archive.len() > constants::SCAN_ZIP_MAX_ENTRIES {
|
|
488 |
+ |
return Some(nested(
|
|
489 |
+ |
LayerVerdict::Error,
|
|
490 |
+ |
format!(
|
|
491 |
+ |
"nested ZIP entry count {} exceeds limit {}",
|
|
492 |
+ |
archive.len(),
|
|
493 |
+ |
constants::SCAN_ZIP_MAX_ENTRIES
|
|
494 |
+ |
),
|
|
495 |
+ |
));
|
|
496 |
+ |
}
|
|
497 |
+ |
for i in 0..archive.len() {
|
|
498 |
+ |
let bytes = match read_zip_entry_bounded(&mut archive, i, budget) {
|
|
499 |
+ |
Ok(b) => b,
|
|
500 |
+ |
Err(why) => return Some(nested(LayerVerdict::Error, why)),
|
|
501 |
+ |
};
|
|
502 |
+ |
if let Some(v) = scan_entry(&bytes, yara_rules, depth, budget) {
|
|
503 |
+ |
return Some(v);
|
|
504 |
+ |
}
|
|
505 |
+ |
}
|
|
506 |
+ |
None
|
|
507 |
+ |
}
|
|
508 |
+ |
|
|
509 |
+ |
/// Run an entry's decompressed bytes through the FailClosed in-process layers,
|
|
510 |
+ |
/// then recurse if the entry is itself an archive.
|
|
511 |
+ |
fn scan_entry(
|
|
512 |
+ |
bytes: &[u8],
|
|
513 |
+ |
yara_rules: Option<&yara_x::Rules>,
|
|
514 |
+ |
depth: u32,
|
|
515 |
+ |
budget: &mut u64,
|
|
516 |
+ |
) -> Option<LayerResult> {
|
|
517 |
+ |
let yara_result = match yara_rules {
|
|
518 |
+ |
Some(rules) => super::yara::scan_with_yara(rules, bytes),
|
|
519 |
+ |
None => LayerResult { layer: "yara", verdict: LayerVerdict::Skip, detail: None },
|
|
520 |
+ |
};
|
|
521 |
+ |
// Inner bundle content is downloadable-by-a-buyer, so it is checked as a
|
|
522 |
+ |
// `Download`: content-type catches disguised HTML/SVG, structural catches
|
|
523 |
+ |
// executables, YARA catches signatures (EICAR, script-in-binary).
|
|
524 |
+ |
let checks = [
|
|
525 |
+ |
super::content_type::verify_content_type(bytes, FileType::Download),
|
|
526 |
+ |
super::structural::analyze_binary(bytes, FileType::Download),
|
|
527 |
+ |
yara_result,
|
|
528 |
+ |
];
|
|
529 |
+ |
for r in checks {
|
|
530 |
+ |
match r.verdict {
|
|
531 |
+ |
LayerVerdict::Fail => {
|
|
532 |
+ |
return Some(nested(
|
|
533 |
+ |
LayerVerdict::Fail,
|
|
534 |
+ |
format!(
|
|
535 |
+ |
"nested archive entry flagged by {}: {}",
|
|
536 |
+ |
r.layer,
|
|
537 |
+ |
r.detail.unwrap_or_default()
|
|
538 |
+ |
),
|
|
539 |
+ |
));
|
|
540 |
+ |
}
|
|
541 |
+ |
LayerVerdict::Error
|
|
542 |
+ |
if super::error_policy_for(r.layer) == ErrorPolicy::FailClosed =>
|
|
543 |
+ |
{
|
|
544 |
+ |
return Some(nested(
|
|
545 |
+ |
LayerVerdict::Error,
|
|
546 |
+ |
format!(
|
|
547 |
+ |
"nested archive entry: {} layer errored: {}",
|
|
548 |
+ |
r.layer,
|
|
549 |
+ |
r.detail.unwrap_or_default()
|
|
550 |
+ |
),
|
|
551 |
+ |
));
|
|
552 |
+ |
}
|
|
553 |
+ |
_ => {}
|
|
554 |
+ |
}
|
|
555 |
+ |
}
|
|
556 |
+ |
if is_archive(bytes) {
|
|
557 |
+ |
if depth == 0 {
|
|
558 |
+ |
return Some(nested(
|
|
559 |
+ |
LayerVerdict::Error,
|
|
560 |
+ |
"nested archive exceeds maximum scan depth; held for review".to_string(),
|
|
561 |
+ |
));
|
|
562 |
+ |
}
|
|
563 |
+ |
return scan_interior(bytes, yara_rules, depth - 1, budget);
|
|
564 |
+ |
}
|
|
565 |
+ |
None
|
|
566 |
+ |
}
|
|
567 |
+ |
|
|
568 |
+ |
fn read_zip_entry_bounded<R: Read + std::io::Seek>(
|
|
569 |
+ |
archive: &mut zip::ZipArchive<R>,
|
|
570 |
+ |
index: usize,
|
|
571 |
+ |
budget: &mut u64,
|
|
572 |
+ |
) -> Result<Vec<u8>, String> {
|
|
573 |
+ |
let mut entry = archive
|
|
574 |
+ |
.by_index(index)
|
|
575 |
+ |
.map_err(|e| format!("read nested ZIP entry {index}: {e}"))?;
|
|
576 |
+ |
read_bounded(&mut entry, budget)
|
|
577 |
+ |
}
|
|
578 |
+ |
|
|
579 |
+ |
fn decompress_one_bounded<R: Read>(
|
|
580 |
+ |
kind: ArchiveKind,
|
|
581 |
+ |
reader: R,
|
|
582 |
+ |
budget: &mut u64,
|
|
583 |
+ |
) -> Result<Vec<u8>, String> {
|
|
584 |
+ |
let mut decoder: Box<dyn Read> = match kind {
|
|
585 |
+ |
ArchiveKind::Gzip => Box::new(flate2::read::MultiGzDecoder::new(reader)),
|
|
586 |
+ |
ArchiveKind::Bzip2 => Box::new(bzip2::read::BzDecoder::new(reader)),
|
|
587 |
+ |
ArchiveKind::Xz => Box::new(xz2::read::XzDecoder::new(reader)),
|
|
588 |
+ |
ArchiveKind::Zstd => zstd::stream::read::Decoder::new(reader)
|
|
589 |
+ |
.map(|d| Box::new(d) as Box<dyn Read>)
|
|
590 |
+ |
.map_err(|e| format!("zstd init failed: {e}"))?,
|
|
591 |
+ |
ArchiveKind::Zip => return Err("zip handled separately".to_string()),
|
|
592 |
+ |
};
|
|
593 |
+ |
read_bounded(decoder.as_mut(), budget)
|
|
594 |
+ |
}
|
|
595 |
+ |
|
|
596 |
+ |
/// Read a decompressed entry into a Vec, enforcing the per-entry ceiling and the
|
|
597 |
+ |
/// shared total budget. Either overflow, or a mid-stream decode error, is a
|
|
598 |
+ |
/// "not fully scanned" condition (the caller fails closed).
|
|
599 |
+ |
fn read_bounded(reader: &mut dyn Read, budget: &mut u64) -> Result<Vec<u8>, String> {
|
|
600 |
+ |
let mut out: Vec<u8> = Vec::new();
|
|
601 |
+ |
let mut buf = [0u8; 8192];
|
|
602 |
+ |
loop {
|
|
603 |
+ |
match reader.read(&mut buf) {
|
|
604 |
+ |
Ok(0) => break,
|
|
605 |
+ |
Ok(n) => {
|
|
606 |
+ |
if out.len() + n > INTERIOR_ENTRY_MAX {
|
|
607 |
+ |
return Err(format!(
|
|
608 |
+ |
"nested entry exceeds {INTERIOR_ENTRY_MAX}-byte interior scan ceiling"
|
|
609 |
+ |
));
|
|
610 |
+ |
}
|
|
611 |
+ |
if n as u64 > *budget {
|
|
612 |
+ |
return Err("nested archive content exceeds total interior scan budget".to_string());
|
|
613 |
+ |
}
|
|
614 |
+ |
*budget -= n as u64;
|
|
615 |
+ |
out.extend_from_slice(&buf[..n]);
|
|
616 |
+ |
}
|
|
617 |
+ |
Err(e) => return Err(format!("decode nested entry: {e}")),
|
|
618 |
+ |
}
|
|
619 |
+ |
}
|
|
620 |
+ |
Ok(out)
|
|
621 |
+ |
}
|
|
622 |
+ |
|
| 455 |
623 |
|
#[cfg(test)]
|
| 456 |
624 |
|
mod tests {
|
| 457 |
625 |
|
use super::*;
|
| 596 |
764 |
|
|
| 597 |
765 |
|
// -- Nesting detection --
|
| 598 |
766 |
|
|
| 599 |
|
- |
#[test]
|
| 600 |
|
- |
fn zip_within_nesting_limit_passes() {
|
| 601 |
|
- |
// SCAN_ZIP_MAX_DEPTH = 2; the check is `nested > limit`, so 2 entries
|
| 602 |
|
- |
// with archive extensions sit exactly at the limit and must pass.
|
| 603 |
|
- |
let data = make_zip(&[
|
| 604 |
|
- |
("data.txt", b"content"),
|
| 605 |
|
- |
("inner1.zip", b"fake zip content"),
|
| 606 |
|
- |
("inner2.zip", b"fake zip content"),
|
| 607 |
|
- |
]);
|
| 608 |
|
- |
let result = check_archive_safety(&data, FileType::Download);
|
| 609 |
|
- |
assert_eq!(result.verdict, LayerVerdict::Pass);
|
|
767 |
+ |
// -- Nested-archive interior scanning (`scan_nested_contents`) --
|
|
768 |
+ |
//
|
|
769 |
+ |
// The old behavior here merely *counted* nested-archive entries and failed a
|
|
770 |
+ |
// ZIP with more than `SCAN_ZIP_MAX_DEPTH` of them, never inspecting their
|
|
771 |
+ |
// contents — counting is not scanning, so a payload in a zip-in-a-zip passed
|
|
772 |
+ |
// Clean (the run #20→#22 chronic). `check_archive_safety` no longer counts;
|
|
773 |
+ |
// interior coverage is `scan_nested_contents`, exercised below with the real
|
|
774 |
+ |
// rule set so an actual signature in a nested archive is caught or held.
|
|
775 |
+ |
|
|
776 |
+ |
/// The compiled production YARA rules (includes the EICAR test signature).
|
|
777 |
+ |
fn test_yara_rules() -> yara_x::Rules {
|
|
778 |
+ |
super::super::yara::compile_rules_from_dir("yara-rules")
|
|
779 |
+ |
.expect("compile yara-rules")
|
|
780 |
+ |
.0
|
|
781 |
+ |
.expect("yara-rules dir has rules")
|
| 610 |
782 |
|
}
|
| 611 |
783 |
|
|
| 612 |
|
- |
#[test]
|
| 613 |
|
- |
fn zip_exceeding_nesting_limit_fails() {
|
| 614 |
|
- |
// SCAN_ZIP_MAX_DEPTH = 2; 3 nested archives trips the limit.
|
| 615 |
|
- |
let data = make_zip(&[
|
| 616 |
|
- |
("inner1.zip", b"fake"),
|
| 617 |
|
- |
("inner2.zip", b"fake"),
|
| 618 |
|
- |
("inner3.zip", b"fake"),
|
| 619 |
|
- |
]);
|
| 620 |
|
- |
let result = check_archive_safety(&data, FileType::Download);
|
| 621 |
|
- |
assert_eq!(result.verdict, LayerVerdict::Fail);
|
| 622 |
|
- |
assert!(result.detail.unwrap().contains("nested archives"));
|
| 623 |
|
- |
}
|
|
784 |
+ |
/// EICAR antivirus test string — matched by `yara-rules/mnw_custom.yar`.
|
|
785 |
+ |
const EICAR: &[u8] =
|
|
786 |
+ |
br#"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"#;
|
| 624 |
787 |
|
|
| 625 |
788 |
|
#[test]
|
| 626 |
|
- |
fn nested_tar_gz_counts() {
|
| 627 |
|
- |
let data = make_zip(&[
|
| 628 |
|
- |
("archive.tar.gz", b"fake"),
|
| 629 |
|
- |
("another.tar.gz", b"fake"),
|
| 630 |
|
- |
("third.tar.gz", b"fake"),
|
| 631 |
|
- |
("fourth.tar.gz", b"fake"),
|
| 632 |
|
- |
]);
|
| 633 |
|
- |
let result = check_archive_safety(&data, FileType::Download);
|
| 634 |
|
- |
assert_eq!(result.verdict, LayerVerdict::Fail);
|
|
789 |
+ |
fn benign_nested_zip_passes() {
|
|
790 |
+ |
let inner = make_zip(&[("hello.txt", b"hello world")]);
|
|
791 |
+ |
let outer = make_zip(&[("inner.zip", &inner), ("notes.txt", b"readme")]);
|
|
792 |
+ |
let result = scan_nested_contents(&outer, Some(&test_yara_rules()));
|
|
793 |
+ |
assert_eq!(result.verdict, LayerVerdict::Pass, "{:?}", result.detail);
|
| 635 |
794 |
|
}
|
| 636 |
795 |
|
|
| 637 |
796 |
|
#[test]
|
| 638 |
|
- |
fn nested_7z_and_rar_count() {
|
| 639 |
|
- |
let data = make_zip(&[
|
| 640 |
|
- |
("a.7z", b"fake"),
|
| 641 |
|
- |
("b.rar", b"fake"),
|
| 642 |
|
- |
("c.zip", b"fake"),
|
| 643 |
|
- |
("d.7z", b"fake"),
|
| 644 |
|
- |
]);
|
| 645 |
|
- |
let result = check_archive_safety(&data, FileType::Download);
|
| 646 |
|
- |
assert_eq!(result.verdict, LayerVerdict::Fail);
|
| 647 |
|
- |
}
|
| 648 |
|
- |
|
| 649 |
|
- |
#[test]
|
| 650 |
|
- |
fn nested_zip_detected_by_magic_without_extension() {
|
| 651 |
|
- |
// Inner file has innocent name but contains real ZIP magic bytes.
|
| 652 |
|
- |
// The extension check misses it; the magic-byte fallback must catch it.
|
| 653 |
|
- |
let inner_zip = make_zip(&[("payload.txt", b"hi")]);
|
| 654 |
|
- |
let data = make_zip(&[
|
| 655 |
|
- |
("a.bin", &inner_zip),
|
| 656 |
|
- |
("b.bin", &inner_zip),
|
| 657 |
|
- |
("c.bin", &inner_zip),
|
| 658 |
|
- |
]);
|
| 659 |
|
- |
let result = check_archive_safety(&data, FileType::Download);
|
| 660 |
|
- |
assert_eq!(result.verdict, LayerVerdict::Fail);
|
| 661 |
|
- |
assert!(result.detail.unwrap().contains("nested archives"));
|
|
797 |
+ |
fn eicar_in_zip_in_zip_is_caught() {
|
|
798 |
+ |
// outer.zip -> inner.zip -> evil.txt(EICAR). The interior bytes must
|
|
799 |
+ |
// traverse YARA exactly as a top-level file would: not Clean.
|
|
800 |
+ |
let inner = make_zip(&[("evil.txt", EICAR)]);
|
|
801 |
+ |
let outer = make_zip(&[("inner.zip", &inner)]);
|
|
802 |
+ |
let result = scan_nested_contents(&outer, Some(&test_yara_rules()));
|
|
803 |
+ |
assert_eq!(
|
|
804 |
+ |
result.verdict,
|
|
805 |
+ |
LayerVerdict::Fail,
|
|
806 |
+ |
"EICAR nested two zips deep must be caught, got {:?}",
|
|
807 |
+ |
result.detail
|
|
808 |
+ |
);
|
| 662 |
809 |
|
}
|
| 663 |
810 |
|
|
| 664 |
811 |
|
#[test]
|
| 665 |
|
- |
fn nested_gzip_detected_by_magic_without_extension() {
|
| 666 |
|
- |
// 1F 8B is gzip magic. No extension hint, must be caught by magic check.
|
| 667 |
|
- |
let gzip_bytes: &[u8] = &[0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00];
|
| 668 |
|
- |
let data = make_zip(&[
|
| 669 |
|
- |
("one.dat", gzip_bytes),
|
| 670 |
|
- |
("two.dat", gzip_bytes),
|
| 671 |
|
- |
("three.dat", gzip_bytes),
|
| 672 |
|
- |
]);
|
| 673 |
|
- |
let result = check_archive_safety(&data, FileType::Download);
|
| 674 |
|
- |
assert_eq!(result.verdict, LayerVerdict::Fail);
|
| 675 |
|
- |
assert!(result.detail.unwrap().contains("nested archives"));
|
|
812 |
+ |
fn eicar_in_single_gzip_is_caught() {
|
|
813 |
+ |
// A standalone gzip member is one logical entry; its decompressed bytes
|
|
814 |
+ |
// must be scanned.
|
|
815 |
+ |
let gz = gzip(EICAR);
|
|
816 |
+ |
let result = scan_nested_contents(&gz, Some(&test_yara_rules()));
|
|
817 |
+ |
assert_eq!(result.verdict, LayerVerdict::Fail, "{:?}", result.detail);
|
| 676 |
818 |
|
}
|
| 677 |
819 |
|
|
| 678 |
820 |
|
#[test]
|
| 679 |
|
- |
fn nested_7z_detected_by_magic_without_extension() {
|
| 680 |
|
- |
// 7z magic: 37 7A BC AF 27 1C
|
| 681 |
|
- |
let sevenz_bytes: &[u8] = &[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00, 0x00];
|
| 682 |
|
- |
let data = make_zip(&[
|
| 683 |
|
- |
("alpha.bin", sevenz_bytes),
|
| 684 |
|
- |
("beta.bin", sevenz_bytes),
|
| 685 |
|
- |
("gamma.bin", sevenz_bytes),
|
| 686 |
|
- |
]);
|
| 687 |
|
- |
let result = check_archive_safety(&data, FileType::Download);
|
| 688 |
|
- |
assert_eq!(result.verdict, LayerVerdict::Fail);
|
|
821 |
+ |
fn nesting_beyond_scan_depth_is_held_not_passed() {
|
|
822 |
+ |
// SCAN_ZIP_MAX_DEPTH = 2. Wrap a benign file in enough ZIP layers that
|
|
823 |
+ |
// the innermost archive sits past the descent budget; the interior is
|
|
824 |
+ |
// not fully scanned, so it must fail closed (Error -> held), never Clean.
|
|
825 |
+ |
let mut nested = make_zip(&[("leaf.txt", b"benign")]);
|
|
826 |
+ |
for _ in 0..4 {
|
|
827 |
+ |
nested = make_zip(&[("inner.zip", &nested)]);
|
|
828 |
+ |
}
|
|
829 |
+ |
let result = scan_nested_contents(&nested, Some(&test_yara_rules()));
|
|
830 |
+ |
assert_eq!(
|
|
831 |
+ |
result.verdict,
|
|
832 |
+ |
LayerVerdict::Error,
|
|
833 |
+ |
"a nest deeper than the scan depth must be held, got {:?}",
|
|
834 |
+ |
result.detail
|
|
835 |
+ |
);
|
|
836 |
+ |
assert_eq!(super::super::error_policy_for(result.layer), ErrorPolicy::FailClosed);
|
| 689 |
837 |
|
}
|
| 690 |
838 |
|
|
| 691 |
839 |
|
#[test]
|
| 692 |
|
- |
fn nested_rar_detected_by_magic_without_extension() {
|
| 693 |
|
- |
// RAR magic: 52 61 72 21 ("Rar!")
|
| 694 |
|
- |
let rar_bytes: &[u8] = &[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0x00];
|
| 695 |
|
- |
let data = make_zip(&[
|
| 696 |
|
- |
("x.bin", rar_bytes),
|
| 697 |
|
- |
("y.bin", rar_bytes),
|
| 698 |
|
- |
("z.bin", rar_bytes),
|
| 699 |
|
- |
]);
|
| 700 |
|
- |
let result = check_archive_safety(&data, FileType::Download);
|
| 701 |
|
- |
assert_eq!(result.verdict, LayerVerdict::Fail);
|
|
840 |
+ |
fn non_archive_has_no_interior() {
|
|
841 |
+ |
let result = scan_nested_contents(b"just some plain bytes", Some(&test_yara_rules()));
|
|
842 |
+ |
assert_eq!(result.verdict, LayerVerdict::Skip);
|
| 702 |
843 |
|
}
|
| 703 |
844 |
|
|
| 704 |
845 |
|
#[test]
|