All research
Analysis
2026-06-24 · 7 min
Blinding the Watchdog: A ZIP Parser Differential in GuardDog
GuardDog is a security scanner built to catch malicious PyPI and npm packages — but it trusted whatever Python's zipfile enumerated from an archive's central directory. A wheel can be crafted so that view comes back empty while the payload still sits in the local file headers: the scanner reports "Found 0 potentially malicious indicators" and returns clean, while pip unpacks and runs the code. A CWE-436 interpretation conflict (CVSS 7.4), reported through the vendor channel and fixed via PR #790.
Who guards the guards?
In software supply-chain security, tools like GuardDog sit on the front line. GuardDog is an open-source CLI from Datadog that inspects PyPI and npm packages before they reach your environment, running a combination of metadata heuristics and Semgrep/YARA code analysis to decide whether a package is clean or carries a malicious payload. Thousands of CI/CD pipelines lean on its verdict.
That raises a question worth pausing on: what happens when the tool that *scans* a package and the tool that *installs* it do not read the same file the same way?
That gap is the heart of this finding. It is not a missing rule or an incomplete Semgrep expression. It lives one layer deeper — in how the archive is read in the first place.
Why the ZIP format is more dangerous than it looks
A Python Wheel (`.whl`) is a ZIP archive with a different name. And ZIP — despite its age and apparent simplicity — is one of the most ambiguous container formats in wide use.
The reason is that a ZIP archive carries **two sources of truth** about its contents:
- Local File Headers**, interleaved before each stored file — this is what an installer follows when it actually unpacks.
- The Central Directory**, at the end of the archive, terminated by an **End of Central Directory (EOCD)** record — this is the index most libraries enumerate from.
The specification does not require these two views to agree. When they disagree, you get a **parser differential**: two readers of the same bytes reach two different conclusions. This class of bug is formally catalogued as **CWE-436 — Interpretation Conflict**: product A handles an input differently from product B, and A then acts on an incorrect perception of B's state.
The core: a watchdog that sees an empty archive
GuardDog unpacks an archive with Python's standard `zipfile` before scanning it — and `zipfile` enumerates contents from the **central directory** view. The finding is that this view can be emptied while the payload remains fully intact in the local file headers. The archive can be framed in at least three ways to achieve this:
- set the EOCD's *size of central directory* to `0`,
- append a second EOCD record declaring zero entries,
- or point the central-directory offset at an empty directory.
In every case, `zipfile.namelist()` comes back empty. The failure then cascades:
1. GuardDog's extraction pulls out **zero** files, because that is all the central directory advertises.
2. With nothing to scan, no rule fires. The tool reports **"Found 0 potentially malicious indicators"** — a confident clean verdict.
3. pip and other installers do *not* rely on that index. They walk the local file headers, unpack the payload, and run it.
In short: the scanner sees an empty box while the user receives a full one. The payload walked through the gate because the guard read the wrong source of truth.
This is not the bypass of one specific rule inside GuardDog — it neutralises the **entire** tool at once. However good the detection rules are, they are worthless if the file they are pointed at appears empty to begin with.
Why this matters more than its score suggests
A CVSS of 7.4 describes the flaw as a technical unit. Its real weight shows in context:
- Silent failure.** Compromised security tools do not scream. GuardDog here does not throw a false alarm or crash — it issues a confident "clean." That is the worst kind of failure: one that looks like success.
- Position in the chain.** The tool sits precisely at the point where an attack is meant to be stopped. Defeating it means everything downstream trusts a broken verdict blindly.
- **Scalability.** One attacker crafts one package and uploads it to a public registry. Every environment that relies on GuardDog in its automated pipeline is equally exposed.
- **Low forensic visibility.** Because the scan "passed," nobody goes back to re-check. The payload has already landed and executed before anyone suspects the scanner itself.
This is not a theoretical class. The underlying problem — that the ZIP specification is ambiguous enough for two conforming parsers to disagree — was formalised in the USENIX Security 2025 paper *"My ZIP isn't your ZIP."* This finding applies that general principle to a precise target: a specific security tool, fully blinded.
The fix: don't trust the index — verify against the headers
Rather than stop at reporting, the fix hardens `safe_extract` directly (PR #790): before extraction, it walks the **local file headers** on its own and refuses the archive if there are more headers than `zipfile` enumerated. It reads each header's compressed size and seeks over the data instead of scanning for the `PK` signature — a raw signature scan false-positives on compressed bytes — and it stops early on data descriptors without inline sizes and on zip64 markers, so it can only ever undercount. That design guarantees it never rejects a legitimate archive while catching the crafted mismatch. A normal wheel still extracts; the `namelist() == []` archive now raises.
The principle behind the fix generalises: **trust in a scan result must never exceed trust in the parser that produced it.** Every layer that reads untrusted input — scanner, installer, or analyser — is a party to a potential parser differential, and the durable defences are normalisation (rewrite the archive to a single canonical form before scanning), strict parsing (reject archives whose internal sources of truth conflict), and parser parity (have the scanner follow the same logic the installer will).
The same layer, more than one weakness
The archive-extraction path in security tooling is itself an attack surface worth auditing independently. Around the same period, GuardDog's `safe_extract()` routine also saw fixes for a **path-traversal issue leading to arbitrary file overwrite and RCE** (CWE-22) and a **zip-bomb denial-of-service** (CWE-409). The code that opens an untrusted archive deserves as much scrutiny as the code that judges its contents.
Disclosure
The issue was reported through Datadog's responsible-disclosure channel and addressed via PR #790. No operational proof-of-concept for weaponising a package was published; this report and the public pull request describe the bug class, the framings that trigger it, and the remediation — not a ready-made malicious build.
In software supply-chain security, tools like GuardDog sit on the front line. GuardDog is an open-source CLI from Datadog that inspects PyPI and npm packages before they reach your environment, running a combination of metadata heuristics and Semgrep/YARA code analysis to decide whether a package is clean or carries a malicious payload. Thousands of CI/CD pipelines lean on its verdict.
That raises a question worth pausing on: what happens when the tool that *scans* a package and the tool that *installs* it do not read the same file the same way?
That gap is the heart of this finding. It is not a missing rule or an incomplete Semgrep expression. It lives one layer deeper — in how the archive is read in the first place.
Why the ZIP format is more dangerous than it looks
A Python Wheel (`.whl`) is a ZIP archive with a different name. And ZIP — despite its age and apparent simplicity — is one of the most ambiguous container formats in wide use.
The reason is that a ZIP archive carries **two sources of truth** about its contents:
- Local File Headers**, interleaved before each stored file — this is what an installer follows when it actually unpacks.
- The Central Directory**, at the end of the archive, terminated by an **End of Central Directory (EOCD)** record — this is the index most libraries enumerate from.
The specification does not require these two views to agree. When they disagree, you get a **parser differential**: two readers of the same bytes reach two different conclusions. This class of bug is formally catalogued as **CWE-436 — Interpretation Conflict**: product A handles an input differently from product B, and A then acts on an incorrect perception of B's state.
The core: a watchdog that sees an empty archive
GuardDog unpacks an archive with Python's standard `zipfile` before scanning it — and `zipfile` enumerates contents from the **central directory** view. The finding is that this view can be emptied while the payload remains fully intact in the local file headers. The archive can be framed in at least three ways to achieve this:
- set the EOCD's *size of central directory* to `0`,
- append a second EOCD record declaring zero entries,
- or point the central-directory offset at an empty directory.
In every case, `zipfile.namelist()` comes back empty. The failure then cascades:
1. GuardDog's extraction pulls out **zero** files, because that is all the central directory advertises.
2. With nothing to scan, no rule fires. The tool reports **"Found 0 potentially malicious indicators"** — a confident clean verdict.
3. pip and other installers do *not* rely on that index. They walk the local file headers, unpack the payload, and run it.
In short: the scanner sees an empty box while the user receives a full one. The payload walked through the gate because the guard read the wrong source of truth.
This is not the bypass of one specific rule inside GuardDog — it neutralises the **entire** tool at once. However good the detection rules are, they are worthless if the file they are pointed at appears empty to begin with.
Why this matters more than its score suggests
A CVSS of 7.4 describes the flaw as a technical unit. Its real weight shows in context:
- Silent failure.** Compromised security tools do not scream. GuardDog here does not throw a false alarm or crash — it issues a confident "clean." That is the worst kind of failure: one that looks like success.
- Position in the chain.** The tool sits precisely at the point where an attack is meant to be stopped. Defeating it means everything downstream trusts a broken verdict blindly.
- **Scalability.** One attacker crafts one package and uploads it to a public registry. Every environment that relies on GuardDog in its automated pipeline is equally exposed.
- **Low forensic visibility.** Because the scan "passed," nobody goes back to re-check. The payload has already landed and executed before anyone suspects the scanner itself.
This is not a theoretical class. The underlying problem — that the ZIP specification is ambiguous enough for two conforming parsers to disagree — was formalised in the USENIX Security 2025 paper *"My ZIP isn't your ZIP."* This finding applies that general principle to a precise target: a specific security tool, fully blinded.
The fix: don't trust the index — verify against the headers
Rather than stop at reporting, the fix hardens `safe_extract` directly (PR #790): before extraction, it walks the **local file headers** on its own and refuses the archive if there are more headers than `zipfile` enumerated. It reads each header's compressed size and seeks over the data instead of scanning for the `PK` signature — a raw signature scan false-positives on compressed bytes — and it stops early on data descriptors without inline sizes and on zip64 markers, so it can only ever undercount. That design guarantees it never rejects a legitimate archive while catching the crafted mismatch. A normal wheel still extracts; the `namelist() == []` archive now raises.
The principle behind the fix generalises: **trust in a scan result must never exceed trust in the parser that produced it.** Every layer that reads untrusted input — scanner, installer, or analyser — is a party to a potential parser differential, and the durable defences are normalisation (rewrite the archive to a single canonical form before scanning), strict parsing (reject archives whose internal sources of truth conflict), and parser parity (have the scanner follow the same logic the installer will).
The same layer, more than one weakness
The archive-extraction path in security tooling is itself an attack surface worth auditing independently. Around the same period, GuardDog's `safe_extract()` routine also saw fixes for a **path-traversal issue leading to arbitrary file overwrite and RCE** (CWE-22) and a **zip-bomb denial-of-service** (CWE-409). The code that opens an untrusted archive deserves as much scrutiny as the code that judges its contents.
Disclosure
The issue was reported through Datadog's responsible-disclosure channel and addressed via PR #790. No operational proof-of-concept for weaponising a package was published; this report and the public pull request describe the bug class, the framings that trigger it, and the remediation — not a ready-made malicious build.
GuardDogDatadogSupply Chain SecurityParser DifferentialCWE-436Interpretation ConflictZIPPython WheelPyPIResponsible DisclosureStatic Analysis