| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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]
|