max / makenotwork
3 files changed,
+156 insertions,
-2 deletions
| @@ -40,8 +40,57 @@ pub fn check_archive_safety(data: &[u8], file_type: FileType) -> LayerResult { | |||
| 40 | 40 | }; | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | - | let cursor = Cursor::new(data); | |
| 44 | - | let mut archive = match zip::ZipArchive::new(cursor) { | |
| 43 | + | inspect_zip(Cursor::new(data)) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Path-based entry. Opens the spooled file directly so we never have to | |
| 47 | + | /// buffer the whole archive; `ZipArchive::new` only needs `Read + Seek`. | |
| 48 | + | /// File-type gating happens at the call site (same shape as the buffered | |
| 49 | + | /// variant — caller already checked `file_type`). | |
| 50 | + | pub fn check_archive_safety_path(path: &std::path::Path, file_type: FileType) -> LayerResult { | |
| 51 | + | if file_type == FileType::Cover { | |
| 52 | + | return LayerResult { | |
| 53 | + | layer: "archive", | |
| 54 | + | verdict: LayerVerdict::Skip, | |
| 55 | + | detail: Some("Archive check skipped for cover images".to_string()), | |
| 56 | + | }; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | let mut file = match std::fs::File::open(path) { | |
| 60 | + | Ok(f) => f, | |
| 61 | + | Err(e) => { | |
| 62 | + | return LayerResult { | |
| 63 | + | layer: "archive", | |
| 64 | + | verdict: LayerVerdict::Error, | |
| 65 | + | detail: Some(format!("open spool {}: {e}", path.display())), | |
| 66 | + | }; | |
| 67 | + | } | |
| 68 | + | }; | |
| 69 | + | ||
| 70 | + | let mut magic = [0u8; 4]; | |
| 71 | + | use std::io::{Read, Seek, SeekFrom}; | |
| 72 | + | let read = file.read(&mut magic).unwrap_or(0); | |
| 73 | + | let is_zip = read == 4 && magic == [0x50, 0x4B, 0x03, 0x04]; | |
| 74 | + | if !is_zip { | |
| 75 | + | return LayerResult { | |
| 76 | + | layer: "archive", | |
| 77 | + | verdict: LayerVerdict::Skip, | |
| 78 | + | detail: Some("Not a ZIP archive".to_string()), | |
| 79 | + | }; | |
| 80 | + | } | |
| 81 | + | if file.seek(SeekFrom::Start(0)).is_err() { | |
| 82 | + | return LayerResult { | |
| 83 | + | layer: "archive", | |
| 84 | + | verdict: LayerVerdict::Error, | |
| 85 | + | detail: Some(format!("seek spool {}", path.display())), | |
| 86 | + | }; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | inspect_zip(file) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | fn inspect_zip<R: std::io::Read + std::io::Seek>(reader: R) -> LayerResult { | |
| 93 | + | let mut archive = match zip::ZipArchive::new(reader) { | |
| 45 | 94 | Ok(a) => a, | |
| 46 | 95 | Err(e) => { | |
| 47 | 96 | return LayerResult { | |
| @@ -534,4 +583,27 @@ mod tests { | |||
| 534 | 583 | assert_eq!(result.verdict, LayerVerdict::Error); | |
| 535 | 584 | assert!(result.detail.unwrap().contains("Failed to parse ZIP")); | |
| 536 | 585 | } | |
| 586 | + | ||
| 587 | + | #[test] | |
| 588 | + | fn path_entry_matches_buffered_for_non_zip() { | |
| 589 | + | let data = b"not a zip at all"; | |
| 590 | + | let buffered = check_archive_safety(data, FileType::Download); | |
| 591 | + | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 592 | + | std::fs::write(tmp.path(), data).unwrap(); | |
| 593 | + | let path_based = check_archive_safety_path(tmp.path(), FileType::Download); | |
| 594 | + | assert_eq!(buffered.verdict, path_based.verdict); | |
| 595 | + | assert_eq!(buffered.verdict, LayerVerdict::Skip); | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | #[test] | |
| 599 | + | fn path_entry_matches_buffered_for_cover_skip() { | |
| 600 | + | let mut data = vec![0x50, 0x4B, 0x03, 0x04]; | |
| 601 | + | data.extend_from_slice(&[0xFF; 100]); | |
| 602 | + | let buffered = check_archive_safety(&data, FileType::Cover); | |
| 603 | + | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 604 | + | std::fs::write(tmp.path(), &data).unwrap(); | |
| 605 | + | let path_based = check_archive_safety_path(tmp.path(), FileType::Cover); | |
| 606 | + | assert_eq!(buffered.verdict, path_based.verdict); | |
| 607 | + | assert_eq!(buffered.verdict, LayerVerdict::Skip); | |
| 608 | + | } | |
| 537 | 609 | } |
| @@ -147,6 +147,37 @@ pub fn verify_content_type(data: &[u8], claimed_type: FileType) -> LayerResult { | |||
| 147 | 147 | } | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | + | /// Path-based entry. Reads only the head bytes (`infer` inspects ~262 | |
| 151 | + | /// bytes); no need to mmap or buffer the whole spooled file. | |
| 152 | + | pub fn verify_content_type_path(path: &std::path::Path, claimed_type: FileType) -> LayerResult { | |
| 153 | + | use std::io::Read; | |
| 154 | + | ||
| 155 | + | let mut file = match std::fs::File::open(path) { | |
| 156 | + | Ok(f) => f, | |
| 157 | + | Err(e) => { | |
| 158 | + | return LayerResult { | |
| 159 | + | layer: "content_type", | |
| 160 | + | verdict: LayerVerdict::Error, | |
| 161 | + | detail: Some(format!("open spool {}: {e}", path.display())), | |
| 162 | + | }; | |
| 163 | + | } | |
| 164 | + | }; | |
| 165 | + | ||
| 166 | + | let mut head = [0u8; 4096]; | |
| 167 | + | let n = match file.read(&mut head) { | |
| 168 | + | Ok(n) => n, | |
| 169 | + | Err(e) => { | |
| 170 | + | return LayerResult { | |
| 171 | + | layer: "content_type", | |
| 172 | + | verdict: LayerVerdict::Error, | |
| 173 | + | detail: Some(format!("read spool head: {e}")), | |
| 174 | + | }; | |
| 175 | + | } | |
| 176 | + | }; | |
| 177 | + | ||
| 178 | + | verify_content_type(&head[..n], claimed_type) | |
| 179 | + | } | |
| 180 | + | ||
| 150 | 181 | #[cfg(test)] | |
| 151 | 182 | mod tests { | |
| 152 | 183 | use super::*; | |
| @@ -324,4 +355,14 @@ mod tests { | |||
| 324 | 355 | let result = verify_content_type(b"just random bytes", FileType::Video); | |
| 325 | 356 | assert_eq!(result.verdict, LayerVerdict::Fail); | |
| 326 | 357 | } | |
| 358 | + | ||
| 359 | + | #[test] | |
| 360 | + | fn path_entry_matches_buffered() { | |
| 361 | + | let data = b"just random bytes"; | |
| 362 | + | let buffered = verify_content_type(data, FileType::Audio); | |
| 363 | + | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 364 | + | std::fs::write(tmp.path(), data).unwrap(); | |
| 365 | + | let path_based = verify_content_type_path(tmp.path(), FileType::Audio); | |
| 366 | + | assert_eq!(buffered.verdict, path_based.verdict); | |
| 367 | + | } | |
| 327 | 368 | } |
| @@ -128,6 +128,21 @@ pub fn scan_with_yara(rules: &yara_x::Rules, data: &[u8]) -> LayerResult { | |||
| 128 | 128 | } | |
| 129 | 129 | } | |
| 130 | 130 | ||
| 131 | + | /// Scan a spooled file against YARA rules. Reads the file into memory | |
| 132 | + | /// (yara-x's `Scanner::scan` takes a byte slice). Path-based entry exists | |
| 133 | + | /// so the streaming code path has a clean call site even though it does | |
| 134 | + | /// not yet save on memory; the win comes when yara-x exposes mmap input. | |
| 135 | + | pub fn scan_with_yara_path(rules: &yara_x::Rules, path: &std::path::Path) -> LayerResult { | |
| 136 | + | match std::fs::read(path) { | |
| 137 | + | Ok(data) => scan_with_yara(rules, &data), | |
| 138 | + | Err(e) => LayerResult { | |
| 139 | + | layer: "yara", | |
| 140 | + | verdict: LayerVerdict::Error, | |
| 141 | + | detail: Some(format!("read spool {}: {e}", path.display())), | |
| 142 | + | }, | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 131 | 146 | #[cfg(test)] | |
| 132 | 147 | mod tests { | |
| 133 | 148 | use super::*; | |
| @@ -183,6 +198,32 @@ mod tests { | |||
| 183 | 198 | assert!(result.detail.unwrap().contains("test_malware")); | |
| 184 | 199 | } | |
| 185 | 200 | ||
| 201 | + | #[test] | |
| 202 | + | fn path_entry_matches_buffered() { | |
| 203 | + | let mut compiler = yara_x::Compiler::new(); | |
| 204 | + | compiler | |
| 205 | + | .add_source( | |
| 206 | + | r#" | |
| 207 | + | rule test_malware { | |
| 208 | + | strings: | |
| 209 | + | $sig = "MALWARE_SIGNATURE" | |
| 210 | + | condition: | |
| 211 | + | $sig | |
| 212 | + | } | |
| 213 | + | "#, | |
| 214 | + | ) | |
| 215 | + | .unwrap(); | |
| 216 | + | let rules = compiler.build(); | |
| 217 | + | ||
| 218 | + | for sample in [&b"clean bytes"[..], &b"hit MALWARE_SIGNATURE here"[..]] { | |
| 219 | + | let buffered = scan_with_yara(&rules, sample); | |
| 220 | + | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 221 | + | std::fs::write(tmp.path(), sample).unwrap(); | |
| 222 | + | let path_based = scan_with_yara_path(&rules, tmp.path()); | |
| 223 | + | assert_eq!(buffered.verdict, path_based.verdict); | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 186 | 227 | // ── Adversarial tests (test-fuzz) ── | |
| 187 | 228 | ||
| 188 | 229 | #[test] |