Skip to main content

max / makenotwork

18.1 KB · 549 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use zip::write::SimpleFileOptions;
5
6 fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
7 let buf = Vec::new();
8 let cursor = Cursor::new(buf);
9 let mut writer = zip::ZipWriter::new(cursor);
10 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
11 for (name, data) in entries {
12 writer.start_file(*name, options).unwrap();
13 std::io::Write::write_all(&mut writer, data).unwrap();
14 }
15 writer.finish().unwrap().into_inner()
16 }
17
18 fn make_compressed_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
19 let buf = Vec::new();
20 let cursor = Cursor::new(buf);
21 let mut writer = zip::ZipWriter::new(cursor);
22 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
23 for (name, data) in entries {
24 writer.start_file(*name, options).unwrap();
25 std::io::Write::write_all(&mut writer, data).unwrap();
26 }
27 writer.finish().unwrap().into_inner()
28 }
29
30 // Skip behavior
31
32 #[test]
33 fn non_zip_skipped() {
34 let result = check_archive_safety(b"not a zip file", FileType::Download);
35 assert_eq!(result.verdict, LayerVerdict::Skip);
36 }
37
38 #[test]
39 fn audio_non_zip_skipped() {
40 let result = check_archive_safety(b"audio data", FileType::Audio);
41 assert_eq!(result.verdict, LayerVerdict::Skip);
42 }
43
44 // 7z / RAR: no pure-Rust bomb checker (R6-Sec-L2)
45
46 #[test]
47 fn sevenzip_rejected_for_non_download() {
48 let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0];
49 let result = check_archive_safety(&data, FileType::Cover);
50 assert_eq!(result.verdict, LayerVerdict::Error);
51 }
52
53 #[test]
54 fn sevenzip_allowed_for_download() {
55 // Download keeps the ClamAV backstop; the in-process layer doesn't reject.
56 let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0];
57 let result = check_archive_safety(&data, FileType::Download);
58 assert_ne!(result.verdict, LayerVerdict::Error);
59 }
60
61 #[test]
62 fn rar_rejected_for_non_download() {
63 let data = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0, 0, 0];
64 let result = check_archive_safety(&data, FileType::MediaImage);
65 assert_eq!(result.verdict, LayerVerdict::Error);
66 }
67
68 #[test]
69 fn cover_zip_skipped() {
70 // A ZIP file claimed as cover should be skipped (layer 1 handles type mismatch)
71 let data = make_zip(&[("test.txt", b"hello")]);
72 let result = check_archive_safety(&data, FileType::Cover);
73 assert_eq!(result.verdict, LayerVerdict::Skip);
74 }
75
76 // Valid archives
77
78 #[test]
79 fn valid_zip_passes() {
80 let data = make_zip(&[("test.txt", b"hello world")]);
81 let result = check_archive_safety(&data, FileType::Download);
82 assert_eq!(result.verdict, LayerVerdict::Pass);
83 }
84
85 #[test]
86 fn empty_zip_passes() {
87 let buf = Vec::new();
88 let cursor = Cursor::new(buf);
89 let writer = zip::ZipWriter::new(cursor);
90 let data = writer.finish().unwrap().into_inner();
91 // Empty ZIPs may not have the PK magic at offset 0, they'd just be
92 // an end-of-central-directory record. If it doesn't start with PK 03 04,
93 // we'll skip it. That's fine.
94 let result = check_archive_safety(&data, FileType::Download);
95 // Either Skip (no local file header) or Pass (valid empty ZIP)
96 assert!(
97 result.verdict == LayerVerdict::Skip || result.verdict == LayerVerdict::Pass,
98 "unexpected verdict: {:?}",
99 result.verdict
100 );
101 }
102
103 #[test]
104 fn multi_entry_zip_passes() {
105 let data = make_zip(&[
106 ("file1.txt", b"content one"),
107 ("subdir/file2.txt", b"content two"),
108 ("readme.md", b"# hello"),
109 ]);
110 let result = check_archive_safety(&data, FileType::Download);
111 assert_eq!(result.verdict, LayerVerdict::Pass);
112 assert!(result.detail.unwrap().contains("3 entries"));
113 }
114
115 // Path traversal
116
117 #[test]
118 fn zip_with_forward_slash_traversal_fails() {
119 let data = make_zip(&[("../../../etc/passwd", b"pwned")]);
120 let result = check_archive_safety(&data, FileType::Download);
121 assert_eq!(result.verdict, LayerVerdict::Fail);
122 assert!(result.detail.unwrap().contains("Path traversal"));
123 }
124
125 #[test]
126 fn zip_with_backslash_traversal_fails() {
127 let data = make_zip(&[("..\\..\\Windows\\System32\\config", b"pwned")]);
128 let result = check_archive_safety(&data, FileType::Download);
129 assert_eq!(result.verdict, LayerVerdict::Fail);
130 assert!(result.detail.unwrap().contains("Path traversal"));
131 }
132
133 #[test]
134 fn zip_with_mid_path_traversal_fails() {
135 let data = make_zip(&[("safe/../../etc/passwd", b"pwned")]);
136 let result = check_archive_safety(&data, FileType::Download);
137 assert_eq!(result.verdict, LayerVerdict::Fail);
138 }
139
140 #[test]
141 fn zip_with_url_encoded_traversal_fails() {
142 // %2e%2e is URL-encoded "..". The check is case-insensitive on the encoding.
143 let data = make_zip(&[("%2E%2E/secrets", b"pwned")]);
144 let result = check_archive_safety(&data, FileType::Download);
145 assert_eq!(result.verdict, LayerVerdict::Fail);
146 assert!(result.detail.unwrap().contains("Path traversal"));
147 }
148
149 #[test]
150 fn zip_with_absolute_path_fails() {
151 let data = make_zip(&[("/etc/passwd", b"pwned")]);
152 let result = check_archive_safety(&data, FileType::Download);
153 assert_eq!(result.verdict, LayerVerdict::Fail);
154 assert!(result.detail.unwrap().contains("Path traversal"));
155 }
156
157 #[test]
158 fn zip_with_null_byte_in_name_fails() {
159 let data = make_zip(&[("legit.txt\0../escape", b"pwned")]);
160 let result = check_archive_safety(&data, FileType::Download);
161 assert_eq!(result.verdict, LayerVerdict::Fail);
162 assert!(result.detail.unwrap().contains("Path traversal"));
163 }
164
165 // Nesting detection
166
167 // Nested-archive interior scanning (`scan_nested_contents`)
168 //
169 // The old behavior here merely *counted* nested-archive entries and failed a
170 // ZIP with more than `SCAN_ZIP_MAX_DEPTH` of them, never inspecting their
171 // contents, counting is not scanning, so a payload in a zip-in-a-zip passed
172 // Clean (the run #20→#22 chronic). `check_archive_safety` no longer counts;
173 // interior coverage is `scan_nested_contents`, exercised below with the real
174 // rule set so an actual signature in a nested archive is caught or held.
175
176 /// The compiled production YARA rules (includes the EICAR test signature).
177 fn test_yara_rules() -> yara_x::Rules {
178 super::super::yara::compile_rules_from_dir("yara-rules")
179 .expect("compile yara-rules")
180 .0
181 .expect("yara-rules dir has rules")
182 }
183
184 /// EICAR antivirus test string, matched by `yara-rules/mnw_test_files.yar`.
185 const EICAR: &[u8] = br"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
186
187 #[test]
188 fn benign_nested_zip_passes() {
189 let inner = make_zip(&[("hello.txt", b"hello world")]);
190 let outer = make_zip(&[("inner.zip", &inner), ("notes.txt", b"readme")]);
191 let result = scan_nested_contents(&outer, Some(&test_yara_rules()));
192 assert_eq!(result.verdict, LayerVerdict::Pass, "{:?}", result.detail);
193 }
194
195 #[test]
196 fn eicar_in_zip_in_zip_is_caught() {
197 // outer.zip -> inner.zip -> evil.txt(EICAR). The interior bytes must
198 // traverse YARA exactly as a top-level file would: not Clean.
199 let inner = make_zip(&[("evil.txt", EICAR)]);
200 let outer = make_zip(&[("inner.zip", &inner)]);
201 let result = scan_nested_contents(&outer, Some(&test_yara_rules()));
202 assert_eq!(
203 result.verdict,
204 LayerVerdict::Fail,
205 "EICAR nested two zips deep must be caught, got {:?}",
206 result.detail
207 );
208 }
209
210 #[test]
211 fn eicar_in_single_gzip_is_caught() {
212 // A standalone gzip member is one logical entry; its decompressed bytes
213 // must be scanned.
214 let gz = gzip(EICAR);
215 let result = scan_nested_contents(&gz, Some(&test_yara_rules()));
216 assert_eq!(result.verdict, LayerVerdict::Fail, "{:?}", result.detail);
217 }
218
219 #[test]
220 fn nesting_beyond_scan_depth_is_held_not_passed() {
221 // SCAN_ZIP_MAX_DEPTH = 2. Wrap a benign file in enough ZIP layers that
222 // the innermost archive sits past the descent budget; the interior is
223 // not fully scanned, so it must fail closed (Error -> held), never Clean.
224 let mut nested = make_zip(&[("leaf.txt", b"benign")]);
225 for _ in 0..4 {
226 nested = make_zip(&[("inner.zip", &nested)]);
227 }
228 let result = scan_nested_contents(&nested, Some(&test_yara_rules()));
229 assert_eq!(
230 result.verdict,
231 LayerVerdict::Error,
232 "a nest deeper than the scan depth must be held, got {:?}",
233 result.detail
234 );
235 assert_eq!(
236 super::super::error_policy_for(result.layer),
237 ErrorPolicy::FailClosed
238 );
239 }
240
241 #[test]
242 fn non_archive_has_no_interior() {
243 let result = scan_nested_contents(b"just some plain bytes", Some(&test_yara_rules()));
244 assert_eq!(result.verdict, LayerVerdict::Skip);
245 }
246
247 #[test]
248 fn non_archive_extensions_ignored() {
249 let data = make_zip(&[
250 ("app.exe", b"binary"),
251 ("readme.txt", b"hello"),
252 ("image.png", b"pixels"),
253 ]);
254 let result = check_archive_safety(&data, FileType::Download);
255 assert_eq!(result.verdict, LayerVerdict::Pass);
256 }
257
258 // Compression ratio (ZIP bomb detection)
259
260 #[test]
261 fn high_compression_ratio_fails() {
262 // Create highly compressible data: repeating zeros compress extremely well
263 // 1MB of zeros should compress to ~1KB with deflate, giving ratio ~1000x
264 let zeros = vec![0u8; 1024 * 1024];
265 let data = make_compressed_zip(&[("bomb.bin", &zeros)]);
266 let result = check_archive_safety(&data, FileType::Download);
267 assert_eq!(
268 result.verdict,
269 LayerVerdict::Fail,
270 "Expected Fail for high compression ratio, got: {:?}",
271 result.detail
272 );
273 assert!(result.detail.unwrap().contains("ZIP bomb"));
274 }
275
276 #[test]
277 fn normal_compression_ratio_passes() {
278 // Random-ish data doesn't compress well, ratio should be ~1x
279 let data_bytes: Vec<u8> = (0..10000).map(|i| (i * 37 + 13) as u8).collect();
280 let data = make_compressed_zip(&[("normal.bin", &data_bytes)]);
281 let result = check_archive_safety(&data, FileType::Download);
282 assert_eq!(result.verdict, LayerVerdict::Pass);
283 }
284
285 // Audio file with ZIP magic (disguised archive)
286
287 #[test]
288 fn zip_disguised_as_audio_checked() {
289 // A ZIP file claimed as Audio should still be checked (not skipped)
290 let data = make_zip(&[("test.txt", b"hello")]);
291 let result = check_archive_safety(&data, FileType::Audio);
292 assert_eq!(result.verdict, LayerVerdict::Pass);
293 }
294
295 #[test]
296 fn zip_disguised_as_audio_with_traversal_fails() {
297 let data = make_zip(&[("../../../etc/passwd", b"pwned")]);
298 let result = check_archive_safety(&data, FileType::Audio);
299 assert_eq!(result.verdict, LayerVerdict::Fail);
300 }
301
302 // Corrupted ZIP
303
304 #[test]
305 fn corrupted_zip_magic_returns_error() {
306 // Valid ZIP magic bytes but garbage after
307 let mut data = vec![0x50, 0x4B, 0x03, 0x04];
308 data.extend_from_slice(&[0xFF; 100]);
309 let result = check_archive_safety(&data, FileType::Download);
310 assert_eq!(result.verdict, LayerVerdict::Error);
311 assert!(result.detail.unwrap().contains("Failed to parse ZIP"));
312 }
313
314 #[test]
315 fn path_entry_matches_buffered_for_non_zip() {
316 let data = b"not a zip at all";
317 let buffered = check_archive_safety(data, FileType::Download);
318 let tmp = tempfile::NamedTempFile::new().unwrap();
319 std::fs::write(tmp.path(), data).unwrap();
320 let path_based = check_archive_safety_path(tmp.path(), FileType::Download);
321 assert_eq!(buffered.verdict, path_based.verdict);
322 assert_eq!(buffered.verdict, LayerVerdict::Skip);
323 }
324
325 #[test]
326 fn path_entry_matches_buffered_for_cover_skip() {
327 let mut data = vec![0x50, 0x4B, 0x03, 0x04];
328 data.extend_from_slice(&[0xFF; 100]);
329 let buffered = check_archive_safety(&data, FileType::Cover);
330 let tmp = tempfile::NamedTempFile::new().unwrap();
331 std::fs::write(tmp.path(), &data).unwrap();
332 let path_based = check_archive_safety_path(tmp.path(), FileType::Cover);
333 assert_eq!(buffered.verdict, path_based.verdict);
334 assert_eq!(buffered.verdict, LayerVerdict::Skip);
335 }
336
337 // Single-stream decompression bombs (gzip / bzip2 / xz / zstd)
338
339 use std::io::Write;
340
341 fn gzip(data: &[u8]) -> Vec<u8> {
342 let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
343 e.write_all(data).unwrap();
344 e.finish().unwrap()
345 }
346 fn bzip2_compress(data: &[u8]) -> Vec<u8> {
347 let mut e = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::new(9));
348 e.write_all(data).unwrap();
349 e.finish().unwrap()
350 }
351 fn xz(data: &[u8]) -> Vec<u8> {
352 let mut e = xz2::write::XzEncoder::new(Vec::new(), 9);
353 e.write_all(data).unwrap();
354 e.finish().unwrap()
355 }
356 fn zstd_compress(data: &[u8]) -> Vec<u8> {
357 zstd::encode_all(data, 19).unwrap()
358 }
359
360 /// 8 MiB of zeros, compresses to a tiny stream at a ratio far above the
361 /// 100x cap, the canonical decompression-bomb shape.
362 fn bomb_payload() -> Vec<u8> {
363 vec![0u8; 8 * 1024 * 1024]
364 }
365
366 /// Moderately-incompressible data: stays well under the ratio cap, so a
367 /// legitimate compressed download passes.
368 fn benign_payload() -> Vec<u8> {
369 (0..200_000u32)
370 .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
371 .collect()
372 }
373
374 #[test]
375 fn gzip_bomb_fails() {
376 let data = gzip(&bomb_payload());
377 let result = check_archive_safety(&data, FileType::Download);
378 assert_eq!(
379 result.verdict,
380 LayerVerdict::Fail,
381 "detail: {:?}",
382 result.detail
383 );
384 assert!(result.detail.unwrap().to_lowercase().contains("bomb"));
385 }
386
387 #[test]
388 fn benign_gzip_passes() {
389 let data = gzip(&benign_payload());
390 let result = check_archive_safety(&data, FileType::Download);
391 assert_eq!(
392 result.verdict,
393 LayerVerdict::Pass,
394 "detail: {:?}",
395 result.detail
396 );
397 }
398
399 #[test]
400 fn bzip2_bomb_fails() {
401 let data = bzip2_compress(&bomb_payload());
402 let result = check_archive_safety(&data, FileType::Download);
403 assert_eq!(
404 result.verdict,
405 LayerVerdict::Fail,
406 "detail: {:?}",
407 result.detail
408 );
409 }
410
411 #[test]
412 fn xz_bomb_fails() {
413 let data = xz(&bomb_payload());
414 let result = check_archive_safety(&data, FileType::Download);
415 assert_eq!(
416 result.verdict,
417 LayerVerdict::Fail,
418 "detail: {:?}",
419 result.detail
420 );
421 }
422
423 #[test]
424 fn zstd_bomb_fails() {
425 let data = zstd_compress(&bomb_payload());
426 let result = check_archive_safety(&data, FileType::Download);
427 assert_eq!(
428 result.verdict,
429 LayerVerdict::Fail,
430 "detail: {:?}",
431 result.detail
432 );
433 }
434
435 #[test]
436 fn gzip_bomb_caught_on_path_variant_too() {
437 let data = gzip(&bomb_payload());
438 let tmp = tempfile::NamedTempFile::new().unwrap();
439 std::fs::write(tmp.path(), &data).unwrap();
440 let result = check_archive_safety_path(tmp.path(), FileType::Download);
441 assert_eq!(
442 result.verdict,
443 LayerVerdict::Fail,
444 "detail: {:?}",
445 result.detail
446 );
447 }
448
449 #[test]
450 fn gzip_bomb_skipped_for_cover() {
451 // Type mismatch is layer 1's job; the archive layer skips covers.
452 let data = gzip(&bomb_payload());
453 let result = check_archive_safety(&data, FileType::Cover);
454 assert_eq!(result.verdict, LayerVerdict::Skip);
455 }
456
457 // Prefixed / self-extracting ZIP (no offset-0 magic)
458
459 #[test]
460 fn prefixed_zip_is_not_silently_skipped() {
461 // A real ZIP with arbitrary bytes prepended (the self-extracting-stub
462 // shape). It lacks the offset-0 PK\x03\x04 magic, so the old offset-0
463 // gate would Skip it. The tail EOCD scan must catch it and hand it to
464 // inspect_zip, the security property is that it is NOT Skipped.
465 let zip = make_zip(&[("readme.txt", b"hello")]);
466 let mut data = b"MZ\x90\x00 this is a self-extracting stub padding ".to_vec();
467 data.extend_from_slice(&zip);
468
469 let result = check_archive_safety(&data, FileType::Download);
470 assert_ne!(
471 result.verdict,
472 LayerVerdict::Skip,
473 "prefixed ZIP must be inspected, not skipped; got {:?}",
474 result.detail
475 );
476
477 // And on the path variant.
478 let tmp = tempfile::NamedTempFile::new().unwrap();
479 std::fs::write(tmp.path(), &data).unwrap();
480 let path_based = check_archive_safety_path(tmp.path(), FileType::Download);
481 assert_ne!(
482 path_based.verdict,
483 LayerVerdict::Skip,
484 "detail: {:?}",
485 path_based.detail
486 );
487 }
488
489 #[test]
490 fn prefixed_zip_bomb_fails() {
491 // Prepend a stub to a high-ratio ZIP; it must still be caught.
492 let zeros = vec![0u8; 1024 * 1024];
493 let zip = make_compressed_zip(&[("bomb.bin", &zeros)]);
494 let mut data = b"self-extracting stub ".to_vec();
495 data.extend_from_slice(&zip);
496 let result = check_archive_safety(&data, FileType::Download);
497 assert_eq!(
498 result.verdict,
499 LayerVerdict::Fail,
500 "detail: {:?}",
501 result.detail
502 );
503 }
504
505 #[test]
506 fn prefixed_7z_polyglot_rejected_for_non_download() {
507 // [PNG header][7z magic][junk]: sniffs as a PNG (passing the content-type
508 // layer) but carries a 7z payload past offset 0. The offset-0-only magic
509 // check missed this and fell through to ClamAV FailOpen (Sec-S1); the
510 // whole-buffer window scan now rejects it for non-Download uploads.
511 let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
512 data.extend_from_slice(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]); // 7z magic
513 data.extend_from_slice(&[0u8; 64]);
514
515 // Cover / image: held for review (Error), not passed to ClamAV alone.
516 let cover = check_archive_safety(&data, FileType::Cover);
517 assert_eq!(
518 cover.verdict,
519 LayerVerdict::Error,
520 "detail: {:?}",
521 cover.detail
522 );
523 assert!(cover.detail.unwrap().contains("7z"));
524
525 // Download keeps the ClamAV backstop, this layer must not reject it.
526 let download = check_archive_safety(&data, FileType::Download);
527 assert_ne!(
528 download.verdict,
529 LayerVerdict::Error,
530 "Download must keep the ClamAV backstop, not be rejected by the container check"
531 );
532 }
533
534 #[test]
535 fn prefixed_rar_polyglot_rejected_for_non_download() {
536 // Same evasion shape with a RAR signature embedded after a JPEG header.
537 let mut data = vec![0xFF, 0xD8, 0xFF, 0xE0]; // JPEG SOI + APP0
538 data.extend_from_slice(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]); // "Rar!\x1a\x07"
539 data.extend_from_slice(&[0u8; 64]);
540 let cover = check_archive_safety(&data, FileType::Cover);
541 assert_eq!(
542 cover.verdict,
543 LayerVerdict::Error,
544 "detail: {:?}",
545 cover.detail
546 );
547 assert!(cover.detail.unwrap().contains("RAR"));
548 }
549