max / makenotwork
7 files changed,
+117 insertions,
-0 deletions
| @@ -4178,6 +4178,7 @@ dependencies = [ | |||
| 4178 | 4178 | "infer", | |
| 4179 | 4179 | "jsonwebtoken", | |
| 4180 | 4180 | "log", | |
| 4181 | + | "memmap2", | |
| 4181 | 4182 | "metrics", | |
| 4182 | 4183 | "metrics-exporter-prometheus", | |
| 4183 | 4184 | "object 0.37.3", |
| @@ -81,6 +81,7 @@ goblin = "0.10" | |||
| 81 | 81 | zip = "8.2" | |
| 82 | 82 | yara-x = "1.16" | |
| 83 | 83 | fs2 = "0.4" | |
| 84 | + | memmap2 = "0.9" | |
| 84 | 85 | ||
| 85 | 86 | # CSV parsing (import system) | |
| 86 | 87 | csv = "1.3" |
| @@ -26,6 +26,17 @@ pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; | |||
| 26 | 26 | /// the modern format and what every current toolchain emits. | |
| 27 | 27 | const APPIMAGE_MARKER: [u8; 3] = *b"AI\x02"; | |
| 28 | 28 | ||
| 29 | + | /// Path-based entry. Mmaps the spooled file and delegates. | |
| 30 | + | pub fn verify_appimage_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult { | |
| 31 | + | if !matches!(file_type, FileType::Download) { | |
| 32 | + | return skip("Not a download file type"); | |
| 33 | + | } | |
| 34 | + | match crate::scanning::spool::mmap_read(path) { | |
| 35 | + | Ok(map) => verify_appimage_signature(&map, file_type), | |
| 36 | + | Err(e) => error(e), | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 29 | 40 | /// Entry point: detect AppImage, walk ELF sections for the signature pair. | |
| 30 | 41 | pub fn verify_appimage_signature(data: &[u8], file_type: FileType) -> LayerResult { | |
| 31 | 42 | if !matches!(file_type, FileType::Download) { | |
| @@ -151,4 +162,14 @@ mod tests { | |||
| 151 | 162 | let r = classify(false, false); | |
| 152 | 163 | assert!(r.detail.unwrap().contains("no signature")); | |
| 153 | 164 | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn path_entry_matches_buffered_on_plain_text() { | |
| 168 | + | let data = b"definitely not an AppImage"; | |
| 169 | + | let buffered = verify_appimage_signature(data, FileType::Download); | |
| 170 | + | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 171 | + | std::fs::write(tmp.path(), data).unwrap(); | |
| 172 | + | let path_based = verify_appimage_signature_path(tmp.path(), FileType::Download); | |
| 173 | + | assert_eq!(buffered.verdict, path_based.verdict); | |
| 174 | + | } | |
| 154 | 175 | } |
| @@ -43,6 +43,19 @@ use super::{ErrorPolicy, LayerResult, LayerVerdict}; | |||
| 43 | 43 | /// dashboard surfaces parser errors via the health panel. | |
| 44 | 44 | pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; | |
| 45 | 45 | ||
| 46 | + | /// Path-based entry. Mmaps the spooled file (DMG signature lookup walks | |
| 47 | + | /// the trailer; MachFile parses headers + load commands — both touch | |
| 48 | + | /// specific offsets, demand-paged through the mmap) and delegates. | |
| 49 | + | pub fn verify_apple_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult { | |
| 50 | + | if !matches!(file_type, FileType::Download | FileType::Insertion) { | |
| 51 | + | return skip("Not a download file type"); | |
| 52 | + | } | |
| 53 | + | match crate::scanning::spool::mmap_read(path) { | |
| 54 | + | Ok(map) => verify_apple_signature(&map, file_type), | |
| 55 | + | Err(e) => error(e), | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 46 | 59 | /// Top-level entry: verify any Apple code-signing evidence in the bytes. | |
| 47 | 60 | pub fn verify_apple_signature(data: &[u8], file_type: FileType) -> LayerResult { | |
| 48 | 61 | // Only download-class uploads are plausible Apple binaries. Audio / | |
| @@ -381,4 +394,14 @@ mod tests { | |||
| 381 | 394 | assert_eq!(r.verdict, LayerVerdict::Pass); | |
| 382 | 395 | assert!(r.detail.unwrap().contains("no signature")); | |
| 383 | 396 | } | |
| 397 | + | ||
| 398 | + | #[test] | |
| 399 | + | fn path_entry_matches_buffered_on_plain_text() { | |
| 400 | + | let data = b"definitely not a mach-o"; | |
| 401 | + | let buffered = verify_apple_signature(data, FileType::Download); | |
| 402 | + | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 403 | + | std::fs::write(tmp.path(), data).unwrap(); | |
| 404 | + | let path_based = verify_apple_signature_path(tmp.path(), FileType::Download); | |
| 405 | + | assert_eq!(buffered.verdict, path_based.verdict); | |
| 406 | + | } | |
| 384 | 407 | } |
| @@ -33,6 +33,19 @@ use super::{ErrorPolicy, LayerResult, LayerVerdict}; | |||
| 33 | 33 | ||
| 34 | 34 | pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; | |
| 35 | 35 | ||
| 36 | + | /// Path-based entry. Mmaps the spooled file and delegates. PE parsing | |
| 37 | + | /// walks the NT headers + attribute-cert directory at fixed offsets, so | |
| 38 | + | /// demand-paging covers the whole inspection without buffering. | |
| 39 | + | pub fn verify_authenticode_path(path: &std::path::Path, file_type: FileType) -> LayerResult { | |
| 40 | + | if !matches!(file_type, FileType::Download) { | |
| 41 | + | return skip("Not a download file type"); | |
| 42 | + | } | |
| 43 | + | match crate::scanning::spool::mmap_read(path) { | |
| 44 | + | Ok(map) => verify_authenticode(&map, file_type), | |
| 45 | + | Err(e) => error(e), | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 36 | 49 | /// Top-level entry. Detect PE format, walk the attribute-cert table, and | |
| 37 | 50 | /// surface a Pass detail describing the signing state. | |
| 38 | 51 | pub fn verify_authenticode(data: &[u8], file_type: FileType) -> LayerResult { | |
| @@ -248,4 +261,14 @@ mod tests { | |||
| 248 | 261 | let r = verify_authenticode(&data, FileType::Download); | |
| 249 | 262 | assert!(matches!(r.verdict, LayerVerdict::Error | LayerVerdict::Pass)); | |
| 250 | 263 | } | |
| 264 | + | ||
| 265 | + | #[test] | |
| 266 | + | fn path_entry_matches_buffered_on_plain_text() { | |
| 267 | + | let data = b"definitely not a PE"; | |
| 268 | + | let buffered = verify_authenticode(data, FileType::Download); | |
| 269 | + | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 270 | + | std::fs::write(tmp.path(), data).unwrap(); | |
| 271 | + | let path_based = verify_authenticode_path(tmp.path(), FileType::Download); | |
| 272 | + | assert_eq!(buffered.verdict, path_based.verdict); | |
| 273 | + | } | |
| 251 | 274 | } |
| @@ -15,6 +15,22 @@ use std::path::{Path, PathBuf}; | |||
| 15 | 15 | use tokio::fs::{File, OpenOptions}; | |
| 16 | 16 | use tokio::io::AsyncWriteExt; | |
| 17 | 17 | ||
| 18 | + | /// Memory-map a spooled file for the byte-slice-only layers (structural, | |
| 19 | + | /// signing_*). Mmap gives us `&[u8]` backed by the OS page cache: the | |
| 20 | + | /// pages get demand-paged in as the parser walks them, so a 500 MB file | |
| 21 | + | /// doesn't allocate 500 MB of RSS. | |
| 22 | + | /// | |
| 23 | + | /// Safety: the caller must guarantee no other process is writing to the | |
| 24 | + | /// file while the mapping is alive. Scan-spool tempfiles are written | |
| 25 | + | /// exclusively by the scanner before the layer runs and unlinked on | |
| 26 | + | /// drop, so no other writer exists. | |
| 27 | + | pub fn mmap_read(path: &Path) -> Result<memmap2::Mmap, String> { | |
| 28 | + | let file = std::fs::File::open(path) | |
| 29 | + | .map_err(|e| format!("open spool {}: {e}", path.display()))?; | |
| 30 | + | unsafe { memmap2::Mmap::map(&file) } | |
| 31 | + | .map_err(|e| format!("mmap spool {}: {e}", path.display())) | |
| 32 | + | } | |
| 33 | + | ||
| 18 | 34 | /// Owned handle to a spooled tempfile. The file is unlinked when the | |
| 19 | 35 | /// handle drops, even if a layer panics mid-scan. | |
| 20 | 36 | pub struct SpoolHandle { |
| @@ -41,6 +41,28 @@ const SUSPICIOUS_MACHO_SYMBOLS: &[&str] = &[ | |||
| 41 | 41 | "thread_create_running", | |
| 42 | 42 | ]; | |
| 43 | 43 | ||
| 44 | + | /// Path-based entry. Mmaps the spooled file so goblin's parsers walk OS | |
| 45 | + | /// page cache rather than a heap buffer; suitable for files larger than | |
| 46 | + | /// `SCAN_MAX_MEMORY_BYTES`. Delegates to `analyze_binary` for the actual | |
| 47 | + | /// analysis. | |
| 48 | + | pub fn analyze_binary_path(path: &std::path::Path, file_type: FileType) -> LayerResult { | |
| 49 | + | if file_type != FileType::Download { | |
| 50 | + | return LayerResult { | |
| 51 | + | layer: "structural", | |
| 52 | + | verdict: LayerVerdict::Skip, | |
| 53 | + | detail: Some("Not a download file".to_string()), | |
| 54 | + | }; | |
| 55 | + | } | |
| 56 | + | match crate::scanning::spool::mmap_read(path) { | |
| 57 | + | Ok(map) => analyze_binary(&map, file_type), | |
| 58 | + | Err(e) => LayerResult { | |
| 59 | + | layer: "structural", | |
| 60 | + | verdict: LayerVerdict::Error, | |
| 61 | + | detail: Some(e), | |
| 62 | + | }, | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 44 | 66 | /// Analyze a binary for suspicious structural patterns. | |
| 45 | 67 | /// Only runs for Download file types; returns Skip for Audio/Cover. | |
| 46 | 68 | pub fn analyze_binary(data: &[u8], file_type: FileType) -> LayerResult { | |
| @@ -488,4 +510,14 @@ mod tests { | |||
| 488 | 510 | let warnings = check_mach_warnings(symbols); | |
| 489 | 511 | assert!(warnings.is_empty()); | |
| 490 | 512 | } | |
| 513 | + | ||
| 514 | + | #[test] | |
| 515 | + | fn path_entry_matches_buffered_on_plain_text() { | |
| 516 | + | let data = b"this is not a binary"; | |
| 517 | + | let buffered = analyze_binary(data, FileType::Download); | |
| 518 | + | let tmp = tempfile::NamedTempFile::new().unwrap(); | |
| 519 | + | std::fs::write(tmp.path(), data).unwrap(); | |
| 520 | + | let path_based = analyze_binary_path(tmp.path(), FileType::Download); | |
| 521 | + | assert_eq!(buffered.verdict, path_based.verdict); | |
| 522 | + | } | |
| 491 | 523 | } |