weightguard 0.1.0__tar.gz

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,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: weightguard
3
+ Version: 0.1.0
4
+ Summary: Multi-format security scanner for ML model artifacts (pickle/PyTorch, Keras, ONNX, GGUF, SafeTensors) with provenance and license checks.
5
+ Author: Sanjeev Jaiswal
6
+ Author-email: Sanjeev Jaiswal <jassics@gmail.com>
7
+ License-Expression: MIT
8
+ Requires-Dist: typer>=0.12
9
+ Requires-Dist: rich>=13.7
10
+ Requires-Dist: pydantic>=2.7
11
+ Requires-Dist: fickling>=0.1.12
12
+ Requires-Dist: safetensors>=0.8.0
13
+ Requires-Dist: huggingface-hub>=1.28.0
14
+ Requires-Dist: gguf>=0.19.0
15
+ Requires-Dist: h5py>=3.16.0
16
+ Requires-Dist: onnx>=1.22.0
17
+ Requires-Python: >=3.11
18
+ Project-URL: Homepage, https://github.com/jassics/weightguard
19
+ Project-URL: Repository, https://github.com/jassics/weightguard
20
+ Description-Content-Type: text/markdown
21
+
22
+ # weightguard
23
+
24
+ Security scanner for ML model artifacts. Point it at a Hugging Face repo, a git
25
+ URL, or a local path, and it flags known artifact-level risks — malicious
26
+ pickle/PyTorch payloads, Keras `Lambda`-layer code injection, ONNX custom-op
27
+ RCE surface, and anomalous/crafted GGUF headers — with a severity and a
28
+ concrete mitigation for each finding.
29
+
30
+ Model files are not inert data. Several common serialization formats can
31
+ embed code that executes the moment the file is *loaded*, before any
32
+ inference happens. `weightguard` is a static, pre-load check you run before
33
+ trusting a downloaded model.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install weightguard
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ # Hugging Face repo
45
+ modelsec scan https://huggingface.co/<org>/<repo>
46
+
47
+ # git repo
48
+ modelsec scan https://github.com/<org>/<repo>.git
49
+
50
+ # local path
51
+ modelsec scan /path/to/model
52
+
53
+ # CI gate — exit non-zero only above a severity threshold (default: HIGH)
54
+ modelsec scan <target> --fail-on CRITICAL
55
+ ```
56
+
57
+ Exit codes: `0` clean, `1` a finding at/above `--fail-on`, `2` target could
58
+ not be resolved.
59
+
60
+ ## What it checks today
61
+
62
+ | Format | Detector | Technique |
63
+ |---|---|---|
64
+ | Pickle / PyTorch (`.pkl`, `.bin`, `.pt`, `.pth`) | Fickling AST analysis | Detects arbitrary-code-execution opcode chains; resistant to the malformed-opcode-stream evasion that defeats denylist scanners |
65
+ | SafeTensors | Format check | Flags files that fail to parse as valid SafeTensors (renamed/spoofed files) |
66
+ | Keras (`.h5`, `.keras`) | Lambda-layer check | Flags `Lambda` layers, which embed a marshalled Python function executed on load |
67
+ | ONNX | Custom-op check | Flags graphs referencing non-standard operator domains (native-code load surface) |
68
+ | GGUF | Header/KV anomaly check | Flags malformed headers, oversized KV metadata, unknown quantization types, and tensor sizes inconsistent with the file |
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ uv sync
74
+ uv run pytest -q
75
+ ```
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,58 @@
1
+ # weightguard
2
+
3
+ Security scanner for ML model artifacts. Point it at a Hugging Face repo, a git
4
+ URL, or a local path, and it flags known artifact-level risks — malicious
5
+ pickle/PyTorch payloads, Keras `Lambda`-layer code injection, ONNX custom-op
6
+ RCE surface, and anomalous/crafted GGUF headers — with a severity and a
7
+ concrete mitigation for each finding.
8
+
9
+ Model files are not inert data. Several common serialization formats can
10
+ embed code that executes the moment the file is *loaded*, before any
11
+ inference happens. `weightguard` is a static, pre-load check you run before
12
+ trusting a downloaded model.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install weightguard
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ # Hugging Face repo
24
+ modelsec scan https://huggingface.co/<org>/<repo>
25
+
26
+ # git repo
27
+ modelsec scan https://github.com/<org>/<repo>.git
28
+
29
+ # local path
30
+ modelsec scan /path/to/model
31
+
32
+ # CI gate — exit non-zero only above a severity threshold (default: HIGH)
33
+ modelsec scan <target> --fail-on CRITICAL
34
+ ```
35
+
36
+ Exit codes: `0` clean, `1` a finding at/above `--fail-on`, `2` target could
37
+ not be resolved.
38
+
39
+ ## What it checks today
40
+
41
+ | Format | Detector | Technique |
42
+ |---|---|---|
43
+ | Pickle / PyTorch (`.pkl`, `.bin`, `.pt`, `.pth`) | Fickling AST analysis | Detects arbitrary-code-execution opcode chains; resistant to the malformed-opcode-stream evasion that defeats denylist scanners |
44
+ | SafeTensors | Format check | Flags files that fail to parse as valid SafeTensors (renamed/spoofed files) |
45
+ | Keras (`.h5`, `.keras`) | Lambda-layer check | Flags `Lambda` layers, which embed a marshalled Python function executed on load |
46
+ | ONNX | Custom-op check | Flags graphs referencing non-standard operator domains (native-code load surface) |
47
+ | GGUF | Header/KV anomaly check | Flags malformed headers, oversized KV metadata, unknown quantization types, and tensor sizes inconsistent with the file |
48
+
49
+ ## Development
50
+
51
+ ```bash
52
+ uv sync
53
+ uv run pytest -q
54
+ ```
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "weightguard"
3
+ version = "0.1.0"
4
+ description = "Multi-format security scanner for ML model artifacts (pickle/PyTorch, Keras, ONNX, GGUF, SafeTensors) with provenance and license checks."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "typer>=0.12",
10
+ "rich>=13.7",
11
+ "pydantic>=2.7",
12
+ "fickling>=0.1.12",
13
+ "safetensors>=0.8.0",
14
+ "huggingface-hub>=1.28.0",
15
+ "gguf>=0.19.0",
16
+ "h5py>=3.16.0",
17
+ "onnx>=1.22.0",
18
+ ]
19
+
20
+ [[project.authors]]
21
+ name = "Sanjeev Jaiswal"
22
+ email = "jassics@gmail.com"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/jassics/weightguard"
26
+ Repository = "https://github.com/jassics/weightguard"
27
+
28
+ [project.scripts]
29
+ modelsec = "modelsec.cli:app"
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.9.2,<0.10.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.uv.build-backend]
36
+ module-name = "modelsec"
37
+
38
+ [dependency-groups]
39
+ dev = ["pytest>=8.0"]
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "weightguard"
3
+ version = "0.1.0"
4
+ description = "Multi-format security scanner for ML model artifacts (pickle/PyTorch, Keras, ONNX, GGUF, SafeTensors) with provenance and license checks."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Sanjeev Jaiswal", email = "jassics@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.11"
11
+ urls.Homepage = "https://github.com/jassics/weightguard"
12
+ urls.Repository = "https://github.com/jassics/weightguard"
13
+ dependencies = [
14
+ "typer>=0.12",
15
+ "rich>=13.7",
16
+ "pydantic>=2.7",
17
+ "fickling>=0.1.12",
18
+ "safetensors>=0.8.0",
19
+ "huggingface-hub>=1.28.0",
20
+ "gguf>=0.19.0",
21
+ "h5py>=3.16.0",
22
+ "onnx>=1.22.0",
23
+ ]
24
+
25
+ [project.scripts]
26
+ modelsec = "modelsec.cli:app"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.9.2,<0.10.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.uv.build-backend]
33
+ module-name = "modelsec"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "pytest>=8.0",
38
+ ]
File without changes
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from modelsec.models import Severity
8
+ from modelsec.resolver import UnresolvableTarget, resolve
9
+ from modelsec.scanner import scan_path
10
+
11
+ app = typer.Typer(help="modelsec — security scanner for ML model artifacts.", no_args_is_help=True)
12
+ console = Console()
13
+
14
+
15
+ @app.command(name="version")
16
+ def version() -> None:
17
+ """Print the modelsec version."""
18
+ from importlib.metadata import version as _v
19
+
20
+ console.print(_v("weightguard"))
21
+
22
+
23
+ @app.command(name="scan")
24
+ def scan(
25
+ target: str = typer.Argument(..., help="Hugging Face repo URL, git URL, or local path to scan."),
26
+ fail_on: Severity = typer.Option(Severity.HIGH, help="Exit non-zero if a finding >= this severity is present."),
27
+ ) -> None:
28
+ """Scan a model (HF repo, git repo, or local directory/file) for known artifact-level security risks."""
29
+ try:
30
+ path = resolve(target)
31
+ except UnresolvableTarget as exc:
32
+ console.print(f"[red]Error:[/red] {exc}")
33
+ raise typer.Exit(code=2) from exc
34
+
35
+ report = scan_path(path)
36
+
37
+ if not report.findings:
38
+ console.print(f"[green]No findings.[/green] Scanned: {target}")
39
+ raise typer.Exit(code=0)
40
+
41
+ table = Table(title=f"modelsec findings — {target}")
42
+ table.add_column("Severity")
43
+ table.add_column("Detector")
44
+ table.add_column("Title")
45
+ table.add_column("File")
46
+ table.add_column("Mitigation")
47
+
48
+ for f in sorted(report.findings, key=lambda f: -f.severity.rank):
49
+ table.add_row(f.severity.value, f.detector, f.title, f.file or "-", f.mitigation)
50
+
51
+ console.print(table)
52
+
53
+ if report.fails(fail_on):
54
+ raise typer.Exit(code=1)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ app()
@@ -0,0 +1,13 @@
1
+ from modelsec.detectors.gguf_detector import GgufDetector
2
+ from modelsec.detectors.keras_detector import KerasDetector
3
+ from modelsec.detectors.onnx_detector import OnnxDetector
4
+ from modelsec.detectors.pickle_detector import PickleDetector
5
+ from modelsec.detectors.safetensors_detector import SafeTensorsDetector
6
+
7
+ ALL_DETECTORS = [
8
+ PickleDetector(),
9
+ SafeTensorsDetector(),
10
+ KerasDetector(),
11
+ OnnxDetector(),
12
+ GgufDetector(),
13
+ ]
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol
5
+
6
+ from modelsec.models import Finding
7
+
8
+
9
+ class Detector(Protocol):
10
+ """A detector inspects one file and returns zero or more findings.
11
+
12
+ Detectors must never execute or deserialize untrusted content outside
13
+ a sandbox (Phase 2). Phase-1 detectors are static-analysis only.
14
+ """
15
+
16
+ name: str
17
+
18
+ def applies_to(self, path: Path) -> bool: ...
19
+
20
+ def scan(self, path: Path) -> list[Finding]: ...
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import gguf
6
+ from gguf.constants import GGUFValueType
7
+
8
+ from modelsec.models import Finding, Severity
9
+
10
+ GGUF_EXTENSIONS = {".gguf"}
11
+
12
+ # No dedicated OSS GGUF security scanner exists (see PROPOSAL.md gap analysis).
13
+ # We reuse gguf-py (llama.cpp) for spec-correct header/KV parsing and layer our
14
+ # own anomaly checks on top, rather than hand-rolling a parser.
15
+ MAX_STRING_KV_BYTES = 1_000_000 # 1MB — legitimate KV strings (names, templates) are tiny
16
+ MAX_ARRAY_KV_LEN = 1_000_000
17
+ KNOWN_QUANT_TYPES = {t.value for t in gguf.GGMLQuantizationType}
18
+
19
+
20
+ class GgufDetector:
21
+ """Static checks on GGUF header/KV metadata for anomalies that could
22
+ indicate a crafted file: oversized KV blobs, unknown quantization types,
23
+ or tensor metadata inconsistent with the file's actual size."""
24
+
25
+ name = "gguf-header-anomaly"
26
+
27
+ def applies_to(self, path: Path) -> bool:
28
+ return path.suffix.lower() in GGUF_EXTENSIONS
29
+
30
+ def scan(self, path: Path) -> list[Finding]:
31
+ try:
32
+ reader = gguf.GGUFReader(str(path))
33
+ except Exception as exc: # noqa: BLE001
34
+ return [
35
+ Finding(
36
+ detector=self.name,
37
+ severity=Severity.HIGH,
38
+ title="Malformed or non-standard GGUF header",
39
+ description=(
40
+ f"gguf-py failed to parse this file's header/KV metadata: {exc}. "
41
+ "A file with the .gguf extension that doesn't conform to the GGUF "
42
+ "spec may be attempting to evade format-based scanning."
43
+ ),
44
+ file=str(path),
45
+ mitigation=(
46
+ "Do not load this file with llama.cpp/Ollama. Verify it is a "
47
+ "genuine GGUF export from a trusted conversion tool."
48
+ ),
49
+ )
50
+ ]
51
+
52
+ findings: list[Finding] = []
53
+ file_size = path.stat().st_size
54
+
55
+ for name, field in reader.fields.items():
56
+ if not field.types:
57
+ continue
58
+ value_type = field.types[-1]
59
+ if value_type == GGUFValueType.STRING:
60
+ length = sum(len(p) for p in field.parts if hasattr(p, "__len__"))
61
+ if length > MAX_STRING_KV_BYTES:
62
+ findings.append(
63
+ _oversized_kv_finding(path, name, f"string KV of {length} bytes")
64
+ )
65
+ elif value_type == GGUFValueType.ARRAY and len(field.parts) > MAX_ARRAY_KV_LEN:
66
+ findings.append(
67
+ _oversized_kv_finding(path, name, f"array KV of {len(field.parts)} elements")
68
+ )
69
+
70
+ for tensor in reader.tensors:
71
+ if int(tensor.tensor_type) not in KNOWN_QUANT_TYPES:
72
+ findings.append(
73
+ Finding(
74
+ detector=self.name,
75
+ severity=Severity.HIGH,
76
+ title="Unknown tensor quantization type",
77
+ description=(
78
+ f"Tensor '{tensor.name}' declares quantization type "
79
+ f"{tensor.tensor_type!r}, which is not a recognized GGML "
80
+ "quantization type. This may indicate a hand-crafted file "
81
+ "targeting a parser bug in a specific loader version."
82
+ ),
83
+ file=str(path),
84
+ evidence=f"tensor={tensor.name} type={tensor.tensor_type!r}",
85
+ mitigation=(
86
+ "Do not load this model with an out-of-date or forked GGUF "
87
+ "loader. Re-export from a trusted source using current llama.cpp "
88
+ "conversion tooling."
89
+ ),
90
+ )
91
+ )
92
+ # A tensor claiming far more elements than the whole file could hold
93
+ # is a classic crafted-header trick to trigger an oversized allocation
94
+ # or out-of-bounds read in a loader.
95
+ if tensor.n_elements > 0 and tensor.n_elements > file_size:
96
+ findings.append(
97
+ Finding(
98
+ detector=self.name,
99
+ severity=Severity.CRITICAL,
100
+ title="Tensor element count exceeds file size",
101
+ description=(
102
+ f"Tensor '{tensor.name}' claims {tensor.n_elements} elements, "
103
+ f"which exceeds the file size ({file_size} bytes). This is "
104
+ "consistent with a crafted header aimed at triggering an "
105
+ "out-of-bounds read or oversized allocation in a loader."
106
+ ),
107
+ file=str(path),
108
+ evidence=f"tensor={tensor.name} n_elements={tensor.n_elements} file_size={file_size}",
109
+ mitigation=(
110
+ "Do not load this file. Report it as a likely malicious/crafted "
111
+ "GGUF artifact."
112
+ ),
113
+ )
114
+ )
115
+
116
+ return findings
117
+
118
+
119
+ def _oversized_kv_finding(path: Path, key: str, detail: str) -> Finding:
120
+ return Finding(
121
+ detector="gguf-header-anomaly",
122
+ severity=Severity.MEDIUM,
123
+ title="Oversized metadata key-value entry",
124
+ description=(
125
+ f"KV key '{key}' contains an unusually large {detail}. Legitimate GGUF "
126
+ "metadata (names, chat templates, tokenizer config) is normally small; "
127
+ "an oversized blob may be an attempt to smuggle payload data or trigger "
128
+ "a parser resource-exhaustion bug."
129
+ ),
130
+ file=str(path),
131
+ evidence=f"key={key} {detail}",
132
+ mitigation="Inspect this KV entry's content manually before loading the model.",
133
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import h5py
7
+
8
+ from modelsec.models import Finding, Severity
9
+
10
+ KERAS_EXTENSIONS = {".h5", ".hdf5", ".keras"}
11
+
12
+ # Layer types that embed and execute arbitrary Python at deserialization time.
13
+ DANGEROUS_LAYER_TYPES = {"Lambda"}
14
+
15
+ _MITIGATION = (
16
+ "Do not call keras.models.load_model() on this file. Inspect the model_config "
17
+ "JSON for the flagged layer(s), rewrite them as named functions registered via "
18
+ "@keras.saving.register_keras_serializable, or reject the model if you cannot "
19
+ "verify the embedded code."
20
+ )
21
+
22
+
23
+ class KerasDetector:
24
+ """Keras H5/.keras files can embed a serialized Python function inside a
25
+ Lambda layer's config, which is deserialized (and effectively executed)
26
+ on load. This is the same technique ModelScan's Keras checks target."""
27
+
28
+ name = "keras-lambda-layer"
29
+
30
+ def applies_to(self, path: Path) -> bool:
31
+ return path.suffix.lower() in KERAS_EXTENSIONS
32
+
33
+ def scan(self, path: Path) -> list[Finding]:
34
+ try:
35
+ with h5py.File(path, "r") as f:
36
+ config_raw = f.attrs.get("model_config")
37
+ except Exception as exc: # noqa: BLE001
38
+ return [
39
+ Finding(
40
+ detector=self.name,
41
+ severity=Severity.INFO,
42
+ title="Could not parse as Keras HDF5",
43
+ description=str(exc),
44
+ file=str(path),
45
+ mitigation="Verify this is a valid Keras H5 file before loading.",
46
+ )
47
+ ]
48
+
49
+ if not config_raw:
50
+ return []
51
+
52
+ if isinstance(config_raw, bytes):
53
+ config_raw = config_raw.decode("utf-8", errors="replace")
54
+
55
+ try:
56
+ config = json.loads(config_raw)
57
+ except json.JSONDecodeError:
58
+ return []
59
+
60
+ findings: list[Finding] = []
61
+ for layer in _iter_layers(config):
62
+ layer_type = layer.get("class_name")
63
+ if layer_type in DANGEROUS_LAYER_TYPES:
64
+ findings.append(
65
+ Finding(
66
+ detector=self.name,
67
+ severity=Severity.CRITICAL,
68
+ title=f"{layer_type} layer embeds arbitrary code",
69
+ description=(
70
+ f"Layer '{layer.get('config', {}).get('name', '?')}' is a "
71
+ f"{layer_type} layer, which stores a marshalled Python function "
72
+ "that executes when the model is loaded."
73
+ ),
74
+ file=str(path),
75
+ evidence=json.dumps(layer.get("config", {}))[:300],
76
+ mitigation=_MITIGATION,
77
+ )
78
+ )
79
+ return findings
80
+
81
+
82
+ def _iter_layers(config: dict) -> list[dict]:
83
+ layers = config.get("config", {}).get("layers", [])
84
+ if isinstance(layers, list):
85
+ return layers
86
+ return []
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import onnx
6
+
7
+ from modelsec.models import Finding, Severity
8
+
9
+ ONNX_EXTENSIONS = {".onnx"}
10
+
11
+ # Ops in these domains are provided by ONNX Runtime's built-in kernels.
12
+ # Anything else means a custom-op shared library must be loaded at runtime.
13
+ STANDARD_DOMAINS = {"", "ai.onnx", "ai.onnx.ml", "ai.onnx.preview.training", "ai.onnx.training"}
14
+
15
+ _MITIGATION = (
16
+ "Custom ops require ai.onnx Runtime to load an external native (.so/.dll) "
17
+ "library at inference time — this is arbitrary native code execution. Verify "
18
+ "the referenced custom-op library's provenance before running this model, or "
19
+ "run inference only inside an isolated sandbox with no filesystem/network access."
20
+ )
21
+
22
+
23
+ class OnnxDetector:
24
+ """Flags ONNX graphs that reference non-standard operator domains, i.e.
25
+ custom ops capable of loading arbitrary native code at inference time."""
26
+
27
+ name = "onnx-custom-op"
28
+
29
+ def applies_to(self, path: Path) -> bool:
30
+ return path.suffix.lower() in ONNX_EXTENSIONS
31
+
32
+ def scan(self, path: Path) -> list[Finding]:
33
+ try:
34
+ model = onnx.load(str(path), load_external_data=False)
35
+ except Exception as exc: # noqa: BLE001
36
+ return [
37
+ Finding(
38
+ detector=self.name,
39
+ severity=Severity.INFO,
40
+ title="Could not parse as ONNX",
41
+ description=str(exc),
42
+ file=str(path),
43
+ mitigation="Verify this is a valid ONNX model file before loading.",
44
+ )
45
+ ]
46
+
47
+ custom_domains = {
48
+ opset.domain for opset in model.opset_import if opset.domain not in STANDARD_DOMAINS
49
+ }
50
+ custom_node_domains = {
51
+ node.domain for node in model.graph.node if node.domain not in STANDARD_DOMAINS
52
+ }
53
+ all_custom = custom_domains | custom_node_domains
54
+
55
+ if not all_custom:
56
+ return []
57
+
58
+ return [
59
+ Finding(
60
+ detector=self.name,
61
+ severity=Severity.HIGH,
62
+ title="Non-standard operator domain(s) referenced",
63
+ description=(
64
+ "This ONNX graph references custom-op domain(s) not provided by "
65
+ "ONNX Runtime's built-in kernel set: " + ", ".join(sorted(all_custom))
66
+ ),
67
+ file=str(path),
68
+ evidence=", ".join(sorted(all_custom)),
69
+ mitigation=_MITIGATION,
70
+ )
71
+ ]
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fickling.analysis import Severity as FicklingSeverity
6
+ from fickling.analysis import check_safety
7
+ from fickling.fickle import Pickled
8
+
9
+ from modelsec.models import Finding, Severity
10
+
11
+ PICKLE_EXTENSIONS = {".pkl", ".pickle", ".bin", ".pt", ".pth"}
12
+
13
+ # Fickling's Severity enum instances are not hashable, so map by rank (the
14
+ # int stored in Severity.value[0]) rather than by dict key.
15
+ _SEVERITY_BY_RANK = {
16
+ FicklingSeverity.LIKELY_SAFE.value[0]: None,
17
+ FicklingSeverity.POSSIBLY_UNSAFE.value[0]: Severity.LOW,
18
+ FicklingSeverity.SUSPICIOUS.value[0]: Severity.MEDIUM,
19
+ FicklingSeverity.LIKELY_UNSAFE.value[0]: Severity.HIGH,
20
+ FicklingSeverity.LIKELY_OVERTLY_MALICIOUS.value[0]: Severity.CRITICAL,
21
+ FicklingSeverity.OVERTLY_MALICIOUS.value[0]: Severity.CRITICAL,
22
+ }
23
+
24
+ _MITIGATION = (
25
+ "Do not call torch.load()/pickle.load() on this file directly. Use "
26
+ "`torch.load(..., weights_only=True)`, migrate the model to SafeTensors, "
27
+ "or inspect it further with `fickling --check-safety` before loading."
28
+ )
29
+
30
+
31
+ class PickleDetector:
32
+ """Wraps Trail of Bits' Fickling for AST-level pickle-bytecode analysis.
33
+
34
+ Fickling is used rather than a hand-rolled opcode allowlist because
35
+ pattern/denylist scanners (e.g. picklescan) are known to be bypassable
36
+ via malformed-but-valid opcode streams (the "nullifAI" technique).
37
+ """
38
+
39
+ name = "pickle-fickling"
40
+
41
+ def applies_to(self, path: Path) -> bool:
42
+ return path.suffix.lower() in PICKLE_EXTENSIONS
43
+
44
+ def scan(self, path: Path) -> list[Finding]:
45
+ try:
46
+ data = path.read_bytes()
47
+ except OSError as exc:
48
+ return [
49
+ Finding(
50
+ detector=self.name,
51
+ severity=Severity.INFO,
52
+ title="Could not read file",
53
+ description=str(exc),
54
+ file=str(path),
55
+ mitigation="Verify the file is accessible and not corrupted.",
56
+ )
57
+ ]
58
+
59
+ try:
60
+ pickled = Pickled.load(data)
61
+ result = check_safety(pickled)
62
+ except Exception as exc: # noqa: BLE001 - malformed pickle is itself a signal
63
+ return [
64
+ Finding(
65
+ detector=self.name,
66
+ severity=Severity.HIGH,
67
+ title="Malformed or non-standard pickle stream",
68
+ description=(
69
+ "The pickle bytecode could not be fully disassembled by Fickling. "
70
+ f"Malformed opcode streams are a known scanner-evasion technique: {exc}"
71
+ ),
72
+ file=str(path),
73
+ mitigation=_MITIGATION,
74
+ )
75
+ ]
76
+
77
+ mapped = _SEVERITY_BY_RANK[result.severity.value[0]]
78
+ if mapped is None:
79
+ return []
80
+
81
+ return [
82
+ Finding(
83
+ detector=self.name,
84
+ severity=mapped,
85
+ title=f"Fickling analysis: {result.severity.value[1]}",
86
+ description=(
87
+ result.to_string()
88
+ or "Fickling could not identify overtly unsafe code, but the pickle "
89
+ "may still be unsafe if the source is untrusted."
90
+ ),
91
+ file=str(path),
92
+ evidence="; ".join(f"{k}: {v}" for k, v in result.detailed_results().get("AnalysisResult", {}).items()),
93
+ mitigation=_MITIGATION,
94
+ )
95
+ ]
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from safetensors import safe_open
6
+
7
+ from modelsec.models import Finding, Severity
8
+
9
+ SAFETENSORS_EXTENSIONS = {".safetensors"}
10
+
11
+
12
+ class SafeTensorsDetector:
13
+ """SafeTensors has no exec surface (header is JSON metadata + raw tensor
14
+ bytes, no opcode/callable resolution) so a well-formed file is safe by
15
+ construction. This detector only flags files that fail to parse as valid
16
+ SafeTensors, since that's either corruption or a format-spoofing attempt
17
+ (e.g. a renamed pickle)."""
18
+
19
+ name = "safetensors-format-check"
20
+
21
+ def applies_to(self, path: Path) -> bool:
22
+ return path.suffix.lower() in SAFETENSORS_EXTENSIONS
23
+
24
+ def scan(self, path: Path) -> list[Finding]:
25
+ try:
26
+ with safe_open(str(path), framework="numpy") as f:
27
+ list(f.keys())
28
+ except Exception as exc: # noqa: BLE001
29
+ return [
30
+ Finding(
31
+ detector=self.name,
32
+ severity=Severity.HIGH,
33
+ title="File claims .safetensors extension but is not valid SafeTensors",
34
+ description=(
35
+ f"Failed to parse as SafeTensors: {exc}. This may be a "
36
+ "misnamed/spoofed file (e.g. a pickle renamed to evade "
37
+ "extension-based scanning)."
38
+ ),
39
+ file=str(path),
40
+ mitigation=(
41
+ "Do not load this file with a SafeTensors loader assuming it is "
42
+ "safe. Re-identify the real format via magic bytes and re-scan."
43
+ ),
44
+ )
45
+ ]
46
+ return []
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class Severity(str, Enum):
9
+ CRITICAL = "CRITICAL"
10
+ HIGH = "HIGH"
11
+ MEDIUM = "MEDIUM"
12
+ LOW = "LOW"
13
+ INFO = "INFO"
14
+
15
+ @property
16
+ def rank(self) -> int:
17
+ return {
18
+ Severity.CRITICAL: 4,
19
+ Severity.HIGH: 3,
20
+ Severity.MEDIUM: 2,
21
+ Severity.LOW: 1,
22
+ Severity.INFO: 0,
23
+ }[self]
24
+
25
+
26
+ class Finding(BaseModel):
27
+ detector: str
28
+ severity: Severity
29
+ title: str
30
+ description: str
31
+ file: str | None = None
32
+ evidence: str | None = None
33
+ mitigation: str
34
+
35
+
36
+ class Report(BaseModel):
37
+ target: str
38
+ findings: list[Finding] = Field(default_factory=list)
39
+
40
+ @property
41
+ def max_severity(self) -> Severity:
42
+ if not self.findings:
43
+ return Severity.INFO
44
+ return max((f.severity for f in self.findings), key=lambda s: s.rank)
45
+
46
+ def fails(self, threshold: Severity) -> bool:
47
+ return self.max_severity.rank >= threshold.rank
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+ from urllib.parse import urlparse
8
+
9
+ HF_HOST = "huggingface.co"
10
+ _HF_REPO_RE = re.compile(r"^/(?P<repo_id>[^/]+/[^/]+?)(?:/tree/[^/]+)?/?$")
11
+
12
+
13
+ class UnresolvableTarget(Exception):
14
+ pass
15
+
16
+
17
+ def resolve(target: str) -> Path:
18
+ """Resolve a CLI target (HF repo URL, generic git URL, or local path) to a
19
+ local directory. Never executes/imports any downloaded file — this stage
20
+ only fetches bytes to disk."""
21
+ parsed = urlparse(target)
22
+
23
+ if parsed.scheme in ("http", "https") and parsed.netloc == HF_HOST:
24
+ return _resolve_huggingface(parsed.path)
25
+
26
+ if parsed.scheme in ("http", "https", "git", "ssh") or target.endswith(".git"):
27
+ return _resolve_git(target)
28
+
29
+ path = Path(target)
30
+ if path.exists():
31
+ return path
32
+
33
+ raise UnresolvableTarget(f"Could not resolve target: {target!r} is not a valid URL or local path")
34
+
35
+
36
+ def _resolve_huggingface(url_path: str) -> Path:
37
+ match = _HF_REPO_RE.match(url_path)
38
+ if not match:
39
+ raise UnresolvableTarget(f"Could not parse Hugging Face repo id from path: {url_path!r}")
40
+ repo_id = match.group("repo_id")
41
+
42
+ from huggingface_hub import snapshot_download
43
+
44
+ local_dir = Path(tempfile.mkdtemp(prefix="modelsec-hf-"))
45
+ snapshot_download(repo_id=repo_id, local_dir=str(local_dir))
46
+ return local_dir
47
+
48
+
49
+ def _resolve_git(url: str) -> Path:
50
+ local_dir = Path(tempfile.mkdtemp(prefix="modelsec-git-"))
51
+ subprocess.run(
52
+ ["git", "clone", "--depth", "1", url, str(local_dir)],
53
+ check=True,
54
+ capture_output=True,
55
+ )
56
+ return local_dir
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from modelsec.detectors import ALL_DETECTORS
6
+ from modelsec.models import Report
7
+
8
+
9
+ def scan_path(target: Path) -> Report:
10
+ """Static scan of a local directory or file. Never executes/deserializes content."""
11
+ report = Report(target=str(target))
12
+ files = [target] if target.is_file() else [p for p in target.rglob("*") if p.is_file()]
13
+
14
+ for file in files:
15
+ for detector in ALL_DETECTORS:
16
+ if detector.applies_to(file):
17
+ report.findings.extend(detector.scan(file))
18
+
19
+ return report