Skip to main content

max / makenotwork

16.4 KB · 523 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4
5 #[test]
6 fn layer_verdict_serializes_lowercase() {
7 assert_eq!(
8 serde_json::to_string(&LayerVerdict::Pass).unwrap(),
9 "\"pass\""
10 );
11 assert_eq!(
12 serde_json::to_string(&LayerVerdict::Fail).unwrap(),
13 "\"fail\""
14 );
15 assert_eq!(
16 serde_json::to_string(&LayerVerdict::Skip).unwrap(),
17 "\"skip\""
18 );
19 assert_eq!(
20 serde_json::to_string(&LayerVerdict::Error).unwrap(),
21 "\"error\""
22 );
23 }
24
25 #[test]
26 fn scan_result_quarantined_on_any_fail() {
27 let layers = [
28 LayerResult {
29 layer: "test1",
30 verdict: LayerVerdict::Pass,
31 detail: None,
32 },
33 LayerResult {
34 layer: "test2",
35 verdict: LayerVerdict::Fail,
36 detail: Some("bad".to_string()),
37 },
38 ];
39 let has_fail = layers.iter().any(|l| l.verdict == LayerVerdict::Fail);
40 assert!(has_fail);
41 }
42
43 #[test]
44 fn panicked_sync_layer_is_held_for_review_not_clean() {
45 // A file that panics a CPU parser must never come back Clean, it is
46 // held for admin review, and the panic is contained in a returnable
47 // result (not an `.expect` that would unwind the scan worker).
48 let r = panicked_sync_result(4096);
49 assert_eq!(r.status, FileScanStatus::HeldForReview);
50 assert_eq!(r.file_size, 4096);
51 assert!(r.sha256.is_empty());
52 assert_eq!(r.layers.len(), 1);
53 assert_eq!(r.layers[0].verdict, LayerVerdict::Error);
54 // The synthetic layer must resolve to a FailClosed policy (the default
55 // for unregistered names), which is what makes final_status hold it.
56 assert_eq!(error_policy_for(r.layers[0].layer), ErrorPolicy::FailClosed);
57 }
58
59 #[test]
60 fn oversize_file_is_held_for_review_not_failed() {
61 // A file above the spool ceiling is held for admin review (explicit,
62 // documented policy) rather than failing the job and stranding the
63 // upload in Pending. Verdict resolves to FailClosed -> HeldForReview.
64 let huge = crate::constants::SCAN_SPOOL_MAX_BYTES + 1;
65 let r = too_large_to_scan(huge);
66 assert_eq!(r.status, FileScanStatus::HeldForReview);
67 assert_eq!(r.file_size, huge);
68 assert!(r.sha256.is_empty());
69 assert_eq!(r.layers[0].layer, "scan_size_limit");
70 assert_eq!(error_policy_for(r.layers[0].layer), ErrorPolicy::FailClosed);
71 }
72
73 #[test]
74 fn sha256_computation() {
75 let mut hasher = Sha256::new();
76 hasher.update(b"hello");
77 let hash = hex::encode(hasher.finalize());
78 assert_eq!(
79 hash,
80 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
81 );
82 }
83
84 // Pipeline integration tests
85
86 /// Create a minimal ScanPipeline with no external deps (no YARA, no ClamAV, no MalwareBazaar).
87 /// Wrapped in `Arc` because `scan` consumes `Arc<Self>` (see `pub async fn scan`).
88 fn make_pipeline() -> std::sync::Arc<ScanPipeline> {
89 std::sync::Arc::new(ScanPipeline {
90 yara_rules: None,
91 yara_rule_count: 0,
92 yara_min_rule_files: 0,
93 clamav_socket: None,
94 clamav_max_scan_bytes: None,
95 malwarebazaar_enabled: false,
96 urlhaus_enabled: false,
97 abuse_ch_auth_key: None,
98 metadefender_api_key: None,
99 })
100 }
101
102 /// SEC-S2: a corpus that compiled fewer rule files than the configured floor
103 /// is degraded coverage masquerading as a live layer, boot must fail closed.
104 #[tokio::test]
105 async fn assert_live_refuses_degraded_yara_corpus() {
106 let mut compiler = yara_x::Compiler::new();
107 compiler
108 .add_source(r#"rule r { strings: $a = "x" condition: $a }"#)
109 .unwrap();
110 let pipeline = std::sync::Arc::new(ScanPipeline {
111 yara_rules: Some(compiler.build()),
112 yara_rule_count: 1,
113 yara_min_rule_files: 2,
114 clamav_socket: None,
115 clamav_max_scan_bytes: None,
116 malwarebazaar_enabled: false,
117 urlhaus_enabled: false,
118 abuse_ch_auth_key: None,
119 metadefender_api_key: None,
120 });
121 let err = pipeline
122 .assert_live()
123 .await
124 .expect_err("degraded corpus must refuse boot");
125 assert!(
126 err.contains("YARA corpus degraded"),
127 "unexpected error: {err}"
128 );
129 }
130
131 /// The complement: a corpus meeting the floor boots, with YARA counted live.
132 #[tokio::test]
133 async fn assert_live_accepts_corpus_meeting_floor() {
134 let mut compiler = yara_x::Compiler::new();
135 compiler
136 .add_source(r#"rule r { strings: $a = "x" condition: $a }"#)
137 .unwrap();
138 let pipeline = std::sync::Arc::new(ScanPipeline {
139 yara_rules: Some(compiler.build()),
140 yara_rule_count: 3,
141 yara_min_rule_files: 3,
142 clamav_socket: None,
143 clamav_max_scan_bytes: None,
144 malwarebazaar_enabled: false,
145 urlhaus_enabled: false,
146 abuse_ch_auth_key: None,
147 metadefender_api_key: None,
148 });
149 pipeline
150 .assert_live()
151 .await
152 .expect("a corpus meeting the floor must boot");
153 }
154
155 #[tokio::test]
156 async fn pipeline_clean_download_passes() {
157 let pipeline = make_pipeline();
158 let result = pipeline
159 .clone()
160 .scan(b"just some file content".to_vec(), FileType::Download)
161 .await;
162 assert_eq!(result.status, FileScanStatus::Clean);
163 assert_eq!(result.file_size, 22);
164 assert!(!result.sha256.is_empty());
165 assert_eq!(result.layers.len(), 12);
166 }
167
168 #[tokio::test]
169 async fn pipeline_unrecognized_audio_quarantined() {
170 let pipeline = make_pipeline();
171 // Unrecognized data claimed as audio should be rejected by content_type layer
172 let result = pipeline
173 .clone()
174 .scan(b"audio data here".to_vec(), FileType::Audio)
175 .await;
176 assert_eq!(result.status, FileScanStatus::Quarantined);
177 }
178
179 #[tokio::test]
180 async fn pipeline_clean_cover_passes() {
181 let pipeline = make_pipeline();
182 // PNG magic bytes
183 let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
184 let result = pipeline.clone().scan(png.to_vec(), FileType::Cover).await;
185 assert_eq!(result.status, FileScanStatus::Clean);
186 }
187
188 #[tokio::test]
189 async fn pipeline_pe_as_audio_quarantined() {
190 let pipeline = make_pipeline();
191 // PE magic bytes, content-type layer should detect application/* and fail
192 let pe_header = b"MZ\x90\x00\x03\x00\x00\x00";
193 let result = pipeline
194 .clone()
195 .scan(pe_header.to_vec(), FileType::Audio)
196 .await;
197 assert_eq!(result.status, FileScanStatus::Quarantined);
198 // Verify content_type layer produced the fail
199 let content_type_layer = result
200 .layers
201 .iter()
202 .find(|l| l.layer == "content_type")
203 .unwrap();
204 assert_eq!(content_type_layer.verdict, LayerVerdict::Fail);
205 }
206
207 #[tokio::test]
208 async fn pipeline_pe_as_cover_quarantined() {
209 let pipeline = make_pipeline();
210 let pe_header = b"MZ\x90\x00\x03\x00\x00\x00";
211 let result = pipeline
212 .clone()
213 .scan(pe_header.to_vec(), FileType::Cover)
214 .await;
215 assert_eq!(result.status, FileScanStatus::Quarantined);
216 }
217
218 #[tokio::test]
219 async fn pipeline_sha256_is_deterministic() {
220 let pipeline = make_pipeline();
221 let data = b"deterministic hash test";
222 let r1 = pipeline
223 .clone()
224 .scan(data.to_vec(), FileType::Download)
225 .await;
226 let r2 = pipeline
227 .clone()
228 .scan(data.to_vec(), FileType::Download)
229 .await;
230 assert_eq!(r1.sha256, r2.sha256);
231 }
232
233 #[tokio::test]
234 async fn pipeline_skips_optional_layers_when_unconfigured() {
235 let pipeline = make_pipeline();
236 let result = pipeline
237 .clone()
238 .scan(b"test".to_vec(), FileType::Download)
239 .await;
240
241 let yara = result.layers.iter().find(|l| l.layer == "yara").unwrap();
242 assert_eq!(yara.verdict, LayerVerdict::Skip);
243
244 let clamav = result.layers.iter().find(|l| l.layer == "clamav").unwrap();
245 assert_eq!(clamav.verdict, LayerVerdict::Skip);
246
247 let mb = result
248 .layers
249 .iter()
250 .find(|l| l.layer == "malwarebazaar")
251 .unwrap();
252 assert_eq!(mb.verdict, LayerVerdict::Skip);
253
254 let uh = result.layers.iter().find(|l| l.layer == "urlhaus").unwrap();
255 assert_eq!(uh.verdict, LayerVerdict::Skip);
256 }
257
258 #[tokio::test]
259 async fn pipeline_always_produces_12_layers() {
260 let pipeline = make_pipeline();
261 for file_type in [FileType::Audio, FileType::Cover, FileType::Download] {
262 let result = pipeline.clone().scan(b"data".to_vec(), file_type).await;
263 // 11 base layers + the recursive archive-interior layer (archive_nested).
264 assert_eq!(
265 result.layers.len(),
266 12,
267 "Expected 12 layers for {file_type:?}"
268 );
269 }
270 }
271
272 #[test]
273 fn suspicion_present_on_fail() {
274 let layers = vec![pass("content_type"), fail("yara")];
275 assert!(suspicion_present(&layers));
276 }
277
278 #[test]
279 fn suspicion_present_on_fail_closed_error() {
280 let layers = vec![pass("content_type"), err("archive")];
281 assert!(suspicion_present(&layers));
282 }
283
284 #[test]
285 fn no_suspicion_when_fail_open_error_only() {
286 // External-layer errors are operational noise, not malware signals;
287 // they must not invoke MetaDefender.
288 let layers = vec![pass("content_type"), err("malwarebazaar"), err("urlhaus")];
289 assert!(!suspicion_present(&layers));
290 }
291
292 #[test]
293 fn no_suspicion_when_all_clean() {
294 let layers = vec![pass("content_type"), skip("yara"), pass("structural")];
295 assert!(!suspicion_present(&layers));
296 }
297
298 #[tokio::test]
299 async fn pipeline_errors_held_for_review() {
300 // Errors from fail-closed layers (archive is in-process deterministic)
301 // should hold the file for admin review.
302 let pipeline = make_pipeline();
303 // Corrupted ZIP magic bytes, archive layer returns Error
304 let mut data = vec![0x50, 0x4B, 0x03, 0x04];
305 data.extend_from_slice(&[0xFF; 100]);
306 let result = pipeline.clone().scan(data, FileType::Download).await;
307 let archive = result.layers.iter().find(|l| l.layer == "archive").unwrap();
308 assert_eq!(archive.verdict, LayerVerdict::Error);
309 assert_eq!(result.status, FileScanStatus::HeldForReview);
310 }
311
312 // Per-layer fail policy tests
313
314 fn err(layer: &'static str) -> LayerResult {
315 LayerResult {
316 layer,
317 verdict: LayerVerdict::Error,
318 detail: None,
319 }
320 }
321 fn pass(layer: &'static str) -> LayerResult {
322 LayerResult {
323 layer,
324 verdict: LayerVerdict::Pass,
325 detail: None,
326 }
327 }
328 fn skip(layer: &'static str) -> LayerResult {
329 LayerResult {
330 layer,
331 verdict: LayerVerdict::Skip,
332 detail: None,
333 }
334 }
335 fn fail(layer: &'static str) -> LayerResult {
336 LayerResult {
337 layer,
338 verdict: LayerVerdict::Fail,
339 detail: None,
340 }
341 }
342
343 #[test]
344 fn yara_tail_unscanned_only_without_backstop() {
345 use crate::constants::SCAN_YARA_MAX_BYTES;
346 let over = SCAN_YARA_MAX_BYTES + 1;
347 // Over the cap, no declared ClamAV coverage → tail unscanned, must hold.
348 assert!(yara_tail_unscanned(over, None));
349 // Over the cap, ClamAV coverage present but SHORT of the file (Run #24
350 // MODERATE: clamd reachable but MaxScanSize doesn't reach) → still hold.
351 assert!(yara_tail_unscanned(over, Some(over as u64 - 1)));
352 // Over the cap, declared coverage reaches the file → ClamAV is a real
353 // full-file backstop, don't hold.
354 assert!(!yara_tail_unscanned(over, Some(over as u64)));
355 assert!(!yara_tail_unscanned(over, Some(u64::MAX)));
356 // Within the YARA cap → whole file scanned by YARA regardless of ClamAV.
357 assert!(!yara_tail_unscanned(SCAN_YARA_MAX_BYTES, None));
358 assert!(!yara_tail_unscanned(1024, None));
359 }
360
361 #[test]
362 fn final_status_clean_when_all_pass() {
363 let layers = vec![
364 pass("content_type"),
365 pass("structural"),
366 pass("archive"),
367 skip("yara"),
368 skip("clamav"),
369 skip("malwarebazaar"),
370 ];
371 assert_eq!(final_status(&layers), FileScanStatus::Clean);
372 }
373
374 #[test]
375 fn final_status_quarantined_on_any_fail() {
376 let layers = vec![pass("content_type"), fail("yara"), skip("clamav")];
377 assert_eq!(final_status(&layers), FileScanStatus::Quarantined);
378 }
379
380 #[test]
381 fn final_status_fail_beats_error() {
382 // A Fail anywhere supersedes any Error, regardless of policy.
383 let layers = vec![err("malwarebazaar"), fail("yara")];
384 assert_eq!(final_status(&layers), FileScanStatus::Quarantined);
385 }
386
387 #[test]
388 fn final_status_held_on_fail_closed_error() {
389 // archive is FailClosed, its Error must hold the file.
390 let layers = vec![pass("content_type"), err("archive"), skip("clamav")];
391 assert_eq!(final_status(&layers), FileScanStatus::HeldForReview);
392 }
393
394 #[test]
395 fn final_status_clean_on_fail_open_error_only() {
396 // malwarebazaar is FailOpen, its Error must NOT hold the file.
397 // This is the regression of 2026-05-10 that motivated the audit.
398 let layers = vec![
399 pass("content_type"),
400 pass("structural"),
401 pass("archive"),
402 skip("yara"),
403 skip("clamav"),
404 err("malwarebazaar"),
405 ];
406 assert_eq!(final_status(&layers), FileScanStatus::Clean);
407 }
408
409 #[test]
410 fn final_status_clean_when_all_external_layers_error() {
411 // Worst-case external-services outage: every network/daemon layer
412 // erroring at once. As long as the in-process layers pass, the file
413 // is Clean. Health is surfaced separately via per-layer monitoring.
414 let layers = vec![
415 pass("content_type"),
416 pass("structural"),
417 pass("archive"),
418 skip("yara"),
419 err("clamav"),
420 err("malwarebazaar"),
421 ];
422 assert_eq!(final_status(&layers), FileScanStatus::Clean);
423 }
424
425 #[test]
426 fn clamav_incomplete_is_fail_closed_and_holds() {
427 // CHRONIC S1: a reachable-but-incomplete clamav scan is emitted under the
428 // `clamav_incomplete` layer, which must be FailClosed → HeldForReview,
429 // distinct from the FailOpen `clamav` layer used for an unreachable daemon.
430 assert_eq!(
431 error_policy_for("clamav_incomplete"),
432 ErrorPolicy::FailClosed
433 );
434 assert_eq!(error_policy_for("clamav"), ErrorPolicy::FailOpen);
435 let layers = vec![
436 pass("content_type"),
437 pass("structural"),
438 pass("archive"),
439 skip("yara"),
440 err("clamav_incomplete"),
441 ];
442 assert_eq!(final_status(&layers), FileScanStatus::HeldForReview);
443 }
444
445 #[test]
446 fn final_status_held_on_unknown_layer_error() {
447 // Defensive default: an unknown layer name that errors falls through
448 // to FailClosed. This is what catches a new layer added without
449 // wiring its policy into `error_policy_for`.
450 let layers = vec![
451 pass("content_type"),
452 err("brand_new_layer_someone_forgot_to_register"),
453 ];
454 assert_eq!(final_status(&layers), FileScanStatus::HeldForReview);
455 }
456
457 #[test]
458 fn error_policy_for_all_known_layers() {
459 // Every layer name produced by the pipeline must have an explicit
460 // declaration in `error_policy_for`. The default branch is reserved
461 // for genuine programmer error (new layer, forgot to register).
462 for name in [
463 "content_type",
464 "structural",
465 "archive",
466 "yara",
467 "clamav",
468 "malwarebazaar",
469 ] {
470 let policy = error_policy_for(name);
471 // Both values are valid; we just want this to not hit the default.
472 // If a layer is renamed without updating `error_policy_for`, this
473 // test still passes (the rename produces a new unknown name), but
474 // the per-layer name tests below catch that.
475 let _ = policy;
476 }
477 }
478
479 #[test]
480 fn content_type_is_fail_closed() {
481 assert_eq!(error_policy_for("content_type"), ErrorPolicy::FailClosed);
482 }
483 #[test]
484 fn structural_is_fail_closed() {
485 assert_eq!(error_policy_for("structural"), ErrorPolicy::FailClosed);
486 }
487 #[test]
488 fn archive_is_fail_closed() {
489 assert_eq!(error_policy_for("archive"), ErrorPolicy::FailClosed);
490 }
491 #[test]
492 fn yara_is_fail_closed() {
493 assert_eq!(error_policy_for("yara"), ErrorPolicy::FailClosed);
494 }
495 #[test]
496 fn clamav_is_fail_open() {
497 assert_eq!(error_policy_for("clamav"), ErrorPolicy::FailOpen);
498 }
499 #[test]
500 fn malwarebazaar_is_fail_open() {
501 assert_eq!(error_policy_for("malwarebazaar"), ErrorPolicy::FailOpen);
502 }
503 #[test]
504 fn urlhaus_is_fail_open() {
505 assert_eq!(error_policy_for("urlhaus"), ErrorPolicy::FailOpen);
506 }
507 #[test]
508 fn signing_macos_is_fail_open() {
509 assert_eq!(error_policy_for("signing_macos"), ErrorPolicy::FailOpen);
510 }
511 #[test]
512 fn metadefender_is_fail_open() {
513 assert_eq!(error_policy_for("metadefender"), ErrorPolicy::FailOpen);
514 }
515 #[test]
516 fn signing_windows_is_fail_open() {
517 assert_eq!(error_policy_for("signing_windows"), ErrorPolicy::FailOpen);
518 }
519 #[test]
520 fn signing_linux_is_fail_open() {
521 assert_eq!(error_policy_for("signing_linux"), ErrorPolicy::FailOpen);
522 }
523