Skip to content

fix(ggml): detect embedded ZIP polyglot payloads - #1780

Open
mldangelo-oai wants to merge 4 commits into
mainfrom
fix/ggml-zip-polyglot-coverage
Open

fix(ggml): detect embedded ZIP polyglot payloads#1780
mldangelo-oai wants to merge 4 commits into
mainfrom
fix/ggml-zip-polyglot-coverage

Conversation

@mldangelo-oai

Copy link
Copy Markdown
Contributor

Summary

  • close a clean-scan security bypass affecting all five supported legacy GGML model signatures
  • reuse the existing bounded ZIP preflight, nested pickle analysis, scanner-selection handling, and S908 polyglot reporting
  • add failing-first malicious-pickle/path-traversal regressions plus a benign invalid-ZIP near match

Validation

  • 876 passed: GGUF, Llamafile, and Joblib scanner suites
  • 131 passed: release workflow regression suite
  • 8 passed: targeted new GGML and existing ZIP policy/preflight regressions
  • repository-wide Ruff lint and formatting checks pass
  • changed-file mypy passes

The broader scanner run also reaches one unrelated compressed-wrapper cache baseline failure, and full local macOS mypy reports existing out-of-scope platform/type errors; current-main GitHub Python CI and Type Check are green.

Copilot AI review requested due to automatic review settings July 22, 2026 22:11
@mldangelo-oai mldangelo-oai added codex security Security-related issues and vulnerabilities codex-automation labels Jul 22, 2026
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Workflow run and artifacts

Performance Benchmarks

Compared 13 shared benchmarks with a regression threshold of 15%.
Status: 0 regressions, 0 improved, 13 stable, 0 new, 0 missing.
Aggregate shared-benchmark median: 4.391s -> 4.358s (-0.8%).

Workload Benchmark Target Size Files Baseline Current Change Status
nested-payload-review tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_nested_payload_review[nested_hex] nested_hex 130 B 1 315.1us 306.6us -2.7% stable
single-checkpoint-preflight tests/benchmarks/test_scan_benchmarks.py::test_scan_single_checkpoint_before_load single_checkpoint.pkl 183.0 KiB 1 106.19ms 103.56ms -2.5% stable
suspicious-pickle-intake tests/benchmarks/test_scan_benchmarks.py::test_scan_suspicious_pickle_intake suspicious-intake 183.8 KiB 4 153.86ms 150.27ms -2.3% stable
duplicate-heavy-registry tests/benchmarks/test_scan_benchmarks.py::test_scan_duplicate_registry_snapshot registry-snapshot 915.2 KiB 13 584.94ms 572.60ms -2.1% stable
mixed-model-repository tests/benchmarks/test_scan_benchmarks.py::test_scan_release_candidate_repository release-candidate 547.3 KiB 32 642.45ms 629.11ms -2.1% stable
nested-payload-review tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_nested_payload_review[nested_base64] nested_base64 98 B 1 301.0us 297.4us -1.2% stable
direct-malicious-upload tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_direct_malicious_upload malicious_reduce 52 B 1 227.5us 224.9us -1.1% stable
chunked-upload-stream tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_chunked_upload_stream chunked_stream 278.2 KiB 1 114.97ms 116.05ms +0.9% stable
nested-payload-review tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_nested_payload_review[nested_raw] nested_raw 78 B 1 276.3us 278.4us +0.8% stable
padded-multi-stream-upload tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_padded_multi_stream_upload multi_stream_padded 4.1 KiB 1 338.0us 336.7us -0.4% stable
warm-cache-rescan tests/benchmarks/test_scan_benchmarks.py::test_scan_warm_cached_repository_rescan release-candidate 547.3 KiB 32 156.95ms 156.75ms -0.1% stable
clean-training-checkpoint tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_clean_training_checkpoint safe_large 278.2 KiB 1 112.03ms 111.91ms -0.1% stable
rejected-basic-auth-candidates tests/benchmarks/test_scan_benchmarks.py::test_rejected_basic_auth_candidates_scan_linearly - 371.1 KiB 1 2.518s 2.516s -0.1% stable

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Closes a security bypass in the GGUF/GGML scanner by ensuring legacy GGML variants also trigger the existing ZIP-polyglot preflight and embedded-archive inspection path, with regressions to prevent recurrence.

Changes:

  • Extend GGUF ZIP polyglot inspection helper to support GGML context labeling and invoke it from GGML scans.
  • Add regression tests covering embedded ZIP polyglots across all legacy GGML magic variants plus an invalid-ZIP near match.
  • Document the security fix in the Unreleased changelog.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
