We maintain a public client library for our API, and in May somebody we had never heard of opened a pull request fixing a typo in the README. Nobody had reviewed it yet. By the time anyone did, the workflow triggered by it had already run a script from their branch with our package registry token in its environment.
The library's pipeline had two workflows. The ordinary one ran tests on pull_request, which for a pull request from a fork runs with a read only token and no secrets, exactly as it should. The other one labelled pull requests and posted a coverage comment, and to be allowed to write a comment it had been switched to pull_request_target. That trigger runs in the context of the base repository, with its secrets and a write token. It is safe only as long as the job never executes anything the contributor wrote.
Ours checked out the head of the pull request so it could measure coverage. Then it ran npm install, which runs the lifecycle scripts in the package file, and the package file was theirs. Their branch added a postinstall line that sent the environment to an external host. The registry token was set at workflow level, because a release job in the same file needed it and whoever wrote it put the variable at the top once rather than in three places.
We rotated the token within the hour of finding it, and the registry's audit log showed no publish from it. I would still rather not have needed the audit log.
Four changes. Nothing that runs with base repository privileges checks out contributor code any more: coverage is computed in the unprivileged workflow, uploaded as an artifact, and a second workflow triggered by workflow_run downloads that file and posts the comment without ever executing it. Secrets are scoped to the single job and the single step that uses them. Publishing uses short lived credentials issued to our release workflow on a protected tag, so there is no long lived token left to steal. And first time contributors need an approval before any workflow runs at all.
A pipeline is a program that executes whatever the trigger hands it. The question for every workflow is whose code runs, and with whose keys, and in ours the answer had quietly become somebody else's code with ours.
– Sergey Shinder
Top comments (0)