Pipeline hardening
Pipeline hardening is configuring your CI/CD system so that the code, tools and people that trigger a build cannot take its credentials or push unreviewed changes toward a release. It covers triggers, token permissions, third-party actions and images, runners, secrets, and the branches and tags releases start from.
A pipeline holds the keys to production: registry tokens, cloud roles and signing identities. Scanners and code review check the application, not the workflow building it. In March 2025 attackers moved version tags of the tj-actions/changed-files action to a malicious commit that printed runner secrets into build logs. Every workflow that referenced a tag ran it; a workflow pinned to a known-good commit SHA did not.
- Who
- Platform or DevOps engineers who own the CI/CD configuration, with AppSec reviewing changes to workflows and release jobs
- When
- When a workflow is created or changed, when a runner type or integration is added, and before a repository goes public
- How often
- A workflow linter on every change; token scopes, runners and integrations reviewed on a regular schedule
- You end up with
- Read-only default tokens, pinned actions and images, untrusted jobs split from release jobs, and protected branches, tags and workflow files
Do this
Map who can trigger what
List the triggers that run on outsider input, such as
pull_request,pull_request_target,issue_commentandworkflow_runin GitHub Actions or merge request pipelines from forks in GitLab, and the jobs that can publish, deploy, sign or read secrets. The risk sits where the two lists overlap. OWASP calls the attack Poisoned Pipeline Execution: changing the pipeline, or a file it runs, so that attacker code runs inside the build.Make tokens read-only by default
In GitHub Actions, set
permissions: contents: read, orpermissions: {}, at the top of every workflow and grant extra scopes only to the job that needs them. Replace long-lived cloud keys with short-lived OIDC credentials whose trust policy names the repository and environment. The OWASP rule is that each pipeline should only have access to the credentials it needs, and each step only to the secrets it requires.Keep untrusted code away from secrets
Build and test fork pull requests under
pull_request, where GitHub gives them a read-only token and no secrets. Workflows onpull_request_targetorworkflow_runmust not check out or run pull request code, and must treat its artifacts as data. Pass event text such as a pull request title throughenv:instead of writing${{ }}insiderun:. In GitLab, mark deployment variables as protected so only pipelines on protected branches or tags receive them.Pin third-party actions and images
Reference each action by its full commit SHA with the version in a comment, such as
uses: actions/checkout@<full SHA> # v7.0.1, and each container image by digest. GitHub states that a full-length commit SHA is currently the only way to use an action as an immutable release. Pins also freeze out fixes, so let Dependabot or Renovate propose updates, and require SHA pinning by organization policy where your platform supports it.Isolate the runners
Prefer hosted or ephemeral runners that are discarded after each job. GitHub warns that self-hosted runners should almost never be used for public repositories, because anyone who opens a pull request can compromise the environment. Do not share a runner between pipelines of different sensitivity, return each node to a clean state after every job, and limit its network access to what the job needs.
Protect branches, tags and workflow files
Require a reviewed pull request and passing checks on the default and release branches, with no bypass list, and stop release tags from being moved or deleted. Put the workflow directory under CODEOWNERS so changes to it need a named reviewer. Gate production with a deployment environment that requires approval: GitHub withholds environment secrets from a job until a reviewer approves it.
Lint every workflow change
Run a workflow linter such as zizmor or actionlint in CI, or OpenSSF Scorecard's Dangerous-Workflow, Token-Permissions and Pinned-Dependencies checks, so a change that brings back a dangerous trigger, an unpinned action or a broad token fails the build. Paste a workflow into GitHub Actions workflow analyzer for a quick local check; CI security checks covers the scanners these jobs run.
Done when
Workflow permissions are read-only by default, and release jobs cannot be triggered from forks.
Mark it in the gap checkCommon mistakes
- Using
pull_request_targetto give fork builds access to secrets, then checking out the fork's code. Its build scripts run with your secrets and a token that can write to the repository. - Pasting
${{ github.event.pull_request.title }}into arun:script. A crafted title becomes shell commands; Nx believes this is how an attacker obtained the npm token used to publish malicious Nx versions in August 2025. - Pinning to a version tag such as
@v4. Whoever controls the action's repository can move the tag to new code; only a full commit SHA is immutable. - Pinning to a SHA and never updating it. GitHub's 2026
actions/checkoutguard against fork checkouts reached floating major tags automatically, but SHA, minor and patch pins had to be upgraded to get it. - Running public-repository workflows on a persistent self-hosted runner, where a fork pull request can leave code behind for the next job.
- Treating log masking as protection. GitLab notes that masking is not a guaranteed way to stop malicious users reading a value; rotate any secret that appears in a log.
OWASP CI/CD risks and the controls above
Five of the OWASP Top 10 CI/CD Security Risks map directly to this practice. The mapping to controls is this site's own.
| OWASP risk | What goes wrong | Control |
|---|---|---|
| CICD-SEC-1 Insufficient Flow Control Mechanisms | One account pushes code or artifacts toward production with no review | Protected branches and tags, required review, approval before deployment |
| CICD-SEC-4 Poisoned Pipeline Execution | A pull request changes the pipeline or a file it runs, and attacker code runs in the build | Fork code runs without secrets; workflow files need owner review |
| CICD-SEC-5 Insufficient PBAC (Pipeline-Based Access Controls) | Code running in a job uses the job's broad permissions to move further | Read-only default tokens, per-job scopes, isolated runners |
| CICD-SEC-6 Insufficient Credential Hygiene | Long-lived secrets spread across pipelines and logs | Short-lived OIDC credentials, secrets scoped per job and environment |
| CICD-SEC-8 Ungoverned Usage of 3rd Party Services | A third-party integration or build tool gets more access than it uses | Approved and pinned actions, least-privilege integrations, unused ones removed |
Tools and guides
- Tool: GitHub Actions workflow analyzer
- Harden GitHub Actions against workflow and supply-chain attacks
Close the paths that give untrusted pull-request code secrets or a write token: pass context through env, keep pull_request_target away from fork code, pin actions to commit SHAs, scope GITHUB_TOKEN and OIDC, and treat artifacts as data.
- Keep untrusted CI work outside the release boundary
Review workflow authority, persistent state and artifact handoffs before contributor-controlled code reaches privileged jobs.
- Protect the path from pull request to release
Separate untrusted contribution checks from privileged release work and verify the identity of the artifact that is actually deployed.
- Protect the source repository: rulesets, reviews and signed commits
Configure the controls every repository that ships software needs: protected branches and tags, required review with CODEOWNERS, signed commits, secret push protection and account security, mapped to the SLSA Source track, the OpenSSF OSPS Baseline and Scorecard.
- Case study: the tj-actions/changed-files compromise (CVE-2025-30066)
In March 2025 an attacker moved the version tags of a popular GitHub Action, and of a reviewdog action before it, to code that printed CI secrets into build logs. This case study traces the token chain back to one pull_request_target workflow, sizes the exposure, and shows how it was caught and which controls held.
Standards
- NIST SSDF 1.1
- PO.3 Implement Supporting ToolchainsPO.5 Implement and Maintain Secure Environments for Software DevelopmentPS.1 Protect All Forms of Code from Unauthorized Access and Tampering
- OWASP SAMM 2
- Secure BuildSecure Deployment
Sources
- GitHub Docs: Secure use reference for GitHub Actions
- OWASP Top 10 CI/CD Security Risks
- OWASP CICD-SEC-4: Poisoned Pipeline Execution (PPE)
- OWASP CICD-SEC-5: Insufficient PBAC (Pipeline-Based Access Controls)
- OWASP CICD-SEC-6: Insufficient Credential Hygiene
- GitHub changelog: Safer pull_request_target defaults for actions/checkout (2026-06-18)
- GitHub Docs: Deployments and environments (required reviewers and environment secrets)
- GitLab Docs: CI/CD variables (protected and masked variables)
- GitHub Advisory GHSA-mrrh-fwg8-r2c3 (CVE-2025-30066): tj-actions/changed-files
- Nx security advisory GHSA-cxm3-wv7p-598c: malicious versions of Nx and some supporting plugins were published. 27 August 2025.
- OpenSSF Scorecard: checks documentation
- zizmor audit rules
- NIST SP 800-218: Secure Software Development Framework (SSDF) Version 1.1. February 2022.