Skip to main content

ai_model_vault/
scanning.rs

1//! Pickle safety scanner for PyTorch model files.
2//!
3//! PyTorch `.pt`, `.pth`, and `.bin` files use Python's pickle protocol,
4//! which can execute arbitrary code during deserialization.  This module
5//! scans for dangerous opcodes (REDUCE, GLOBAL, BUILD, INST, OBJ, STACK_GLOBAL,
6//! etc.) and reports findings with severity levels.
7//!
8//! This is a static analysis tool — it does NOT execute any pickle code.
9
10use std::collections::HashMap;
11use std::fmt;
12use std::fs;
13use std::path::Path;
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::Result;
18
19/// Ceiling on bytes decompressed out of a ZIP while scanning, so a zip bomb
20/// cannot turn a scan into an out-of-memory abort.
21const MAX_SCAN_INFLATE_BYTES: u64 = 512 * 1024 * 1024;
22
23/// Ceiling on ZIP members inspected per file.
24const MAX_SCAN_ZIP_MEMBERS: usize = 4096;
25
26// ── Dangerous pickle opcodes ─────────────────────────────────────────────────
27
28/// Pickle opcodes that can trigger arbitrary code execution.
29const 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
63/// Patterns found in malicious pickle payloads (byte sequences).
64const 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// ── Severity ─────────────────────────────────────────────────────────────────
112
113/// Severity level of a scanning finding.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
115pub enum Severity {
116    /// Informational — common in legitimate files
117    Info,
118    /// Warning — potentially dangerous but may be legitimate
119    Warning,
120    /// Critical — strong indicator of malicious payload
121    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// ── Scan finding ─────────────────────────────────────────────────────────────
135
136/// Individual finding from a pickle scan.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ScanFinding {
139    /// Severity level
140    pub severity: Severity,
141    /// Short identifier
142    pub code: String,
143    /// Human-readable description
144    pub description: String,
145    /// Byte offset(s) where the pattern was found
146    pub offsets: Vec<usize>,
147    /// Number of occurrences
148    pub count: usize,
149}
150
151// ── Scan report ──────────────────────────────────────────────────────────────
152
153/// Complete scan report for a model file.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct ScanReport {
156    /// File that was scanned
157    pub file_path: String,
158    /// File size in bytes
159    pub file_size: u64,
160    /// Whether this appears to be a pickle-based format
161    pub is_pickle_format: bool,
162    /// Whether this appears to be a ZIP-based PyTorch checkpoint
163    pub is_zip_archive: bool,
164    /// Overall safety assessment
165    pub safe: bool,
166    /// Individual findings
167    pub findings: Vec<ScanFinding>,
168    /// Summary counts by severity
169    pub summary: HashMap<String, usize>,
170    /// Recommendation
171    pub recommendation: String,
172}
173
174// ── Scanner ──────────────────────────────────────────────────────────────────
175
176/// Scans model files for dangerous pickle opcodes and patterns.
177pub struct PickleScanner;
178
179impl PickleScanner {
180    /// Scan a file and return a [`ScanReport`].
181    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]; // PK zip magic
187
188        // Check for pickle magic bytes (protocol markers \x80\x02 through \x80\x05)
189        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        // Sort findings by severity (critical first)
197        findings.sort_by_key(|f| std::cmp::Reverse(f.severity));
198
199        // Build summary
200        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    /// Scan a model stored in the vault by name/version.
242    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    /// Scan a file's contents, descending into a ZIP container when there is
294    /// one so that compressed members are examined as the loader will see them.
295    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                // Unparseable as a ZIP despite the magic — fall back to the
305                // raw bytes rather than silently scanning nothing.
306                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    /// Collapse findings that share a code, summing counts.
320    ///
321    /// A container yields one finding per member; the report is about the file.
322    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    /// Run the opcode and pattern scans over one logical payload.
340    ///
341    /// `label` is `None` for the file itself and `Some(member)` for a ZIP
342    /// member, so a finding can say where inside the container it came from.
343    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    /// Decompress the members of a ZIP container so their contents can be
363    /// scanned.
364    ///
365    /// `torch.save` writes *stored* (uncompressed) members, so a raw-byte scan
366    /// happens to see the payload. But `torch.load` accepts a DEFLATE-compressed
367    /// archive just as happily, and then nothing in the file literally contains
368    /// the pickle opcodes or the `os\nsystem` string — a scanner that only reads
369    /// raw bytes declares such a file clean. Anything that decides whether a
370    /// model is safe to load has to look at what will actually be unpickled.
371    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    /// [`Self::zip_members`] with explicit limits, so the bounds can be tested
376    /// without inflating half a gigabyte.
377    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            // Read at most the remaining budget, so a zip bomb cannot make the
399            // scanner allocate without bound.
400            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    /// Does this payload look like a pickle stream worth opcode-scanning?
416    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(), // limit stored offsets
443                });
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// ── Tests ────────────────────────────────────────────────────────────────────
471
472#[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        // Simulate a pickle stream with REDUCE opcode
499        let mut data = vec![0x80, 0x02]; // pickle protocol 2
500        data.push(0x52); // REDUCE opcode
501        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    /// Build a PyTorch-shaped ZIP (`archive/data.pkl` plus a tensor blob) with
509    /// a chosen compression method.
510    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    /// A malicious pickle: protocol 2, a GLOBAL naming `os.system`, and REDUCE.
526    fn malicious_pickle() -> Vec<u8> {
527        let mut p = vec![0x80, 0x02];
528        p.push(0x63); // GLOBAL
529        p.extend_from_slice(b"os\nsystem\n");
530        p.push(0x52); // REDUCE
531        p.push(0x2e); // STOP
532        p
533    }
534
535    /// `torch.save` writes uncompressed ZIP members, so the payload is visible
536    /// in the raw bytes. But `torch.load` accepts a DEFLATE-compressed archive
537    /// just the same, and then the payload is not literally present anywhere in
538    /// the file — a scanner reading raw bytes sees nothing and reports the file
539    /// as clean.
540    #[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    /// Decompressing to scan must stay bounded: a small archive that inflates
567    /// far beyond the budget has to stop at it rather than exhaust memory.
568    /// Exercised with small limits so the test costs milliseconds.
569    #[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            // 1 MiB of zeroes per member compresses to ~1 KiB.
579            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        // Budget of 100 KiB against 8 MiB of content.
593        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        // The member cap is enforced independently of the byte budget.
602        let capped = PickleScanner::zip_members_bounded(&bomb, u64::MAX, 3);
603        assert_eq!(capped.len(), 3);
604
605        // And a bomb still yields a report rather than hanging or aborting.
606        let report = PickleScanner::scan_bytes(&bomb, "bomb.pt");
607        assert!(report.is_zip_archive);
608    }
609
610    /// Findings from several members collapse into one entry per code, so the
611    /// report describes the file rather than listing the same issue N times.
612    #[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]; // PK zip magic
646        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}