Back to all posts
Published on · by Renaud Deraison

You checked the wrong forty minutes

On August 14, 2026, SOCRadar published a reattribution of the LiteLLM supply-chain compromise: 95% of the affected organizations had already been harvested days earlier, by Aqua Security's own Trivy scanner. Five months of incident response were scoped to the wrong window, because every control in the chain needed someone to name the bad package first. Bromure Agentic Coding's supply-chain and credential layers don't need the name.

You were told to check whether you installed a Python package during a forty-minute window on March 24. Five months later, SOCRadar found that 95% of the victims had already been emptied out five days earlier, by their vulnerability scanner.

On August 14, SecurityWeek's Ionut Arghire published SOCRadar's reattribution of one of the year's largest developer-infrastructure compromises. SOCRadar held the record-level dataset, with per-organization collection records for 2,188 entities, and asked a question nobody had asked: when did each organization's data leave?

For 2,085 of them, collection had ended before the malicious packages everyone was warned about were published. That is 95%. SOCRadar's conclusion runs to one sentence:

"That timing lines up with the upstream Trivy compromise rather than the LiteLLM install window. The 40 minutes everyone reported was the closing act, not the whole play."

Researchers took the payload apart back in March. SOCRadar changed the timeline. For five months the industry ran incident response against the wrong window, using controls that could do nothing until somebody named the bad thing.

The closing act

The famous forty minutes belong to LiteLLM. On March 24, versions 1.82.7 and 1.82.8 went up on PyPI at 10:39 UTC and lasted about forty minutes before PyPI quarantined them. Guidance at the time told you to treat any install through 16:00 UTC that day as suspect.

The payload deserves a moment for what it defeats. It is a .pth file, litellm_init.pth, dropped into site-packages. Python executes .pth files at interpreter startup whether or not anything imports the package that installed them. There is no setup.py to inspect and no install script to strip, so --ignore-scripts and every control built on the install-script execution model sail past it. The Hacker News covered the mechanism on August 12 and noted the ecosystem-level identifier, CVE-2026-33634. The harvest went to models.litellm[.]cloud.

The attackers pushed those wheels with a stolen PyPI publishing token, past LiteLLM's own release pipeline. So where did that token come from?

The whole play

Trivy. Aqua Security's open-source vulnerability scanner, which tens of thousands of pipelines run to find this kind of problem.

Aqua's advisory, GHSA-69fq-xp46-6x23, does not soften the root cause. A threat actor tracked as TeamPCP (UNC6780 to Google) lifted a personal access token out of Trivy's GitHub Actions environment through a pull_request_target workflow in late February. Aqua disclosed that on March 1 and rotated credentials. The advisory then says the rotation was "not atomic (not all credentials were revoked simultaneously)." The attacker sat inside the rotation and collected the new secrets as Aqua minted them.

Nineteen days later they spent them. On March 19 they force-pushed 76 of the 77 version tags in aquasecurity/trivy-action, and all seven tags in aquasecurity/setup-trivy, onto malicious commits, so a workflow pinned to @v0.28.0 fetched new code with nothing in the repository appearing to change. Using the compromised aqua-bot service account, they published Trivy v0.69.4 to GHCR, ECR Public, Docker Hub, deb and rpm. Docker Hub images v0.69.5 and v0.69.6 followed on March 22–23 on a separately compromised credential.

StepSecurity's teardown describes what the injected stealer did once a pipeline ran it:

  • Read every runner process's environment through /proc/*/environ.
  • Dumped the GitHub Actions runner worker's memory via /proc/<pid>/mem, using base64-encoded Python. That recovers the secrets a runner masks in its logs, since masking is a log filter and the process memory holds the unmasked values.
  • Swept the filesystem for SSH private keys, git credentials, AWS, GCP and Azure tokens, Kubernetes secrets, Docker configs, database credentials, Terraform state and cryptocurrency wallets.
  • Sealed all of it under a hardcoded RSA-4096 public key with hybrid encryption and shipped it to scan.aquasecurtiy.org.

Read that hostname again. It is the vendor's own domain with two letters transposed. To a domain-reputation feed, or to an analyst skimming egress at 2am, a scanner talking to something that looks like aquasecurity.org is the least remarkable line on the page.

If the upload failed, the stealer created a public repository named tpcp-docs on the victim's own GitHub account and attached the loot as release assets.

SOCRadar clocked the first collection eighteen minutes after the malicious build went up.

