Security Research · Supply Chain

When the Build System Is the Backdoor

An attacker stole a PyPI token, published three malicious versions of a 400k-download Microsoft package in 35 minutes, and bypassed every scanner. Here's what behavioral provenance checking would have caught — and why blocklists always lose this race.

20 May 2026
8 min read
Security Research

You added a dependency months ago. It passed your audit, your SAST tool flagged nothing, and your blocklist showed it as clean. Then the maintainer's token was stolen, three new versions shipped in 35 minutes, and you pulled one of them before anyone noticed.

That is not a hypothetical. That is what happened with durabletask-python in May 2026.

TL;DR

  • An attacker stole a PyPI API token for microsoft/durabletask-python and published 1.4.1, 1.4.2, 1.4.3 in roughly 35 minutes. No blocklist caught it in time.
  • Three behavioral signals were visible from the start: no corresponding GitHub tags or workflow runs, three versions in 35 minutes, and malicious tarballs that differed from any clean build.
  • At CodeSlick, we built provenance checking to catch this class of attack at PR time. Here's how it works and where it stops.

What Actually Happened

The attack unfolded in two distinct steps, and conflating them leads to the wrong takeaways.

First, GitHub confirmed that an attacker gained access to internal repositories using a malicious VS Code extension installed on an employee machine. That breach was the entry point for a broader campaign, tracked by Wiz and StepSecurity as TeamPCP / Mini Shai-Hulud.

Second, and separately, a PyPI API token for durabletask-python was stolen — likely from GitHub Secrets exposed during that broader campaign. The attacker used that token to publish 1.4.1, 1.4.2, and 1.4.3 to PyPI. The microsoft/durabletask-python repository itself showed nothing: no new tags, no releases, no workflow runs. The malicious packages were assembled locally with credential-stealing droppers injected, then uploaded via twinedirectly, bypassing the project's publishing workflow entirely.

What was stolen: SSH keys, cloud credentials, Docker configs, Kubernetes secrets — from any machine that imported the malicious versions. The package has 426,407 monthly downloads (as of May 2026). All three versions have been yanked. The latest clean version is 1.4.0.

Independent analyses from Wiz and StepSecurity attributed the releases to the TeamPCP campaign, identifying overlaps in C2 infrastructure, payload behavior, and publication tactics with prior compromises targeting npm and PyPI ecosystems.

Why Blocklists Always Lose This Race

Every major dependency scanner — Snyk, Dependabot, OWASP Dependency-Check — works from a database of known-bad packages and CVEs. When a package is found to be malicious, researchers flag it, the databases update, and the scanners start blocking it.

That process takes hours to days. durabletask-python had already spread into CI/CD pipelines and developer machines by the time it appeared on any blocklist.

// the fundamental limit

You can only block what's already been caught.

The attackers couldn't hide one thing: behavior.

Three Signals That Were There From the Start

Look at durabletask 1.4.1 the day it was published and three things stand out — none of which required knowing the package was malicious in advance.

No GitHub provenance

Versions 1.4.1 through 1.4.3 appeared on PyPI with zero corresponding tags, releases, or workflow runs in the upstream repository. Legitimate releases leave a trail. A package version that exists on the registry but not in source control is a reason to stop before installing.

Version velocity

Three versions in approximately 35 minutes. That is not how maintainers cut releases — even aggressive patch cycles have review time between them. A burst of versions from the same package in a short window is a reliable anomaly signal. The attacker was testing and adjusting a payload, not shipping a feature.

Hash mismatchCRITICAL

The malicious tarballs differed from what a clean build would produce from the source repository. If you rebuild the package from the tagged commit and the hash does not match what PyPI is serving, the investigation is over. This is the strongest registry-level signal — and the hardest to fake without also compromising the source repository.

Note on the publisher signal:A “new publisher not in version history” check does notapply here. The attacker used a legitimate maintainer's PyPI token, so the publisher account appears throughout the version history and looks clean. That signal catches a different class of attack — fresh malicious accounts — but it was the wrong lens for this one.

Provenance Checking: A Different Question

Instead of asking “is this on a known-bad list?”, provenance checking asks: can you trace this artifact back to its source? Does this version on PyPI correspond to a commit in the upstream repository, built by a known workflow, with a hash that matches?

When any link in that chain is missing or broken, the package warrants scrutiny regardless of its reputation.

SignalPointsFires on durabletask?
Tarball hash doesn't match registryCritical (auto-flag)Yes — hash mismatch confirmed
Version published in last 6 hours+4Yes — same-day release
Publisher not in recent version history+6No — legitimate token used
No GitHub provenance / attestationRoadmapYes — no tags or workflow runs

How we built it at CodeSlick

CodeSlick's provenance checks run at PR time, across npm, PyPI, Maven, and Go. Every dependency change in a pull request gets checked before the PR can merge.

Version older than 72 hours → passes without further checks
Version newer than 72 hours → promoted to full provenance suite

Current coverage: version freshness fires today. Hash mismatch fires for npm packages today. PyPI hash verification against source provenance and GitHub attestation checking are on the roadmap — we are not there yet, and we will not claim otherwise. The version freshness signal would have surfaced 1.4.1 before it reached your requirements.txt.

Where Provenance Checking Stops

Provenance checking is not a replacement for CVE scanning. They catch different things.

CVE scanning tells you a version of lodash has a known exploit. Provenance checking tells you a version of requestswas just pushed by someone who's never touched the codebase. Both matter.

A few things worth stating plainly:

  • The publisher signal does not fire when the attacker uses a stolen legitimate token. The account looks clean because it is.
  • A maintainer who poisons their own package slowly, without triggering velocity or publisher anomalies, won't trip these checks. That requires reputation signals not available through public APIs today.
  • Hash checking only works if the source repository is intact. An attacker who also compromises the upstream repo can match hashes.

But the durabletask class of attack — hijacked maintainer account, malicious versions pushed fast, no source trail — is detectable. It's also the class spreading right now.

What to Check Before the Next Campaign

Whether or not you're running automated provenance checks, three things are worth doing for your critical dependencies today:

Find out who published the last version

Go to the package's PyPI or npm page and check the release history. Does the publisher match the pattern of previous releases?

Use lockfiles with pinned hashes

pip install --require-hashes and npm ci with a checked-in package-lock.json catch tampered tarballs at install time.

Review new versions before merging

Dependabot and Renovate both support automerge: false for production dependencies. A human reviewing the diff costs less than a credential-stealing dropper in CI.

This is what provenance checking automates. The registry APIs are public. The signals are documented. The gap is running these checks at the moment a PR changes your dependencies, not after the breach report.

Run provenance checks on your next PR

CodeSlick's GitHub App checks dependency provenance on every pull request — version freshness, publisher history, and hash integrity across npm, PyPI, Maven, and Go.

Install CodeSlick on GitHub