18/09/2026

The package was never the target

The package was never the target

elusive thoughts // supply chain // september 2026

Ask most teams what a successful supply chain attack looks like and they describe a malicious package getting installed. That is the delivery. The success condition is a valid credential leaving your CI runner, and by that measure the attackers are winning comfortably while the industry argues about CVE counts.

Two pieces landed this month that I have been reading side by side. GitGuardian wrote up what recent supply chain campaigns were actually after once they got in. Aikido wrote up why the number of critical CVEs at the biggest software companies went from under 100 a month to over 600 since spring, and why that number is the wrong thing to stare at. Put them together and you get an uncomfortable picture of a pipeline that has learned to ship faster than it can reason about what it is shipping.

Define success from the attacker's chair

GitGuardian's list of entry points is varied. Compromised maintainer accounts. Abused CI/CD workflows. Rewritten GitHub Action tags. Poisoned packages that were trusted yesterday. Their line is that whatever the door was, "the objective was remarkably consistent: get credentials."

The numbers behind that are not small. Shai-Hulud 2.0 left behind 20,649 exfiltration repositories holding 33,185 unique secrets, of which 3,760 were confirmed still valid when they were checked. The GhostAction campaign injected malicious workflows into 817 repositories across 327 GitHub users and pulled out 3,325 secrets, including PyPI, npm and DockerHub tokens, by plain HTTP POST. Nothing clever. The runner had the tokens in its environment, so the workflow read them and sent them somewhere.

And the speed. When a poisoned Axios version hit npm, Dependabot opened its first pull request within five minutes. 895 repositories were affected inside the attack window. The automation you set up to keep dependencies fresh is the same automation that hand-delivers the compromised version to your build.

entry pointmaintainer account · CI workflow · Action tag rewrite · poisoned version
automated pullDependabot / Renovate / lockfile update. first PR in ~5 min (Axios)
build runs itinstall scripts and workflows execute with the runner's environment
credentials outGitHub tokens · publish tokens · SSH keys · cloud keys
reuseopen the next system, or publish the next poisoned version
◀ publishing rights feed the next round. this is a loop, not a line.
Fig 1. The chain as GitGuardian describes it. The green box is the success condition. Everything to its left is logistics.

So the outcome that matters is the green box. A credential with reach. GitGuardian is explicit about what it buys: it can "open another system, expose additional secrets" or "give the attacker the publishing rights needed to push malicious software to another set of victims." One publish token turns a single compromised package into the next campaign. That is the whole business model of the mini Shai-Hulud waves we have seen since the original.

Why do fresh packages keep shipping with day-zero problems

Here is where the Aikido piece comes in, because it explains the environment that lets the chain above run so freely.

The a16z and Epoch AI numbers cover 21 major software companies including Apple, AWS, Microsoft, Google and Adobe. Reported critical vulnerabilities at those companies never cleared 100 per month in four years. Since spring they are running above 600 per month. Aikido's read, which I agree with, is that the software did not get six times worse in one season. AI made finding bugs cheap. Frontier models are surfacing vulnerabilities faster than humans can validate them, including old ones that survived years of manual review.

Detection got commoditised. Remediation did not move. Aikido calls it the remediation paradox and the Verizon 2026 DBIR figures back it up: only 26% of confirmed actively exploited vulnerabilities on CISA's KEV list were fully remediated in 2025, down from 38% the year before. Median patch time went from 32 to 43 days. Even the best resourced organisations fix 30 to 40% in the first week.

discovery (critical CVEs / month, 21 major vendors)
2022 to early 2026 ceiling
< 100
since spring 2026
> 600
remediation (KEV vulns fully fixed, Verizon DBIR)
2024
38%
2025
26%
median days to patch
previous year
32 days
2025
43 days
scoring backlog (NIST NVD)
18 months ago
13,000
May 2026 audit
27,000+
Fig 2. Finding goes up six-fold. Fixing goes down. The reference database gives up on scoring everything. Sources: a16z / Epoch AI via Aikido, Verizon 2026 DBIR, NIST federal audit May 2026.

The NIST line deserves its own sentence. The NVD backlog went from 13,000 to over 27,000 unscored CVEs in eighteen months, and the response after a May 2026 federal audit was to stop scoring everything and only prioritise CVEs tied to federal use, critical software, or the KEV list. The shared reference the whole industry keyed its SLAs to has formally admitted it cannot keep up.

Now connect the two articles. A maintainer publishes a new version. That version is either a poisoned one, or a legitimate one with a vulnerability that an AI-assisted researcher will find in a fortnight and a CVE that NVD may or may not score. Your dependency bot pulls it in minutes. Your build runs its install scripts with your publish tokens and cloud keys in the environment. And the thing that would tell you something was wrong, a scored CVE or a vendor advisory, arrives days or weeks later if it arrives at all. Aikido's own telemetry on StyleSmuggler is the tell: they had the Adobe Commerce RCE tracked before it had a CVE number, and 67% of the packages they found vulnerabilities in were never disclosed to any public database.

Is it too fast, or do we not understand the impact

Both, and they reinforce each other.

The speed problem is real and it is self-inflicted. We built automation that treats "new version exists" as sufficient reason to bring it into the build within minutes. That was a reasonable trade when the failure mode was a broken test. It is not a reasonable trade when the failure mode is a workflow that POSTs your npm token to a stranger. Five minutes is faster than any human triage, faster than any scanner refresh, and faster than the registry's own takedown.

The impact problem is subtler and I think it is the bigger one. Most organisations have an SBOM now. Compliance asked for it, a tool generated it, it lists every component and version. What almost nobody has is the other inventory: which credentials sit in which runner, what each of them can reach, and who owns them. GitGuardian's advice is blunt about this. Build the credential map before the incident, because "waiting until the incident to build that map means reconstructing it while the response clock is already running."

An SBOM tells you the poisoned package is in 40 repos. It does not tell you that 12 of those repos build on a runner holding a production deploy key and a publish token for your own internal packages. The first fact is a remediation ticket. The second fact is the incident. Teams are reading the bill of materials and not the bill of access, and that is why the impact keeps being underestimated until the exfiltration repos show up in someone's research post.

THE INVENTORY GAPSBOM answers "what is in the build". Nobody is answering "what can the build reach". The second question is the one the attacker is asking.

There is a third thing that makes it worse and it is cultural. Vulnerability programmes are still run on CVE volume. Count of criticals, days open, percentage closed. When the volume goes up six-fold the honest response is that the metric broke, and instead the response is usually to work harder on the same queue. Aikido's framing is the right one: "Risk is determined by how fast you can find out which vulnerabilities matter, and how fast they get fixed once they're validated." A number that says how many CVEs exist tells you about the researchers. It does not tell you about your exposure.