modelaudit/scanners/gguf_scanner.py Parameterizes ZIP polyglot detection labeling and enables it for GGML scans.
tests/scanners/test_gguf_scanner.py Adds malicious polyglot regressions for GGML variants and a benign invalid-ZIP near-match test.
CHANGELOG.md Notes the GGML ZIP/pickle inspection security hardening under Unreleased.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8ee962c4c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"""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 👍 / 👎.

"""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.

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 👍 / 👎.

Copilot AI review requested due to automatic review settings August 1, 2026 13:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

modelaudit/scanners/gguf_scanner.py:1053

  • _scan_ggml() always reports ZIP polyglot findings as "GGML …" even for other GGML-variant magics (e.g., GGMF/GGJT/GGLA/GGSA). Since result.metadata["magic"] is already set from the real header, the check name/message can become misleading for users triaging alerts on those variant files.
        result.metadata["format"] = "ggml"
        result.metadata["magic"] = magic.decode("ascii", "ignore")
        self._scan_zip_polyglot(result, format_name="GGML")

The main merge joined the new Security section directly onto Bug Fixes, which
fails the documentation formatting check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 1, 2026 13:31
@mldangelo
mldangelo enabled auto-merge (squash) August 1, 2026 13:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

modelaudit/scanners/gguf_scanner.py:767

  • _scan_zip_polyglot calls zipfile.is_zipfile(...) and then delegates to merge_executable_zip_container_findings(...), which (per modelaudit/scanners/archive_dispatch.py:538-540) calls zipfile.is_zipfile(...) again and performs additional imports before returning. With this PR, the double ZIP probe now runs for GGML as well, which is extra overhead and also increases exposure to pathological ZIP central directories being parsed twice.

Consider refactoring so the ZIP validity/preflight is performed once (ideally via the existing bounded ZIP preflight helpers in zip_scanner.py), and then pass that result into merge_executable_zip_container_findings (or restructure it to accept an already-confirmed ZIP) to avoid redundant parsing and heavy imports on non-ZIP files.

    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

zipfile.is_zipfile only proves an end-of-central-directory signature is present:
it returns True for any file whose trailing 64 KiB contains PK\x05\x06 followed
by 18 bytes. Model tensor data hits that by chance, so a benign file was reported
as CRITICAL S908 with an archive carrying zero members.

This was already reachable on main for GGUF; extending the preflight to the five
legacy GGML magics would have widened it to raw tensor blobs. The existing benign
near-match test cannot catch it because it appends PK\x03\x04, a local-file-header
signature that end-of-central-directory scanning never inspects.

A directory that fails to OPEN is deliberately not treated as benign: it falls
through to the preflight so corrupted or truncated archives still fail closed as
incomplete. Guarding on open-failure too silently skipped that path and broke the
corrupted-directory near-match regressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 1, 2026 14:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

tests/scanners/test_gguf_scanner.py:2973

  • The parameterized GGML magic list includes b"GGSN", but the supported legacy GGML variant magic appears to be b"GGSA" (and this file’s other parametrization uses GGSA). Using an unknown magic here can make the test exercise the wrong code path (or fail early) for the 5th variant.
@pytest.mark.parametrize("magic", [b"GGML", b"GGMF", b"GGJT", b"GGLA", b"GGSN"])

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 64e076a723

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +780 to +781
if embedded_members is not None and not embedded_members:
return False

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 Do not trust an empty final EOCD as an empty archive

When a GGML polyglot contains a valid nonempty ZIP and an attacker appends a second empty EOCD record, Python's ZipFile.namelist() selects the final record and returns [], so this early return produces a successful scan with no S908 or nested-pickle finding. The bounded ZIP preflight deliberately examines earlier EOCD candidates and rejects this exact construction as ambiguous, but the return prevents it from running; continue into preflight so such hidden content at least fails closed.

AGENTS.md reference: AGENTS.md:L115-L115

Useful? React with 👍 / 👎.

Comment on lines +776 to +777
with zipfile.ZipFile(self.current_file_path) as embedded_archive:
embedded_members: list[str] | None = embedded_archive.namelist()

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 Run bounded preflight before materializing ZIP entries

For a GGUF/GGML polyglot whose central directory exceeds max_zip_central_directory_size or max_zip_entries, constructing ZipFile and calling namelist() parses and materializes the attacker-controlled directory before ZipScanner can enforce either configured bound. A sufficiently large directory can therefore consume excessive memory and CPU in the scanner despite the archive preflight limits; determine emptiness through the bounded preflight rather than opening the archive first.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex codex-automation security Security-related issues and vulnerabilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants