1use std::collections::HashMap;
11use std::fmt;
12use std::fs;
13use std::path::Path;
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::Result;
18
19const MAX_SCAN_INFLATE_BYTES: u64 = 512 * 1024 * 1024;
22
23const MAX_SCAN_ZIP_MEMBERS: usize = 4096;
25
26const DANGEROUS_OPCODES: &[(u8, &str, &str)] = &[
30 (
31 0x52,
32 "REDUCE",
33 "Calls a callable with args — arbitrary code execution",
34 ),
35 (
36 0x63,
37 "GLOBAL",
38 "Imports a module attribute — can import os, subprocess, etc.",
39 ),
40 (
41 0x62,
42 "BUILD",
43 "Calls __setstate__ — can trigger arbitrary code in __setstate__",
44 ),
45 (
46 0x69,
47 "INST",
48 "Instantiates a class — can run __init__ with arbitrary args",
49 ),
50 (
51 0x81,
52 "NEWOBJ",
53 "Creates a new object — tp.__new__(cls, *args)",
54 ),
55 (0x92, "NEWOBJ_EX", "Extended NEWOBJ with kwargs"),
56 (
57 0x93,
58 "STACK_GLOBAL",
59 "Push a global from stack values — similar to GLOBAL",
60 ),
61];
62
63const DANGEROUS_PATTERNS: &[(&[u8], &str, &str)] = &[
65 (
66 b"os\n",
67 "os module import",
68 "Accesses the operating system module",
69 ),
70 (
71 b"subprocess",
72 "subprocess module",
73 "Can execute shell commands",
74 ),
75 (
76 b"__builtin__",
77 "__builtin__ access",
78 "Access to Python builtins (exec, eval)",
79 ),
80 (b"builtins", "builtins module", "Access to Python builtins"),
81 (
82 b"commands",
83 "commands module",
84 "Legacy command execution module",
85 ),
86 (b"nt\nsystem", "nt.system call", "Windows system() call"),
87 (b"posix\nsystem", "posix.system call", "Unix system() call"),
88 (
89 b"exec\n",
90 "exec function",
91 "Arbitrary Python code execution",
92 ),
93 (
94 b"eval\n",
95 "eval function",
96 "Arbitrary Python expression evaluation",
97 ),
98 (
99 b"__import__",
100 "__import__ function",
101 "Dynamic module importing",
102 ),
103 (b"runpy", "runpy module", "Runs Python modules/scripts"),
104 (
105 b"webbrowser",
106 "webbrowser module",
107 "Can open arbitrary URLs",
108 ),
109];
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
115pub enum Severity {
116 Info,
118 Warning,
120 Critical,
122}
123
124impl fmt::Display for Severity {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 match self {
127 Severity::Info => write!(f, "INFO"),
128 Severity::Warning => write!(f, "WARN"),
129 Severity::Critical => write!(f, "CRIT"),
130 }
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ScanFinding {
139 pub severity: Severity,
141 pub code: String,
143 pub description: String,
145 pub offsets: Vec<usize>,
147 pub count: usize,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct ScanReport {
156 pub file_path: String,
158 pub file_size: u64,
160 pub is_pickle_format: bool,
162 pub is_zip_archive: bool,
164 pub safe: bool,
166 pub findings: Vec<ScanFinding>,
168 pub summary: HashMap<String, usize>,
170 pub recommendation: String,
172}
173
174pub struct PickleScanner;
178
179impl PickleScanner {
180 pub fn scan(path: &Path) -> Result<ScanReport> {
182 let data = fs::read(path)?;
183 let file_size = data.len() as u64;
184 let file_path = path.display().to_string();
185
186 let is_zip = data.len() >= 2 && data[0..2] == [0x50, 0x4B]; let is_pickle = data.len() >= 2 && data[0] == 0x80 && (2..=5).contains(&data[1]);
190
191 let is_pickle_format = is_pickle || is_zip || Self::has_pickle_extension(path);
192
193 let mut findings = Vec::new();
194 Self::scan_container(&data, is_zip, is_pickle_format, &mut findings);
195
196 findings.sort_by_key(|f| std::cmp::Reverse(f.severity));
198
199 let mut summary = HashMap::new();
201 for f in &findings {
202 *summary.entry(format!("{}", f.severity)).or_insert(0) += 1;
203 }
204
205 let critical_count = summary.get("CRIT").copied().unwrap_or(0);
206 let warning_count = summary.get("WARN").copied().unwrap_or(0);
207
208 let safe = critical_count == 0 && warning_count == 0;
209
210 let recommendation = if critical_count > 0 {
211 "DANGEROUS: This file contains opcodes/patterns strongly associated with \
212 malicious payloads. Do NOT load this file with pickle.loads() or torch.load(). \
213 Consider using safetensors format instead."
214 .to_string()
215 } else if warning_count > 0 {
216 "CAUTION: This file contains potentially dangerous opcodes. These are common \
217 in legitimate PyTorch files but can be exploited. Consider converting to \
218 safetensors format for maximum safety."
219 .to_string()
220 } else if is_pickle_format {
221 "LOW RISK: No dangerous opcodes or patterns detected. The file uses pickle \
222 format which inherently carries risk. Consider converting to safetensors \
223 for guaranteed safety."
224 .to_string()
225 } else {
226 "SAFE: This file does not appear to use pickle serialization.".to_string()
227 };
228
229 Ok(ScanReport {
230 file_path,
231 file_size,
232 is_pickle_format,
233 is_zip_archive: is_zip,
234 safe,
235 findings,
236 summary,
237 recommendation,
238 })
239 }
240
241 pub fn scan_bytes(data: &[u8], name: &str) -> ScanReport {
243 let mut findings = Vec::new();
244
245 let is_pickle = data.len() >= 2 && data[0] == 0x80 && (2..=5).contains(&data[1]);
246 let is_zip = data.len() >= 2 && data[0..2] == [0x50, 0x4B];
247 let is_pickle_format = is_pickle || is_zip;
248
249 Self::scan_container(data, is_zip, is_pickle_format, &mut findings);
250
251 findings.sort_by_key(|f| std::cmp::Reverse(f.severity));
252
253 let mut summary = HashMap::new();
254 for f in &findings {
255 *summary.entry(format!("{}", f.severity)).or_insert(0) += 1;
256 }
257
258 let critical_count = summary.get("CRIT").copied().unwrap_or(0);
259 let warning_count = summary.get("WARN").copied().unwrap_or(0);
260 let safe = critical_count == 0 && warning_count == 0;
261
262 let recommendation = if critical_count > 0 {
263 "DANGEROUS: Contains malicious opcodes/patterns.".to_string()
264 } else if warning_count > 0 {
265 "CAUTION: Contains potentially dangerous opcodes.".to_string()
266 } else {
267 "No dangerous patterns detected.".to_string()
268 };
269
270 ScanReport {
271 file_path: name.to_string(),
272 file_size: data.len() as u64,
273 is_pickle_format,
274 is_zip_archive: is_zip,
275 safe,
276 findings,
277 summary,
278 recommendation,
279 }
280 }
281
282 fn has_pickle_extension(path: &Path) -> bool {
283 path.extension()
284 .and_then(|e| e.to_str())
285 .is_some_and(|ext| {
286 matches!(
287 ext.to_lowercase().as_str(),
288 "pt" | "pth" | "bin" | "pkl" | "pickle"
289 )
290 })
291 }
292
293 fn scan_container(
296 data: &[u8],
297 is_zip: bool,
298 is_pickle_format: bool,
299 findings: &mut Vec<ScanFinding>,
300 ) {
301 if is_zip {
302 let members = Self::zip_members(data);
303 if members.is_empty() {
304 Self::scan_payload(data, None, is_pickle_format, findings);
307 return;
308 }
309 for (name, bytes) in members {
310 let treat_as_pickle = Self::looks_like_pickle(&bytes, &name);
311 Self::scan_payload(&bytes, Some(&name), treat_as_pickle, findings);
312 }
313 Self::merge_findings(findings);
314 } else {
315 Self::scan_payload(data, None, is_pickle_format, findings);
316 }
317 }
318
319 fn merge_findings(findings: &mut Vec<ScanFinding>) {
323 let mut merged: Vec<ScanFinding> = Vec::new();
324 for f in findings.drain(..) {
325 if let Some(existing) = merged.iter_mut().find(|e| e.code == f.code) {
326 existing.count += f.count;
327 existing.severity = existing.severity.max(f.severity);
328 if existing.offsets.len() < 10 {
329 let room = 10 - existing.offsets.len();
330 existing.offsets.extend(f.offsets.into_iter().take(room));
331 }
332 } else {
333 merged.push(f);
334 }
335 }
336 *findings = merged;
337 }
338
339 fn scan_payload(
344 data: &[u8],
345 label: Option<&str>,
346 treat_as_pickle: bool,
347 findings: &mut Vec<ScanFinding>,
348 ) {
349 let before = findings.len();
350 if treat_as_pickle {
351 Self::scan_opcodes(data, findings);
352 }
353 Self::scan_patterns(data, findings);
354
355 if let Some(member) = label {
356 for f in &mut findings[before..] {
357 f.description = format!("{} (in ZIP member `{member}`)", f.description);
358 }
359 }
360 }
361
362 fn zip_members(data: &[u8]) -> Vec<(String, Vec<u8>)> {
372 Self::zip_members_bounded(data, MAX_SCAN_INFLATE_BYTES, MAX_SCAN_ZIP_MEMBERS)
373 }
374
375 fn zip_members_bounded(
378 data: &[u8],
379 mut budget: u64,
380 max_members: usize,
381 ) -> Vec<(String, Vec<u8>)> {
382 use std::io::Read;
383
384 let mut out = Vec::new();
385 let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(data)) else {
386 return out;
387 };
388
389 for i in 0..archive.len().min(max_members) {
390 let Ok(entry) = archive.by_index(i) else {
391 continue;
392 };
393 if entry.is_dir() {
394 continue;
395 }
396 let name = entry.name().to_string();
397
398 let mut buf = Vec::new();
401 if entry.take(budget).read_to_end(&mut buf).is_err() {
402 continue;
403 }
404 budget = budget.saturating_sub(buf.len() as u64);
405 out.push((name, buf));
406
407 if budget == 0 {
408 break;
409 }
410 }
411
412 out
413 }
414
415 fn looks_like_pickle(data: &[u8], name: &str) -> bool {
417 let proto = data.len() >= 2 && data[0] == 0x80 && (2..=5).contains(&data[1]);
418 proto || name.ends_with(".pkl") || name.ends_with(".pickle") || name.ends_with("data.pkl")
419 }
420
421 fn scan_opcodes(data: &[u8], findings: &mut Vec<ScanFinding>) {
422 for &(opcode, name, desc) in DANGEROUS_OPCODES {
423 let offsets: Vec<usize> = data
424 .iter()
425 .enumerate()
426 .filter(|(_, &b)| b == opcode)
427 .map(|(i, _)| i)
428 .collect();
429
430 if !offsets.is_empty() {
431 let severity = match name {
432 "REDUCE" | "STACK_GLOBAL" => Severity::Warning,
433 "GLOBAL" | "INST" => Severity::Warning,
434 _ => Severity::Info,
435 };
436
437 findings.push(ScanFinding {
438 severity,
439 code: name.to_string(),
440 description: desc.to_string(),
441 count: offsets.len(),
442 offsets: offsets.into_iter().take(10).collect(), });
444 }
445 }
446 }
447
448 fn scan_patterns(data: &[u8], findings: &mut Vec<ScanFinding>) {
449 for &(pattern, name, desc) in DANGEROUS_PATTERNS {
450 let offsets: Vec<usize> = data
451 .windows(pattern.len())
452 .enumerate()
453 .filter(|(_, w)| *w == pattern)
454 .map(|(i, _)| i)
455 .collect();
456
457 if !offsets.is_empty() {
458 findings.push(ScanFinding {
459 severity: Severity::Critical,
460 code: name.to_string(),
461 description: desc.to_string(),
462 count: offsets.len(),
463 offsets: offsets.into_iter().take(10).collect(),
464 });
465 }
466 }
467 }
468}
469
470#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn test_scan_safe_file() {
478 let data = b"This is just some safe binary data with no pickle opcodes";
479 let report = PickleScanner::scan_bytes(data, "safe.safetensors");
480 assert!(report.safe);
481 assert!(!report.is_pickle_format);
482 }
483
484 #[test]
485 fn test_scan_dangerous_patterns() {
486 let mut data = Vec::new();
487 data.extend_from_slice(b"some header data");
488 data.extend_from_slice(b"os\nsystem");
489 data.extend_from_slice(b"more data");
490
491 let report = PickleScanner::scan_bytes(&data, "suspicious.pt");
492 assert!(!report.safe);
493 assert!(report.findings.iter().any(|f| f.code == "os module import"));
494 }
495
496 #[test]
497 fn test_scan_pickle_with_reduce() {
498 let mut data = vec![0x80, 0x02]; data.push(0x52); data.extend_from_slice(b"some more data");
502
503 let report = PickleScanner::scan_bytes(&data, "model.pt");
504 assert!(report.is_pickle_format);
505 assert!(report.findings.iter().any(|f| f.code == "REDUCE"));
506 }
507
508 fn torch_zip(pickle: &[u8], method: zip::CompressionMethod) -> Vec<u8> {
511 use std::io::Write;
512 let mut buf = std::io::Cursor::new(Vec::new());
513 {
514 let mut zw = zip::ZipWriter::new(&mut buf);
515 let opts = zip::write::SimpleFileOptions::default().compression_method(method);
516 zw.start_file("archive/data.pkl", opts).unwrap();
517 zw.write_all(pickle).unwrap();
518 zw.start_file("archive/data/0", opts).unwrap();
519 zw.write_all(&[0u8; 64]).unwrap();
520 zw.finish().unwrap();
521 }
522 buf.into_inner()
523 }
524
525 fn malicious_pickle() -> Vec<u8> {
527 let mut p = vec![0x80, 0x02];
528 p.push(0x63); p.extend_from_slice(b"os\nsystem\n");
530 p.push(0x52); p.push(0x2e); p
533 }
534
535 #[test]
541 fn test_scan_sees_payload_inside_a_compressed_zip_member() {
542 let stored = torch_zip(&malicious_pickle(), zip::CompressionMethod::Stored);
543 let deflated = torch_zip(&malicious_pickle(), zip::CompressionMethod::Deflated);
544
545 let stored_report = PickleScanner::scan_bytes(&stored, "stored.pt");
546 assert!(
547 stored_report
548 .findings
549 .iter()
550 .any(|f| f.code == "os module import"),
551 "uncompressed payload must be caught"
552 );
553
554 let deflated_report = PickleScanner::scan_bytes(&deflated, "deflated.pt");
555 assert!(
556 deflated_report
557 .findings
558 .iter()
559 .any(|f| f.code == "os module import"),
560 "a DEFLATE-compressed member hides the payload from a raw-byte scan; \
561 the scanner must decompress before deciding a file is clean"
562 );
563 assert!(!deflated_report.safe);
564 }
565
566 #[test]
570 fn test_zip_bomb_is_bounded() {
571 use std::io::Write;
572
573 let mut buf = std::io::Cursor::new(Vec::new());
574 {
575 let mut zw = zip::ZipWriter::new(&mut buf);
576 let opts = zip::write::SimpleFileOptions::default()
577 .compression_method(zip::CompressionMethod::Deflated);
578 for i in 0..8 {
580 zw.start_file(format!("m{i}.pkl"), opts).unwrap();
581 zw.write_all(&vec![0u8; 1024 * 1024]).unwrap();
582 }
583 zw.finish().unwrap();
584 }
585 let bomb = buf.into_inner();
586 assert!(
587 bomb.len() < 64 * 1024,
588 "8 MiB of zeroes should compress small; got {} bytes",
589 bomb.len()
590 );
591
592 let budget = 100 * 1024;
594 let members = PickleScanner::zip_members_bounded(&bomb, budget, MAX_SCAN_ZIP_MEMBERS);
595 let total: u64 = members.iter().map(|(_, b)| b.len() as u64).sum();
596 assert!(
597 total <= budget,
598 "inflated {total} bytes, over the {budget}-byte budget"
599 );
600
601 let capped = PickleScanner::zip_members_bounded(&bomb, u64::MAX, 3);
603 assert_eq!(capped.len(), 3);
604
605 let report = PickleScanner::scan_bytes(&bomb, "bomb.pt");
607 assert!(report.is_zip_archive);
608 }
609
610 #[test]
613 fn test_findings_merge_across_zip_members() {
614 use std::io::Write;
615
616 let mut buf = std::io::Cursor::new(Vec::new());
617 {
618 let mut zw = zip::ZipWriter::new(&mut buf);
619 let opts = zip::write::SimpleFileOptions::default()
620 .compression_method(zip::CompressionMethod::Deflated);
621 for i in 0..3 {
622 zw.start_file(format!("archive/{i}/data.pkl"), opts)
623 .unwrap();
624 zw.write_all(&malicious_pickle()).unwrap();
625 }
626 zw.finish().unwrap();
627 }
628
629 let report = PickleScanner::scan_bytes(&buf.into_inner(), "multi.pt");
630 let os_findings: Vec<_> = report
631 .findings
632 .iter()
633 .filter(|f| f.code == "os module import")
634 .collect();
635 assert_eq!(os_findings.len(), 1, "one finding per code, not per member");
636 assert_eq!(
637 os_findings[0].count, 3,
638 "but the count reflects all members"
639 );
640 assert!(!report.safe);
641 }
642
643 #[test]
644 fn test_scan_zip_format() {
645 let mut data = vec![0x50, 0x4B, 0x03, 0x04]; data.extend_from_slice(&[0; 100]);
647
648 let report = PickleScanner::scan_bytes(&data, "checkpoint.pt");
649 assert!(report.is_zip_archive);
650 assert!(report.is_pickle_format);
651 }
652
653 #[test]
654 fn test_severity_ordering() {
655 assert!(Severity::Critical > Severity::Warning);
656 assert!(Severity::Warning > Severity::Info);
657 }
658}