Contribution
This post adds a detection method, not another package-compromise recap. It treats GitHub's three-day dependency cooldown and PyPI's 14-day file-upload cutoff as separate clocks, then joins package version age, per-file upload timestamps, locked hashes, CI execution and the first cloud use of a runner identity. That join catches a class of release mutation that neither control identifies on its own.
The pattern
GitHub and PyPI have both made time part of software supply-chain security, but they are defending different moments. GitHub's Dependabot change waits until a new release has been available for at least three days before opening a routine version-update pull request. The default applies across supported ecosystems on github.com. It does not delay Dependabot security updates, and repository owners can change or remove the cooldown in .github/dependabot.yml.
The idea is sensible. A malicious or badly broken release often needs time to attract reports, get yanked or appear in advisory data. An automated update raised within minutes of publication can beat that community response into a production branch. Waiting three days gives maintainers a chance to see trouble before a routine dependency PR arrives.
PyPI's change closes a different path. PyPI now rejects new files uploaded to a release more than 14 days after publication. Before that change, a stolen publishing token or compromised workflow could add another wheel to an old, trusted version. A consumer asking for the same package and version could then receive a file that did not exist when the version was first reviewed. The version string would stay put while the release's file set changed underneath it.
PyPI says it is not aware of attackers abusing that exact old-release path. The restriction is preventive. It also warns users not to treat a release as formally closed because there is no API that reports that state yet. Upload 2.0 and Staged Previews are expected to define those semantics later. For now, defenders can observe file timestamps and hashes, but they cannot query a supported closed=true property.
Put the two controls on one timeline and an awkward gap appears. A version becomes eligible for a routine Dependabot pull request after day three. PyPI can still accept a new wheel or source distribution for that same release until day 14. That leaves 11 days in which a reviewed version may acquire another artefact without causing a new version-update pull request. The exact risk depends on how an installer selects files for its platform, but the clock mismatch is real.
The controls also do little for teams that bypass Dependabot, install directly from broad version ranges, rebuild containers without lockfile hashes or restore a package cache after its contents have changed. Time can reduce exposure. It cannot tell a defender which file was installed or whether that file matched the artefact originally approved.
The durabletask investigation by StepSecurity shows why the distinction matters even though that incident used new malicious versions rather than late files on an old release. Three malicious versions appeared on PyPI without corresponding repository tags, releases or CI runs. The package behaved normally during installation. Its malicious activity began on import, when Python contacted an unapproved domain, downloaded another payload and started credential collection. Registry metadata, repository provenance and runtime behaviour each held a different part of the answer.
Why it matters to cloud defenders
Package compromise often starts in a developer or CI context, but its useful target is the identity already present there. A build runner may hold an OIDC trust path into AWS, Azure or GCP. A developer workstation may have cached cloud profiles, Kubernetes configuration or GitHub credentials. A package does not need a cloud-specific exploit if ordinary import-time code can read those files or invoke the same command-line clients as the user.
StepSecurity's durabletask reproduction makes the sequence concrete. Installation contacted expected package hosts, so a network rule scoped only to pip install would have seen nothing unusual. Import then spawned child Python processes, reached an external payload host, probed for Kubernetes tooling and ran commands associated with credential access. The report also describes AWS Systems Manager and Kubernetes execution as lateral movement paths after secrets were collected.
A three-day adoption delay helps only before the dependency enters the environment. It does not contain code after import. PyPI's 14-day cutoff prevents one mutation path after the deadline, but it does not prove that files uploaded before the deadline are benign. It does not cover npm, container registries or source archives fetched from a Git tag either. Cloud defenders therefore need an evidence chain that begins at dependency resolution and ends at the first privileged action by the runner or workload.
That chain should answer four questions. Which exact filename and SHA-256 digest did the resolver choose? When was that file uploaded relative to the first file for the release? Did a matching source tag and approved build produce it? What did the consuming identity do after the package was installed or imported? A version string alone answers none of them.
This is also why security-update exceptions deserve separate handling. GitHub deliberately sends Dependabot security updates immediately. That is the right bias for a known vulnerable dependency, but it removes the observation window. A security-labelled update should therefore receive stronger provenance checks rather than being waved through because the normal cooldown did not apply.
ATT&CK mapping
The entry point maps to T1195.001, Compromise Software Dependencies and Development Tools. A poisoned package, stolen publishing identity or compromised release workflow turns the dependency graph into initial access. The important object is not merely malicious Python. It is malicious code delivered through a channel the build already trusts.
Execution maps to T1059, Command and Scripting Interpreter. In the durabletask reproduction, imported Python spawned more Python processes and command-line tools. The useful signal is that process boundary: a language runtime loading a newly admitted dependency, then starting package managers, systemctl, kubectl, cloud CLIs, gh auth or gpg while making an unrelated outbound connection.
The data payoff maps to T1552.001, Credentials In Files. Cloud profiles, environment files, Kubernetes configuration and tool-specific credential stores are common targets after package code runs. The durabletask analysis observed commands and collectors aimed at that material. If a stolen credential is later used against a cloud API, the resulting provider audit event is follow-on evidence, not proof that the package itself was malicious.
These techniques sit on different planes. T1195.001 is visible in registry metadata, source-control history, attestations, dependency diffs and build records. T1059 and T1552.001 are visible on the host through process ancestry, file access and attempts to invoke credential-bearing tools. The cloud control plane sees the result: a runner identity used from a new network, an unusual service call or a resource action outside the job's normal profile. Joining the planes is more reliable than trying to make one alert carry the whole case.
Detection guidance
Start by recording an artefact manifest at dependency resolution. For every selected distribution, keep the package name, version, filename, SHA-256 digest and upload_time_iso_8601. PyPI's JSON API exposes those fields for release files. Keep the resolver's timestamp and the workflow run ID beside them. The record needs to survive longer than an ephemeral runner so investigators can compare a later rebuild with the earlier install.
For each release, calculate two values: release_age, measured from the newest selected file to the resolution time, and file_set_span, measured from the earliest to the latest file upload for that version. Do not treat a release as closed when it reaches 14 days because PyPI explicitly says there is no supported state API. Treat the timestamps as evidence of what existed, not as a registry guarantee.
A practical admission policy can then work as follows:
- Hold routine updates while
release_ageis below the organisation's observation window. Three days matches GitHub's new default, but high-trust build tooling may warrant longer. - Require a human review when a version gains a file after its first publication, especially when the new file is the wheel selected for your runner's operating system or Python version. Some maintainers legitimately add platform wheels, so this is a review signal rather than an automatic compromise verdict.
- Fail the build when the selected file's digest differs from the lockfile or approved manifest. A matching version with a different hash is still a different artefact.
- Compare the registry artefact with repository provenance. A package uploaded without a matching tag, release or authorised build deserves escalation. The durabletask case had precisely that mismatch.
The highest-value runtime alert begins when a newly admitted dependency is imported. On Linux runners, look for the language runtime spawning unexpected package managers, shells, systemctl, kubectl, cloud CLIs, gh auth or gpg during tests or application start-up. Pair that with an outbound connection to a domain absent from the job's dependency-fetch allow-list. Expected calls to pypi.org and files.pythonhosted.org during installation are weak evidence; a Python child process contacting an unrelated new domain immediately after import is much stronger.
Then join the runner event to cloud audit data by workload identity and time window. In AWS, watch the assumed-role session used by the job for calls outside the repository's normal service set, and for Systems Manager activity against instances the workflow does not manage. In Azure, compare the service principal or federated identity with its usual resource providers and subscription scope. In GCP, compare the workload identity principal with its normal methods and projects. The alert should cite the package digest and workflow run that preceded the cloud event. Without that link, a new source address alone will be noisy on hosted runners.
False positives usually come from release engineering and integration tests. Projects may add a wheel for a new Python or operating-system target within PyPI's open period. Build jobs may legitimately call gpg, a cloud CLI or kubectl. Tune against the repository, job name, expected child-process tree, approved domains and normal cloud API set. Do not suppress the signal globally because one release job needs those tools.
What to do now
Keep GitHub's default cooldown enabled for routine Dependabot updates unless a documented release process replaces it. Review any cooldown.exclude entries and repositories that opted out; the exclude list always wins over the include list, so an excluded package updates immediately. Security updates arrive without the delay, so route them through hash and provenance checks before merge.
For Python builds, generate a manifest from the resolver and PyPI metadata, then store it with the build attestation. Pin hashes with the package manager where possible. On a later build, compare both the selected digest and the release's file list with the approved record. Alert when the resolver selects a file that was absent from the earlier manifest, even if the package version has not changed.
Move publishing away from long-lived registry tokens. PyPI Trusted Publishing exchanges short-lived OIDC identity tokens with an approved workflow instead of a manually generated API token, and the minted upload token expires within minutes, reducing the value of a copied credential. It does not make the uploaded code safe, so keep source tag, workflow identity and artefact digest checks in place.
Restrict network egress from build runners to the destinations each job needs. Record process trees and DNS or proxy events long enough to investigate import-time execution. Keep cloud roles narrow and short-lived so a stolen runner credential cannot wander across accounts or projects.
Finally, rehearse the response to a hash mismatch. Quarantine the runner, preserve the resolved artefact and build logs, identify every workload built from it, and rotate credentials reachable from the process. Package cooldowns buy investigation time. The manifest and cross-plane join tell you whether you needed it.