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.
- pickle_sentry/__init__.py +3 -0
- pickle_sentry/cli.py +102 -0
- pickle_sentry/hub.py +88 -0
- pickle_sentry/inspector.py +184 -0
- pickle_sentry/model.py +48 -0
- pickle_sentry/report.py +55 -0
- pickle_sentry/scanner.py +144 -0
- pickle_sentry-0.2.0.dist-info/METADATA +146 -0
- pickle_sentry-0.2.0.dist-info/RECORD +13 -0
- pickle_sentry-0.2.0.dist-info/WHEEL +5 -0
- pickle_sentry-0.2.0.dist-info/entry_points.txt +2 -0
- pickle_sentry-0.2.0.dist-info/licenses/LICENSE +21 -0
- pickle_sentry-0.2.0.dist-info/top_level.txt +1 -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
|
+
}
|
pickle_sentry/report.py
ADDED
|
@@ -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"
|
pickle_sentry/scanner.py
ADDED
|
@@ -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,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
|