Skip to main content

max / makenotwork

Treat ClamAV as a full-file scan backstop only up to declared coverage clamd silently scans just the first MaxScanSize/MaxFileSize bytes and returns OK, and those limits aren't queryable over its socket, so trusting "socket configured" let a large file with a payload past the 512 MiB YARA prefix certify Clean. Add operator-declared CLAMAV_MAX_SCAN_BYTES; yara_tail_unscanned now holds a file for review unless declared coverage reaches its size (undeclared coverage is fail-closed), and assert_live warns at boot when coverage is unset or below the spool ceiling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-06 13:17 UTC
Commit: d70ea9a74fd5381d7179994bf4d37c7b6373516d
Parent: 703a7ec
3 files changed, +94 insertions, -18 deletions
@@ -535,6 +535,18 @@ pub struct ScanConfig {
535 535 /// fails boot loudly rather than degrading coverage unnoticed. Set it
536 536 /// explicitly when pointing `YARA_RULES_DIR` at a larger external corpus.
537 537 pub yara_min_rule_files: usize,
538 + /// The number of bytes ClamAV actually scans per object — the operator's
539 + /// declared `min(MaxScanSize, MaxFileSize, StreamMaxLength)` from `clamd.conf`.
540 + ///
541 + /// clamd does NOT expose these limits over its socket (only `PING`/`VERSION`),
542 + /// so the server cannot probe them; the operator must declare the coverage.
543 + /// It gates whether ClamAV counts as a *full-file backstop* for the YARA
544 + /// prefix cap ([`crate::constants::SCAN_YARA_MAX_BYTES`]): only a file whose
545 + /// size is within this many bytes is treated as fully covered. `None` (the
546 + /// default) means "coverage unknown" and is fail-closed — any file above the
547 + /// YARA prefix is held for review rather than certified Clean on a
548 + /// possibly-partial ClamAV scan (ultra-fuzz Run #24 Security MODERATE).
549 + pub clamav_max_scan_bytes: Option<u64>,
538 550 }
539 551
540 552 /// Floor for [`ScanConfig::yara_min_rule_files`], matching the count of `.yar`
@@ -579,6 +591,16 @@ impl ScanConfig {
579 591 }),
580 592 Err(_) => DEFAULT_YARA_MIN_RULE_FILES,
581 593 },
594 + clamav_max_scan_bytes: match std::env::var("CLAMAV_MAX_SCAN_BYTES") {
595 + Ok(v) => v.parse::<u64>().map(Some).unwrap_or_else(|_| {
596 + tracing::warn!(
597 + value = %v,
598 + "CLAMAV_MAX_SCAN_BYTES is set but is not a valid number — treating ClamAV as no full-file backstop (large files held for review)"
599 + );
600 + None
601 + }),
602 + Err(_) => None,
603 + },
582 604 })
583 605 }
584 606 }
@@ -592,6 +614,7 @@ impl std::fmt::Debug for ScanConfig {
592 614 .field("urlhaus_enabled", &self.urlhaus_enabled)
593 615 .field("abuse_ch_auth_key", &self.abuse_ch_auth_key.as_ref().map(|_| "<set>"))
594 616 .field("metadefender_api_key", &self.metadefender_api_key.as_ref().map(|_| "<set>"))
617 + .field("clamav_max_scan_bytes", &self.clamav_max_scan_bytes)
595 618 .finish()
596 619 }
597 620 }
@@ -167,10 +167,24 @@ fn final_status(layers: &[LayerResult]) -> FileScanStatus {
167 167 }
168 168
169 169 /// Whether a file's tail would go unscanned — true when it exceeds the YARA
170 - /// prefix cap while no ClamAV full-file backstop is configured. Such a file must
171 - /// be held for review rather than certified Clean on a prefix-only scan.
172 - fn yara_tail_unscanned(data_len: usize, clamav_configured: bool) -> bool {
173 - data_len > crate::constants::SCAN_YARA_MAX_BYTES && !clamav_configured
170 + /// prefix cap ([`crate::constants::SCAN_YARA_MAX_BYTES`]) and ClamAV does not
171 + /// provide a *full-file* backstop for it. Such a file must be held for review
172 + /// rather than certified Clean on a prefix-only scan.
173 + ///
174 + /// ClamAV is a full-file backstop only up to the operator-declared
175 + /// [`ScanConfig::clamav_max_scan_bytes`]: clamd silently scans only the first
176 + /// `MaxScanSize`/`MaxFileSize` bytes and returns `OK` with no error, so trusting
177 + /// "socket configured" alone let a large file with a payload past the YARA
178 + /// prefix certify Clean (ultra-fuzz Run #24 Security MODERATE). `None` (coverage
179 + /// undeclared) is fail-closed: no backstop, hold for review.
180 + fn yara_tail_unscanned(data_len: usize, clamav_backstop_bytes: Option<u64>) -> bool {
181 + if data_len <= crate::constants::SCAN_YARA_MAX_BYTES {
182 + return false;
183 + }
184 + // Backstop only covers the file if the operator declared coverage that
185 + // reaches its size. Undeclared coverage does NOT count.
186 + let covered = clamav_backstop_bytes.is_some_and(|max| data_len as u64 <= max);
187 + !covered
174 188 }
175 189
176 190 /// Fail-closed result for when a CPU scan layer panics on crafted input.
@@ -239,6 +253,10 @@ pub struct ScanPipeline {
239 253 yara_rule_count: usize,
240 254 yara_min_rule_files: usize,
241 255 clamav_socket: Option<String>,
256 + /// Operator-declared ClamAV per-object scan coverage; see
257 + /// [`ScanConfig::clamav_max_scan_bytes`]. `None` = coverage unknown =
258 + /// ClamAV is not treated as a full-file backstop (fail-closed).
259 + clamav_max_scan_bytes: Option<u64>,
242 260 malwarebazaar_enabled: bool,
243 261 urlhaus_enabled: bool,
244 262 abuse_ch_auth_key: Option<String>,
@@ -314,6 +332,7 @@ impl ScanPipeline {
314 332 yara_rule_count,
315 333 yara_min_rule_files: config.yara_min_rule_files,
316 334 clamav_socket: config.clamav_socket.clone(),
335 + clamav_max_scan_bytes: config.clamav_max_scan_bytes,
317 336 malwarebazaar_enabled: config.malwarebazaar_enabled,
318 337 urlhaus_enabled: config.urlhaus_enabled,
319 338 abuse_ch_auth_key: config.abuse_ch_auth_key.clone(),
@@ -341,6 +360,27 @@ impl ScanPipeline {
341 360 return Err(format!("ClamAV socket {socket} unreachable: {e}"));
342 361 }
343 362 }
363 + // ClamAV being reachable does not mean it scans whole objects: clamd
364 + // silently truncates at MaxScanSize/MaxFileSize and reports OK, and
365 + // those limits aren't queryable over the socket. Surface loudly at
366 + // boot when the operator hasn't declared coverage reaching the spool
367 + // ceiling — above the declared coverage, large files are held for
368 + // review rather than certified Clean (ultra-fuzz Run #24 Security).
369 + match self.clamav_max_scan_bytes {
370 + None => tracing::warn!(
371 + yara_prefix = crate::constants::SCAN_YARA_MAX_BYTES,
372 + "CLAMAV_MAX_SCAN_BYTES is not set — ClamAV is NOT treated as a full-file backstop; \
373 + files larger than the YARA prefix will be held for admin review. Set it to your \
374 + clamd min(MaxScanSize, MaxFileSize, StreamMaxLength) to auto-clear large uploads."
375 + ),
376 + Some(max) if max < crate::constants::SCAN_SPOOL_MAX_BYTES => tracing::warn!(
377 + declared_coverage = max,
378 + spool_ceiling = crate::constants::SCAN_SPOOL_MAX_BYTES,
379 + "CLAMAV_MAX_SCAN_BYTES is below the scan spool ceiling — uploads between the declared \
380 + ClamAV coverage and the spool ceiling will be held for admin review, not auto-cleared."
381 + ),
382 + Some(_) => {}
383 + }
344 384 }
345 385 if self.yara_rules.is_some() {
346 386 // Expected-rule-count floor: a corpus that quietly dropped below the
@@ -593,13 +633,16 @@ impl ScanPipeline {
593 633 // backstop, so scanning a generous prefix bounds peak RAM without
594 634 // surrendering the floor.
595 635 if data.len() > crate::constants::SCAN_YARA_MAX_BYTES {
596 - if yara_tail_unscanned(data.len(), self.clamav_socket.is_some()) {
597 - // No ClamAV means there is NO full-file backstop, so the
598 - // bytes past the YARA prefix would go entirely unscanned —
599 - // a tail-of-file evasion (append the payload past the cap
600 - // and it passes Clean). Hold the file rather than certify
601 - // an unscanned tail (ultra-fuzz Run 13 Storage). `yara` is
602 - // FailClosed, so this Error → HeldForReview.
636 + if yara_tail_unscanned(data.len(), self.clamav_max_scan_bytes) {
637 + // No full-file backstop reaches this size, so the bytes
638 + // past the YARA prefix would go entirely unscanned — a
639 + // tail-of-file evasion (append the payload past the cap
640 + // and it passes Clean). This is true both when ClamAV is
641 + // absent and when it is present but its declared
642 + // MaxScanSize doesn't cover the file (Run #24 Security).
643 + // Hold the file rather than certify an unscanned tail
644 + // (ultra-fuzz Run 13 Storage). `yara` is FailClosed, so
645 + // this Error → HeldForReview.
603 646 tracing::warn!(
604 647 full_bytes = data.len(),
605 648 capped_bytes = crate::constants::SCAN_YARA_MAX_BYTES,
@@ -733,6 +776,7 @@ mod tests {
733 776 yara_rule_count: 0,
734 777 yara_min_rule_files: 0,
735 778 clamav_socket: None,
779 + clamav_max_scan_bytes: None,
736 780 malwarebazaar_enabled: false,
737 781 urlhaus_enabled: false,
738 782 abuse_ch_auth_key: None,
@@ -753,6 +797,7 @@ mod tests {
753 797 yara_rule_count: 1,
754 798 yara_min_rule_files: 2,
755 799 clamav_socket: None,
800 + clamav_max_scan_bytes: None,
756 801 malwarebazaar_enabled: false,
757 802 urlhaus_enabled: false,
758 803 abuse_ch_auth_key: None,
@@ -780,6 +825,7 @@ mod tests {
780 825 yara_rule_count: 3,
781 826 yara_min_rule_files: 3,
782 827 clamav_socket: None,
828 + clamav_max_scan_bytes: None,
783 829 malwarebazaar_enabled: false,
784 830 urlhaus_enabled: false,
785 831 abuse_ch_auth_key: None,
@@ -983,13 +1029,19 @@ mod tests {
983 1029 #[test]
984 1030 fn yara_tail_unscanned_only_without_backstop() {
985 1031 use crate::constants::SCAN_YARA_MAX_BYTES;
986 - // Over the cap with no ClamAV → tail unscanned, must hold.
987 - assert!(yara_tail_unscanned(SCAN_YARA_MAX_BYTES + 1, false));
988 - // Over the cap but ClamAV present → ClamAV scans the full file.
989 - assert!(!yara_tail_unscanned(SCAN_YARA_MAX_BYTES + 1, true));
990 - // Within the cap → YARA scanned the whole file regardless of ClamAV.
991 - assert!(!yara_tail_unscanned(SCAN_YARA_MAX_BYTES, false));
992 - assert!(!yara_tail_unscanned(1024, false));
1032 + let over = SCAN_YARA_MAX_BYTES + 1;
1033 + // Over the cap, no declared ClamAV coverage → tail unscanned, must hold.
1034 + assert!(yara_tail_unscanned(over, None));
1035 + // Over the cap, ClamAV coverage present but SHORT of the file (Run #24
1036 + // MODERATE: clamd reachable but MaxScanSize doesn't reach) → still hold.
1037 + assert!(yara_tail_unscanned(over, Some(over as u64 - 1)));
1038 + // Over the cap, declared coverage reaches the file → ClamAV is a real
1039 + // full-file backstop, don't hold.
1040 + assert!(!yara_tail_unscanned(over, Some(over as u64)));
1041 + assert!(!yara_tail_unscanned(over, Some(u64::MAX)));
1042 + // Within the YARA cap → whole file scanned by YARA regardless of ClamAV.
1043 + assert!(!yara_tail_unscanned(SCAN_YARA_MAX_BYTES, None));
1044 + assert!(!yara_tail_unscanned(1024, None));
993 1045 }
994 1046
995 1047 #[test]
@@ -271,6 +271,7 @@ impl TestHarness {
271 271 abuse_ch_auth_key: None,
272 272 metadefender_api_key: None,
273 273 yara_min_rule_files: 0,
274 + clamav_max_scan_bytes: None,
274 275 };
275 276 ScanPipeline::new(&scan_config).expect("ScanPipeline::new with no-op config")
276 277 }