A CI/CD pipeline runs code that anyone with commit or pull request access can influence, on infrastructure that holds the keys to production, so a weak workflow is a direct path from a code change to a supply chain compromise. This guide covers pipeline security as a whole: the OWASP Top 10 CI/CD Security Risks, poisoned pipeline execution, GitHub Actions hardening, GitLab CI and Jenkins equivalents, runner isolation, branch protection, artifact integrity and logging. It is written for platform engineers, DevSecOps teams and security architects who own build systems. Pipeline credentials and OIDC federation are covered in the CI/CD Pipeline Identity Security Guide and not repeated here.
Key takeaways
- Treat every workflow trigger as a trust decision. Code from forks, issue text, branch names and pull request titles are untrusted input; they must never run, or be interpolated into scripts, in a job that holds secrets or a write token.
- Least privilege applies to the pipeline itself. Set the default job token to read-only, grant write scopes per job, and gate production deployments behind protected environments with required reviewers.
- Third-party actions and plugins are your code. Pin them to full commit SHAs (or a lockfile), allowlist what may run, and review them before adoption; the tj-actions and reviewdog compromises showed what moving tags allow.
- Runners are the blast radius. Untrusted builds belong on hosted or single-use runners with no network path to internal systems; persistent self-hosted runners shared across trust levels turn a pipeline compromise into an infrastructure compromise.
- Protect what feeds the pipeline and what it produces. Branch rulesets, code owner review on workflow files, signed and provenance-attested artifacts and complete audit logs close the gaps that pipeline hardening alone leaves open.
The OWASP Top 10 CI/CD Security Risks
The OWASP Top 10 CI/CD Security Risks (an OWASP Lab project led by Omer Gil, Ory Segal and Daniel Krivelevich) is the common taxonomy for pipeline risk. Risk names below are the project's own; the controls column is our practical reading.
| ID | Risk | Primary controls |
|---|---|---|
| CICD-SEC-1 | Insufficient Flow Control Mechanisms | Branch rulesets, required reviews, protected environments, no admin bypass |
| CICD-SEC-2 | Inadequate Identity and Access Management | Access reviews, SSO and MFA, scoped tokens (see the identity guide) |
| CICD-SEC-3 | Dependency Chain Abuse | Lock files, internal proxy registry, scoped package names |
| CICD-SEC-4 | Poisoned Pipeline Execution (PPE) | Trigger separation, protected config, isolated runners |
| CICD-SEC-5 | Insufficient PBAC (Pipeline-Based Access Controls) | Per-job permissions, environment-scoped secrets, runner groups |
| CICD-SEC-6 | Insufficient Credential Hygiene | Secrets manager, short-lived credentials, log and artifact scanning |
| CICD-SEC-7 | Insecure System Configuration | Hardening baselines, patching, configuration as code |
| CICD-SEC-8 | Ungoverned Usage of 3rd Party Services | Allowlists, app reviews, periodic permission audit |
| CICD-SEC-9 | Improper Artifact Integrity Validation | Signing, provenance verification, digest pinning |
| CICD-SEC-10 | Insufficient Logging and Visibility | Audit log streaming to the SIEM, retention, alerting |
MITRE ATT&CK now has a dedicated technique for the central risk: T1677 Poisoned Pipeline Execution (Execution tactic, added in 2025, mitigations M1054 Software Configuration and M1018 User Account Management). Downstream effects usually map to T1195.002 Compromise Software Supply Chain.
Poisoned pipeline execution and workflow injection
OWASP defines poisoned pipeline execution as an attacker with access to source control causing malicious code to run in the CI build without direct access to the build environment. It comes in three forms, and each needs a different defence.
- Direct PPE (D-PPE): the attacker edits the pipeline definition itself (a workflow file,
.gitlab-ci.ymlorJenkinsfile) on an unprotected branch or in a pull request, and the pipeline runs the modified definition. Defence: protect every branch that triggers a pipeline with secrets and require review of CI configuration. - Indirect PPE (I-PPE): the pipeline definition is protected, but it calls files the attacker can change: Makefiles, test scripts, linter or scanner configuration that loads code. Defence: give build scripts the same code owner review as pipeline files, and run unreviewed builds on isolated runners without secrets.
- Public PPE (3PE): a public repository runs CI on anonymous pull requests; if those builds share runners or a CI instance with private projects, their secrets are reachable. Defence: approval for outside contributors and no shared execution across trust levels.
Script injection
When a GitHub workflow interpolates attacker-controlled context (a pull request title, issue body or branch name) into a run: script with ${{ ... }}, the value becomes part of the generated shell script. GitHub recommends an intermediate environment variable, double-quoted in the shell:
- name: Check title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "Checking: $PR_TITLE"
OpenSSF Scorecard and CodeQL's Actions queries both flag this pattern. The Ultralytics PyPI compromise shows the outcome when an injectable workflow sits next to a usable publishing credential.
Hardening GitHub Actions
Token permissions
Set the repository or organisation default for GITHUB_TOKEN to read-only for contents, then declare permissions explicitly in every workflow. Once any scope is listed, all unlisted scopes become none; permissions: {} removes everything. Grant write scopes only on the job that needs them:
permissions:
contents: read
jobs:
release:
permissions:
contents: write
id-token: write
Also disable the setting that allows GitHub Actions to create and approve pull requests unless a specific automation needs it, because a workflow that can approve its own pull request is a code review bypass.
Triggers
- pull_request: runs fork pull requests with a read-only token and no secrets other than
GITHUB_TOKEN. Use it for any job that builds or tests contributor code. - pull_request_target: runs in the context of the base repository's default branch with a read/write token and access to secrets, even for public forks. GitHub warns: "Avoid using this event if you need to build or run code from the pull request."
- workflow_run: the recommended privilege split. An unprivileged
pull_requestworkflow builds and uploads artifacts; a privilegedworkflow_runworkflow comments or labels. GitHub Security Lab warns that "Artifacts resulting from untrusted PR data are themselves untrusted", so the privileged job may read values from them but must never execute them. - Checkout credentials:
actions/checkoutpersists the token by default. Setpersist-credentials: falseunless a later step pushes, and never upload the whole workspace as an artifact; the ArtiPACKED research found live tokens in public workflow artifacts.
Pinning and third-party action review
Tags such as @v4 are mutable: whoever compromises the action's repository can move them. Pin to a full-length commit SHA from the action's own repository (not a fork), with a version comment:
- uses: actions/checkout@<full 40-character commit SHA> # v4.x.y
GitHub's secure use reference also describes workflow dependency lockfiles, which record the exact commit of every action including transitive ones and supersede SHA pinning where adopted. Two caveats: Dependabot does not raise vulnerability alerts for SHA-pinned actions, so enable version updates for the github-actions ecosystem; and composite actions can pull their own unpinned dependencies, the path the reviewdog/action-setup compromise took downstream. Before approving an action, review its source, maintainers, release process and requested scopes, then add it to the allowed actions policy.
Environments and CODEOWNERS
- Protected environments: put production secrets in an environment with required reviewers (up to six, self-review prevented), deployment branch restrictions and administrator bypass disabled. A job cannot read environment secrets until a reviewer approves. On GitHub Free, Pro and Team, required reviewers apply only to public repositories.
- Workflow ownership: add
.github/workflows/and.github/actions/to CODEOWNERS so every change to pipeline logic needs approval from the platform or security team.
GitLab CI and Jenkins equivalents
| Control | GitHub Actions | GitLab CI/CD | Jenkins |
|---|---|---|---|
| Restrict secrets to trusted refs | Environment secrets with branch rules | Protected variables, available only on protected branches and tags | Folder-scoped credentials, restricted to specific jobs |
| Fork contribution builds | pull_request (no secrets), approval for outside contributors | Fork MR pipelines run in the fork by default; disable ci_allow_fork_pipelines_to_run_in_parent_project if not needed | Multibranch trust settings for forked pull requests |
| Job token scope | permissions block | CI/CD job token allowlist | Per-credential binding in the Pipeline step |
| Pin external code | Commit SHA or lockfile | Image SHA digests, pinned include refs, lock files | Pinned plugin versions, shared libraries at fixed versions |
| Isolate execution | Hosted or JIT runners, runner groups | Protected runners, per-project runners | Zero executors on the built-in node, agents as separate OS users |
| Deployment gate | Environment required reviewers | Protected environments with approvals | Input step with submitter restrictions |
- GitLab specifics: GitLab states that CI/CD variables are "less secure than secrets management providers", recommends masking, hiding and protecting them, and prefers CI/CD inputs to pipeline variables, which can override predefined variables. For fork merge requests run in the parent project it warns that "Fork merge requests can contain malicious code that tries to steal secrets in the parent project", even before merge.
- Jenkins specifics: set the built-in node to zero executors, because builds there have the same access to the controller file system as the Jenkins process. Run agents as a separate OS user without access to the Jenkins home directory or
sudo. Agent to Controller Access Control is always on since Jenkins 2.326. Keep Groovy in the Script Security sandbox, treat script approvals as privileged changes, and minimise and patch plugins.
Runner and build server isolation
GitHub advises that self-hosted runners should almost never serve public repositories and are not guaranteed to be clean between jobs. Even for ephemeral runners it notes "there is no way to guarantee that a self-hosted runner only runs one job", so automation must ensure a clean environment on reused hardware.
| Runner model | Isolation | Suits | Main risk |
|---|---|---|---|
| Platform-hosted | Fresh VM per job | Public repositories, untrusted builds, most teams | Limited network control |
| Self-hosted, single-use (JIT or autoscaled) | New VM or pod per job, destroyed afterwards | Private builds needing internal network or special hardware | Image hygiene, shared host kernel in container setups |
| Self-hosted, persistent | None between jobs | Only a single trusted repository with no external contributors | Persistence, cross-job secret theft, pivot into the internal network |
- Segment: place runners in a dedicated network zone with egress allowlisting, and block cloud instance metadata endpoints unless a job needs them. The NSA and CISA guidance calls for network segmentation, a DMZ and traffic filtering.
- Scope: use runner groups (GitHub), protected runners (GitLab) or labels and job restrictions (Jenkins) so a runner serving production deployments never accepts jobs from untrusted repositories.
- Clean up: destroy temporary VMs and clusters after each run, as NSA and CISA recommend, and rebuild runner images from a hardened baseline.
The CI/CD pipeline exploitation case is a worked example: in a researcher's demonstration, an exposed .git directory and a Bitbucket pipeline led to server takeover.
Source control: branch protection, review and signing
The NSA and CISA guidance asks for a two-person rule: "No single developer should be able to check in code without another developer reviewing and approving the changes." In GitHub, a ruleset on the default and release branches should enable:
- Require a pull request before merging: at least one approval (two for sensitive repositories), code owner review, "Dismiss stale pull request approvals when new commits are pushed" and "Require approval of the most recent reviewable push", which stops code being added after approval.
- Require status checks to pass before merging and Require code scanning results.
- Block force pushes and Restrict deletions, so history and tags cannot be rewritten. Protect release tags with their own ruleset; the Laravel-Lang compromise rewrote every release tag with one stolen token.
- Require signed commits: GitHub verifies GPG, SSH and S/MIME signatures. Signing proves which key made a commit, not that the change is safe, so it complements review rather than replacing it.
Review deploy keys too: a deploy key is an SSH key for a single repository, read-only unless write access is ticked, and write-enabled keys bypass human review. GitLab's equivalents are protected branches with "Allowed to push" set to no one, merge request approval rules and code owner approval.
Artifact integrity and secrets in pipelines
- Verify before deploy: accept only artifacts referenced by digest, signed by the expected build identity, with provenance naming the source repository, ref and workflow. See the SLSA and Build Provenance Guide.
- Pin inputs: reference images by digest, install from lock files and verify checksums of downloaded tools; the Codecov breach began with a modified uploader script that pipelines ran unverified. See the Open Source Security Guide.
- Scan for secrets: push protection on commits, then scanning of build logs, artifacts and images. Do not rely on masking: in the tj-actions incident CISA warned that exposed secrets may appear as a "double-encoded base64 payload", which value-based masking does not recognise.
- Never pass secrets in plaintext: the NSA and CISA wording is "Never pass secrets in plaintext anywhere in the pipeline." Avoid command-line arguments, which other processes on the same runner can read. See the Secrets Management Guide.
Where identity fits
Most pipeline attacks end in misuse of a non-human credential: a job token, cloud role or publishing token. Workflow hardening reduces the chance that untrusted code runs with those credentials; short-lived federated credentials reduce what an attacker gains when it does. OIDC trust policies, trusted publishing and credential inventory are covered in the CI/CD Pipeline Identity Security Guide.
Implementation roadmap
- Inventory (weeks 1 to 2): list pipelines, triggers, third-party actions and plugins, and self-hosted runners with the repositories that can use them. Run OpenSSF Scorecard or an equivalent for a baseline.
- Fix dangerous patterns (weeks 2 to 4): rewrite privileged workflows that check out pull request code, fix expression injection, and remove self-hosted runners from public repositories.
- Set safe defaults (weeks 4 to 6): find jobs that write (push, release, comment) and give them explicit permissions, then switch the default token to read-only on a pilot set of repositories. Failures show a clear permission error; rollback is reverting the setting.
- Pin and allowlist (weeks 6 to 10): rewrite action references to SHAs with a tool, enable Dependabot version updates, then enforce the allowed actions policy in a pilot organisation first.
- Protect branches and environments (weeks 8 to 12): run rulesets in evaluate mode where available, then activate them; move production secrets into protected environments.
- Isolate runners (quarter 2): migrate persistent runners to single-use runners in a segmented network, by runner group.
- Verify and monitor (quarter 2 onwards): add provenance verification at deploy and build the detections below.
Decision guidance
- Hosted versus self-hosted runners: default to hosted. Choose self-hosted only for internal network access, special hardware or cost at scale, and then as single-use runners scoped to private repositories.
- pull_request_target versus workflow_run: if the job needs write access or secrets in response to a fork pull request, use the two-workflow split. Use
pull_request_targetonly for jobs that never touch pull request content, such as labelling by metadata. - Small organisations: hosted runners, read-only tokens, SHA pinning with Dependabot, one default-branch ruleset and CODEOWNERS on workflow files cover most of the risk. Check plan limits for environment protections on private repositories.
- Large organisations: enforce settings at enterprise or organisation level, publish approved reusable workflows, run a central action review, and track CI configuration drift as a finding.
Detection and measurement
The NSA and CISA guidance sets the bar for audit logs: "An audit log should provide clear information on who committed, reviewed, and deployed what, when, and where." Stream the source control audit log (for example GitHub's org.update_actions_secret event), CI job logs and runner host telemetry to the SIEM. Alert on:
- Changes to workflow files, rulesets, environments, runner registration and the allowed actions policy; rule bypasses and tag moves.
- New third-party apps or actions with write access, and new deploy keys with write access.
- Runner outbound connections to unexpected destinations.
- Unusual commits adding workflows, the pattern of campaigns such as GhostAction and Megalodon that planted secret-stealing workflows.
Metrics that show the programme is working:
- Explicit permissions coverage: percentage of workflows with a top-level
permissionsblock (target 100%). - Pinned dependency rate: percentage of third-party action and plugin references pinned to a SHA or lockfile.
- Dangerous trigger count: privileged workflows that check out untrusted code (target zero).
- Branch protection coverage: percentage of production repositories with an active ruleset requiring review and code owner approval on workflow files.
- Runner exposure: number of persistent self-hosted runners reachable by public or fork-triggered jobs (target zero).
- Time to rotate: hours from a pipeline compromise advisory to rotation of every affected secret.
Framework mapping
| Framework | Relevant items | Pipeline controls |
|---|---|---|
| NIST CSF 2.0 | PR.PS-01, PR.PS-06, GV.SC | Configuration management, secure development practices, supplier risk for actions and plugins |
| NIST SP 800-53 Rev. 5 | CM-3, CM-5, SA-10, SI-7, AC-6, AU-12 | Change control and access restrictions for change, developer configuration management, software integrity, least privilege, audit generation |
| NIST SP 800-218 (SSDF) | PO.5, PS.1, PS.2 | Secure build environments, protecting code from unauthorised change, release integrity verification |
| CIS Controls v8.1 | Control 16 | Application software security, including secure build and third-party component management |
| ISO/IEC 27001:2022 Annex A | A.8.4, A.8.9, A.8.25, A.8.31, A.8.32 | Access to source code, configuration management, secure development life cycle, separation of environments, change management |
| MITRE ATT&CK | T1677, T1195.002 | Poisoned Pipeline Execution; Compromise Software Supply Chain |
Common failure patterns
- Trusting mutable tags: workflows referencing version tags silently ran a modified action. CISA's advisory on the tj-actions/changed-files compromise (CVE-2025-30066, fixed in v46.0.1) told every affected user to rotate secrets exposed between 12 and 15 March 2025.
- Shared persistent runners: one pool serving public, private and deployment jobs, so one malicious build reaches production credentials.
- Incomplete rotation after an incident: in the Trivy supply chain attack, a stolen PAT and an incomplete rotation let attackers turn a security scanner into a credential stealer; rotate every credential the pipeline could reach, not just the one named.
- Trusting the CI platform as a vault: the CircleCI breach forced customers to rotate every secret stored in the platform. Keep high-value secrets in a dedicated secrets manager with short-lived issuance.
- Admin bypass left on: approvals that administrators can skip silently fail exactly when an admin account is stolen.
Practitioner checklist
- Set the default job token to read-only and add explicit, per-job
permissionsto every workflow. - Remove privileged workflows that check out or execute pull request code; split them with
workflow_run. - Pass untrusted context through environment variables, never inline
${{ }}in scripts. - Pin all third-party actions and plugins to full commit SHAs or a lockfile, enforce an allowlist, and enable Dependabot version updates.
- Run untrusted builds on hosted or single-use runners; segment self-hosted runners.
- Apply rulesets with code owner review, most-recent-push approval, blocked force pushes and protected tags.
- Put production secrets behind protected environments with required reviewers and admin bypass disabled.
- Verify provenance before deployment and scan logs and artifacts for secrets.
- Stream audit logs to the SIEM and alert on pipeline configuration changes.
Standards and references
- OWASP: Top 10 CI/CD Security Risks
- NSA and CISA: Defending Continuous Integration/Continuous Delivery (CI/CD) Environments
- MITRE ATT&CK: T1677 Poisoned Pipeline Execution
- GitHub Docs: Secure use reference for GitHub Actions
- GitHub Security Lab: Keeping your GitHub Actions and workflows secure, Preventing pwn requests
- GitHub Docs: Deployments and environments
- GitLab Docs: Pipeline security
- Jenkins: Controller Isolation
Related NHI Mgmt Group resources: CI/CD Pipeline Identity Security Guide · SLSA and Build Provenance Guide · Open Source Security Guide · Secrets Management Guide