Pipeline Security and Secrets: Least Privilege, Short-Lived Credentials and Supply Chain
The pipeline is the most powerful user, holding production keys; keeping secrets in a store, least privilege per job, short-lived tokens via OIDC instead of long-lived cloud keys and pinning actions to commit SHAs close most of the risk. Real act output showing a base64-encoded secret escaping masking, script injection caught by actionlint, fork PR risks, and a short look at SLSA v1.2 and SBOMs.
Version note: The examples in this post were run with .NET 10 (SDK 10.0.402), act 0.2.97, actionlint 1.7.12 and Docker 29.
Guess First
Bookstore's pipeline has a secret called DB_PASSWORD. One step prints the value to the log as is; the CI (Continuous Integration) tool masks it with ***. If the same step first converts the value to base64 and then prints it, what shows up in the log: *** again, or the base64 form? The answer comes a few sections later, in real output from a run with act.
Threat Model: The Pipeline Is the Most Powerful User
Let's list what Bookstore's deploy pipeline can reach: permission to push to the container registry, permission to deploy to production, connection details for database migrations, perhaps a signing key. No developer on the team is expected to hold all of these at once; the pipeline holds them all. On top of that, the pipeline runs code that anyone who can write to the repository can change, and it runs hundreds of actions, plugins and packages downloaded from the internet with its own permissions.
That is why you need to look at the pipeline through an attacker's eyes. There are four main paths: secrets leaking into logs or artifacts, a dependency (action, plugin, package) being compromised, untrusted code (for example a PR (pull request) from a fork) running in a privileged context, and the runner itself being compromised. In this post we look at risk, prevention and detection for each of them, not at how to carry out an attack.
Where Secrets Live
A secret does not live in the repository; not in appsettings.Production.json, not in a .env file, and not in a YAML (YAML Ain't Markup Language) variable because "it's a private repo anyway". Git history is permanent; a secret committed once lives on in history even after it is deleted, and it has to be rotated. The right place is a secret store: the CI tool's own secret storage, or a service such as Azure Key Vault or HashiCorp Vault. I will cover which to choose when in the upcoming Configuration and Secret Management series; I covered the application side of secret management in the Web Security (OWASP Top 10) series post "OWASP A04: Cryptographic Failures (2) — Password Storage, Data Protection and Secret Management".
What matters from the pipeline's point of view is the scope of the secret. In GitHub Actions, environment secrets are only given to a job that references that environment, and only after the environment's protection rules pass; that is why the approval mechanism in the previous post protects secrets too. There is no reason for the production database password to be reachable from a PR's test job.
The Limits of Masking
CI tools mask secret values when they see them in the log. But that is nothing more than searching for a known string in the text and replacing it. A small workflow to show this:
name: secrets-demo
on: workflow_dispatch
permissions:
contents: read
jobs:
demo:
runs-on: ubuntu-latest
steps:
- env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: |
echo "plain: $DB_PASSWORD"
encoded="$(printf '%s' "$DB_PASSWORD" | base64)"
echo "base64: $encoded"
echo "::add-mask::$encoded"
echo "base64 after add-mask: $encoded"
I ran it with act, using a test value I made up just for this experiment:
$ act workflow_dispatch -W .github/workflows/secrets-demo.yml -s DB_PASSWORD=bookstore-test-only-42
[secrets-demo/demo] | plain: ***
[secrets-demo/demo] | base64: Ym9va3N0b3JlLXRlc3Qtb25seS00Mg==
[secrets-demo/demo] | base64 after add-mask: ***
[secrets-demo/demo] 🏁 Job succeeded
The answer to the opening question: the plain value was masked, the base64 form showed up as is. Anyone who can read the log can turn it back with a single command. GitHub's documentation says the same: redaction is not guaranteed; putting a secret inside structured data such as JSON (JavaScript Object Notation) or YAML makes masking much less likely, and values derived from a secret (base64, its URL (Uniform Resource Locator) encoded form, a signed token) should also be registered as secrets. The ::add-mask:: command adds a derived value to the mask at run time; you can see in the last line of the output that it worked.
This output shows act's masking behaviour; GitHub's runner works on the same principle, but I did not run this workflow on GitHub. The real lesson is tool-independent: never printing the secret is better than relying on masking. Turning on set -x for debugging or dumping the headers of an HTTP (Hypertext Transfer Protocol) request to the log are the most common causes of leaks that masking cannot catch.
Least Privilege: Permissions per Job
GitHub Actions gives every job a GITHUB_TOKEN that can act on the repository. Reducing that token's permissions to the minimum limits the damage a compromised step can do. In Bookstore's deploy workflow:
name: deploy-azure
on:
push:
branches: [main]
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
with:
global-json-file: global.json
- run: dotnet publish src/Bookstore.Api -c Release -o artifacts/api
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
The top-level permissions: {} turns everything off; each job turns on only what it needs. build needs nothing more than reading the repository. deploy additionally asks for id-token: write, the subject of the next section. Setting the default token permission to "read" in the repository or organization settings is also GitHub's recommendation; even if permissions is forgotten in a workflow, the token does not come with write access.
I ran the build job with act, and it finished without problems using actions pinned to a SHA (Secure Hash Algorithm):
[deploy-azure/build] ✅ Success - Main actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 [97.18375ms]
[deploy-azure/build] ✅ Success - Main actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 [13.972664208s]
[deploy-azure/build] ✅ Success - Main dotnet publish src/Bookstore.Api -c Release -o artifacts/api [5.79498175s]
[deploy-azure/build] 🏁 Job succeeded
OIDC Instead of Long-Lived Keys
In the classic approach you create a service principal for access to the cloud account, put its client secret into the CI's secret storage, and it stays there for years. If that secret leaks, production can be reached from wherever it leaked to, and it stays valid until it expires.
With OIDC (OpenID Connect) there is no secret stored in the pipeline. While each job runs, it gets a short-lived JWT (JSON Web Token) from GitHub's OIDC provider (https://token.actions.githubusercontent.com). The token carries the job's identity as claims: which repository, which branch or environment. On the cloud side you set up a trust relationship: "grant this permission only to tokens whose sub claim is repo:my-org/bookstore:environment:production". The cloud provider validates the token and returns an access token that is also short-lived. When the job ends, no valid credential is left lying around.
All the workflow needs is the id-token: write permission and the provider's login action; you can see both in the deploy job above. The client ID and tenant ID passed through vars are not secrets; they only say which identity to log in as. The point GitHub's documentation underlines is how strict the trust condition is: defining at least one condition is mandatory, otherwise tokens from other repositories might be accepted too. Tying the sub claim to the environment ensures the token can only be produced in a job that has passed the production protection rules.
I could not run the deploy job locally: the service that issues the OIDC token is GitHub itself, and act has no such provider. I validated this job with actionlint; its behaviour was not run on GitHub.
Untrusted Code: Fork PRs and Script Injection
In an open source repository anyone can fork and send a PR, and that PR's code runs in your pipeline. That is why, for pull_request events from forks, GitHub does not pass secrets other than GITHUB_TOKEN to the runner and makes the token read-only. The dangerous part is the triggers that get around this protection: pull_request_target and workflow_run run with the target repository's permissions and secrets. GitHub's advice is clear: do not check out and run the PR's code under these triggers. Split privileged work (writing a comment, adding a label) from unprivileged work (building and testing the PR's code) into separate workflows. Not using self-hosted runners on public repositories is part of the same risk: code from a fork can leave a lasting trace on the runner machine.
A sneakier path is placing a user-written value, such as a PR title or branch name, directly inside a script. Because a ${{ }} expression is substituted as text before the script runs, special characters in the title can become part of the shell command. actionlint catches it:
.github/workflows/pr-title.yml:12:36: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
|
12 | - run: echo "PR title is ${{ github.event.pull_request.title }}"
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The fix is to pass the value through an environment variable; the shell sees it as data, not code:
jobs:
check:
runs-on: ubuntu-latest
steps:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "PR title is $PR_TITLE"
This version passed actionlint cleanly.
Supply Chain: Pinning, SLSA and SBOMs
Pinning third-party actions and plugins
When you write actions/checkout@v7, v7 is a tag, a name that can move. If the action's owner (or someone who takes over their account) moves the tag to another commit, your pipeline runs that commit with your secrets on its next run. GitHub's recommendation is to pin the action to a full-length commit SHA and to confirm the SHA comes from the action's own repository. In the workflow above, actions/checkout@3d3c42e5… is pinned like that; the # v7.0.1 comment next to it is for humans to read. I found the SHA from the tag like this:
$ git ls-remote --tags https://github.com/actions/checkout 'v7.0.1*'
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1
Pinned actions do not update themselves; Dependabot or Renovate recognise SHA pins and can open PRs for new versions, so updates go through review too. The same principle applies to Jenkins plugins: pin every plugin in plugins.txt in the name:version form. In the test Jenkins I set up for this series I left the plugins without versions; for a production Jenkins, that means different plugin versions on every image build.
A short look at SLSA and SBOMs
SLSA (Supply-chain Levels for Software Artifacts) v1.2 splits the build track into four levels. Level 0 (L0) has no guarantees. At level 1 (L1) the build platform produces provenance: a document describing who built the artifact, with which process and which inputs; it helps catch mistakes but is easy to forge. At level 2 (L2) the build runs on a hosted platform and the platform signs the provenance; it protects against tampering with the artifact after the build. At level 3 (L3) the platform isolates runs from each other and keeps the signing keys away from user-defined build steps; protection against tampering during the build. v1.2 also brings back a source track that covers the source code itself.
An SBOM (Software Bill of Materials) is a machine-readable list of every component inside an artifact and its version; CycloneDX and SPDX (Software Package Data Exchange) are the two most common formats. When a new vulnerability is announced, it lets you answer "which of our images contain this package?" within minutes. I will cover applying SLSA levels in practice and producing SBOMs in the upcoming DevSecOps series; I covered the risks of the dependency chain, and SRI (Subresource Integrity), in the Web Security (OWASP Top 10) series post "OWASP A03: Software Supply Chain Failures — NuGetAudit, npm audit and SRI".
In Production: Detection
Prevention is never complete; noticing that something has gone wrong is part of the plan too. Require reviews on pipeline files with CODEOWNERS and send every change to those files to a notification channel. Review the platform's audit log (secret creation, environment rule changes, runners being added) regularly. Turn on secret scanning and push protection; keys in known formats that someone tries to commit are rejected at push time. When you suspect a secret may have leaked, the first job is not deleting the log but rotating the secret; copies of the log may already have gone elsewhere.
Keep runners ephemeral. Runners that are deleted after every job (GitHub-hosted runners work this way) prevent the traces left by one job from carrying over to the next. If you use self-hosted runners, try to get the same behaviour with ephemeral runners.
Common Pitfalls
Broad permissions at workflow level: permissions: write-all, or writing no permissions at all, gives every job more than it needs.
Printing a secret for debugging: the line added "just once, I'll delete it later" stays in the log and in copies of the log.
Checking out PR code with pull_request_target: it means running the code of someone you don't know in a context that has access to secrets.
Trusting tags: the contents of an action used with @v7 or @main can change without you changing anything.
Putting long-lived cloud keys into the secret store "temporarily": temporary solutions tend to become permanent; moving to OIDC cuts the window of a leak from years to minutes.
A Small Experiment
★☆☆ Run your own workflow files through actionlint. Did a script injection or any other warning turn up?
★★☆ Add a top-level permissions: {} to every workflow in your repository and give jobs only the permissions they need. Which jobs really needed write access?
★★★ List the third-party actions you use, pin each one to its commit SHA, and turn on Dependabot for the github-actions ecosystem so updates arrive as PRs.
In the last post of the series we will measure the pipeline and make it faster: calculating DORA (DevOps Research and Assessment) metrics from deploy records, cache key design, parallelism, and why optimisation without measurement misleads.