Pipeline secrets: how not to end up with your keys on GitHub - init.d
IT

# Pipeline secrets: how not to end up with your keys on GitHub

Alessandro Corbelli~7 min
Table of Contents

A pipeline that publishes to production needs credentials: a cloud provider token, an SSH key, a database password. They are the keys to the house, handed to a machine that works on its own every time the code changes.

How those keys are stored is the most delicate part of the whole setup, and the part most often got wrong. Not out of ignorance: it goes wrong in a hurry, on a Friday, when the release has to go out and the short way works immediately. A token pasted into a config file does exactly its job, and nobody notices until it is too late.

Let us look at where they really belong, what to do when one has already leaked, and what is today the best answer: having no keys to store at all.

A secret in the repository cannot be deleted

The first rule is that a credential never goes into a versioned file. The reason is not obvious: the problem is not the file, it is the history.

Git keeps every previous version of every file. If you write a token into a file and delete it tomorrow, today’s file is clean, but yesterday’s commit still contains the credential, readable by anyone with access to the repository, forever. Whoever arrives six months later does not even have to look for it: scrolling the changes is enough.

From this follows something that surprises people every time: deleting the secret solves nothing. If a credential has landed in a repository, that credential must be treated as compromised and replaced, meaning revoked at the service that issued it and generated again. Rewriting history is possible, with tools made for it, but it is an invasive operation that breaks everyone’s clones and still gives you no guarantee about who has already read it.

If the repository is public, there is even less time: programs watch GitHub pushes in real time looking for credentials, and the gap between publishing and abuse is measured in minutes. GitHub itself scans public code for known key formats and notifies the provider that issued them, which often revokes them on the spot. That is a safety net, not a solution.

The right place: the CI system’s store

Every CI/CD pipeline system has somewhere to keep credentials outside the code: secrets on GitHub Actions, CI/CD variables on GitLab. The value is entered once from the interface, and the pipeline only uses the name:

- run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Whoever reads the file sees DEPLOY_TOKEN, not its value. But putting them there is not enough: two settings matter, and almost nobody looks for them.

Masking keeps the value out of job logs. Without it, a command that prints the environment, or a slightly talkative error from a program, is enough to leave the credential in plain text in a log that is usually readable by far more people than can see the settings page.

Restriction to protected branches decides which runs receive the secret. It is the most important protection and the least used: without it, anyone who can create a branch in the project can write a three-line pipeline that prints the token and read it at leisure. If the secret is reserved to the main branch, and the main branch only accepts reviewed changes, that road is closed.

The step up: no key to store at all

The best protection for a key is not having one. For a few years now the common CI systems have been able to authenticate to a provider without permanent credentials, through a mechanism called OIDC (OpenID Connect).

It works like this. At run time the CI system issues a signed document stating who is asking: this repository, this branch, this run. The pipeline presents it to the cloud provider, which verifies it and, if it matches a rule configured in advance, returns short-lived credentials valid for a few minutes.

On GitHub Actions it is two blocks:

permissions:
id-token: write # the pipeline may request the identity document
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy-site
aws-region: eu-south-1

There is no key anywhere. The deploy-site role, on the provider side, is configured to trust only that repository and only that branch: another project making the same request is refused.

The gain is substantial. There is nothing to steal, because the credentials last as long as the run. There is nothing to rotate every six months, and therefore nothing that expires on a Friday afternoon. And trust is tied to a verifiable identity rather than to possession of a string, which is the difference between an ID document and a key found on the pavement. It is worth adopting as soon as your provider supports it, and today the major ones do.

Least privilege, identities included

Key or short-lived identity, one question remains: how much can that credential do? The correct answer is the bare minimum.

A token that publishes a website should be able to publish that website. Not administer the account, not read the other applications, not touch DNS. It is a difference you only feel on the day of the incident, and that day decides whether the damage is “they republished the site” or “they own everything”.

The same reasoning applies inside the pipeline. On GitHub Actions the permissions block limits what a run can do to the repository itself: declaring it explicitly, instead of leaving the defaults, keeps a script running in the pipeline from writing where it should not. The principle is the same one that applies to administrative access to servers or an OAuth authorisation: a permission granted “for convenience” is a permission that sooner or later someone else will use.

The three traps we see most often

Change proposals from strangers. If the project accepts outside contributions, a pipeline triggered by a proposed change runs code written by whoever sent it. GitHub has a trigger, pull_request_target, that runs with the target repository’s permissions: using it together with a checkout of the proposed code means handing your secrets to a stranger. It is one of the most exploited misconfigurations there is.

Dependencies. The pipeline installs libraries, and installing them means running someone else’s code on a machine holding production credentials. A compromised package, as in every npm episode of the past year, reads the environment variables and ships them out. Practical defences: block install scripts, freeze versions, and do not hand secrets to stages that are not publishing. Whoever runs the tests does not need the production token.

Logs. A command run with the variable inline, a curl -v printing headers, an error echoing the full URL with the token in it: job logs are often where the secret escapes, and also the place nobody thinks to check.

If a secret has already leaked

In order, skipping nothing. Revoke it immediately at the service that issued it: it is the only action that actually stops the bleeding, and it comes before everything else. Generate a new one, with the minimum permissions genuinely required, and put it in the CI system’s store. Check what was done with the old one, looking at the provider’s access logs for the preceding hours. Only at the end, if it still matters, clean up the file.

Once that is settled, it pays to add a net: there are tools that scan code for credentials and can run automatically before every commit, so the next key pasted in a hurry is stopped on the spot instead of discovered six months later.

In short

Pipeline secrets do not belong in the repository, because Git history does not forget and a credential that lands there must be considered burnt: revoke and regenerate, do not delete. They belong in the CI system’s store, with masking in the logs and restriction to protected branches, which is what stops any random branch from reading them. Better still, do not store them at all: with OIDC the pipeline proves who it is and receives credentials that last minutes, so there is nothing to steal and nothing to rotate. Above all sits least privilege, which is what decides whether an incident is a nuisance or a disaster.

Sources

Tux versione Gandalf, mascotte del blog init.d

init.d is the team led by Alessandro Corbelli, a Linux systems administrator and backend developer with over twenty years of experience. He designs and runs cloud infrastructure (Google Cloud, AWS, Azure), server farms and high-availability architectures, and builds custom software in Laravel/PHP and Vue - from the Take2Me food delivery platform to our clients’ management tools. On this blog we share technical notes on Linux, system administration, development, DevOps and e-commerce.


More Posts