All research
Analysis · 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.

Updated 2026-10-03

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,
  • 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.

Original source

Related research