March 2026 — exposure windows, to scaleFeb 28Mar 19Mar 22–23Mar 24Aug 14PAT stolen · rotation "not atomic"trivy-action — 76 of 77 tags force-pushed~12 hsetup-trivy~4 hv0.69.4~3 hDocker Hub .5 / .6~10 hLiteLLM 1.82.7 / 1.82.840 min — the window everyone checked2,085 of 2,188 organizations — 95% — finished being collected inside this spanreattributedWindows and versions from Aqua Security advisory GHSA-69fq-xp46-6x23. Record-level counts from SOCRadar,reported by SecurityWeek, 2026-08-14. Horizontal axis is schematic within each day.
Every artifact window in the March campaign was measured in hours, and each one closed before the advisory that named it. The window the world acted on, forty minutes on PyPI five days later, was the last one to open.

What left the pipelines

Hudson Rock obtained the archive and Help Net Security reported its size on August 13: 153 GB, 433,909 files, 118,829 CI runner dumps mapped to 2,488 corporate domains. CloudSEK's independent count of roughly 434,000 files put exposure at close to 2,500 organizations and more than 430,000 pipelines. Hudson Rock CTO Alon Gal called it a "global ethical disclosure effort" and said the magnitude "pushes us into a completely new world regarding the type of response required." Kevin Beaumont's read: "It's a massive supply chain breach due to poor AI security."

SOCRadar's per-organization breakdown goes further. More than a thousand organizations lost JWTs and authentication tokens. Hundreds lost private keys, AWS access keys, GitLab tokens, OpenAI API keys, Slack webhooks, GitHub Actions tokens and Google API keys. One organization lost roughly 3,477 individual secrets. Eleven hundred exposed their committers' email addresses. The runners were GitHub Actions, GitLab CI, Jenkins, Bitbucket, CircleCI and Buildkite. Germany, Brazil and France took the worst of it, and the collections are now being brokered on Telegram.

Every control in that chain needed a name

Line up the defenses that existed in March, and note what each one needed before it could act.

The advisory needed Aqua to identify the compromise. The yank needed PyPI to identify the package. The IoC list needed someone to read scan.aquasecurtiy.org as hostile rather than as a typo of a vendor everybody trusts. The "did you install between 10:39 and 16:00 UTC" checklist needed the right five hours. Domain reputation needed the domain to have a reputation. The reattribution that told 2,085 organizations they had been looking at the wrong week needed a leaked archive to surface and a research team to sit down with it, five months later.

Each of those is an identity control: it acts on a thing after somebody names the thing. Naming is how the ecosystem shares knowledge and it works, so this is not a complaint about the people who build advisories. It is a point about order of operations. An identity control arrives after the naming, and in this campaign each artifact was live for three to twelve hours. The eighteen minutes between publication and first collection answer the question of whether that ordering was good enough.

The controls that change the outcome here are the ones that do not need the name.

A clock, not an oracle

Bromure Agentic Coding runs every package fetch through the host-side MITM proxy before a byte reaches the VM, and the age gate is the only supply-chain layer that is on by default. It refuses any package version younger than a cutoff. Two days, out of the box.

It performs no lookup. It holds no opinion about the maintainer, the publisher, the signature or the name. It reads the publish timestamp and applies a floor, on the theory that a fresh release is the one most likely to have been hijacked an hour ago, and that waiting out that window costs a working developer close to nothing.

Against this campaign, that is the entire fight. LiteLLM 1.82.7 and 1.82.8 existed for forty minutes. A two-day floor means they never existed at all from inside the VM. The proxy strips too-fresh versions out of the registry metadata and re-aims latest and the other dist-tags at the newest surviving version, so pip install litellm and every semver range resolve to 1.82.6 with no error and nothing for an agent to route around. Ask for a poisoned version by exact pin and the artifact-fetch backstop returns a 451 whose body states the package's real age against the required minimum. Pip prints it verbatim:

Bromure Supply-Chain Security blocked this request: pypi package
[email protected] published 34 minutes ago — policy requires 2 days minimum

The gate covers npm, PyPI, Cargo, RubyGems and Packagist with full publish-time data. For pip it performs an on-demand lookup against PyPI's JSON API, since the default PEP 503 index carries no timestamps. Every decision lands in the Security Log (Window → Supply Chain Log…) as it happens.

IDENTITY CONTROL — acts after the name existsartifact publishedt + 0someone noticeshours … monthsattributionadvisory, IoCsyour control blockssecrets left at t + 18 minreattributed at t + 5 monthsTIME FLOOR — acts at the fetchartifact publishedt + 0agent resolves the dependencyproxy reads the publish timeage < 2 days → not servedmetadata rewrite, or 451 on a pinno name required,no advisory required
An identity control has to wait for the world to name the artifact. A time floor never learns the name and never needs to: it compares a publish timestamp against a number you chose in advance.

What a memory dump gets

The stealer's best move was reading the CI runner worker's memory to recover secrets that log masking had hidden. It works because masking is cosmetic and the process holds the values.

Run it against a Bromure workspace and it recovers placeholders, because the process holds placeholders. The wire boundary is the product's central mechanism: each credential you configure appears inside the VM as a structure-preserving fake, while the real value stays encrypted on your Mac. The host proxy substitutes it onto the wire after the request has left the VM, and only when the request is bound for the host that credential was minted for.

Walk the stealer's own list against a Bromure VM:

/proc/*/environ

ANTHROPIC_API_KEY is sk-ant-api03-brm-…. OPENAI_API_KEY is sk-brm-…. GH_TOKEN is ghp_ plus 36 characters. Structure-preserving, so claude and gh accept them without complaint, and worth nothing to anybody else.

/proc/<pid>/mem

Dumping the process recovers the same placeholders the environment holds. No privileged copy hides deeper in memory: the host proxy performs the substitution outside the VM, after the bytes have left.

SSH private keys

There are none. SSH_AUTH_SOCK points at an ssh-agent bridge over vsock port 8444. The private key bytes never enter the guest and cannot be read out of it. Bromure leaves your macOS login agent unexposed by design.

Cloud, k8s, Docker, database

~/.aws/config, ~/.kube/config, ~/.docker/config.json, ~/.git-credentials, ~/.config/doctl/config.yaml. All present, all populated, all fake: brm-k8s-…, brm-docker-…, brm-db-…, glpat- plus 20, dop_v1_ plus hex.

Bromure derives each fake from the real value plus a per-install 32-byte salt through HKDF-SHA256, so a tool that fingerprints its own key (Claude Code caches a key hash) never sees the credential rotate between sessions. There is also no switch to forget. The proxy is the VM's only route to the network, so a request that bypasses it carries a placeholder and fails upstream authentication. The boundary fails closed by construction.

The typosquat nobody had ever seen

Now the exfiltration. On March 19, scan.aquasecurtiy.org had no reputation, no history, and no reason to sit on anybody's blocklist. That is why TeamPCP chose it.

Bromure's compromise detector reads none of that. It watches your credentials rather than the destination's reputation. An Aho-Corasick automaton built from the workspace's own minted fakes sweeps every outbound request, headers and body. When one of those fakes turns up in a request bound for a host outside the scope it was minted for, the proxy treats the request as attempted exfiltration:

  1. The proxy refuses the request with HTTP 451 and forwards not one byte to the destination.
  2. Bromure pauses the VM on the spot.
  3. An alert names what happened: an outbound attempt to leak a session credential to a host it was not minted for.

Bromure then marks the workspace compromised, and the next launch requires wiping the VM disk image and the persistent home before it will boot. Your tokens, SSH keys and workspace settings survive that. The Trace Inspector (⇧⌘I) and bromure-cli trace leaks show the offending host and the exact request, so you can tell within a minute whether a dependency or a prompt-injected instruction was responsible.

An AKIA-shaped mint scoped to amazonaws.com, appearing in a POST to scan.aquasecurtiy.org, trips that automaton on the first attempt. The detector has never heard of the domain and has no need to. It knows that this credential has one legitimate destination family, and that this is a different one. There is nothing to enable; it runs on every request.

The timeline you already own

The record-keeping problem here outlasts the incident.

Researchers reconstructed the timeline from the outside, from a leaked 153 GB archive that had to surface, be obtained, and be sifted before anyone could say which window mattered. The victims could not answer from their own records, because those records left out the two facts that decided it: which artifacts their pipelines fetched and when, and where the resulting processes sent traffic.

A Bromure workspace produces that record as a side effect of how it works. Every fetch crosses the host proxy, so the Security Log holds each age-gate, OSV, socket.dev and 451 decision as a live tail. Every request crosses the same proxy, so bromure-cli trace ls gives you host, method, status, latency and swap×N / LEAK×N flags per workspace, trace hostnames lists each distinct host a session contacted, and trace summary aggregates the lot. All of it stays encrypted at rest on your Mac under the vault master key.

So the question SOCRadar answered in August, was I collected, and when?, becomes a two-minute lookup you run yourself in March, against your own data, without waiting for anyone to publish the right name.

That is the argument. Nobody can make the ecosystem faster at naming things: March showed the vendor's own incident response leaving a nineteen-day tail and a wrong attribution behind it. What you can change is whether your outcome depends on the name. Put a clock in front of the registry, put placeholders in the machine that runs the code, and keep your own log of what it fetched and where it talked.


Sources: SecurityWeek, "Trivy, Not LiteLLM Behind the 2,500 Org Compromise" (Aug 14, 2026) · Aqua Security advisory GHSA-69fq-xp46-6x23 · StepSecurity, "Trivy Compromised a Second Time" · The Hacker News (Aug 12, 2026) · Help Net Security (Aug 13, 2026) · CrowdStrike, "From Scanner to Stealer"