Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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