| 83 |
83 |
|
/// Check a file for archive / decompression-bomb safety issues.
|
| 84 |
84 |
|
/// Runs regardless of claimed type so a disguised archive is still inspected.
|
| 85 |
85 |
|
pub fn check_archive_safety(data: &[u8], file_type: FileType) -> LayerResult {
|
| 86 |
|
- |
// If an archive is disguised as a cover image, layer 1 (content_type)
|
| 87 |
|
- |
// handles the type mismatch; skip the archive walk for covers.
|
| 88 |
|
- |
if file_type == FileType::Cover {
|
| 89 |
|
- |
return skip("Archive check skipped for cover images");
|
|
86 |
+ |
inspect_archive(data, file_type, None).0
|
|
87 |
+ |
}
|
|
88 |
+ |
|
|
89 |
+ |
/// Outcome of buffering a decompressed entry's prefix for the interior scan.
|
|
90 |
+ |
enum ContentBuf {
|
|
91 |
+ |
/// Fully buffered within the per-entry ceiling and shared budget.
|
|
92 |
+ |
Buffered(Vec<u8>),
|
|
93 |
+ |
/// Decompressed size exceeded `INTERIOR_ENTRY_MAX` — cannot content-scan.
|
|
94 |
+ |
Overflow,
|
|
95 |
+ |
/// The shared interior budget was exhausted before this entry finished.
|
|
96 |
+ |
BudgetExceeded,
|
|
97 |
+ |
}
|
|
98 |
+ |
|
|
99 |
+ |
/// Decompress `reader` to completion (or until well past the bomb cap),
|
|
100 |
+ |
/// returning the FULL decompressed byte count (for bomb accounting) and — when
|
|
101 |
+ |
/// `want_content` is set — the buffered prefix (≤ `INTERIOR_ENTRY_MAX`, charged
|
|
102 |
+ |
/// against `budget`) for the interior content scan. A single decompression now
|
|
103 |
+ |
/// serves both the bomb-defense walk and the interior scan; they previously
|
|
104 |
+ |
/// decompressed every entry independently (Run #2 Performance SERIOUS).
|
|
105 |
+ |
///
|
|
106 |
+ |
/// `Err(detail)` is a mid-stream decode failure: the caller decides what that
|
|
107 |
+ |
/// means for each dimension (ZIP bomb accounting uses a conservative estimate
|
|
108 |
+ |
/// and continues; a single-stream compressor fails closed).
|
|
109 |
+ |
fn tee_decompress(
|
|
110 |
+ |
reader: &mut dyn Read,
|
|
111 |
+ |
bomb_abs_limit: u64,
|
|
112 |
+ |
want_content: bool,
|
|
113 |
+ |
budget: &mut u64,
|
|
114 |
+ |
) -> Result<(u64, Option<ContentBuf>), (u64, String)> {
|
|
115 |
+ |
let mut counted: u64 = 0;
|
|
116 |
+ |
let mut buf = [0u8; 8192];
|
|
117 |
+ |
let mut content = if want_content {
|
|
118 |
+ |
Some(ContentBuf::Buffered(Vec::new()))
|
|
119 |
+ |
} else {
|
|
120 |
+ |
None
|
|
121 |
+ |
};
|
|
122 |
+ |
loop {
|
|
123 |
+ |
match reader.read(&mut buf) {
|
|
124 |
+ |
Ok(0) => break,
|
|
125 |
+ |
Ok(n) => {
|
|
126 |
+ |
counted += n as u64;
|
|
127 |
+ |
if let Some(ContentBuf::Buffered(ref mut v)) = content {
|
|
128 |
+ |
if v.len() + n > INTERIOR_ENTRY_MAX {
|
|
129 |
+ |
content = Some(ContentBuf::Overflow);
|
|
130 |
+ |
} else if (n as u64) > *budget {
|
|
131 |
+ |
content = Some(ContentBuf::BudgetExceeded);
|
|
132 |
+ |
} else {
|
|
133 |
+ |
*budget -= n as u64;
|
|
134 |
+ |
v.extend_from_slice(&buf[..n]);
|
|
135 |
+ |
}
|
|
136 |
+ |
}
|
|
137 |
+ |
// Stop once the bomb cap is blown AND there's no more content to
|
|
138 |
+ |
// buffer — reading further serves neither dimension.
|
|
139 |
+ |
if counted > bomb_abs_limit
|
|
140 |
+ |
&& !matches!(content, Some(ContentBuf::Buffered(_)))
|
|
141 |
+ |
{
|
|
142 |
+ |
break;
|
|
143 |
+ |
}
|
|
144 |
+ |
}
|
|
145 |
+ |
Err(e) => return Err((counted, format!("{e}"))),
|
|
146 |
+ |
}
|
| 90 |
147 |
|
}
|
|
148 |
+ |
Ok((counted, content))
|
|
149 |
+ |
}
|
|
150 |
+ |
|
|
151 |
+ |
/// Single-pass archive inspection. Decompresses each top-level entry ONCE and
|
|
152 |
+ |
/// derives BOTH the bomb-defense verdict (`"archive"`) and the interior content
|
|
153 |
+ |
/// verdict (`"archive_nested"`) from the same decompression. `check_archive_safety`,
|
|
154 |
+ |
/// `check_archive_safety_path`, and `scan_nested_contents` all delegate here so
|
|
155 |
+ |
/// the two layers can never drift apart.
|
|
156 |
+ |
///
|
|
157 |
+ |
/// `yara_rules == None` still runs the interior content scan (content-type +
|
|
158 |
+ |
/// structural layers); it only skips the YARA sub-check. Callers that want bomb
|
|
159 |
+ |
/// defense only (`check_archive_safety`) take the first element and discard the
|
|
160 |
+ |
/// second.
|
|
161 |
+ |
pub fn inspect_archive(
|
|
162 |
+ |
data: &[u8],
|
|
163 |
+ |
file_type: FileType,
|
|
164 |
+ |
yara_rules: Option<&yara_x::Rules>,
|
|
165 |
+ |
) -> (LayerResult, LayerResult) {
|
|
166 |
+ |
// A cover-disguised archive: layer 1 (content_type) handles the type
|
|
167 |
+ |
// mismatch, so the bomb walk is skipped — but the interior is still scanned
|
|
168 |
+ |
// (the nested layer never gated on file type). `bomb` off, `content` on.
|
|
169 |
+ |
let bomb = file_type != FileType::Cover;
|
| 91 |
170 |
|
|
| 92 |
171 |
|
match detect_kind(data) {
|
| 93 |
|
- |
Some(ArchiveKind::Zip) => inspect_zip(Cursor::new(data)),
|
| 94 |
|
- |
Some(stream) => inspect_compressed_stream(stream, data.len() as u64, Cursor::new(data)),
|
|
172 |
+ |
Some(ArchiveKind::Zip) => walk_zip(Cursor::new(data), bomb, true, yara_rules),
|
|
173 |
+ |
Some(stream) => walk_compressed(stream, data.len() as u64, Cursor::new(data), bomb, true, yara_rules),
|
| 95 |
174 |
|
None if has_zip_eocd(data) => {
|
| 96 |
175 |
|
// Prefixed / self-extracting ZIP: no offset-0 magic, but a real
|
| 97 |
176 |
|
// central directory at the tail. ZipArchive locates it from the end.
|
| 98 |
|
- |
inspect_zip(Cursor::new(data))
|
|
177 |
+ |
walk_zip(Cursor::new(data), bomb, true, yara_rules)
|
|
178 |
+ |
}
|
|
179 |
+ |
None => (
|
|
180 |
+ |
if bomb { skip("Not a recognized archive") } else { skip("Archive check skipped for cover images") },
|
|
181 |
+ |
nested(LayerVerdict::Skip, "Not an archive; no interior to scan".to_string()),
|
|
182 |
+ |
),
|
|
183 |
+ |
}
|
|
184 |
+ |
}
|
|
185 |
+ |
|
|
186 |
+ |
/// Bomb-defense result to surface when the bomb dimension is disabled (covers).
|
|
187 |
+ |
fn bomb_disabled_archive() -> LayerResult {
|
|
188 |
+ |
skip("Archive check skipped for cover images")
|
|
189 |
+ |
}
|
|
190 |
+ |
|
|
191 |
+ |
/// Walk a ZIP once, computing the bomb-defense verdict (when `bomb`) and the
|
|
192 |
+ |
/// interior content verdict (when `content`). Returns `(archive, archive_nested)`.
|
|
193 |
+ |
fn walk_zip<R: std::io::Read + std::io::Seek>(
|
|
194 |
+ |
reader: R,
|
|
195 |
+ |
bomb: bool,
|
|
196 |
+ |
content: bool,
|
|
197 |
+ |
yara_rules: Option<&yara_x::Rules>,
|
|
198 |
+ |
) -> (LayerResult, LayerResult) {
|
|
199 |
+ |
let mut archive = match zip::ZipArchive::new(reader) {
|
|
200 |
+ |
Ok(a) => a,
|
|
201 |
+ |
Err(e) => {
|
|
202 |
+ |
return (
|
|
203 |
+ |
if bomb {
|
|
204 |
+ |
LayerResult {
|
|
205 |
+ |
layer: "archive",
|
|
206 |
+ |
verdict: LayerVerdict::Error,
|
|
207 |
+ |
detail: Some(format!("Failed to parse ZIP: {e}")),
|
|
208 |
+ |
}
|
|
209 |
+ |
} else {
|
|
210 |
+ |
bomb_disabled_archive()
|
|
211 |
+ |
},
|
|
212 |
+ |
if content {
|
|
213 |
+ |
nested(LayerVerdict::Error, format!("cannot open nested ZIP: {e}"))
|
|
214 |
+ |
} else {
|
|
215 |
+ |
nested(LayerVerdict::Skip, String::new())
|
|
216 |
+ |
},
|
|
217 |
+ |
);
|
|
218 |
+ |
}
|
|
219 |
+ |
};
|
|
220 |
+ |
|
|
221 |
+ |
let count = archive.len();
|
|
222 |
+ |
|
|
223 |
+ |
// `archive_res`/`nested_res` freeze the first non-clean verdict for each
|
|
224 |
+ |
// dimension; the loop keeps running for the OTHER dimension so a bomb Fail
|
|
225 |
+ |
// and a content Fail are both surfaced (and a later bomb can still upgrade
|
|
226 |
+ |
// a merely-held file to a quarantine).
|
|
227 |
+ |
let mut archive_res: Option<LayerResult> = None;
|
|
228 |
+ |
let mut nested_res: Option<LayerResult> = None;
|
|
229 |
+ |
|
|
230 |
+ |
if count > constants::SCAN_ZIP_MAX_ENTRIES {
|
|
231 |
+ |
if bomb {
|
|
232 |
+ |
archive_res = Some(LayerResult {
|
|
233 |
+ |
layer: "archive",
|
|
234 |
+ |
verdict: LayerVerdict::Fail,
|
|
235 |
+ |
detail: Some(format!(
|
|
236 |
+ |
"ZIP entry count {count} exceeds limit {}",
|
|
237 |
+ |
constants::SCAN_ZIP_MAX_ENTRIES
|
|
238 |
+ |
)),
|
|
239 |
+ |
});
|
|
240 |
+ |
}
|
|
241 |
+ |
if content {
|
|
242 |
+ |
nested_res = Some(nested(
|
|
243 |
+ |
LayerVerdict::Error,
|
|
244 |
+ |
format!(
|
|
245 |
+ |
"nested ZIP entry count {count} exceeds limit {}",
|
|
246 |
+ |
constants::SCAN_ZIP_MAX_ENTRIES
|
|
247 |
+ |
),
|
|
248 |
+ |
));
|
|
249 |
+ |
}
|
|
250 |
+ |
return (
|
|
251 |
+ |
archive_res.unwrap_or_else(bomb_disabled_archive),
|
|
252 |
+ |
nested_res.unwrap_or_else(|| nested(LayerVerdict::Skip, String::new())),
|
|
253 |
+ |
);
|
|
254 |
+ |
}
|
|
255 |
+ |
|
|
256 |
+ |
let mut total_compressed: u64 = 0;
|
|
257 |
+ |
let mut total_uncompressed: u64 = 0;
|
|
258 |
+ |
let mut interior_budget: u64 = constants::SCAN_ZIP_MAX_UNCOMPRESSED;
|
|
259 |
+ |
|
|
260 |
+ |
for i in 0..count {
|
|
261 |
+ |
// Once both dimensions are settled, nothing more to learn.
|
|
262 |
+ |
let bomb_active = bomb && archive_res.is_none();
|
|
263 |
+ |
let content_active = content && nested_res.is_none();
|
|
264 |
+ |
if !bomb_active && !content_active {
|
|
265 |
+ |
break;
|
|
266 |
+ |
}
|
|
267 |
+ |
|
|
268 |
+ |
let (name, entry_compressed, claimed_size) = match archive.by_index_raw(i) {
|
|
269 |
+ |
Ok(e) => (e.name().to_string(), e.compressed_size(), e.size()),
|
|
270 |
+ |
Err(e) => {
|
|
271 |
+ |
if bomb_active {
|
|
272 |
+ |
archive_res = Some(LayerResult {
|
|
273 |
+ |
layer: "archive",
|
|
274 |
+ |
verdict: LayerVerdict::Error,
|
|
275 |
+ |
detail: Some(format!("Failed to read ZIP entry {i}: {e}")),
|
|
276 |
+ |
});
|
|
277 |
+ |
}
|
|
278 |
+ |
if content_active {
|
|
279 |
+ |
nested_res = Some(nested(
|
|
280 |
+ |
LayerVerdict::Error,
|
|
281 |
+ |
format!("read nested ZIP entry {i}: {e}"),
|
|
282 |
+ |
));
|
|
283 |
+ |
}
|
|
284 |
+ |
continue;
|
|
285 |
+ |
}
|
|
286 |
+ |
};
|
|
287 |
+ |
|
|
288 |
+ |
// Path traversal is a bomb-dimension failure (the nested walk never
|
|
289 |
+ |
// checked entry names).
|
|
290 |
+ |
if bomb_active {
|
|
291 |
+ |
let name_lower = name.to_ascii_lowercase();
|
|
292 |
+ |
if name.contains("../")
|
|
293 |
+ |
|| name.contains("..\\")
|
|
294 |
+ |
|| name_lower.contains("%2e%2e")
|
|
295 |
+ |
|| name.starts_with('/')
|
|
296 |
+ |
|| name.contains('\0')
|
|
297 |
+ |
{
|
|
298 |
+ |
archive_res = Some(LayerResult {
|
|
299 |
+ |
layer: "archive",
|
|
300 |
+ |
verdict: LayerVerdict::Fail,
|
|
301 |
+ |
detail: Some(format!("Path traversal in entry: {name}")),
|
|
302 |
+ |
});
|
|
303 |
+ |
// Bomb verdict frozen; keep going only if content still needs us.
|
|
304 |
+ |
if !content_active {
|
|
305 |
+ |
break;
|
|
306 |
+ |
}
|
|
307 |
+ |
}
|
|
308 |
+ |
}
|
|
309 |
+ |
|
|
310 |
+ |
// Decompress the entry exactly once, teeing the full size (bomb) and the
|
|
311 |
+ |
// buffered prefix (content).
|
|
312 |
+ |
let want_content = content && nested_res.is_none();
|
|
313 |
+ |
let (counted, content_buf, decode_err) = match archive.by_index(i) {
|
|
314 |
+ |
Ok(mut entry) => match tee_decompress(
|
|
315 |
+ |
&mut entry,
|
|
316 |
+ |
constants::SCAN_ZIP_MAX_UNCOMPRESSED,
|
|
317 |
+ |
want_content,
|
|
318 |
+ |
&mut interior_budget,
|
|
319 |
+ |
) {
|
|
320 |
+ |
Ok((c, cb)) => (c, cb, None),
|
|
321 |
+ |
Err((c, why)) => (c, None, Some(why)),
|
|
322 |
+ |
},
|
|
323 |
+ |
// Could not open the entry to decompress: bomb uses a conservative
|
|
324 |
+ |
// estimate; content is held for review.
|
|
325 |
+ |
Err(e) => (
|
|
326 |
+ |
claimed_size.saturating_mul(10).max(1024 * 1024),
|
|
327 |
+ |
None,
|
|
328 |
+ |
Some(format!("{e}")),
|
|
329 |
+ |
),
|
|
330 |
+ |
};
|
|
331 |
+ |
|
|
332 |
+ |
// Bomb accounting (skipped once the bomb verdict is frozen).
|
|
333 |
+ |
if bomb && archive_res.is_none() {
|
|
334 |
+ |
// A decode error mid-stream gets the conservative estimate rather
|
|
335 |
+ |
// than trusting the attacker-controlled claimed size.
|
|
336 |
+ |
let actual_size = if decode_err.is_some() {
|
|
337 |
+ |
claimed_size.saturating_mul(10).max(1024 * 1024)
|
|
338 |
+ |
} else {
|
|
339 |
+ |
counted
|
|
340 |
+ |
};
|
|
341 |
+ |
if actual_size > constants::SCAN_ZIP_MAX_UNCOMPRESSED {
|
|
342 |
+ |
archive_res = Some(LayerResult {
|
|
343 |
+ |
layer: "archive",
|
|
344 |
+ |
verdict: LayerVerdict::Fail,
|
|
345 |
+ |
detail: Some(format!(
|
|
346 |
+ |
"Actual decompressed size exceeds {} bytes (possible ZIP bomb)",
|
|
347 |
+ |
constants::SCAN_ZIP_MAX_UNCOMPRESSED
|
|
348 |
+ |
)),
|
|
349 |
+ |
});
|
|
350 |
+ |
} else {
|
|
351 |
+ |
total_compressed += entry_compressed;
|
|
352 |
+ |
total_uncompressed += actual_size;
|
|
353 |
+ |
if entry_compressed > 0 && actual_size >= 1024 * 1024 {
|
|
354 |
+ |
let entry_ratio = actual_size as f64 / entry_compressed as f64;
|
|
355 |
+ |
if entry_ratio > constants::SCAN_ZIP_MAX_RATIO {
|
|
356 |
+ |
archive_res = Some(LayerResult {
|
|
357 |
+ |
layer: "archive",
|
|
358 |
+ |
verdict: LayerVerdict::Fail,
|
|
359 |
+ |
detail: Some(format!(
|
|
360 |
+ |
"Entry {name} compression ratio {entry_ratio:.1}x exceeds limit of {:.0}x (possible ZIP bomb)",
|
|
361 |
+ |
constants::SCAN_ZIP_MAX_RATIO
|
|
362 |
+ |
)),
|
|
363 |
+ |
});
|
|
364 |
+ |
}
|
|
365 |
+ |
}
|
|
366 |
+ |
}
|
|
367 |
+ |
}
|
|
368 |
+ |
|
|
369 |
+ |
// Interior content scan (skipped once the nested verdict is frozen).
|
|
370 |
+ |
if content && nested_res.is_none() {
|
|
371 |
+ |
if let Some(why) = decode_err {
|
|
372 |
+ |
nested_res = Some(nested(
|
|
373 |
+ |
LayerVerdict::Error,
|
|
374 |
+ |
format!("decode nested entry: {why}"),
|
|
375 |
+ |
));
|
|
376 |
+ |
} else {
|
|
377 |
+ |
match content_buf {
|
|
378 |
+ |
Some(ContentBuf::Buffered(bytes)) => {
|
|
379 |
+ |
if let Some(v) = scan_entry(
|
|
380 |
+ |
&bytes,
|
|
381 |
+ |
yara_rules,
|
|
382 |
+ |
constants::SCAN_ZIP_MAX_DEPTH,
|
|
383 |
+ |
&mut interior_budget,
|
|
384 |
+ |
) {
|
|
385 |
+ |
nested_res = Some(v);
|
|
386 |
+ |
}
|
|
387 |
+ |
}
|
|
388 |
+ |
Some(ContentBuf::Overflow) => {
|
|
389 |
+ |
nested_res = Some(nested(
|
|
390 |
+ |
LayerVerdict::Error,
|
|
391 |
+ |
format!(
|
|
392 |
+ |
"nested entry exceeds {INTERIOR_ENTRY_MAX}-byte interior scan ceiling"
|
|
393 |
+ |
),
|
|
394 |
+ |
));
|
|
395 |
+ |
}
|
|
396 |
+ |
Some(ContentBuf::BudgetExceeded) => {
|
|
397 |
+ |
nested_res = Some(nested(
|
|
398 |
+ |
LayerVerdict::Error,
|
|
399 |
+ |
"nested archive content exceeds total interior scan budget".to_string(),
|
|
400 |
+ |
));
|
|
401 |
+ |
}
|
|
402 |
+ |
None => {}
|
|
403 |
+ |
}
|
|
404 |
+ |
}
|
| 99 |
405 |
|
}
|
| 100 |
|
- |
None => skip("Not a recognized archive"),
|
| 101 |
406 |
|
}
|
|
407 |
+ |
|
|
408 |
+ |
// Finalize bomb verdict from the totals if nothing tripped mid-walk.
|
|
409 |
+ |
let archive_result = match archive_res {
|
|
410 |
+ |
Some(r) => r,
|
|
411 |
+ |
None if !bomb => bomb_disabled_archive(),
|
|
412 |
+ |
None => {
|
|
413 |
+ |
if total_uncompressed > constants::SCAN_ZIP_MAX_UNCOMPRESSED {
|
|
414 |
+ |
LayerResult {
|
|
415 |
+ |
layer: "archive",
|
|
416 |
+ |
verdict: LayerVerdict::Fail,
|
|
417 |
+ |
detail: Some(format!(
|
|
418 |
+ |
"Total uncompressed size {total_uncompressed} bytes exceeds limit of {} bytes",
|
|
419 |
+ |
constants::SCAN_ZIP_MAX_UNCOMPRESSED
|
|
420 |
+ |
)),
|
|
421 |
+ |
}
|
|
422 |
+ |
} else if total_compressed > 0
|
|
423 |
+ |
&& (total_uncompressed as f64 / total_compressed as f64) > constants::SCAN_ZIP_MAX_RATIO
|
|
424 |
+ |
{
|
|
425 |
+ |
LayerResult {
|
|
426 |
+ |
layer: "archive",
|
|
427 |
+ |
verdict: LayerVerdict::Fail,
|
|
428 |
+ |
detail: Some(format!(
|
|
429 |
+ |
"Compression ratio {:.1}x exceeds limit of {:.0}x (possible ZIP bomb)",
|
|
430 |
+ |
total_uncompressed as f64 / total_compressed as f64,
|
|
431 |
+ |
constants::SCAN_ZIP_MAX_RATIO
|
|
432 |
+ |
)),
|
|
433 |
+ |
}
|
|
434 |
+ |
} else {
|
|
435 |
+ |
LayerResult {
|
|
436 |
+ |
layer: "archive",
|
|
437 |
+ |
verdict: LayerVerdict::Pass,
|
|
438 |
+ |
detail: Some(format!(
|
|
439 |
+ |
"{count} entries, {:.1}x ratio",
|
|
440 |
+ |
if total_compressed > 0 {
|
|
441 |
+ |
total_uncompressed as f64 / total_compressed as f64
|
|
442 |
+ |
} else {
|
|
443 |
+ |
0.0
|
|
444 |
+ |
}
|
|
445 |
+ |
)),
|
|
446 |
+ |
}
|
|
447 |
+ |
}
|
|
448 |
+ |
}
|
|
449 |
+ |
};
|
|
450 |
+ |
|
|
451 |
+ |
let nested_result = match nested_res {
|
|
452 |
+ |
Some(r) => r,
|
|
453 |
+ |
None if !content => nested(LayerVerdict::Skip, String::new()),
|
|
454 |
+ |
None => nested(
|
|
455 |
+ |
LayerVerdict::Pass,
|
|
456 |
+ |
"Archive interior fully scanned; no threats found".to_string(),
|
|
457 |
+ |
),
|
|
458 |
+ |
};
|
|
459 |
+ |
|
|
460 |
+ |
(archive_result, nested_result)
|
|
461 |
+ |
}
|
|
462 |
+ |
|
|
463 |
+ |
/// Walk a single-stream compressor (gzip/bzip2/xz/zstd) once, computing the
|
|
464 |
+ |
/// bomb-defense verdict (when `bomb`) and the interior content verdict (when
|
|
465 |
+ |
/// `content`). Returns `(archive, archive_nested)`.
|
|
466 |
+ |
fn walk_compressed<R: Read>(
|
|
467 |
+ |
kind: ArchiveKind,
|
|
468 |
+ |
compressed_size: u64,
|
|
469 |
+ |
reader: R,
|
|
470 |
+ |
bomb: bool,
|
|
471 |
+ |
content: bool,
|
|
472 |
+ |
yara_rules: Option<&yara_x::Rules>,
|
|
473 |
+ |
) -> (LayerResult, LayerResult) {
|
|
474 |
+ |
let mut decoder: Box<dyn Read> = match kind {
|
|
475 |
+ |
ArchiveKind::Gzip => Box::new(flate2::read::MultiGzDecoder::new(reader)),
|
|
476 |
+ |
ArchiveKind::Bzip2 => Box::new(bzip2::read::BzDecoder::new(reader)),
|
|
477 |
+ |
ArchiveKind::Xz => Box::new(xz2::read::XzDecoder::new(reader)),
|
|
478 |
+ |
ArchiveKind::Zstd => match zstd::stream::read::Decoder::new(reader) {
|
|
479 |
+ |
Ok(d) => Box::new(d),
|
|
480 |
+ |
Err(e) => {
|
|
481 |
+ |
let why = format!("zstd init failed: {e}");
|
|
482 |
+ |
return (
|
|
483 |
+ |
if bomb { error(why.clone()) } else { bomb_disabled_archive() },
|
|
484 |
+ |
if content {
|
|
485 |
+ |
nested(LayerVerdict::Error, why)
|
|
486 |
+ |
} else {
|
|
487 |
+ |
nested(LayerVerdict::Skip, String::new())
|
|
488 |
+ |
},
|
|
489 |
+ |
);
|
|
490 |
+ |
}
|
|
491 |
+ |
},
|
|
492 |
+ |
ArchiveKind::Zip => unreachable!("zip is handled by walk_zip"),
|
|
493 |
+ |
};
|
|
494 |
+ |
|
|
495 |
+ |
let abs_limit = constants::SCAN_ZIP_MAX_UNCOMPRESSED;
|
|
496 |
+ |
let ratio_limit = compressed_size.saturating_mul(constants::SCAN_ZIP_MAX_RATIO as u64);
|
|
497 |
+ |
let mut interior_budget: u64 = constants::SCAN_ZIP_MAX_UNCOMPRESSED;
|
|
498 |
+ |
|
|
499 |
+ |
let (counted, content_buf, decode_err) =
|
|
500 |
+ |
match tee_decompress(decoder.as_mut(), abs_limit, content, &mut interior_budget) {
|
|
501 |
+ |
Ok((c, cb)) => (c, cb, None),
|
|
502 |
+ |
Err((c, why)) => (c, None, Some(why)),
|
|
503 |
+ |
};
|
|
504 |
+ |
|
|
505 |
+ |
// Bomb verdict.
|
|
506 |
+ |
let archive_result = if !bomb {
|
|
507 |
+ |
bomb_disabled_archive()
|
|
508 |
+ |
} else if let Some(ref why) = decode_err {
|
|
509 |
+ |
// Mid-stream decode error is suspicious; fail closed for review.
|
|
510 |
+ |
error(format!("{} decode error: {why}", kind.label()))
|
|
511 |
+ |
} else if counted > abs_limit {
|
|
512 |
+ |
LayerResult {
|
|
513 |
+ |
layer: "archive",
|
|
514 |
+ |
verdict: LayerVerdict::Fail,
|
|
515 |
+ |
detail: Some(format!(
|
|
516 |
+ |
"{} stream decompresses past {abs_limit} bytes (possible decompression bomb)",
|
|
517 |
+ |
kind.label()
|
|
518 |
+ |
)),
|
|
519 |
+ |
}
|
|
520 |
+ |
} else if compressed_size > 0 && counted > ratio_limit {
|
|
521 |
+ |
LayerResult {
|
|
522 |
+ |
layer: "archive",
|
|
523 |
+ |
verdict: LayerVerdict::Fail,
|
|
524 |
+ |
detail: Some(format!(
|
|
525 |
+ |
"{} compression ratio exceeds {:.0}x (possible decompression bomb)",
|
|
526 |
+ |
kind.label(),
|
|
527 |
+ |
constants::SCAN_ZIP_MAX_RATIO
|
|
528 |
+ |
)),
|
|
529 |
+ |
}
|
|
530 |
+ |
} else {
|
|
531 |
+ |
LayerResult {
|
|
532 |
+ |
layer: "archive",
|
|
533 |
+ |
verdict: LayerVerdict::Pass,
|
|
534 |
+ |
detail: Some(format!(
|
|
535 |
+ |
"{} stream, {counted} bytes uncompressed ({:.1}x)",
|
|
536 |
+ |
kind.label(),
|
|
537 |
+ |
if compressed_size > 0 { counted as f64 / compressed_size as f64 } else { 0.0 }
|
|
538 |
+ |
)),
|
|
539 |
+ |
}
|
|
540 |
+ |
};
|
|
541 |
+ |
|
|
542 |
+ |
// Interior content verdict.
|
|
543 |
+ |
let nested_result = if !content {
|
|
544 |
+ |
nested(LayerVerdict::Skip, String::new())
|
|
545 |
+ |
} else if let Some(why) = decode_err {
|
|
546 |
+ |
nested(LayerVerdict::Error, format!("decode nested entry: {why}"))
|
|
547 |
+ |
} else {
|
|
548 |
+ |
match content_buf {
|
|
549 |
+ |
Some(ContentBuf::Buffered(bytes)) => scan_entry(
|
|
550 |
+ |
&bytes,
|
|
551 |
+ |
yara_rules,
|
|
552 |
+ |
constants::SCAN_ZIP_MAX_DEPTH,
|
|
553 |
+ |
&mut interior_budget,
|
|
554 |
+ |
)
|
|
555 |
+ |
.unwrap_or_else(|| {
|
|
556 |
+ |
nested(
|
|
557 |
+ |
LayerVerdict::Pass,
|
|
558 |
+ |
"Archive interior fully scanned; no threats found".to_string(),
|
|
559 |
+ |
)
|
|
560 |
+ |
}),
|
|
561 |
+ |
Some(ContentBuf::Overflow) => nested(
|
|
562 |
+ |
LayerVerdict::Error,
|
|
563 |
+ |
format!("nested entry exceeds {INTERIOR_ENTRY_MAX}-byte interior scan ceiling"),
|
|
564 |
+ |
),
|
|
565 |
+ |
Some(ContentBuf::BudgetExceeded) => nested(
|
|
566 |
+ |
LayerVerdict::Error,
|
|
567 |
+ |
"nested archive content exceeds total interior scan budget".to_string(),
|
|
568 |
+ |
),
|
|
569 |
+ |
None => nested(
|
|
570 |
+ |
LayerVerdict::Pass,
|
|
571 |
+ |
"Archive interior fully scanned; no threats found".to_string(),
|
|
572 |
+ |
),
|
|
573 |
+ |
}
|
|
574 |
+ |
};
|