What I would actually do about it

Everything below is a gate in the execution path, not a policy document. The people and agents pulling packages will not read the document. They will notice the gate only if it is implemented wrong.

install
48h minimum package age verify against threat intel before disk
shell-level wrap. the caller does not know it is proxied.
build / CI
short-lived OIDC creds, no static tokens secrets scan blocks merge (CI, not pre-commit) one blocking status
nothing worth stealing sits on the runner.
triage
reachability before severity do not wait for a CVE ID
the queue is the CVEs you can execute, not the CVEs that exist.
fix
autofix opens the PR credential map ready before the incident
remediation measured in hours, owned by the pipeline.
Fig 3. Where the gates go. Amber is a control. Green text is what each stage looks like when the control is working and nobody notices it.

1. Put a clock on new versions

We run a 48-hour minimum package age at the install layer. Every npm, pnpm, yarn, bun, pip, uv and poetry call goes through a shell alias into a local proxy that checks the package and its dependency tree against threat intel before anything hits disk. The agent running the install does not know the proxy exists. It gets a package or it gets an error. Shai-Hulud spread across 160 plus packages in hours and was publicly identified inside two days. Two days of waiting removes most of that window and costs, in practice, almost nothing.

2. Make the runner boring to rob

If the success condition is a credential leaving the runner, the fix is to stop putting credentials worth having on the runner. Short-lived OIDC federation to the cloud instead of static keys. Publish tokens that exist for one job and expire. Agent identities on 30-day keys, separate from the human ones, so the audit trail can tell them apart. GitGuardian's recommendation to scope runner permissions to exactly the "publishing credentials, source code, deployment credentials, or other secrets" a job needs is the same point. GhostAction worked because the tokens were just there.

3. Scan in CI and stop pretending pre-commit counts

We run secret detection twice. Pre-commit is a developer courtesy and anyone can skip it with one flag on a machine we do not control. The CI run is the control. It blocks the merge and nobody overrides it, because an override that exists gets used at five on a Friday. One blocking status, not six advisory ones. Six advisory checks train people to scroll past, and that habit generalises to the check that mattered.

4. Triage on reachability, and stop waiting for the number

Aikido's first recommendation is reachability analysis, filtering out findings with no execution path to the vulnerable code, and it is the only way I know to survive 600 criticals a month. Their second is not to wait for a CVE ID or a vendor advisory before acting. The StyleSmuggler case is the proof. A working fix shipped through autofix across multiple pinned versions more than 24 hours before Adobe's own hotfix. When two thirds of the vulnerable packages you find were never disclosed anywhere, a process that begins at "CVE published" is a process that begins late by design.

5. Let the fix arrive as a diff

The adoption curve of a security fix depends almost entirely on the form it arrives in. A Jira ticket that says "upgrade X" competes with the sprint. A small pull request that already passes tests gets merged before lunch. Our SCA tooling opens the remediation PR itself. Same fix. Completely different outcome, and it is the only way the remediation side of the paradox ever catches up with the discovery side.

6. Build the credential map now

Which secrets exist, where they live, what they reach, who owns them. Per repo, per runner, per agent. This is the inventory that an SBOM was never going to give you and it is the one you will need at 2am when a research team publishes a list of exfiltration repos and one of them has your org name in it. GitGuardian's post-incident sequence is scope, ownership, prioritise by reach, revoke or rotate. Every one of those steps is faster if the map already exists.

The honest costs

None of this is free and I would rather say so than have you find out. The 48-hour hold generates a real argument every so often, usually when the fix you need shipped that morning. Moving CI to short-lived credentials means AppSec owns the integration work instead of pushing tickets to teams, and it took a lot longer than a tool rollout. Reachability analysis is only as good as the call graph, and it will occasionally file a real problem under "unreachable". The credential map rots the moment you stop maintaining it, and a stale map is worse than none because people trust it.

Verdict

A supply chain attack succeeds when a credential with reach leaves your build. Not when a package gets installed, and not when a CVE gets published. The industry is counting the wrong thing at both ends: installs and CVEs, when the attacker is counting valid tokens. Six hundred criticals a month is going to be the floor from here, because the tools that find them are not getting slower. The only variable you control is how little there is to steal when one gets through, and how many hours it takes you to close it once you know.

Read the two pieces that prompted this. GitGuardian on what supply chain attacks are really after, and Aikido on why the CVE spike is a remediation problem. They are better read together than apart.

06/09/2026

The CVE Explosion Nobody Budgeted For

The CVE Explosion Nobody Budgeted For

72,000 vulnerabilities a year, a funding scare at the program's core, and an exploit window that's gone negative. Notes on a queue that stopped being a queue.

Forget the AI-ends-vuln-management headlines for a second. The number that should actually worry you is more boring than that: volume. In the first half of 2026, the CVE program published 35,364 new identifiers. That is a 49.5% jump over the same six months in 2025, and on its own it beat every full calendar year before 2024. One new CVE roughly every 7.4 minutes, all day, every day, for six straight months.

Run the tape back and the shape of this stops looking like growth and starts looking like compounding.

New CVEs published per year
the queue everyone is patching against, and losing to
7,938
2014
16,500
2018
20,000
2021
25,081
2022
28,902
2023
40,009
2024
48,185
2025
~72,000
2026*
*2026: H1 actual annualised. Sources: JerryGamblin.com CVE data reviews, FIRST.org 2026 Vulnerability Report, StingRAI Vulnerability Statistics 2026.

2024 was the first year to cross 40,000. 2025 added another 20% on top of that. FIRST's own forecasting group, the people whose job is literally predicting this curve, opened 2026 with a median call of roughly 59,000 CVEs for the year, which would have been the first time anyone crossed 50,000. By July, with H1 already running at 35,364, they'd revised the projection north of 90,000. Straight-line off the actual H1 pace, the year lands closer to 70-72k. Either way, the headline is the same: every single year since 2017 has been a record year. FIRST's own report flags a structural change in CVE publication patterns starting around 2017-2018, without spelling out every cause, and whatever triggered it never reversed. It just kept accelerating.

The plumbing nearly failed at the worst possible moment

Here's the part that should bother you more than the raw count. In April 2025, right as this curve was steepening, the CVE program's own funding nearly lapsed. MITRE's DHS contract for running CVE and CWE was set to expire on April 16, and CISA only pushed through an 11th-hour extension to keep "no lapse in critical CVE services," in their own words. The bedrock database that every vulnerability scanner, every SOC, every vendor advisory ultimately traces back to came within a day of going dark, not because of an attack, but because of a lapsed federal contract.

Meanwhile NIST's National Vulnerability Database, the enrichment layer that adds CVSS scores and CPE data on top of raw CVE entries, has been running a backlog since early 2024 that analysts projected could hit 30,000 unanalyzed vulnerabilities. VulnCheck has been tracking exploitation activity sitting quietly inside that backlog, which is exactly the kind of blind spot you do not want when disclosure volume is going vertical. The industry built its entire tooling stack on the assumption that someone, somewhere, was triaging and enriching every CVE before it reached your scanner. That assumption is now visibly under strain.

Zero-days aren't exploding. They're concentrating.

If you only look at raw zero-day counts, 2025 looks almost calm. Google's Threat Intelligence Group tracked 90 zero-days exploited in the wild for the year, sitting comfortably inside the 60-100 band the industry has held for five years running (2023 hit 100, 2024 came in at 78). The real story is where those 90 landed.

Enterprise technology took 48% of them, the highest share GTIG has recorded, up from 46% in 2024. Security and networking products (the VPNs, the firewalls, the SASE boxes you bought specifically to reduce your attack surface) accounted for 21 zero-days on their own, 14 of them in edge devices specifically. PRC-nexus groups, UNC3886 and UNC5221 among them, burned roughly 10 zero-days on exactly that category, chaining things like a Juniper router flaw (CVE-2025-21590) and an Ivanti VPN bug (CVE-2025-0282) to sit inside networks that were supposed to be the hardened perimeter. Ransomware crews weren't far behind. FIN11 and the CL0P-linked extortion operation used zero-days in Oracle E-Business Suite (CVE-2025-61882 and CVE-2025-61884) to run a mass-exploitation campaign against exactly the kind of ERP system that nobody patches on a Friday.

And for the first time, commercial spyware vendors (the Intellexa-adjacent surveillance-for-hire crowd) outpaced state-sponsored groups as zero-day consumers. The zero-day market got more efficient at finding the boxes that matter most and staying there, without needing to get bigger.

What the money people think about all this

This isn't just an engineering problem anymore, and you don't need to take an AppSec engineer's word for it. The Bank of England runs a Systemic Risk Survey twice a year asking the UK's largest financial institutions what keeps them up at night. In the 2026 H1 round, 82% of respondents named cyberattack as one of their top five risks to the financial system, and 26% called it the single most important risk they face, up six points from the prior survey. Cyberattack ranks second only to geopolitical risk, and 77% of respondents said it's the hardest risk on the list to actually manage, up four points on the prior survey. This has now been a top-tier concern in every BoE survey since it resumed in 2021.

The World Economic Forum's Global Risks Report 2026 puts cyber insecurity at #6 on the two-year outlook, grouped with disinformation as a technological risk the report says is "growing largely unchecked." ENISA's Threat Landscape 2025, covering roughly 4,900 incidents across the EU between July 2024 and June 2025, found vulnerability exploitation behind 21.3% of intrusions, with ransomware driving 81.1% of cybercrime incidents against European organisations. None of these bodies are trying to sell you a scanner. They're trying to figure out how much of the financial system falls over if this queue keeps growing the way it has.

CVE to PoC: the part that should actually keep you up

Everything above is a volume story. This is a speed story, and it's the one that actually changes what "patch management" means.

Median time from CVE disclosure to working exploit
log scale. attackers didn't get patient, they got tooling
2018
756 days
2022
32 days
2023
5 days
2025-26
same day, or before
Sources: Cloud Security Alliance ("The Collapsing Exploit Window"), VulnCheck State of Exploitation 1H-2025, Mandiant M-Trends 2026.

In 2018, the median gap between a CVE going public and a working exploit hitting the wild was 756 days. Two years ago that was down to roughly 32 days. Last year it was 5. VulnCheck's mid-2025 data found that 32.1% of known-exploited vulnerabilities had exploitation evidence on or before the CVE's own publication date, up from 23.6% the year before. Mandiant's M-Trends 2026 puts the mean time-to-exploit at approximately negative seven days. Read that again: on average, exploitation is happening before the advisory exists. CrowdStrike's 2026 numbers back this up from a different angle, 42% of exploited vulnerabilities were attacked before public disclosure, and once an intrusion starts, average eCrime breakout time is 29 minutes, with the fastest observed case at 27 seconds.

That collapse shows up in the breach data too. For the first time in the DBIR's history, vulnerability exploitation overtook phishing and credential abuse to become the single most common initial access vector, at 31% of incidents, up 55% year over year from 20% in 2025. And the defenders' side of the same report is going the wrong direction: median remediation time for known-exploited vulnerabilities climbed to 43 days in 2026, up from 32 the year before, only 26% of known-exploited vulnerabilities got fully remediated (down from 38%), and 60-70% were still sitting unpatched at day seven regardless of how mature the organisation claimed to be.

The part that actually broke this for me is the economics. A Cloud Security Alliance whitepaper on what they're calling "AI-speed vulnerability weaponization" documents AI models producing working proof-of-concept code for a published CVE in 10-15 minutes, at around a dollar an attempt. The CVE-Genie research framework automatically reproduced working exploits for 51% of tested 2024-2025 CVEs at an average cost of $2.77 each. One documented agent swarm found more than 100 exploitable kernel vulnerabilities across major hardware vendors in 30 days, for about $600 total. Building a PoC used to be the bottleneck between "there's a CVE" and "there's a problem." That bottleneck is gone. It costs less than your coffee and takes less time than your standup.

The take: "Patch faster" was always weak advice and now it's actively delusional. You cannot out-run a queue growing 20-50% a year with a remediation cycle measured in weeks, against an exploit window measured in minutes and increasingly measured in negative days. The only posture that survives contact with these numbers is assuming disclosed means exploited, on day zero, by default, and building exposure reduction that doesn't wait on a patch cycle to start doing its job. Vulnerability management as a discipline built around "assess, prioritise, patch" is a good process for a world that stopped existing around 2023.

Keep patching. Just stop treating the CVE feed as a to-do list. Treat it as a live threat feed running hours behind, not weeks, and budget your team accordingly.

30/08/2026

The AI pentest that survives day three: a Temporal backbone for agentic offensive security

The AI pentest that survives day three: a Temporal backbone for agentic offensive security

The AI pentest that survives day three

Everyone is shipping autonomous pentesters. Almost nobody is shipping one that survives a real engagement. This is the backbone that does: Temporal underneath, Kali and Burp doing the work, a human holding the trigger.

August 2026  ·  offensive AI  ·  durable execution  ·  ~18 min read

I have an agentic pentest harness. It is a Kali box exposing 26 tools over SSH through an MCP server, an LLM planning the next move, and enough glue to let it run recon, enumeration and a first pass at exploitation without me touching the keyboard. On a lab target it is genuinely impressive. It chains subfinder into httpx into nmap, reads the output, picks a plausible next step, and keeps going.

Then I pointed it at a real engagement. It died on day three.

Not because the model was stupid. Because the LLM API rate-limited me halfway through a scan and the loop threw an exception and unwound. Because a forty minute nmap run against a large range hung and I had no way to tell whether it was alive or wedged. Because the Kali VPS rebooted overnight and took every scrap of in-memory state with it. Because at one point the agent stopped running commands and started imagining their output, confidently reporting findings for hosts it had never touched. That last one has a name now. The EnIGMA researchers called it soliloquising, and once you have watched an agent do it you stop trusting anything it says without a receipt.

The intelligence was fine. The execution layer under it was cardboard. My harness was a clever brain bolted onto a fragile nervous system, and a real engagement is exactly the environment that finds every fragile thing you shipped.

This post is about the nervous system. It is about wrapping the whole workflow in durable execution with Temporal, so the engagement retries when the model flakes, resumes when the box dies, waits days for a human to approve an exploit, and writes an audit trail you could hand to a client or a regulator without flinching. Kali and Burp still do the actual work. Temporal is what makes the work trustworthy enough to put your name on.

Code is Python throughout, because that is what the harness is already written in. Let me start with why the obvious build is a trap.

The build everyone reaches for first

Strip away the framework of the week and every agentic pentester is the same loop. Ask the model what to do next. Run it. Feed the result back. Repeat until it thinks it is finished.

# the harness, roughly. runs great in a demo.
context = []
while not done:
    step   = llm.plan(context)          # what should we do next?
    result = ssh_kali(step.command)      # run it on the Kali box
    context.append((step, result))       # remember it... in RAM
    done   = llm.is_finished(context)

Five lines. Every failure from the intro is already in here.

context is a list in memory, so when the VPS reboots the engagement is gone. ssh_kali raises on a dropped connection and the whole loop unwinds. llm.plan returns a 429 under load and the loop unwinds again. There is no point in those five lines where a human approves anything before it fires, and there is no record of what happened beyond whatever you thought to print at the time.

So you start bolting things on. A try/except around the SSH call. A retry decorator on the model call. Postgres for the context so it survives a reboot. An approvals table with a poller. A logging pipeline so you have some idea what ran. Six months later you have written a worse, undocumented, untested version of a durable execution engine, and you are maintaining it alone.

Durable execution is the part of the agent nobody demos and everybody eventually rebuilds badly.

Temporal is that engine, done properly. Before the architecture, here is the whole argument in one table.

When this happensAgent as a scriptDurable workflow
The box crashes mid-engagementstate lost, start from zeroresumes at the exact step
LLM returns 429 or 5xxexception unwinds the runactivity retries with backoff
A 40-minute nmap runhung or working? no ideaheartbeat proves liveness
Exploit needs human sign-offan input() prompt, or nothingsignal, waits for days
Client asks what you ranwhatever you loggedfull event history
Target drifts out of scopea hope and a commentnon-retryable guard refuses
200 hosts to covergather() and praychild workflow per host, bounded

Same model, same tools. The right-hand column is what a backbone buys you.

The architecture

Here is the whole thing on one page. Read it top to bottom. A trigger starts a workflow, the workflow orchestrates activities, the activities drive Kali and Burp through a governance layer, and everything that happens lands in an event history that doubles as your evidence and your metrics.

Figure 1  /  end-to-end architecture
Orchestration · Temporal
Trigger
code change · CI · schedule
PentestWorkflow
holds state · zero I/O
🔒 Human gate
approval signal
Activities · all I/O lives here
llm_plan
next move
recon / enum
Kali tools
burp_scan
web DAST
exploit
after the gate
report
findings + KPIs
Governance · every tool call
Tool gateway
identity · scope · risk → allow / block / redact / escalate
Opal
just-in-time access
hardened hooks
last guard before action
Execution
Kali box
MCP harness · 26 tools · SSH · nmap, nuclei, sqlmap, amass
Burp Suite DAST
GraphQL · create_schedule_item
Scoped targets · rules of engagement enforced in code
Durability · the payoff
Event history
every call + return, replayable
Audit trail
chain of custody
SDLC KPIs
coverage · MTTR · FP rate

Figure 1. The workflow never touches the network. Everything with a blast radius is an activity, behind governance, against a scoped target, recorded in history.

The single most important rule is the boring one at the top. The workflow orchestrates and holds state. It never does I/O. Every call to the model, every tool run, every Burp scan, every line written to a report is an activity. That split is not a style preference. It is the thing that makes the workflow replayable, and replay is where all the durability comes from. I will come back to it.

Notice the band in the middle. The agent driving this pentest is still an agent, and it gets governed like every other agent in the estate. Tool calls route through a gateway that checks four things in order: which agent this is, whether it is allowed to reach this tool at all, what it may do inside it, and whether this specific call is safe. Its access is just-in-time, granted for the engagement and pulled afterwards, so a compromised pentest agent can reach whatever it holds in that window and nothing else. Offensive tooling is the last thing in your estate you want holding standing production credentials.

Scope follows the diff

The trigger box in Figure 1 says code change on purpose. The useful version of this system does not wait for a quarterly booking. It starts when a pull request does, and the scope of the test is derived from what actually changed.

A diff that adds one endpoint does not need an estate sweep. It needs that endpoint hammered. A change across the API surface needs the whole web app API scanned. A Terraform change needs the cloud infra looked at, not the login form. Same engine, different scope, decided automatically from the diff.

Figure 2  /  scope follows the diff
PR / commit
changed paths
diff classifier
derive scope + task queue
targeted
one new endpoint → Burp API scan + authz/BOLA probes
api-wide
whole web app API → DAST over the OpenAPI spec + nuclei
infra
cloud / Terraform → recon + misconfig & exposure sweep
full
nightly / weekly → child workflow per host

Figure 2. One dispatcher, four scopes. The blast radius of the test matches the blast radius of the change.

What changedDerived scopeWhat the workflow runs
One new route, e.g. POST /v2/payoutsthat route + its authBurp API scan of the route, BOLA / authz probes, an llm_plan focused on the new surface
Broad API change, OpenAPI spec bumpedwhole web app APIBurp DAST driven from the spec, nuclei templates, parameter fuzzing
Terraform / IaC changecloud infra in the blast radiusrecon, exposed-service sweep, nuclei misconfig checks against the changed footprint
Nightly schedulefull agreed scopeeverything, fanned out one child workflow per host

The dispatcher is thin. A CI step or a webhook inspects the changed paths, builds a scope, and starts the right workflow. The important detail is the workflow ID.

from temporalio.client import Client
from dataclasses import dataclass

@dataclass
class ScopeSpec:
    kind: str               # "targeted" | "api_wide" | "infra" | "full"
    targets: list[str]      # hosts, base URLs, or cloud accounts in scope
    openapi: str | None = None
    engagement_id: str = ""

def classify(changed: list[str]) -> ScopeSpec:
    if any(p.endswith(".tf") or p.startswith("infra/") for p in changed):
        return ScopeSpec("infra", targets=cloud_scope())
    if "openapi.yaml" in changed:
        return ScopeSpec("api_wide", targets=[API_BASE], openapi="openapi.yaml")
    routes = [p for p in changed if p.startswith("api/routes/")]
    if routes:
        return ScopeSpec("targeted", targets=routes_to_urls(routes))
    return ScopeSpec("full", targets=full_scope())

async def dispatch(pr: str, sha: str, changed: list[str]) -> None:
    client = await Client.connect("temporal:7233", namespace="pentest")
    scope = classify(changed)
    scope.engagement_id = f"{pr}-{sha[:12]}"
    await client.start_workflow(
        PentestWorkflow.run,
        scope,
        id=f"pentest-{scope.engagement_id}",   # one run per PR+sha, re-triggers dedupe
        task_queue=f"pentest-{scope.kind}",       # infra work runs off its own worker pool
    )

The workflow ID is keyed to the PR and commit, so pushing the same commit twice does not start two engagements. The task queue is keyed to the scope kind, so a cloud scan never lands on the workers that run web tests.

Because it starts in CI, it gates in CI. The workflow returns a verdict, the PR check goes red on a confirmed high, and every run stamps the same event history you will later mine for coverage-per-change and time-to-first-finding. This sits right beside the SAST and SCA already running on the diff. Same trigger, deeper test.

Temporal in five minutes, for people who break things

Three concepts and one rule.

Workflows and activities

A workflow is your orchestration logic. It decides what happens next. An activity is anything that touches the outside world: a model call, a shell command on Kali, a Burp scan, a write to disk. Activities are allowed to be slow, flaky and failure-prone, and Temporal wraps every one of them in configurable timeouts and retries.

The rule from Figure 1, restated because it is the whole game: orchestration goes in the workflow, I/O goes in activities. Break that and everything below stops working.

Determinism and replay

Temporal does not snapshot your process memory. It records an event history: every activity the workflow scheduled and every result that came back. When a worker dies, a fresh one replays that history through your workflow code, rebuilds the exact in-memory state, and carries on from the last completed step. Nothing re-runs that already finished.

That is why the workflow has to be deterministic. A raw datetime.now(), a bare random(), a direct HTTP call, any of them make the replay diverge from the recorded history and Temporal will refuse it. You get deterministic replacements for the safe things, workflow.now() and workflow.random(), and activities for everything else. It feels like a constraint for about a day, then it feels like the reason the thing never loses state.

The one thing to remember

The event history that makes the workflow crash-proof is the same artefact that makes it auditable. You are not building durability and evidence separately. They are the same log.

The engagement as a workflow

Model the engagement as a state machine. Each phase is durable, so the workflow always knows where it is, even after the third worker restart of the week.

Figure 3  /  engagement state machine
Scoping
RoE, targets
Recon
activity, retried
Enumerate
heartbeat
Analysis
llm + dedupe
🔒 Gate
signal / 24h timer
Exploit
approved only
Post-ex
proof
Report
findings + KPIs

Figure 3. Every box is a persisted state. The amber one does not advance until a human sends a signal, and it will wait for days without holding anything open.

In code the skeleton is small. State lives in plain instance attributes, which is exactly what Temporal rebuilds on replay. A query exposes progress to a dashboard without interrupting the run.

from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy

with workflow.unsafe.imports_passed_through():
    from .activities import llm_plan, run_tool, burp_scan, exploit, write_report
    from .types import ScopeSpec, Finding, Approval

@workflow.defn
class PentestWorkflow:
    def __init__(self) -> None:
        self.phase: str = "scoping"
        self.findings: list[Finding] = []
        self.approval: Approval | None = None

    @workflow.query
    def status(self) -> dict:
        return {"phase": self.phase, "findings": len(self.findings)}

    @workflow.run
    async def run(self, scope: ScopeSpec) -> dict:
        self.phase = "recon"
        recon = await workflow.execute_activity(
            run_tool, ("recon", scope),
            start_to_close_timeout=timedelta(minutes=45),
            heartbeat_timeout=timedelta(seconds=60),
            retry_policy=RetryPolicy(maximum_attempts=4),
        )
        self.phase = "analysis"
        self.findings = await self._analyse(scope, recon)     # llm_plan + burp_scan, below

        approved = await self._await_gate()                     # the human gate, below
        if approved:
            self.phase = "exploit"
            await self._exploit_confirmed(scope)

        self.phase = "report"
        return await workflow.execute_activity(
            write_report, (scope, self.findings),
            start_to_close_timeout=timedelta(minutes=5),
        )

The run method reads like a plan of the engagement because that is what it is. The interesting parts, the analysis, the gate and the exploit, are next.

Driving Kali and Burp as activities

This is where the real work lives, and it is deliberately dull. An activity is a normal async function with a decorator. It can be as slow and unreliable as the tool it wraps, because the workflow above it does not care how many times it had to retry, only that it eventually returned.

Kali, over the harness

The Kali side goes through the MCP harness: 26 tools exposed over SSH. A single recon activity picks the right tool, checks the target is in scope, and streams the output back while heartbeating so Temporal can tell a live scan from a wedged one.

from temporalio import activity
from .mcp import kali          # MCP client to the Kali harness (SSH)

@activity.defn
async def run_tool(kind: str, scope: ScopeSpec) -> ToolResult:
    tool = pick_tool(kind, scope)              # nmap, nuclei, amass, ...
    guard_scope(tool.target, scope)            # refuse out-of-scope (see below)
    async with kali.stream(tool) as proc:      # a 40-minute nmap is fine here
        async for line in proc.stdout:
            activity.heartbeat(proc.progress)  # "I am alive, here is where I am"
            if activity.is_cancelled():         # timed out or workflow cancelled
                await proc.terminate()
                raise
    return ToolResult(tool=tool.name, raw=proc.output, target=tool.target)

The heartbeat is the whole point. If Kali stops sending progress for longer than the heartbeat_timeout set at the call site, Temporal declares the activity dead and reschedules it, without waiting out the full 45-minute start-to-close window.

Burp, over the DAST GraphQL API

Burp Suite DAST exposes a GraphQL API. You launch a scan with the create_schedule_item mutation and poll the scan query for status and issues. Two operations:

# the two Burp operations
CREATE_SCAN = """
mutation($site_ids: [ID!]!) {
  create_schedule_item(input: {site_ids: $site_ids}) {
    schedule_item { id scheduled_run_time }
  }
}"""

SCAN_STATUS = """
query($id: ID!) {
  scan(id: $id) {
    status
    issue_counts { total }
    issues(start: 0, count: 1000) { serial_number type_index severity }
  }
}"""

Wrapped in an activity, launch then poll, heartbeating on every tick:

import asyncio, httpx
from temporalio import activity

@activity.defn
async def burp_scan(base_url: str, openapi: str | None) -> list[Finding]:
    async with httpx.AsyncClient(headers=burp_auth()) as http:
        site = await ensure_site(http, base_url, openapi)   # idempotent: reuse if exists
        started = await gql(http, CREATE_SCAN, {"site_ids": [site]})
        scan_id = started["create_schedule_item"]["schedule_item"]["id"]
        while True:
            s = await gql(http, SCAN_STATUS, {"id": scan_id})
            status = s["scan"]["status"]
            activity.heartbeat(status)
            if status in ("succeeded", "failed"):
                break
            await asyncio.sleep(15)
    return [to_finding(i) for i in s["scan"]["issues"]]

One detail that will bite you: activities retry, so launching a scan must be idempotent. ensure_site reuses a site if it already exists, and you tag the scan with the engagement ID so a retry after a network blip rejoins the existing scan instead of starting a second one. More on idempotency where it actually hurts, further down.

The planner is an activity, and that is the whole trick

The model is the least reliable component in the system. It rate-limits, it times out, it occasionally refuses on content policy, and once in a while it hands back confident nonsense. So it goes in an activity, sealed off from the workflow, wrapped in a retry policy, and backstopped by a fallback model. The workflow never sees the flakiness. It asks for a plan and eventually gets one, or a clean, loud failure.

Figure 4  /  the planner never takes the engagement down
llm_plan
activity · non-determinism sealed inside
✓ ok
plan validated against scope, returned to workflow
↻ 429 / 5xx / timeout
retry with backoff, then fall back to another model
✗ auth / refusal / malformed
non-retryable, fail fast, surface it

Figure 4. Transient failures are Temporal's job. Hard failures fail loudly rather than silently corrupting the run. Nothing here reaches the workflow as a half-state.

The retry policy is set where the activity is called, not on the activity itself. Transient errors retry with exponential backoff. A short list of error types is marked non-retryable, because retrying an auth failure or a malformed plan six times just wastes ninety seconds before failing anyway.

from temporalio.exceptions import ActivityError

@activity.defn
async def llm_plan(ctx: PlanContext, model: str) -> Plan:
    resp = await llm(model).plan(ctx)     # RateLimited / AuthError / PolicyRefusal
    return validate_plan(resp)            # malformed → MalformedPlan (non-retryable)


# ...called from the workflow, with the policy and a one-shot fallback:
async def _next_step(self, ctx: PlanContext) -> Plan:
    policy = RetryPolicy(
        initial_interval=timedelta(seconds=2),
        backoff_coefficient=2.0,
        maximum_attempts=6,
        non_retryable_error_types=["AuthError", "PolicyRefusal", "MalformedPlan"],
    )
    try:
        return await workflow.execute_activity(
            llm_plan, (ctx, PRIMARY_MODEL),
            start_to_close_timeout=timedelta(seconds=90), retry_policy=policy)
    except ActivityError:                  # primary exhausted or hard-failed
        return await workflow.execute_activity(
            llm_plan, (ctx, FALLBACK_MODEL),
            start_to_close_timeout=timedelta(seconds=90), retry_policy=policy)

Six attempts with backoff rides out a rate-limit storm. A hard model outage falls through to a second provider once. Only then does the phase fail, and it fails with a full history of every attempt.

There is a subtler win here, and it is the one that matters for trust. The model is non-deterministic, but that non-determinism is trapped inside the activity. All the workflow records in its history is llm_plan returned this plan. Replay the engagement a year later and it rebuilds identically, even though the model that produced the plan would answer differently today. The audit trail is stable because the chaos is quarantined.

The model proposes. The workflow disposes. A plan is still checked against the rules of engagement before a single packet leaves the box.

The human gate

Recon and scanning can run unattended all day. Exploitation cannot. Before anything with a blast radius fires against a client's production, a human approves it. In most agent frameworks that is a fragile bit of glue: a blocking prompt, or a database row and a poller someone wrote on a Friday. In Temporal it is a first-class primitive. The workflow blocks on a condition, a signal releases it, and a durable timer covers the case where nobody answers.

Figure 5  /  approval before anything fires
Analysis
proposed exploit + evidence
🔒 GATE
wait_condition(approved) · 24h durable timer
signal: approve
→ run the exploit
signal: reject
→ skip straight to report
timer fires, no answer
→ escalate, do not exploit

Figure 5. The workflow can sit at this gate over a weekend. It holds no thread, no connection, no process. The timer and the wait survive every restart in between.

import asyncio

@workflow.signal
def approve(self, decision: Approval) -> None:
    self.approval = decision        # who, when, approved bool, note

async def _await_gate(self) -> bool:
    self.phase = "awaiting_approval"    # now visible to the dashboard via status()
    try:
        await workflow.wait_condition(
            lambda: self.approval is not None,
            timeout=timedelta(hours=24),
        )
    except asyncio.TimeoutError:
        await workflow.execute_activity(
            escalate, self.findings,
            start_to_close_timeout=timedelta(minutes=1))
        return False               # nobody answered in 24h: refuse to exploit
    return self.approval.approved

The signal can arrive from Slack, a web UI, a phone. Wherever it comes from, the approver, the timestamp and the note land in the event history. Who authorised the exploit becomes part of the evidence, not a memory.

This is also the answer to a governance question that keeps security leaders up at night. There is no override on this gate, because an override that exists gets used under deadline pressure at exactly the wrong moment. The gate is part of the machine, not a step in a runbook that a tired human can wave through. The automation physically cannot proceed to exploitation without a recorded human decision.

This is where the pentester lives

The gate is not scaffolding you remove once the model gets good enough. It is the point of contact between machine breadth and human judgment. Deciding whether an exploit is safe to fire against production, whether the finding is real, whether the chain is worth pursuing, that is the job. The workflow just makes the decision unskippable and permanent.

Rules of engagement, enforced in code

An out-of-scope target is not a retryable error. It is a full stop. The model can hallucinate a target all it likes. It hits a wall written in deterministic Python, not a polite request in a system prompt.

from temporalio.exceptions import ApplicationError

def guard_scope(target: str, scope: ScopeSpec) -> None:
    if not in_scope(target, scope):
        raise ApplicationError(
            f"target {target} outside {scope.kind} scope",
            type="OutOfScopeError",
            non_retryable=True,          # do not retry a scope violation
        )

Called at the top of every tool and exploit activity. A refusal is recorded in history with the target and the reason, which is itself proof you stayed inside the lines.

That guard is the innermost ring. Outside it, the gateway from Figure 1 blocks any tool call the agent is not authorised to make, and the just-in-time credentials never reach out-of-scope systems in the first place. If the workflow logic were somehow wrong, two more layers still hold, and each one records what it refused. Scope is not a promise in a statement of work. It is enforced three times and logged every time.

The event history is your evidence

Here is the part that turns an AI-assisted test into something you can defend. The event history Temporal keeps so it can survive a crash is, without any extra work, the most complete record of an engagement you have ever had. It looks like this.

# excerpt, PentestWorkflow event history
09:14:02  WorkflowStarted        scope=api_wide  pr=418  sha=4b1c9e0
09:14:03  ActivityScheduled      run_tool(recon)
09:41:20  ActivityCompleted      run_tool(recon)   214 hosts, 9 services
09:41:21  ActivityScheduled      burp_scan(base=api.acme.test)
10:58:07  ActivityCompleted      burp_scan          17 issues
10:58:08  ActivityScheduled      llm_plan(PRIMARY_MODEL)
10:58:10  ActivityFailed         llm_plan  RateLimited  (attempt 1/6)
10:58:14  ActivityCompleted      llm_plan           plan: probe BOLA on /v2/payouts
10:58:15  TimerStarted           gate-timeout 24h
11:03:44  SignalReceived         approve  by=alex  note="prod ok, low blast radius"
11:03:45  ActivityScheduled      exploit(finding=BOLA-/v2/payouts)
11:07:12  ActivityCompleted      exploit            confirmed, evidence stored

Every decision, every retry, every approval, timestamped and immutable. Note the rate-limit at 10:58:10 that retried and succeeded four seconds later. The engagement never noticed.

Figure 6  /  one log, two products
Event history
every scheduled + completed event
Evidence / chain of custody
what ran, when, against what, who approved · replay it a year later
SDLC KPIs
coverage · time-to-first-finding · MTTR · false-positive rate · % needing a human · analyst-hours saved

Figure 6. Because the metrics are computed from the log rather than self-reported, time-to-first-finding is literally the first finding event minus the start event. Nobody is massaging a spreadsheet.

Your numbers go here

The KPIs are emitted per run, but the baselines are yours. Drop in your real coverage percentage, your median time-to-first-finding, your false-positive rate before and after the gate, and the analyst-hours the automation gave back last quarter. Those are the numbers that turn this from an architecture into a business case.

Scaling without nuking the client

The nightly full-scope run and the api-wide scan both fan out across many targets. The wrong way is asyncio.gather over every host at once, which is a great way to knock over the thing you were hired to test. The right way is a child workflow per host, launched in bounded batches.

@workflow.defn
class FullScopeWorkflow:
    @workflow.run
    async def run(self, scope: ScopeSpec) -> list[dict]:
        results = []
        for batch in chunked(scope.targets, size=10):     # never more than 10 hosts at once
            results += await asyncio.gather(*[
                workflow.execute_child_workflow(
                    PentestWorkflow.run,
                    host_scope(scope, host),
                    id=f"pentest-{scope.engagement_id}-{host}",
                    task_queue="pentest-host",
                )
                for host in batch
            ])
        return results

Each host is its own child workflow, with its own history, its own retries and its own gate. One host falling over does not take the campaign with it, and the parent history stays small.

The real throttle is the task queue. Workers pull work from pentest-host, so sizing that worker pool caps how many host scans run concurrently across every engagement at once, not just within one. And for a long-lived campaign that watches a scope continuously, the history would grow without bound, so you call continue_as_new to start a fresh history while carrying the state forward.

if workflow.info().get_current_history_length() > 10_000:
    workflow.continue_as_new(carry_forward(self.state))   # fresh history, same campaign

The honest costs

This is not free, and pretending it is would be the kind of vendor pitch this blog exists to mock.

The determinism tax is real. You cannot reach for datetime.now() or a stray HTTP call inside the workflow. Everything with a side effect becomes an activity. It is a genuine discipline and it will trip you up in the first week, until it becomes the reason nothing ever loses state.

Idempotency is the sharp edge. Temporal executes activities at least once, which means an activity can run twice, for instance after a heartbeat timeout on a scan that was merely slow rather than dead. A recon scan running twice is wasteful. A destructive exploit running twice is an incident. So exploit activities carry an idempotency key per finding, and the genuinely dangerous ones sit behind the human gate, where a double-fire would need a double-approval that is never going to happen by accident. Design this in from the start. It is the one place the model of at-least-once bites hard.

You now run infrastructure. A Temporal cluster or Temporal Cloud, workers, task queues, a namespace. For a solo consultant chasing one engagement that is heavier than a Python script and probably not worth it. For a team running continuous, change-triggered testing across an estate, it pays for itself the first time an engagement survives a 3am worker reboot that would otherwise have cost a day.

The harness is still yours. Temporal makes the MCP tools, the Burp integration and the plan validation reliable. It does not make them exist. That work is still on you, and the plan-validation layer in particular needs care, because it is the thing standing between a hallucinated plan and a real packet.

Why the pentester is still the point

Step back from the plumbing and look at what the machine is actually good at, because the honest version of this argument needs both halves.

The autonomous tools are strong at the front of the pipeline. Given a one-day CVE with the advisory in hand, a current model exploits it around 87% of the time. Take the description away and that falls to 7%, which tells you precisely what kind of strong it is: fast at applying what is already written down. Recon chaining is reliable. SSRF and injection are the highest-performing classes across every benchmark. None of it gets bored on host two hundred.

Now the other half. Point the same class of agent at real vulnerabilities instead of sanitised lab ones and success drops to about 13%, and to nearly zero on hard targets. Roughly 70% of critical web vulnerabilities are business logic flaws, and no autonomous agent detects those reliably, because a business logic flaw only exists in the context of how your business is meant to work. Chained exploitation across five or more conditional steps is still out of reach. The first head-to-head test on a live enterprise network, 8,000 hosts, had the AI find 9 valid issues while the best human found 13, at around 18 dollars an hour of value against 60 for the professional.

The headline case proves the rule. XBOW topped HackerOne's US leaderboard with over a thousand submissions in a few months. Read past the headline: 132 were confirmed and resolved, hundreds were duplicates or informative, and every finding was reviewed by human staff before submission. The autonomous tool that beat the humans still ran on humans.

So the shape of the win is not subtle. The AI does the volume. The human owns the call. Durable execution is what lets you run the two together across a real engagement, ride out the model's bad days, hold the exploit until a person says go, and hand over a log you can replay line by line. The gate in Figure 5 is not the automation's weakness. It is the automation being honest about where judgment belongs.

Build the backbone first

One thing to take away: a model without durable execution is a demo, and a demo is not an engagement. Build the nervous system before you fall in love with the brain. Wrap the workflow in Temporal, put every model call and every tool call in an activity, gate the dangerous phase behind a human signal, and let the event history be both your crash recovery and your evidence.

Do that and the AI stops being a party trick and becomes a force multiplier you can bill for. The pentester goes back to doing the part that was always the actual job. And the client gets a report backed by a trail you can replay a year later, which is the only kind of trust that survives contact with a real audit.

My harness started as the brain. Temporal is the nervous system that made it worth pointing at anything that matters.

Sources & further reading

Temporal · Durable Execution meets AI
XBOW tops HackerOne · TechRepublic
AI pentesting agents, 2026 field data · AppSec Santa research
Burp Suite DAST GraphQL API · PortSwigger docs
Temporal Python SDK · official docs

23/08/2026

Forty Minutes, Five Months

Forty minutes. That is how long two malicious LiteLLM releases sat on PyPI back in March. 24 March, 10:39 UTC, versions 1.82.7 and 1.82.8 go live. Around 11:19 they are gone, and the project told anyone who installed before 16:00 UTC that day to assume the worst.

As I write this, five months later almost to the day, credentials stolen in those forty minutes still work.

The malware was competent but ordinary. The gap is the story.

The chain

Start at the entry point, because it should make you uncomfortable. Nobody phished a LiteLLM maintainer. The attacker came in through Trivy, a vulnerability scanner, compromised earlier in March in a separate supply chain attack. A PyPI API token got exposed through the compromised Trivy dependency, and a publishing token is a skeleton key. You do not open a pull request. You do not pass review. You do not touch the project's CI at all. You push straight to the index and the index says thank you.

So the release process everyone trusts, the pipeline with the gates and the checks, was bypassed by design. The attacker held the one credential that sits above all of it.

Then there is the payload delivery, which deserves more attention than it got. Version 1.82.8 shipped a file called litellm_init.pth. Python has an ancient feature where .pth files in site-packages execute at interpreter startup. Not at import. Startup. Once that file is on disk, every Python process on the machine runs the payload. Your linter runs it. Your database migration runs it. You never import litellm once and it makes no difference.

On a CI runner that means the harvest happens at job start, when the environment is at its richest. Environment variables, SSH keys, cloud credentials, Kubernetes tokens, database passwords. And because this is 2026, the model keys too, OPENAI_API_KEY and ANTHROPIC_API_KEY sitting right there in env.

TIMELINE
24 Mar 10:39 UTC  . . .  1.82.7 / 1.82.8 live on PyPI
24 Mar ~11:19 UTC  . . .  pulled. exposure window: ~40 min
13 Aug  . . . . . . . .  Hudson Rock publishes the fallout
today  . . . . . . . . .  tested credentials still valid

What forty minutes buys

Hudson Rock put numbers on it in August. A 153GB archive. 433,909 files. 118,829 CI runner dumps, tied to 2,488 corporate domains. CloudSEK maps the exposure to more than 2,500 organisations, and the names in the pile are not small: NVIDIA, Cisco, Deloitte, Volkswagen, FedEx, Siemens, X Corp.

Read those numbers against the window. Nobody triaged anything in forty minutes. No human saw an alert and made a decision inside that window. Automated builds pulled the poisoned versions, executed the payload at interpreter startup and shipped their own secrets out, at machine speed, on the attacker's behalf. The attack was over before the defenders' day started.

The five months

Here is the part that should actually scare you. PyPI pulled the packages, LiteLLM published an advisory, and for most affected teams that was the end of the incident. Quarantine got treated as closure. The artifact is gone, therefore the problem is gone.

Except the incident does not end when the package dies. It ends when the credentials the package saw are dead. Those are different events, and the distance between them turned a forty minute compromise into a five month one.

One researcher checked an organisation that claimed it had rotated everything. Almost every credential he tested still worked.

That tracks with everything we know about rotation. Finding secrets is a solved problem. Rotating them is not, because rotation means knowing what breaks when the value changes, and that knowledge is usually in one engineer's head, and that engineer may have left. So the dashboard says remediated while the attacker's copy keeps working.

Rotate on exposure. Not on confirmation. If the artifact ran where your secrets live, the secrets are gone. Behave accordingly.

Your scanner is a dependency

Sit with the entry point again. The ingress for one of the worst CI/CD compromises of the year was a security tool. Trivy sits inside build pipelines on purpose, with publishing rights and tokens in reach, and we extend it a trust we would never extend to a random utility library. There is no technical basis for that trust. A scanner is code you pull from the internet, running with more privilege than the code it inspects.

Threat model your security tooling like any other dependency, because the attackers already do.

What actually stops this

In our pipeline the control that turns this whole incident into a non event is boring: a minimum package age. Installs route through a proxy that refuses anything published less than 48 hours ago. The malicious versions lived for forty minutes. Under a 48 hour hold, nothing in the building could have pulled them, not a dev shell, not a runner, and nobody had to be fast or even awake. The window closed because the window was never open.

The hold costs something. Occasionally a fix you genuinely need shipped that morning and someone has an argument with the proxy. That argument is the price, and against 118,829 runner dumps it is nothing.

Beyond that, the homework this incident sets is short. Grep your build logs for litellm 1.82.7 and 1.82.8 around 24 March, and treat any hit as full compromise of everything that environment held. Go and look at what your CI runners actually expose in env at job start, because that inventory is precisely what got dumped here. Audit site-packages for .pth files you cannot explain. Then look hard at how many of your credentials are long lived, because a stolen token that expires in an hour is a very different artifact from one that still works in August.

Forty minutes of attacker effort. Five months of defender debt. The ratio is the lesson, and it only moves in one direction: mechanically, before the window opens, because nothing human moves inside it.

Sources:

The Hacker News: malicious LiteLLM releases tied to Trivy compromise (CloudSEK)

Help Net Security: Hudson Rock on the stolen credential leak

The package was never the target

The package was never the target elusive thoughts // supply chain // september 2026 Ask most teams what a successful supply chain att...