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.
- weightguard-0.1.0/PKG-INFO +79 -0
- weightguard-0.1.0/README.md +58 -0
- weightguard-0.1.0/pyproject.toml +39 -0
- weightguard-0.1.0/pyproject.toml.orig +38 -0
- weightguard-0.1.0/src/modelsec/__init__.py +0 -0
- weightguard-0.1.0/src/modelsec/cli.py +58 -0
- weightguard-0.1.0/src/modelsec/detectors/__init__.py +13 -0
- weightguard-0.1.0/src/modelsec/detectors/base.py +20 -0
- weightguard-0.1.0/src/modelsec/detectors/gguf_detector.py +133 -0
- weightguard-0.1.0/src/modelsec/detectors/keras_detector.py +86 -0
- weightguard-0.1.0/src/modelsec/detectors/onnx_detector.py +71 -0
- weightguard-0.1.0/src/modelsec/detectors/pickle_detector.py +95 -0
- weightguard-0.1.0/src/modelsec/detectors/safetensors_detector.py +46 -0
- weightguard-0.1.0/src/modelsec/models.py +47 -0
- weightguard-0.1.0/src/modelsec/resolver.py +56 -0
- weightguard-0.1.0/src/modelsec/scanner.py +19 -0
|
@@ -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
|