Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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]

### Security

- Inspect hidden ZIP archives and malicious pickle payloads in legacy GGML model variants.

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

### Bug Fixes
Expand Down
11 changes: 6 additions & 5 deletions modelaudit/scanners/gguf_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,8 +760,8 @@ def _scan_gguf(self, f: BinaryIO, file_size: int, result: ScanResult) -> None:
)
result.bytes_scanned = max(result.bytes_scanned, f.tell())

def _scan_zip_polyglot(self, result: ScanResult) -> bool:
"""Inspect ZIP members even when GGUF metadata or tensor parsing fails."""
def _scan_zip_polyglot(self, result: ScanResult, *, format_name: str = "GGUF") -> bool:
"""Inspect ZIP members even when GGUF/GGML header parsing fails."""
if not zipfile.is_zipfile(self.current_file_path):
return False

Expand All @@ -776,7 +776,7 @@ def _scan_zip_polyglot(self, result: ScanResult) -> bool:
self.current_file_path,
result,
archive_config,
context="GGUF trailing ZIP polyglot",
context=f"{format_name} trailing ZIP polyglot",
)
rejected_paths = result._private_metadata.get(
_ZIP_CONTAINER_PREFLIGHT_REJECTED_PATHS_PRIVATE_METADATA_KEY,
Expand All @@ -789,9 +789,9 @@ def _scan_zip_polyglot(self, result: ScanResult) -> bool:
return False

result.add_check(
name="GGUF ZIP Polyglot Detection",
name=f"{format_name} ZIP Polyglot Detection",
passed=False,
message="GGUF file is also a valid ZIP archive and may contain hidden archive content",
message=f"{format_name} file is also a valid ZIP archive and may contain hidden archive content",
severity=IssueSeverity.CRITICAL,
location=self.current_file_path,
details={"embedded_format": "zip"},
Expand Down Expand Up @@ -1050,6 +1050,7 @@ def _scan_ggml(
"""Basic GGML file validation with security checks."""
result.metadata["format"] = "ggml"
result.metadata["magic"] = magic.decode("ascii", "ignore")
self._scan_zip_polyglot(result, format_name="GGML")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve nested ZIP byte accounting

When a compressed ZIP member expands beyond the outer file size, this call merges the member scan's bytes_scanned, but _scan_ggml later replaces it with file_size. In a directory containing several GGML polyglots whose individual uncompressed totals are below max_total_size, aggregate accounting can therefore remain below --max-size and continue scanning past the safety budget; retain at least the merged count, as _scan_gguf does, and cover this budget outcome.

AGENTS.md reference: AGENTS.md:L137-L137

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore outer GGML identity after ZIP merging

When a ZIP member is itself a GGUF/GGML model, merge_executable_zip_container_findings() merges that child's metadata into result; because the outer format and magic are assigned before this call, the child can replace them. For example, a GGMF polyglot containing nested.gguf is reported with format == "gguf", while a nested GGML variant can replace the outer magic. Move the outer identity assignments after the merge or restore them so reports describe the scanned outer artifact.

Useful? React with 👍 / 👎.


if file_size < 32:
result.add_check(
Expand Down
45 changes: 45 additions & 0 deletions tests/scanners/test_gguf_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,51 @@ def test_ggml_variant_scanner_basic(tmp_path):
assert result.metadata.get("magic") == "GGMF"


@pytest.mark.parametrize(
("magic", "suffix"),
[
(b"GGML", ".ggml"),
(b"GGMF", ".ggmf"),
(b"GGJT", ".ggjt"),
(b"GGLA", ".ggla"),
(b"GGSA", ".ggsa"),
],
ids=["ggml", "ggmf", "ggjt", "ggla", "ggsa"],
)
def test_ggml_scanner_inspects_embedded_zip_polyglot_members(
tmp_path: Path,
magic: bytes,
suffix: str,
) -> None:
path = tmp_path / f"polyglot{suffix}"
_write_ggml_variant_file(path, magic)
pickle_path = create_malicious_pickle(tmp_path / "payload.pkl")
_append_gguf_zip(path, {"payload.pkl": pickle_path.read_bytes(), "../escaped.txt": b"escape"})

direct = GgufScanner().scan(str(path))
aggregate = scan_model_directory_or_file(str(path), cache_enabled=False)

for result in (direct, aggregate):
assert any(issue.rule_code == "S908" and issue.severity == IssueSeverity.CRITICAL for issue in result.issues)
assert any(issue.rule_code == "S201" and "system" in issue.message.lower() for issue in result.issues)
assert any(issue.rule_code == "S405" and "escaped.txt" in issue.message for issue in result.issues)
assert any(check.name == "GGML ZIP Polyglot Detection" for check in direct.checks)
assert determine_exit_code(aggregate) == 1


def test_ggml_scanner_does_not_misclassify_invalid_zip_near_match(tmp_path: Path) -> None:
path = tmp_path / "zip-near-match.ggml"
_write_ggml_file(path)
with path.open("ab") as handle:
handle.write(b"PK\x03\x04not-a-valid-archive")

result = GgufScanner().scan(str(path))

assert result.success is True
assert not any(issue.rule_code == "S908" for issue in result.issues)
assert not any(issue.severity == IssueSeverity.CRITICAL for issue in result.issues)


def test_ggml_scanner_suspicious_version(tmp_path):
"""Test that GGML scanner handles unusual versions gracefully."""
path = tmp_path / "unusual_version.ggml"
Expand Down
Loading