A security company wanted to buy inference capacity. Before handing over their data, they did what almost nobody does at the diligence stage: they pointed their own tooling at the vendor’s public attack surface and let it run. Twenty-five minutes later they were holding a live GitHub personal access token with administrator rights on the vendor’s main product repository, the GitOps repository that drives its clusters, and its Homebrew tap.
The vendor was Baseten. The finding was published by Strix on 1 September 2026, and to Baseten’s credit the token was rotated and the exposed registry project locked down by the following afternoon. The part worth your attention is not the response time. It is where the credential was sitting, because it was not in any file, in any layer, in any repository that a normal scanner would have opened.
TL;DR
- A GitHub token baked into a container image on 3 March 2023 was still valid when it was found in July 2026, with admin and push access to production repositories.
- It was not in a filesystem layer. It was in the image config blob, under
history[].created_by, where Docker records the build command that created each step. - We verified the retrieval path ourselves: the config blob for a public image is 5,664 bytes and downloads with an anonymous pull token, before a single layer is fetched.
- Trivy’s
--scannersflag defaults tovuln,secretand scans files.--image-config-scannershas no default at all, so the config is skipped unless you ask for it. - A classic PAT carrying the
reposcope cannot be limited to one repository. The blast radius was decided in 2023 and nothing since then narrowed it.
The chain, briefly
Recon turned up a Harbor registry on a subdomain. One project inside it was marked public, which in Harbor means anyone can list its repositories and mint an anonymous pull token. That gave access to an image called baseten/baseten-app.
An exposed registry on its own is a finding with no teeth, because plenty of companies publish images deliberately. So the agent pulled the image and went looking for impact. The first hit was a pair of AWS keys, which turned out to be dead: a read-only sts:GetCallerIdentity call returned InvalidClientTokenId. The second hit was the live one. A GitHub token in the build history, a read-only GET /user confirming it belonged to basetenbot, and X-OAuth-Scopes: repo in the response headers.
Seven repositories were reachable. Three with admin: true and push: true, four more private with read and write, including repositories dedicated to individual customers.
Where the credential actually lived
The Dockerfile pattern behind this is one of the most common in private-dependency builds. A token comes in as a build argument, gets written into Git’s global config so that [email protected]: URLs resolve over HTTPS with credentials attached, and the build works.
ARG GITHUB_TOKEN
RUN GITHUB_TOKEN=${GITHUB_TOKEN} bash -c '\
if [[ "${GITHUB_TOKEN}" != "" ]]; then \
git config --global --add \
url."https://${GITHUB_TOKEN}@github.com/".insteadOf "[email protected]:"; \
fi'
There are two separate leaks in those six lines, and they need two separate fixes. git config --global writes the authenticated URL into a file inside the image, which layer scanning would catch. The other one is quieter: Docker records the expanded command in the image’s build history, so the token value ends up in the image metadata regardless of what the filesystem looks like afterwards. Deleting the config file cleans the layer and leaves the copy in the history untouched.
We checked how cheap that metadata is to read
It is worth being precise about the retrieval path, because it changes how you think about the exposure. Using nothing but an anonymous token from Docker Hub scoped to repository:library/python:pull, we requested the manifest for python:3.12-slim, read the config digest out of it, and fetched that blob.
curl -s -H "Authorization: Bearer $TOKEN" \
https://registry-1.docker.io/v2/library/python/blobs/sha256:ec7d6c95...
The response was 5,664 bytes of JSON containing ten history entries, each with the full command that produced it, plus the image’s complete Env array. No layer was downloaded. Nothing was extracted. On a public project, anyone who can reach the registry can read every build command you have ever shipped for the cost of a single small request, and can do it faster than your logs will make it look interesting.
Why your scanner did not flag it
Here is the detail that should go into your pipeline this week. In Trivy, the --scanners flag defaults to vuln,secret, and secret scanning walks plaintext files in the image. There is a second, separate flag, --image-config-scanners, which accepts misconfig and secret and controls whether the image configuration is examined. It has no default value. If you have never typed it, you have never scanned the place this token was found.
There is a related default worth knowing about. Trivy detects the base image and skips those layers when scanning for secrets, because base images are large and slow. Sensible for performance, and another way a credential introduced early in a long inheritance chain stays quiet.
Neither of these is a bug. Both are documented. The failure is that “we scan our images” gets written on a compliance questionnaire as though it describes a single, complete activity, when it describes a configuration with two flags and a lot of surface outside them.
The blast radius was set in 2023
GitHub’s classic personal access tokens have no concept of repository selection. The repo scope grants full read and write access to every public and private repository the account can reach, and, per GitHub’s own documentation, also grants management access to organisation-owned resources including projects, invitations, team memberships and webhooks. One checkbox in 2023 defined everything the finding was worth in 2026.
Fine-grained tokens fix the shape of this. You choose a resource owner, you choose specific repositories, and the default lifetime is 30 days with a documented maximum of 366. Non-expiring tokens are still possible, which is why the control that matters is the organisation-level maximum lifetime policy rather than the good intentions of whoever generates the next one.
One more inference, and it is the uncomfortable one. GitHub automatically removes personal access tokens that have not been used in a year. This token survived from March 2023 to July 2026. Either the dormancy sweep does not work as described, or the token was still in active use throughout, which means it was not a forgotten artefact at all. It was infrastructure.
The checkbox that made it public
Harbor has exactly two kinds of project: public, where any user can pull, and private, where only members can. It is one checkbox at creation, it can be toggled either way at any time afterwards, and it applies to every repository in the project rather than to individual images.
That is not a criticism of Harbor, which documents all of this plainly. It is an observation about where a permission boundary of that weight is recorded: in a UI toggle, not in the code review, not in the infrastructure repository, not in anything that appears in a diff when it changes.
Do this to your own registry
- List every registry project you run and record its public or private status as data, in the same place you keep the rest of your infrastructure state, so a change to it shows up in a review.
- Pull the config blob for your ten most-deployed images and grep the
historyentries for tokens, keys and internal hostnames. It is minutes of work and needs no access you do not already have. - Add
--image-config-scanners secret,misconfigto your scanning step. The flag you are missing is the one with no default. - Replace build-argument credentials with BuildKit secret mounts, which do not persist into the image or its metadata.
- Rotate anything a build argument ever touched. Changing the Dockerfile does nothing about images already published, and you cannot recall a blob someone has already pulled.
- Move remaining classic PATs to fine-grained tokens with explicit repository selection, and enforce a maximum lifetime policy at the organisation level.
And do it to your vendors
The genuinely novel thing in this story is not the Dockerfile mistake, which is nearly a decade old. It is that a customer ran a real test against a supplier’s public surface before signing, found something critical, and disclosed it privately. Most diligence at that stage is a questionnaire answered by the party being assessed. A black-box scan of a public attack surface is cheap, requires no cooperation, and produces evidence rather than assertions.
If your build pipelines are publishing credentials into artefact metadata, or your vendor assessments stop at a completed spreadsheet, that is fixable work with a defined end point. REPTILEHAUS builds and audits CI/CD and container infrastructure for teams who would rather find this themselves than read about it. Get in touch and we will start with your registry.
📷 Photo by Erwan Hesry on Unsplash


