pickle-sentry 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """pickle-sentry: scan for unsafe pickle deserialization. Never executes inspected files."""
2
+
3
+ __version__ = "0.2.0"
pickle_sentry/cli.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .inspector import inspect_file
9
+ from .model import ScanResult
10
+ from .report import to_json, to_sarif, to_text
11
+ from .scanner import scan_python_file
12
+
13
+ SKIP_DIRS = {".git", "__pycache__", ".venv", "venv", "node_modules", ".tox", ".mypy_cache"}
14
+ PICKLE_EXTS = {".pkl", ".pickle", ".joblib", ".pt", ".pth"}
15
+
16
+
17
+ def collect(root: str, exclude: list[str] | None = None) -> ScanResult:
18
+ exclude = exclude or []
19
+ result = ScanResult(root=root)
20
+ paths: list[str] = []
21
+ if os.path.isfile(root):
22
+ paths = [root]
23
+ else:
24
+ for dirpath, dirnames, filenames in os.walk(root):
25
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
26
+ for name in filenames:
27
+ paths.append(os.path.join(dirpath, name))
28
+ if exclude:
29
+ paths = [p for p in paths if not any(x in p for x in exclude)]
30
+ for p in sorted(paths):
31
+ ext = os.path.splitext(p)[1].lower()
32
+ if ext == ".py":
33
+ result.files_scanned += 1
34
+ result.findings.extend(scan_python_file(p))
35
+ elif ext in PICKLE_EXTS:
36
+ result.pickle_files_inspected += 1
37
+ result.findings.extend(inspect_file(p))
38
+ return result
39
+
40
+
41
+ def _hub_main(argv: list[str]) -> int:
42
+ from .hub import scan_hub_repo
43
+ ap = argparse.ArgumentParser(
44
+ prog="pickle-sentry hub",
45
+ description="Scan a Hugging Face Hub repository for pickle files and unsafe "
46
+ "deserialization. Files are inspected by disassembly, never executed.")
47
+ ap.add_argument("repo_id", help="e.g. user/dataset-name")
48
+ ap.add_argument("--repo-type", choices=["dataset", "model", "space"], default="dataset")
49
+ ap.add_argument("--revision", default="main")
50
+ ap.add_argument("--max-size-mb", type=float, default=64.0,
51
+ help="Per-file inspection cap in MB (default: 64)")
52
+ ap.add_argument("--format", choices=["text", "json", "sarif"], default="text")
53
+ ap.add_argument("--output", help="Write the report to this file instead of stdout")
54
+ ap.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "none"],
55
+ default="high")
56
+ args = ap.parse_args(argv)
57
+ result = scan_hub_repo(args.repo_id, repo_type=args.repo_type, revision=args.revision,
58
+ max_bytes=int(args.max_size_mb * 1024 * 1024))
59
+ rendered = {"text": to_text, "json": to_json, "sarif": to_sarif}[args.format](result)
60
+ if args.output:
61
+ with open(args.output, "w", encoding="utf-8") as fh:
62
+ fh.write(rendered)
63
+ else:
64
+ sys.stdout.write(rendered)
65
+ return 1 if result.meets(args.fail_on) else 0
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ argv = list(sys.argv[1:] if argv is None else argv)
70
+ if argv and argv[0] == "hub":
71
+ return _hub_main(argv[1:])
72
+ ap = argparse.ArgumentParser(
73
+ prog="pickle-sentry",
74
+ description="Scan code and dataset files for unsafe pickle deserialization "
75
+ "(incl. CVE-2026-57845). Inspected pickle files are never executed.")
76
+ ap.add_argument("path", help="File or directory to scan")
77
+ ap.add_argument("--format", choices=["text", "json", "sarif"], default="text")
78
+ ap.add_argument("--output", help="Write the report to this file instead of stdout")
79
+ ap.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "none"],
80
+ default="high",
81
+ help="Exit 1 if any finding is at or above this severity (default: high)")
82
+ ap.add_argument("--exclude", action="append", default=[],
83
+ help="Skip paths containing this substring (repeatable, e.g. --exclude /tests/)")
84
+ ap.add_argument("--version", action="version", version=f"pickle-sentry {__version__}")
85
+ args = ap.parse_args(argv)
86
+
87
+ if not os.path.exists(args.path):
88
+ print(f"error: no such path: {args.path}", file=sys.stderr)
89
+ return 2
90
+
91
+ result = collect(args.path, exclude=args.exclude)
92
+ rendered = {"text": to_text, "json": to_json, "sarif": to_sarif}[args.format](result)
93
+ if args.output:
94
+ with open(args.output, "w", encoding="utf-8") as fh:
95
+ fh.write(rendered)
96
+ else:
97
+ sys.stdout.write(rendered)
98
+ return 1 if result.meets(args.fail_on) else 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main())
pickle_sentry/hub.py ADDED
@@ -0,0 +1,88 @@
1
+ """Scan a Hugging Face Hub repository without executing anything in it.
2
+
3
+ Lists the repo's files via the Hub API, downloads pickle-family files and
4
+ inspects them by disassembly only, and AST-scans any Python files.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import urllib.request
11
+ import urllib.error
12
+
13
+ from .inspector import inspect_pickle_bytes
14
+ from .model import Finding, ScanResult
15
+ from .scanner import scan_python_source
16
+
17
+ PICKLE_EXTS = {".pkl", ".pickle", ".joblib", ".pt", ".pth"}
18
+ # Model weights in classic PyTorch format are pickle zip archives.
19
+ MODEL_PICKLE_NAMES = {"pytorch_model.bin"}
20
+ DEFAULT_MAX_BYTES = 64 * 1024 * 1024 # per file
21
+
22
+
23
+ def _default_fetch(url: str) -> bytes:
24
+ req = urllib.request.Request(url, headers={"User-Agent": "pickle-sentry/0.2"})
25
+ token = os.environ.get("HF_TOKEN")
26
+ if token:
27
+ req.add_header("Authorization", f"Bearer {token}")
28
+ with urllib.request.urlopen(req, timeout=60) as resp:
29
+ return resp.read()
30
+
31
+
32
+ def _api_base(repo_type: str) -> str:
33
+ return {"dataset": "https://huggingface.co/api/datasets/",
34
+ "model": "https://huggingface.co/api/models/",
35
+ "space": "https://huggingface.co/api/spaces/"}[repo_type]
36
+
37
+
38
+ def _resolve_base(repo_id: str, repo_type: str, revision: str) -> str:
39
+ prefix = {"dataset": "datasets/", "model": "", "space": "spaces/"}[repo_type]
40
+ return f"https://huggingface.co/{prefix}{repo_id}/resolve/{revision}/"
41
+
42
+
43
+ def scan_hub_repo(repo_id: str, repo_type: str = "dataset", revision: str = "main",
44
+ max_bytes: int = DEFAULT_MAX_BYTES, fetch=None) -> ScanResult:
45
+ fetch = fetch or _default_fetch
46
+ result = ScanResult(root=f"hub://{repo_type}/{repo_id}@{revision}")
47
+ try:
48
+ info = json.loads(fetch(_api_base(repo_type) + repo_id))
49
+ except (urllib.error.URLError, ValueError) as exc:
50
+ result.findings.append(Finding("HUB-UNREACHABLE", "low", result.root, None,
51
+ f"Could not read Hub repo info: {exc}"))
52
+ return result
53
+
54
+ siblings = info.get("siblings") or []
55
+ base = _resolve_base(repo_id, repo_type, revision)
56
+ for sib in siblings:
57
+ name = sib.get("rfilename", "")
58
+ ext = os.path.splitext(name)[1].lower()
59
+ display = f"{result.root}/{name}"
60
+ is_pickle = ext in PICKLE_EXTS or os.path.basename(name) in MODEL_PICKLE_NAMES
61
+ if is_pickle:
62
+ result.pickle_files_inspected += 1
63
+ size = sib.get("size")
64
+ if size is not None and size > max_bytes:
65
+ result.findings.append(Finding(
66
+ "HUB-PICKLE-TOO-LARGE", "high", display, None,
67
+ f"Pickle-family file of {size/1e6:.1f} MB exceeds the {max_bytes/1e6:.0f} MB "
68
+ "inspection cap and was NOT downloaded. Inspect it locally before trusting it.",
69
+ cve="CVE-2026-57845"))
70
+ continue
71
+ try:
72
+ data = fetch(base + urllib.request.quote(name))
73
+ except urllib.error.URLError as exc:
74
+ result.findings.append(Finding("HUB-DOWNLOAD-FAILED", "low", display, None,
75
+ f"Could not download for inspection: {exc}"))
76
+ continue
77
+ result.findings.extend(inspect_pickle_bytes(data, display))
78
+ elif ext == ".py":
79
+ result.files_scanned += 1
80
+ try:
81
+ source = fetch(base + urllib.request.quote(name)).decode("utf-8", "replace")
82
+ except urllib.error.URLError:
83
+ continue
84
+ result.findings.extend(scan_python_source(source, display))
85
+ if not siblings:
86
+ result.findings.append(Finding("HUB-EMPTY", "info", result.root, None,
87
+ "Hub API returned no files for this repository."))
88
+ return result
@@ -0,0 +1,184 @@
1
+ """Inspect pickle byte streams WITHOUT executing them.
2
+
3
+ Uses pickletools.genops only — a pure disassembler. A malicious pickle's
4
+ __reduce__ payload is never run by this module.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import io
9
+ import pickletools
10
+ import zipfile
11
+
12
+ from .model import Finding
13
+
14
+ # Globals that give a pickle direct code / command execution, file or network access.
15
+ DANGEROUS_GLOBALS = {
16
+ "os.system", "os.popen", "os.execv", "os.execve", "os.execvp", "os.spawnl",
17
+ "os.spawnle", "os.spawnlp", "os.spawnve", "os.spawnvp", "posix.system",
18
+ "nt.system", "subprocess.Popen", "subprocess.run", "subprocess.call",
19
+ "subprocess.check_output", "subprocess.check_call", "subprocess.getoutput",
20
+ "builtins.eval", "builtins.exec", "builtins.__import__", "builtins.compile",
21
+ "builtins.open", "builtins.input", "builtins.getattr", "builtins.setattr",
22
+ "__builtin__.eval", "__builtin__.exec", "__builtin__.__import__",
23
+ "pty.spawn", "commands.getoutput", "socket.socket", "urllib.request.urlopen",
24
+ "http.client.HTTPConnection", "shutil.rmtree", "pathlib.Path.write_text",
25
+ "pathlib.Path.write_bytes", "code.InteractiveConsole", "code.eval",
26
+ "runpy.run_module", "runpy.run_path", "importlib.import_module",
27
+ "ctypes.CDLL", "ctypes.cdll", "multiprocessing.Process",
28
+ }
29
+
30
+ # Modules whose mere import inside a pickle is a strong smell.
31
+ DANGEROUS_MODULES = {"os", "posix", "nt", "subprocess", "pty", "commands",
32
+ "socket", "shutil", "runpy", "ctypes"}
33
+
34
+ # Globals routinely used to reconstruct ordinary data (DataFrames, tensors,
35
+ # datetimes). A REDUCE invoking only these is normal pickle mechanics.
36
+ BENIGN_GLOBAL_PREFIXES = (
37
+ "collections.", "copyreg.", "builtins.object", "__builtin__.object",
38
+ "numpy.", "pandas.", "pyarrow.", "torch.", "datetime.", "dateutil.",
39
+ "decimal.", "uuid.", "re.", "codecs.", "typing.", "enum.",
40
+ )
41
+
42
+
43
+ def _globals_from_ops(data: bytes) -> tuple[list[str], bool]:
44
+ """Return (global names referenced, whether a REDUCE opcode is present)."""
45
+ globals_found: list[str] = []
46
+ has_reduce = False
47
+ buf: list[str] = [] # for STACK_GLOBAL: two strings pushed before the opcode
48
+ last_strings: list[str] = []
49
+ try:
50
+ for opcode, arg, _pos in pickletools.genops(data):
51
+ if opcode.name == "REDUCE":
52
+ has_reduce = True
53
+ elif opcode.name == "GLOBAL":
54
+ # arg is "module name"
55
+ if isinstance(arg, str):
56
+ parts = arg.split(" ", 1)
57
+ if len(parts) == 2:
58
+ globals_found.append(f"{parts[0]}.{parts[1]}")
59
+ elif opcode.name in ("SHORT_BINUNICODE", "BINUNICODE", "UNICODE", "BINUNICODE8"):
60
+ if isinstance(arg, str):
61
+ last_strings.append(arg)
62
+ last_strings = last_strings[-2:]
63
+ elif opcode.name == "STACK_GLOBAL":
64
+ if len(last_strings) >= 2:
65
+ globals_found.append(f"{last_strings[-2]}.{last_strings[-1]}")
66
+ last_strings = []
67
+ except Exception:
68
+ # Truncated / corrupt stream: return what we have; caller reports parse failure.
69
+ return globals_found, has_reduce
70
+ return globals_found, has_reduce
71
+
72
+
73
+ def inspect_pickle_bytes(data: bytes, path: str) -> list[Finding]:
74
+ # Torch checkpoints / model .bin files are zip archives holding a pickle stream.
75
+ if data[:2] == b"PK":
76
+ try:
77
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
78
+ inner: list[Finding] = []
79
+ for name in zf.namelist():
80
+ if name.endswith("data.pkl") or name.endswith(".pkl"):
81
+ inner.extend(inspect_pickle_bytes(zf.read(name), f"{path}!{name}"))
82
+ if inner:
83
+ return inner
84
+ except zipfile.BadZipFile:
85
+ pass
86
+ return [Finding("PICKLE-FILE-PRESENT", "medium", path, None,
87
+ "Zip-based checkpoint present; no pickle stream could be parsed. "
88
+ "Treat as untrusted code-bearing data until verified.")]
89
+ findings: list[Finding] = []
90
+ globals_found, has_reduce = _globals_from_ops(data)
91
+ dangerous = sorted({g for g in globals_found if g in DANGEROUS_GLOBALS})
92
+ dangerous_mods = sorted({g for g in globals_found
93
+ if g.split(".")[0] in DANGEROUS_MODULES and g not in dangerous})
94
+
95
+ if dangerous and has_reduce:
96
+ findings.append(Finding(
97
+ rule_id="PICKLE-DANGEROUS-REDUCE",
98
+ severity="critical",
99
+ path=path, line=None,
100
+ message=("Pickle calls a dangerous callable via REDUCE "
101
+ f"({', '.join(dangerous)}). Loading this file executes code. "
102
+ "Treat as malicious unless you created it yourself."),
103
+ evidence=", ".join(dangerous),
104
+ cve="CVE-2026-57845",
105
+ ))
106
+ elif dangerous:
107
+ findings.append(Finding(
108
+ rule_id="PICKLE-DANGEROUS-GLOBAL",
109
+ severity="high",
110
+ path=path, line=None,
111
+ message=f"Pickle references dangerous callable(s): {', '.join(dangerous)}.",
112
+ evidence=", ".join(dangerous),
113
+ cve="CVE-2026-57845",
114
+ ))
115
+
116
+ if dangerous_mods:
117
+ findings.append(Finding(
118
+ rule_id="PICKLE-SUSPICIOUS-MODULE",
119
+ severity="high" if has_reduce else "medium",
120
+ path=path, line=None,
121
+ message=f"Pickle references OS/subprocess-level module globals: {', '.join(dangerous_mods)}.",
122
+ evidence=", ".join(dangerous_mods),
123
+ ))
124
+
125
+ # Any other callable invoked via REDUCE: the pickle runs *someone's* function
126
+ # on load — e.g. the CVE PoC's __main__._write_marker_and_return_dataframe.
127
+ unknown_callables = sorted({g for g in globals_found
128
+ if g not in dangerous
129
+ and not g.startswith(BENIGN_GLOBAL_PREFIXES)})
130
+ if has_reduce and unknown_callables and not dangerous:
131
+ findings.append(Finding(
132
+ rule_id="PICKLE-REDUCE-CALLABLE",
133
+ severity="high",
134
+ path=path, line=None,
135
+ message=("Pickle invokes non-standard callable(s) via REDUCE "
136
+ f"({', '.join(unknown_callables)}). Loading it runs that code — "
137
+ "this is the shape of the CVE-2026-57845 PoC payload."),
138
+ evidence=", ".join(unknown_callables),
139
+ cve="CVE-2026-57845",
140
+ ))
141
+
142
+ if not findings:
143
+ findings.append(Finding(
144
+ rule_id="PICKLE-FILE-PRESENT",
145
+ severity="medium",
146
+ path=path, line=None,
147
+ message=("Pickle file present. Pickle is code, not data: never load it from an "
148
+ "untrusted source. Globals seen: "
149
+ + (", ".join(sorted(set(globals_found))[:8]) or "none parsed")),
150
+ evidence=", ".join(sorted(set(globals_found))[:8]),
151
+ ))
152
+ return findings
153
+
154
+
155
+ def inspect_file(path: str) -> list[Finding]:
156
+ """Inspect a .pkl/.pickle/.joblib file, or a torch .pt/.pth zip archive."""
157
+ try:
158
+ with open(path, "rb") as fh:
159
+ head = fh.read(4)
160
+ except OSError as exc:
161
+ return [Finding("PICKLE-UNREADABLE", "low", path, None, f"Could not read file: {exc}")]
162
+
163
+ # Torch checkpoints are zip archives containing a pickled data.pkl stream.
164
+ if head[:2] == b"PK":
165
+ findings: list[Finding] = []
166
+ try:
167
+ with zipfile.ZipFile(path) as zf:
168
+ for name in zf.namelist():
169
+ if name.endswith("data.pkl") or name.endswith(".pkl"):
170
+ findings.extend(inspect_pickle_bytes(zf.read(name), f"{path}!{name}"))
171
+ if findings:
172
+ return findings
173
+ except zipfile.BadZipFile:
174
+ pass
175
+ return [Finding("PICKLE-FILE-PRESENT", "medium", path, None,
176
+ "Zip-based checkpoint present; no pickle stream could be parsed. "
177
+ "Treat as untrusted code-bearing data until verified.")]
178
+
179
+ try:
180
+ with open(path, "rb") as fh:
181
+ data = fh.read()
182
+ except OSError as exc:
183
+ return [Finding("PICKLE-UNREADABLE", "low", path, None, f"Could not read file: {exc}")]
184
+ return inspect_pickle_bytes(data, path)
pickle_sentry/model.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, asdict
4
+
5
+ SEVERITY_ORDER = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
6
+
7
+
8
+ @dataclass
9
+ class Finding:
10
+ rule_id: str
11
+ severity: str # info | low | medium | high | critical
12
+ path: str
13
+ line: int | None
14
+ message: str
15
+ evidence: str = ""
16
+ cve: str | None = None
17
+
18
+ def to_dict(self) -> dict:
19
+ return asdict(self)
20
+
21
+
22
+ @dataclass
23
+ class ScanResult:
24
+ root: str
25
+ files_scanned: int = 0
26
+ pickle_files_inspected: int = 0
27
+ findings: list[Finding] = field(default_factory=list)
28
+
29
+ @property
30
+ def max_severity(self) -> str | None:
31
+ if not self.findings:
32
+ return None
33
+ return max((f.severity for f in self.findings), key=lambda s: SEVERITY_ORDER[s])
34
+
35
+ def meets(self, threshold: str) -> bool:
36
+ if threshold == "none" or not self.findings:
37
+ return False
38
+ t = SEVERITY_ORDER[threshold]
39
+ return any(SEVERITY_ORDER[f.severity] >= t for f in self.findings)
40
+
41
+ def to_dict(self) -> dict:
42
+ return {
43
+ "root": self.root,
44
+ "files_scanned": self.files_scanned,
45
+ "pickle_files_inspected": self.pickle_files_inspected,
46
+ "max_severity": self.max_severity,
47
+ "findings": [f.to_dict() for f in self.findings],
48
+ }
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from .model import ScanResult, SEVERITY_ORDER
6
+
7
+ _ICON = {"critical": "CRITICAL", "high": "HIGH", "medium": "MEDIUM", "low": "LOW", "info": "INFO"}
8
+
9
+
10
+ def to_text(result: ScanResult) -> str:
11
+ lines = [
12
+ f"pickle-sentry scan: {result.root}",
13
+ f" python files scanned: {result.files_scanned}",
14
+ f" pickle files inspected (never executed): {result.pickle_files_inspected}",
15
+ f" findings: {len(result.findings)}"
16
+ + (f" (max severity: {result.max_severity})" if result.max_severity else ""),
17
+ "",
18
+ ]
19
+ ordered = sorted(result.findings, key=lambda f: (-SEVERITY_ORDER[f.severity], f.path, f.line or 0))
20
+ for f in ordered:
21
+ loc = f.path + (f":{f.line}" if f.line else "")
22
+ lines.append(f"[{_ICON[f.severity]}] {f.rule_id} {loc}")
23
+ lines.append(f" {f.message}")
24
+ if f.cve:
25
+ lines.append(f" CVE: {f.cve}")
26
+ if not result.findings:
27
+ lines.append("No unsafe deserialization patterns found.")
28
+ return "\n".join(lines) + "\n"
29
+
30
+
31
+ def to_json(result: ScanResult) -> str:
32
+ return json.dumps(result.to_dict(), indent=2) + "\n"
33
+
34
+
35
+ def to_sarif(result: ScanResult) -> str:
36
+ runs = [{
37
+ "tool": {"driver": {"name": "pickle-sentry", "version": "0.2.0",
38
+ "informationUri": "https://github.com/Sun-Wukhan/pickle-sentry"}},
39
+ "results": [
40
+ {
41
+ "ruleId": f.rule_id,
42
+ "level": {"critical": "error", "high": "error", "medium": "warning",
43
+ "low": "note", "info": "note"}[f.severity],
44
+ "message": {"text": f.message},
45
+ "locations": [{"physicalLocation": {
46
+ "artifactLocation": {"uri": f.path},
47
+ "region": {"startLine": f.line or 1},
48
+ }}],
49
+ "properties": {"cve": f.cve} if f.cve else {},
50
+ } for f in result.findings
51
+ ],
52
+ }]
53
+ return json.dumps({"version": "2.1.0",
54
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
55
+ "runs": runs}, indent=2) + "\n"
@@ -0,0 +1,144 @@
1
+ """AST-based code scanner. Parses Python source; never imports or runs it."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+
6
+ from .model import Finding
7
+
8
+ PICKLE_MODULES = {"pickle", "cPickle", "_pickle", "dill", "cloudpickle", "marshal", "shelve"}
9
+ UNPICKLE_FUNCS = {"load", "loads", "Unpickler"}
10
+
11
+
12
+ class _Visitor(ast.NodeVisitor):
13
+ def __init__(self, path: str):
14
+ self.path = path
15
+ self.findings: list[Finding] = []
16
+ self.aliases: dict[str, str] = {} # local name -> dotted module/object
17
+ self.from_imports: dict[str, str] = {} # local name -> full dotted name
18
+
19
+ # -- import bookkeeping -------------------------------------------------
20
+ def visit_Import(self, node: ast.Import):
21
+ for a in node.names:
22
+ self.aliases[a.asname or a.name.split(".")[0]] = a.name
23
+ self.generic_visit(node)
24
+
25
+ def visit_ImportFrom(self, node: ast.ImportFrom):
26
+ mod = node.module or ""
27
+ for a in node.names:
28
+ self.from_imports[a.asname or a.name] = f"{mod}.{a.name}" if mod else a.name
29
+ self.generic_visit(node)
30
+
31
+ # -- helpers ------------------------------------------------------------
32
+ def _dotted(self, node: ast.expr) -> str | None:
33
+ parts: list[str] = []
34
+ cur: ast.expr | None = node
35
+ while isinstance(cur, ast.Attribute):
36
+ parts.append(cur.attr)
37
+ cur = cur.value
38
+ if isinstance(cur, ast.Name):
39
+ parts.append(cur.id)
40
+ parts.reverse()
41
+ root = self.aliases.get(parts[0], parts[0])
42
+ if parts[0] in self.from_imports:
43
+ return ".".join([self.from_imports[parts[0]]] + parts[1:])
44
+ return ".".join([root] + parts[1:])
45
+ return None
46
+
47
+ def _add(self, rule, severity, node, message, cve=None):
48
+ self.findings.append(Finding(
49
+ rule_id=rule, severity=severity, path=self.path,
50
+ line=getattr(node, "lineno", None), message=message, cve=cve))
51
+
52
+ # -- calls ---------------------------------------------------------------
53
+ def visit_Call(self, node: ast.Call):
54
+ dotted = self._dotted(node.func) or ""
55
+
56
+ # Hugging Face datasets: load_dataset("pandas", ...) — CVE-2026-57845
57
+ if dotted == "load_dataset" or dotted.endswith(".load_dataset"):
58
+ if node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value == "pandas":
59
+ self._add("HF-DATASETS-PANDAS-BUILDER", "critical", node,
60
+ 'load_dataset("pandas", ...) routes files to pandas.read_pickle(). '
61
+ "A crafted pickle executes code on load (CVE-2026-57845, CVSS 8.8; "
62
+ "affects datasets <= 5.1.0, no patched release yet). "
63
+ "Use parquet/csv/json/arrow, or Dataset.from_pandas() for your own DataFrame.",
64
+ cve="CVE-2026-57845")
65
+ elif not (node.args and isinstance(node.args[0], ast.Constant)
66
+ and isinstance(node.args[0].value, str)):
67
+ # Dynamic / inferred builder: the extension decides, and .pkl means pandas.
68
+ for kw in node.keywords:
69
+ if kw.arg == "data_files":
70
+ self._add("HF-DATASETS-DATA-FILES", "info", node,
71
+ "load_dataset(...) with data_files and no explicit safe builder: "
72
+ "confirm the builder inferred from the file extensions is not "
73
+ "'pandas' and the source is trusted.")
74
+
75
+ # pickle-family unpickling: pickle.load/loads, dill.load, marshal.load, shelve.open...
76
+ root = dotted.split(".")[0] if dotted else ""
77
+ leaf = dotted.rsplit(".", 1)[-1] if dotted else ""
78
+ if root in PICKLE_MODULES and leaf in UNPICKLE_FUNCS:
79
+ self._add("UNSAFE-DESERIALIZATION", "high", node,
80
+ f"{dotted}(...) deserializes pickle-family data. Never call it on "
81
+ "untrusted input — it is arbitrary code execution by design.")
82
+ elif dotted.endswith(".read_pickle"):
83
+ self._add("PANDAS-READ-PICKLE", "high", node,
84
+ "pandas.read_pickle(...) executes pickle payloads. Only use on files you "
85
+ "created; prefer read_parquet / read_csv for shared data.",
86
+ cve="CVE-2026-57845")
87
+
88
+ # joblib.load is pickle under the hood
89
+ if dotted == "joblib.load" or dotted.endswith(".joblib.load"):
90
+ self._add("UNSAFE-DESERIALIZATION", "high", node,
91
+ "joblib.load(...) is pickle deserialization. Never call it on untrusted files.")
92
+
93
+ # torch.load without weights_only=True
94
+ if dotted == "torch.load" or dotted.endswith(".torch.load"):
95
+ wo = None
96
+ for kw in node.keywords:
97
+ if kw.arg == "weights_only":
98
+ wo = kw.value.value if isinstance(kw.value, ast.Constant) else "dynamic"
99
+ if wo is True:
100
+ pass # safe form
101
+ elif wo is False:
102
+ self._add("TORCH-LOAD-WEIGHTS-ONLY-FALSE", "high", node,
103
+ "torch.load(..., weights_only=False) unpickles the checkpoint — "
104
+ "arbitrary code execution from a crafted file. Use weights_only=True.")
105
+ else:
106
+ self._add("TORCH-LOAD-NO-WEIGHTS-ONLY", "medium", node,
107
+ "torch.load(...) without an explicit weights_only=True. Older torch "
108
+ "defaults to full unpickling; set weights_only=True for shared checkpoints.")
109
+
110
+ # numpy.load(..., allow_pickle=True) — np.load defaults to allow_pickle=False, so only True is a sink
111
+ if dotted == "numpy.load" or dotted.endswith(".numpy.load"):
112
+ for kw in node.keywords:
113
+ if kw.arg == "allow_pickle" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
114
+ self._add("NUMPY-ALLOW-PICKLE", "high", node,
115
+ "numpy.load(..., allow_pickle=True) unpickles object arrays. "
116
+ "Never use it on shared .npy/.npz files; save data arrays instead.")
117
+
118
+ # yaml.load without SafeLoader
119
+ if dotted in ("yaml.load",) or dotted.endswith(".yaml.load"):
120
+ has_loader = any(kw.arg == "Loader" for kw in node.keywords) or len(node.args) >= 2
121
+ if not has_loader:
122
+ self._add("YAML-UNSAFE-LOAD", "medium", node,
123
+ "yaml.load(...) without a SafeLoader can construct arbitrary objects. "
124
+ "Use yaml.safe_load(...).")
125
+ self.generic_visit(node)
126
+
127
+
128
+ def scan_python_source(source: str, path: str) -> list[Finding]:
129
+ try:
130
+ tree = ast.parse(source)
131
+ except SyntaxError as exc:
132
+ return [Finding("PARSE-ERROR", "low", path, exc.lineno,
133
+ f"Could not parse Python source: {exc.msg}")]
134
+ v = _Visitor(path)
135
+ v.visit(tree)
136
+ return v.findings
137
+
138
+
139
+ def scan_python_file(path: str) -> list[Finding]:
140
+ try:
141
+ with open(path, encoding="utf-8", errors="replace") as fh:
142
+ return scan_python_source(fh.read(), path)
143
+ except OSError as exc:
144
+ return [Finding("UNREADABLE", "low", path, None, f"Could not read file: {exc}")]
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: pickle-sentry
3
+ Version: 0.2.0
4
+ Summary: Scan code and dataset files for unsafe pickle deserialization — including the Hugging Face datasets pandas builder RCE (CVE-2026-57845). Never executes the files it inspects.
5
+ Author: Navid Khan
6
+ License: MIT
7
+ Keywords: security,pickle,deserialization,huggingface,cve-2026-57845,scanner
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # pickle-sentry
14
+
15
+ Scan code and dataset files for unsafe pickle deserialization — including the
16
+ Hugging Face `datasets` pandas builder RCE, **CVE-2026-57845** (CVSS 8.8).
17
+
18
+ Pickle is code, not data. Any file, checkpoint, or dataset that passes through
19
+ a pickle loader executes whatever its author put in it. `pickle-sentry` finds
20
+ those paths in your code, and inspects pickle files by *disassembly only* —
21
+ **it never executes the files it inspects** (its test suite proves this with a
22
+ canary payload).
23
+
24
+ ## Why now
25
+
26
+ CVE-2026-57845 (published 23 Oct 2026): Hugging Face `datasets` through 5.1.0 —
27
+ the latest release — routes `load_dataset("pandas", data_files=...)` to
28
+ `pandas.read_pickle()`. A crafted pickle runs arbitrary Python with the
29
+ victim's privileges the moment the dataset loads. The upstream fix
30
+ (huggingface/datasets#8879) disables the pandas builder, but no patched
31
+ release exists yet. Until one ships, the mitigation is: find every place you
32
+ load pickle-backed data, and migrate to Parquet / CSV / JSON / Arrow.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install -e .
38
+ ```
39
+
40
+ Zero dependencies. Python 3.9+.
41
+
42
+ ## Use
43
+
44
+ ```bash
45
+ pickle-sentry scan-path/ # text report, exit 1 on high+
46
+ pickle-sentry . --format json --output report.json
47
+ pickle-sentry . --format sarif --output report.sarif # for GitHub code scanning
48
+ pickle-sentry . --fail-on critical # CI: only fail on critical
49
+ pickle-sentry . --exclude /tests/ # production surface only
50
+ ```
51
+
52
+ ### Scan a Hugging Face Hub repo (new in 0.2.0)
53
+
54
+ ```bash
55
+ pickle-sentry hub stanfordnlp/imdb # a dataset
56
+ pickle-sentry hub hf-internal-testing/tiny-random-bert --repo-type model
57
+ pickle-sentry hub user/repo --max-size-mb 16 --format json
58
+ ```
59
+
60
+ Lists the repo's files via the Hub API, downloads pickle-family files
61
+ (`.pkl`, `.pickle`, `.joblib`, `.pt`, `.pth`, and classic `pytorch_model.bin`)
62
+ and inspects them by disassembly — including the pickle stream inside torch
63
+ zip archives — and AST-scans any Python files in the repo. Files over the
64
+ size cap are flagged, not downloaded. Set `HF_TOKEN` for private repos.
65
+ Nothing downloaded is ever executed.
66
+
67
+ ## What it catches
68
+
69
+ **In Python code (AST scan — parses, never imports your code):**
70
+
71
+ | Rule | Severity | What |
72
+ |---|---|---|
73
+ | `HF-DATASETS-PANDAS-BUILDER` | critical | `load_dataset("pandas", ...)` — CVE-2026-57845 |
74
+ | `UNSAFE-DESERIALIZATION` | high | `pickle.load/loads`, `dill`, `cloudpickle`, `marshal`, `shelve`, `joblib.load` |
75
+ | `PANDAS-READ-PICKLE` | high | `pandas.read_pickle(...)` |
76
+ | `TORCH-LOAD-WEIGHTS-ONLY-FALSE` | high | `torch.load(..., weights_only=False)` |
77
+ | `TORCH-LOAD-NO-WEIGHTS-ONLY` | medium | `torch.load(...)` with no explicit `weights_only=True` |
78
+ | `YAML-UNSAFE-LOAD` | medium | `yaml.load(...)` without a SafeLoader |
79
+ | `NUMPY-ALLOW-PICKLE` | high | `numpy.load(..., allow_pickle=True)` |
80
+
81
+ **In data files (disassembly via `pickletools`, never unpickled):**
82
+
83
+ | Rule | Severity | What |
84
+ |---|---|---|
85
+ | `PICKLE-DANGEROUS-REDUCE` | critical | Pickle calls `os.system`, `subprocess.*`, `eval`, `exec`, etc. via `REDUCE` |
86
+ | `PICKLE-REDUCE-CALLABLE` | high | Pickle invokes a non-standard callable via `REDUCE` (the CVE PoC payload's shape) |
87
+ | `PICKLE-DANGEROUS-GLOBAL` / `PICKLE-SUSPICIOUS-MODULE` | high/medium | Dangerous globals referenced without a reduce |
88
+ | `PICKLE-FILE-PRESENT` | medium | Any other `.pkl` / `.pickle` / `.joblib` file, and pickle streams inside torch `.pt`/`.pth` zip archives |
89
+
90
+ ## Real-life use case
91
+
92
+ [docs/USE-CASE.md](docs/USE-CASE.md) walks through a real one end to end:
93
+ scanning the **actual published CVE-2026-57845 PoC repository** before
94
+ touching it. pickle-sentry flags the vulnerable loader (`run_poc.py:139`,
95
+ critical) and disassembles the live payload (`attacker-controlled.pkl`,
96
+ high) — and the PoC's own `marker.txt` proves the payload never executed
97
+ during the scan.
98
+
99
+ ## Live examples
100
+
101
+ Scanned two real codebases on 9 Nov 2026 — full results, methodology, and
102
+ honest limits in [docs/LIVE-EXAMPLES.md](docs/LIVE-EXAMPLES.md):
103
+
104
+ - **huggingface/datasets**: 258 files, 24 findings — and exactly **one** in
105
+ production source: the CVE sink itself, `pd.read_pickle(f)` at
106
+ `packaged_modules/pandas/pandas.py:56`.
107
+ - **scikit-learn**: 1,037 files, 73 findings — 16 outside tests, concentrated
108
+ in `sklearn/datasets/` loaders unpickling cached copies of remotely fetched
109
+ datasets.
110
+
111
+ Also included: a composite **GitHub Action** (`action.yml`, use it as
112
+ `uses: Sun-Wukhan/pickle-sentry@main`) and a **pre-commit hook**
113
+ (`.pre-commit-hooks.yaml`). Use `--exclude /tests/` to cut test-fixture noise.
114
+
115
+ ## Demo
116
+
117
+ ```bash
118
+ pickle-sentry examples/vulnerable # flags the CVE pattern + every sink, exit 1
119
+ pickle-sentry examples/safe # clean, exit 0
120
+ pickle-sentry examples/hf-datasets-pandas.py
121
+ # ^ the actual upstream datasets pandas builder (verbatim from
122
+ # huggingface/datasets main): flags pd.read_pickle(f) at line 56 —
123
+ # the exact sink behind CVE-2026-57845.
124
+ ```
125
+
126
+ ## Migrate off pickle
127
+
128
+ - Datasets: `load_dataset("parquet" | "csv" | "json" | "arrow", ...)`
129
+ - Your own DataFrame: `Dataset.from_pandas(df)` instead of a pickle file
130
+ - DataFrames on disk: `df.to_parquet(...)` / `pd.read_parquet(...)`
131
+ - Torch: `torch.load(path, weights_only=True)`
132
+
133
+ ## Tests
134
+
135
+ ```bash
136
+ pip install pytest
137
+ pytest
138
+ ```
139
+
140
+ The inspector tests build a real malicious pickle (`__reduce__` → `os.system`)
141
+ and assert two things: it is flagged critical, and the payload's canary file
142
+ **never appears** — i.e. inspection did not execute it.
143
+
144
+ ## Licence
145
+
146
+ MIT
@@ -0,0 +1,13 @@
1
+ pickle_sentry/__init__.py,sha256=DpF_J_lwwRLWCWd4owZIC3dykM3okZ9xJn3RFozdpFE,116
2
+ pickle_sentry/cli.py,sha256=h52vygg2xD1CbrgZBp-b6d1GMrAAQgbV-_9ddXvYn-U,4416
3
+ pickle_sentry/hub.py,sha256=Or5lBonj9ODZJKzj9dbXLbKvNAC4otnHVDi-4I9djKg,3850
4
+ pickle_sentry/inspector.py,sha256=R7oRG6mvaAZBS8EIJqsZ7Vy4s8RZ7rpbvAfEkxzdGJE,8348
5
+ pickle_sentry/model.py,sha256=lXC5QeIwrQIJIJcEAxLI0QhzcdUTd_n_7vCDZzOt42w,1348
6
+ pickle_sentry/report.py,sha256=7u478n1Rm1gkXbqYXmiaJt-nCVB3mRJCX4n56Epz7Z8,2164
7
+ pickle_sentry/scanner.py,sha256=JTUpFxDG1enPyxfuPlc2ZyOa2qSK4ChWTiiPGT3gJF0,7355
8
+ pickle_sentry-0.2.0.dist-info/licenses/LICENSE,sha256=Ie-HoTe4ZNaEAGslImATP-l7iMRef20baLqXUDlDDV0,1067
9
+ pickle_sentry-0.2.0.dist-info/METADATA,sha256=oGVyauvrFINMI62trAzO_o8_jhxKX2JTvy1lJN1K0H0,6038
10
+ pickle_sentry-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ pickle_sentry-0.2.0.dist-info/entry_points.txt,sha256=wfeBPlTLGq-WApa0n3K38tUn96KXz5-DaTrDQgIn7Gg,57
12
+ pickle_sentry-0.2.0.dist-info/top_level.txt,sha256=Nkx1JGlrcF5UARKdDylJlUDT9squOniJI2k_aJGfK9A,14
13
+ pickle_sentry-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pickle-sentry = pickle_sentry.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Navid Khan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pickle_sentry