Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c49fdac
fix: avoid false positives for batched PyTorch state dictionaries
mldangelo-oai Jul 27, 2026
6ab180a
fix: require patched picklescan and preserve Ruff policy
mldangelo-oai Jul 27, 2026
117dc52
fix: stream bounded canonical tensor dictionary batches
mldangelo-oai Jul 27, 2026
662ee8b
fix(picklescan): preserve release order and mixed tensor batches
mldangelo-oai Jul 27, 2026
165da93
fix(picklescan): retain bounded mixed checkpoint state
mldangelo-oai Jul 27, 2026
b6e5500
fix(picklescan): retain malicious storage detection for wide dictiona…
mldangelo-oai Jul 27, 2026
8c1de87
fix(picklescan): validate post-tensor metadata without trusting storage
mldangelo-oai Jul 27, 2026
5accbc4
fix(picklescan): reject transformed untrusted storage in tensor batches
mldangelo-oai Jul 27, 2026
6acec3c
fix(picklescan): distrust discarded persistent storage references
mldangelo-oai Jul 27, 2026
a2f6598
fix(picklescan): bound provenance and preserve discarded storage cove…
mldangelo-oai Jul 27, 2026
fc0cb23
fix(picklescan): distrust duplicate provenance in ordered state dicti…
mldangelo-oai Jul 27, 2026
d394b41
fix(picklescan): distrust storage consumed by build and conversion re…
mldangelo-oai Jul 27, 2026
a9b5322
fix(picklescan): retain coverage when malformed stacks discard storage
mldangelo-oai Jul 27, 2026
1ae8668
fix(picklescan): preserve discarded compacted tensor provenance
mldangelo-oai Jul 27, 2026
5f68a46
fix(picklescan): retain storage operands discarded by mutation opcodes
mldangelo-oai Jul 27, 2026
37ac497
fix(picklescan): reject raw storage injected into canonical tensor state
mldangelo-oai Jul 27, 2026
48d2778
fix(picklescan): track storage nested before canonical tensor batches
mldangelo-oai Jul 27, 2026
119697c
fix: retain untrusted storage provenance in plain dictionaries
mldangelo-oai Jul 27, 2026
96ed814
fix: buffer safe metadata before canonical tensor batches
mldangelo-oai Jul 27, 2026
e9cb6a8
fix: bound dictionary storage provenance without retaining containers
mldangelo-oai Jul 27, 2026
d6f6771
fix: preserve safe repeated tensor memo references
mldangelo-oai Jul 27, 2026
fd602ee
fix: preserve canonical state across realistic metadata layouts
mldangelo-oai Jul 27, 2026
2bdd951
fix: preserve storage provenance across enclosing containers
mldangelo-oai Jul 27, 2026
fcd0e42
fix: inspect every nested storage provenance entry
mldangelo-oai Jul 27, 2026
7081570
Merge remote-tracking branch 'origin/main' into fix/hf-pytorch-batche…
mldangelo-oai Jul 27, 2026
9c2735c
fix: validate final storage and bounded nested tensor state
mldangelo-oai Jul 27, 2026
3e5b248
fix: preserve direct size-proven storage payload coverage
mldangelo-oai Jul 27, 2026
46a8fb7
fix: preserve bounded storage discovery across compacted tensor batches
mldangelo-oai Jul 27, 2026
d9f9103
fix: retain storage containers and probe every expanded tensor batch
mldangelo-oai Jul 27, 2026
9226edd
fix: bound expanded tensor-storage prefix probes
mldangelo-oai Jul 27, 2026
5c56a10
chore(picklescan): drop unrelated ruff select override
mldangelo Aug 1, 2026
21bc219
fix(picklescan): keep the expanded storage probe additive for coverage
mldangelo Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This repo publishes **two PyPI packages with independent versions**:
| `modelaudit` | `./` (root) | `pyproject.toml` + `uv.lock` | `CHANGELOG.md` |
| `modelaudit-picklescan` | `packages/modelaudit-picklescan/` | `pyproject.toml` + `Cargo.toml` | `packages/modelaudit-picklescan/CHANGELOG.md` |

Root `modelaudit` hard-requires `modelaudit-picklescan>=0.1.10,<0.2.0` — when the sibling crosses `0.2.0`, bump the constraint in the same PR or the next `modelaudit` release is uninstallable. Both packages are driven by a single `release-please` workflow (`.github/workflows/release-please.yml`) with components defined in `release-please-config.json` and current versions in `.release-please-manifest.json`. Full publishing details — trusted publishing, manual `workflow_dispatch` recovery (`root_version` / `picklescan_version`), and yank procedure — are in [`docs/agents/release-process.md`](docs/agents/release-process.md). For work inside the picklescan package, start from [`packages/modelaudit-picklescan/AGENTS.md`](packages/modelaudit-picklescan/AGENTS.md).
Root `modelaudit` hard-requires `modelaudit-picklescan>=0.1.11,<0.2.0` — when the sibling crosses `0.2.0`, bump the constraint in the same PR or the next `modelaudit` release is uninstallable. Both packages are driven by a single `release-please` workflow (`.github/workflows/release-please.yml`) with components defined in `release-please-config.json` and current versions in `.release-please-manifest.json`. Full publishing details — trusted publishing, manual `workflow_dispatch` recovery (`root_version` / `picklescan_version`), and yank procedure — are in [`docs/agents/release-process.md`](docs/agents/release-process.md). For work inside the picklescan package, start from [`packages/modelaudit-picklescan/AGENTS.md`](packages/modelaudit-picklescan/AGENTS.md).

## Mission & Principles

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Bug Fixes

- Avoid false-positive PyTorch tensor reconstruction findings for bounded batched state dictionaries.

## [0.2.52](https://github.com/promptfoo/modelaudit/compare/v0.2.51...v0.2.52) (2026-07-22)

### Bug Fixes
Expand Down
2 changes: 1 addition & 1 deletion docs/agents/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Both packages are driven by a single [release-please](https://github.com/googlea

The root release intentionally omits `package-name` and `component`. Release-please reads `project.name` from `pyproject.toml` for Python updates, while the empty branch component lets a root-only grouped Release PR match `release-please--branches--main` and keeps the existing `v{X.Y.Z}` tags. Restoring a non-empty `package-name` or `component` makes root-only releases look like a different component and silently skips publication after merge.

The root `modelaudit` wheel declares a **hard dependency** on `modelaudit-picklescan>=0.1.10,<0.2.0` in `pyproject.toml`. When the sibling version crosses `0.2.0`, the constraint must be bumped in the same PR.
The root `modelaudit` wheel declares a **hard dependency** on `modelaudit-picklescan>=0.1.11,<0.2.0` in `pyproject.toml`. When the sibling version crosses `0.2.0`, the constraint must be bumped in the same PR.

## Normal flow

Expand Down
4 changes: 2 additions & 2 deletions packages/modelaudit-picklescan/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Scoped agent guide for work inside `packages/modelaudit-picklescan/`. The root [

## What this package is

`modelaudit-picklescan` is the Rust-backed pickle scanner that ships as an independent PyPI package. The root `modelaudit` wheel depends on it at runtime via a hard `modelaudit-picklescan>=0.1.10,<0.2.0` pin in the root `pyproject.toml`.
`modelaudit-picklescan` is the Rust-backed pickle scanner that ships as an independent PyPI package. The root `modelaudit` wheel depends on it at runtime via a hard `modelaudit-picklescan>=0.1.11,<0.2.0` pin in the root `pyproject.toml`.

- **Public API** — exported from `src/modelaudit_picklescan/__init__.py`: `PickleScanner`, `ScanOptions`, `scan_file`, `scan_bytes`, `scan_stream`, `shared_source_sensitive_caches`, `PickleReport`, `Finding`, `Notice`, `ScanError`, `Severity`, `ScanStatus`, `SafetyVerdict`, `CoverageSummary`. Treat these names as a stable surface.
- **Rust engine** — `rust/src/` compiled to `modelaudit_picklescan._rust` via maturin + PyO3. Rust 1.83+, edition 2021.
Expand Down Expand Up @@ -74,7 +74,7 @@ Root-level validation (`uv run ruff check modelaudit/ packages/modelaudit-pickle
- Release tag format: `modelaudit-picklescan-v{X.Y.Z}`.
- Bumps are driven by Conventional Commits that **touch files inside `packages/modelaudit-picklescan/`**. Commits that only touch `modelaudit/` or the repo root do not bump this package.

When this package reaches `0.2.0`, the root `pyproject.toml` `modelaudit-picklescan>=0.1.10,<0.2.0` constraint must be widened in the same PR, or the next `modelaudit` release will be uninstallable.
When this package reaches `0.2.0`, the root `pyproject.toml` `modelaudit-picklescan>=0.1.11,<0.2.0` constraint must be widened in the same PR, or the next `modelaudit` release will be uninstallable.

## Publishing

Expand Down
4 changes: 4 additions & 0 deletions packages/modelaudit-picklescan/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Bug Fixes

- Validate bounded batched PyTorch state-dictionary entries without falsely flagging canonical tensor reconstruction.

## [0.1.10](https://github.com/promptfoo/modelaudit/compare/modelaudit-picklescan-v0.1.9...modelaudit-picklescan-v0.1.10) (2026-07-22)

### Bug Fixes
Expand Down
2 changes: 1 addition & 1 deletion packages/modelaudit-picklescan/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/modelaudit-picklescan/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "modelaudit-picklescan-rust"
version = "0.1.10" # x-release-please-version
version = "0.1.11" # x-release-please-version
edition = "2021"
rust-version = "1.83"
description = "Native pickle security scanner engine for modelaudit-picklescan"
Expand Down
3 changes: 2 additions & 1 deletion packages/modelaudit-picklescan/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "modelaudit-picklescan"
version = "0.1.10" # x-release-please-version
version = "0.1.11" # x-release-please-version
Comment thread
mldangelo-oai marked this conversation as resolved.
Outdated
description = "Standalone pickle security scanner extracted from ModelAudit"
authors = [
{ name = "Ian Webster", email = "ian@promptfoo.dev" },
Expand Down Expand Up @@ -47,6 +47,7 @@ line-length = 120
target-version = "py310"

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
extend-select = [
"I",
"W",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1932,14 +1932,14 @@ def apply_setitems_to_target(items: tuple[tuple[Any, Any], ...]) -> None:
else:
poison_stack_top()

def pop_marked_tuple() -> tuple[Any, ...] | None:
def pop_marked_tuple(*, max_width: int = _PYTORCH_STORAGE_TRUST_MAX_TUPLE_WIDTH) -> tuple[Any, ...] | None:
items: list[Any] = []
while stack:
item = stack.pop()
if item is marker:
return tuple(reversed(items))
items.append(item)
if len(items) > _PYTORCH_STORAGE_TRUST_MAX_TUPLE_WIDTH:
if len(items) > max_width:
raise ValueError("PyTorch storage persistent ID tuple exceeded trust parser width")
Comment thread
mldangelo-oai marked this conversation as resolved.
Outdated
return None

Expand Down Expand Up @@ -2192,7 +2192,7 @@ def reduce_result(function: Any, args: Any, reduce_position: int) -> Any:
key = stack.pop()
apply_setitems_to_target(((key, value),))
elif opcode_name == "SETITEMS":
setitem_items = pop_marked_tuple()
setitem_items = pop_marked_tuple(max_width=_PYTORCH_STORAGE_TRUST_MAX_STACK_DEPTH)
Comment thread
mldangelo-oai marked this conversation as resolved.
if setitem_items is None or len(setitem_items) % 2 != 0 or not stack:
clear_stack_after_malformed_provenance()
continue
Expand Down
86 changes: 86 additions & 0 deletions packages/modelaudit-picklescan/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3432,6 +3432,92 @@ def test_scan_file_suppresses_rebuild_tensor_v2_for_real_ordered_state_dict(tmp_
assert _torch_rebuild_tensor_v2_warning_dicts(report) == []


def _write_large_batched_pytorch_state_dict(path: Path, *, malicious: bool = False) -> None:
entry_count = package_api._PYTORCH_STORAGE_TRUST_MAX_TUPLE_WIDTH // 2 + 1
entries: list[bytes] = []
for index in range(entry_count):
key_bytes = f"weight_{index}".encode("ascii")
key = b"X" + len(key_bytes).to_bytes(4, "little") + key_bytes
if malicious and index == entry_count - 1:
value = b"cos\nsystem\n(S'echo malicious-near-match'\ntR"
elif index == 0:
value = _pytorch_rebuild_tensor_v2_payload(key=str(index))[2:-1]
else:
Comment thread
mldangelo-oai marked this conversation as resolved.
Outdated
value = _pytorch_rebuild_tensor_v2_reduce_expr(key=str(index))
entries.append(key + value)

payload = b"\x80\x04" + _global(b"collections", b"OrderedDict") + b")R(" + b"".join(entries) + b"u."
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("archive/data.pkl", payload)
archive.writestr("archive/version", "3\n")
archive.writestr("archive/byteorder", "little")
for index in range(entry_count - int(malicious)):
archive.writestr(f"archive/data/{index}", b"\x00" * 24)


def test_pytorch_storage_trust_parses_large_batched_state_dict_without_framework(tmp_path: Path) -> None:
archive_path = tmp_path / "large-batched-state.pt"
_write_large_batched_pytorch_state_dict(archive_path)
with zipfile.ZipFile(archive_path) as archive:
payload = archive.read("archive/data.pkl")

parsed = package_api._pytorch_storage_keys_from_pickle_bytes(payload)

assert parsed.parse_complete is True
assert len(parsed.referenced_keys) == package_api._PYTORCH_STORAGE_TRUST_MAX_TUPLE_WIDTH // 2 + 1
assert len(parsed.canonical_tensor_rebuild_invocations) == len(parsed.referenced_keys)


def test_scan_file_suppresses_rebuild_tensor_v2_for_large_batched_state_dict(tmp_path: Path) -> None:
_require_torch_distribution()
archive_path = tmp_path / "large-batched-state.pt"
_write_large_batched_pytorch_state_dict(archive_path)

report = _scan_file_report_dict_subprocess(archive_path)

assert report["status"] == "complete"
assert report["verdict"] == "clean"
assert _torch_rebuild_tensor_v2_warning_dicts(report) == []
assert not any(finding["rule_code"] == "PERSISTENT_ID" for finding in report["findings"])


def test_scan_file_preserves_malicious_call_in_large_batched_state_dict(tmp_path: Path) -> None:
archive_path = tmp_path / "large-batched-malicious-state.pt"
_write_large_batched_pytorch_state_dict(archive_path, malicious=True)

report = _scan_file_report_dict_subprocess(archive_path)

assert report["verdict"] == "malicious"
assert any(
finding["severity"] == "critical"
and finding["details"].get("module") in {"os", "posix", "nt"}
and finding["details"].get("name") == "system"
for finding in report["findings"]
)


def test_pytorch_storage_trust_rejects_setitems_beyond_stack_limit() -> None:
entries = package_api._PYTORCH_STORAGE_TRUST_MAX_STACK_DEPTH // 2 + 1
oversized_payload = b"\x80\x04}(" + (b"K\x00K\x00" * entries) + b"u."

parsed = package_api._pytorch_storage_keys_from_pickle_bytes(oversized_payload)

assert parsed.parse_complete is False
assert parsed.referenced_keys == set()
assert parsed.canonical_tensor_rebuild_invocations == set()


def test_pytorch_storage_trust_preserves_tuple_width_limit() -> None:
tuple_items = package_api._PYTORCH_STORAGE_TRUST_MAX_TUPLE_WIDTH + 1
oversized_payload = b"\x80\x04(" + (b"K\x00" * tuple_items) + b"t."

parsed = package_api._pytorch_storage_keys_from_pickle_bytes(oversized_payload)

assert parsed.parse_complete is False
assert parsed.referenced_keys == set()
assert parsed.canonical_tensor_rebuild_invocations == set()


def test_scan_bytes_keeps_rebuild_tensor_v2_warning_for_raw_data_pickle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down
2 changes: 1 addition & 1 deletion packages/modelaudit-picklescan/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ dependencies = [
"posthog>=7.0.0",
"protobuf>=5.29.0",
"msgpack>=1.2.1,<2.0",
"modelaudit-picklescan>=0.1.10,<0.2.0",
"modelaudit-picklescan>=0.1.11,<0.2.0",
]

[project.optional-dependencies]
Expand Down
15 changes: 15 additions & 0 deletions tests/scanners/test_pytorch_zip_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10003,6 +10003,21 @@ def test_pytorch_zip_scanner_suppresses_rebuild_tensor_v2_in_embedded_data_pkl(t
assert _rebuild_tensor_v2_issue_dicts(report) == []


def test_pytorch_zip_scanner_suppresses_rebuild_tensor_v2_in_large_batched_state_dict(tmp_path: Path) -> None:
_require_torch_distribution()
import torch

model_path = tmp_path / "large-batched-state.pt"
torch.save({f"weight_{index}": torch.zeros(1) for index in range(40)}, model_path)

report = _scan_pytorch_zip_report_dict_subprocess(model_path)

assert report["success"] is True
assert report["has_warnings"] is False
assert report.get("metadata", {}).get("pickle_verdict") == "clean"
assert _rebuild_tensor_v2_issue_dicts(report) == []


def test_pytorch_zip_embedded_context_falls_back_with_older_picklescan_scan_stream(tmp_path: Path) -> None:
_require_torch_distribution()
from modelaudit_picklescan import PickleScanner as StandalonePickleScanner
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dependency_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
PICKLESCAN_PYPROJECT = ROOT_DIR / "packages" / "modelaudit-picklescan" / "pyproject.toml"
PATCHED_GITPYTHON_FLOOR = (3, 1, 50)
PINNED_MATURIN_BACKEND = "maturin===1.13.3"
REQUIRED_PICKLESCAN_RELEASE = "modelaudit-picklescan>=0.1.10,<0.2.0"
REQUIRED_PICKLESCAN_RELEASE = "modelaudit-picklescan>=0.1.11,<0.2.0"
PATCHED_PY7ZR_REQUIREMENT = "py7zr>=1.1.3"
PY7ZR_EXTRAS = ("sevenzip", "all-ci", "all")
NUMPY_REQUIREMENTS = {
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading