The pull request looked like a routine dependency bump. Buried in the diff was a small edit to the CI configuration that would have added one extra step to every build. The maintainer who told this story on Medium ran his jobs on a self-hosted runner, an inexpensive VPS he kept because hosted minutes add up, and that box accepted jobs from forks while holding cloud credentials in a config file. He read the diff properly, closed the PR, and nothing happened. My argument today is that the rest of us should behave as if that same PR is already sitting in our review queue.
If you maintain a public PHP package, think about what your pipeline does when a stranger opens a PR. composer install can execute plugins and scripts before your first test even starts. Your matrix probably spans several PHP versions on a runner that caches dependencies between jobs. Triggering CI from a fork means running code you have never read on hardware you are responsible for. That is a fine service to offer contributors. It turns dangerous the moment the machine can reach anything you care about, and a credentials file left there by past you definitely qualifies.
The reflex fix is to wrap the runner in a container, and I won't pretend that does nothing. But a container borrows the host's kernel, most CI setups mount the Docker socket into it so builds can build images, and the cache directory that makes your pipeline fast is writable by whoever ran the previous job. One malicious build can leave a present for the next one, and restarting the container in between won't clear it. Isolation that shares state across jobs is isolation with an asterisk.
The author's answer was one microVM per job: every build gets a fresh Firecracker VM, the same isolation layer AWS built Lambda on, booting in under a second, destroyed as soon as the build finishes, living on a private network with no ports exposed. He runs this through a service called Krova Cloud, and yes, the post reads partly like an ad for it. Strip the branding and the property still holds up: when a machine's entire lifetime is a single job, persistence stops being something an attacker can achieve. A tampered cache dies with the box, and so does anything a build wrote to disk hoping a future job would stumble over it.
Before you go shopping for microVM hosting, though, the boring counter-argument deserves its due. GitHub's hosted runners are already ephemeral, and for the median PHP library the free minutes cover the whole test matrix. You can also require manual approval before workflows from outside contributors run at all. If you self-host because integration tests need a beefy machine, fair enough. If you self-host purely to save minutes on a public repo, the safest change is a settings page and takes five minutes. Ephemerality you don't have to build yourself is worth a lot.
Whatever you run jobs on, the piece of this I'd pin to the team wiki concerns credentials, and here the author is refreshingly honest about the limits of his own setup: a pristine VM does nothing for a long-lived cloud key you inject into every build. The exfiltration just happens from nicer hardware. Mint tokens per job through OIDC federation, scope them to the one bucket or registry the job actually touches, and let them expire in minutes. If your test job can still list every resource in the account, the fancy runner architecture is decoration.
What stays with me is how ordinary the attack was. No zero-day, no kernel escape. A hunk in a workflow file and a maintainer reviewing late on a Tuesday, that was the entire plan, and it nearly worked. The countermeasure is equally ordinary: review any change under .github/workflows with the same suspicion you'd give a change to your auth middleware, and treat the machine that runs fork PRs as hostile ground by default. So tell me how you handle this in practice. Do you self-host runners for a public repo, and if so, what concretely stands between a fork job and your environment? I'd love to hear about the setups that survived contact with a stranger's PR, and just as much about the ones that didn't.




Comments
No comments yet — be the first.
Open the discussion
No account or password needed — just enter your e-mail and we’ll send you a one-time sign-in link. First time here? You’re set up automatically.
Your rating will be applied automatically after you sign in.
Check your inbox
We’ve sent a sign-in link to …. Open it on this device — this tab will sign you in automatically.
Nothing arrived? Check your spam folder — and mark the mail as "Not spam" so it lands in your inbox next time.