CI/CD pipeline security is the set of controls that stop an attacker from turning your continuous integration and delivery chain into a path to code execution, credential theft or tampered artifact delivery. The pipeline is a high-value target: it holds source code, deployment secrets, artifact registries and, often, direct access to production. Compromising a single workflow can be worth more than compromising a hundred workstations. This guide is a practical hardening playbook for 2026, focused on pipeline execution logic, secrets and runners, not the dependencies we already cover in the supply-chain guide.
It is worth drawing the distinction up front. Software supply-chain attacks target the dependencies you consume: poisoned npm or PyPI packages, typosquatting, dependency confusion. This article goes one step deeper, into the machinery that builds and deploys: who can modify a workflow, which credentials a job sees, and what privilege a runner runs with. This is the surface that the OWASP Top 10 CI/CD Security Risks project codifies, from CICD-SEC-1 through CICD-SEC-10.
The CI/CD pipeline attack surface
A modern pipeline is code, not static configuration. A file in .github/workflows, a .gitlab-ci.yml or a Jenkinsfile describes steps that run automatically on an event, usually a push or a pull request. That automation is exactly what an attacker wants to hijack. The relevant attack surface splits into four blocks:
- The pipeline definition: who can edit the workflow, and whether an unreviewed change can run on its own.
- The secrets: cloud tokens, signing keys, registry credentials and the CI system token itself.
- The runner: the process that executes the steps, its privilege level, whether it is ephemeral and whether it is shared across projects of different trust.
- The artifacts: the image or binary that comes out at the end, and whether its provenance is verifiable.
Close all four blocks and you remove the majority of practical attack paths against your build chain.
Poisoned Pipeline Execution (PPE): the number one risk
Poisoned Pipeline Execution (PPE, CICD-SEC-4 in the OWASP list) means injecting malicious commands into the pipeline definition so the CI system runs them with its own privileges. It is the most direct route to secrets and to production. It comes in three variants:
- Direct PPE (D-PPE): the attacker modifies the workflow file itself. If they have write access to a branch that triggers builds, they add a step that exfiltrates
envto an external server. - Indirect PPE (I-PPE): when the workflow definition is protected, the attacker injects commands into files the pipeline invokes, such as a
Makefile, a test script or an npm task. The workflow is unchanged, but it runs attacker-controlled code. - Public PPE (3PE): the pipeline is triggered by pull requests from external forks and runs untrusted code with access to secrets.
The most famous case in GitHub Actions is the pull_request_target trigger. Unlike pull_request, it runs in the context of the base repository with a writable GITHUB_TOKEN and access to secrets, yet it can check out code from an untrusted fork. If that workflow then runs the fork's code (for example npm install with post-install scripts), an external attacker gains execution with your secrets. The rule is simple: never check out or run fork code inside a pull_request_target workflow.
The tj-actions/changed-files incident (CVE-2025-30066, March 2025) showed the real blast radius: a very popular GitHub Action was compromised to dump runner secrets into the build logs of thousands of repositories. The lesson was not just to update, but to stop trusting mutable tags.
Secrets leakage in the pipeline
Credential hygiene (CICD-SEC-6) is where most teams stumble. Secrets leak in surprisingly mundane ways:
- An
echoor aset -xthat prints a masked variable into a log. CI masking helps, but it does not cover transformations of the value (base64, chunking). - Secrets written to disk in the workspace and later picked up by a step or an artifact.
- Long-lived credentials injected into every job when only one step needs them.
Run secret scanning with Gitleaks or TruffleHog both at pre-commit and inside the pipeline, so the secret is caught before it reaches history. Shrink the blast radius by scoping each secret to the job that actually uses it and aggressively rotating any credential that may have touched a log. The root cause, though, is almost always the same one behind so many cloud misconfigurations: static, long-lived keys where ephemeral credentials belong.
OIDC federation: killing long-lived credentials
The best way to protect a deployment key is to not have one. OIDC federation lets the pipeline exchange a short-lived identity token, signed by the CI provider, for temporary credentials from the cloud provider, storing no permanent key at all.
In GitHub Actions you enable it with permissions: id-token: write and the aws-actions/configure-aws-credentials action, which assumes an IAM role. The critical piece is the role's trust policy: the OIDC provider is token.actions.githubusercontent.com and the condition must pin the sub claim to the repository and, better still, to a specific environment or branch, for example repo:myorg/myrepo:environment:production. If you leave the sub open with a wildcard, any repository in the organization (or worse, any GitHub repo) could assume your role. This ties directly into AWS IAM privilege escalation paths: a loose trust condition is an escalation door.
The same pattern exists in GCP (Workload Identity Federation) and Azure (federated credentials), and GitLab CI issues OIDC tokens via id_tokens. Migrating from static keys to OIDC removes the entire "cloud secret leaked in a log" category at once.
Runner isolation
The runner is where code actually executes, so its isolation defines the blast radius of any failure. Two principles:
- Ephemeral runners: every job should run on a clean machine or container that is destroyed when it finishes. A persistent runner accumulates cached credentials, SSH keys and artifacts that the next (potentially malicious) job can read.
- Never self-hosted runners on public repositories: GitHub's own documentation warns against it. A fork can open a PR that runs arbitrary code on your self-hosted runner, and pivot from there into your internal network.
Segregate runners by trust level: jobs that touch production should not share a fleet with jobs that build feature branches. In GitLab, use tags to route jobs to specific runners and avoid privileged = true in the Docker executor unless strictly necessary, because a privileged runner is equivalent to root on the host. In Kubernetes, the same isolation leans on the practices we cover in Kubernetes pentesting and cluster security.
SLSA and verifiable provenance
Even if the pipeline runs clean, you need to be able to prove that the artifact you deployed is the one that pipeline produced. The SLSA framework (Supply-chain Levels for Software Artifacts) defines increasing build levels: at Build L1 you generate provenance; at L2 you sign it and use a managed build service; at L3 the build is isolated and the provenance is unforgeable.
In practice this means signing artifacts with Cosign and Sigstore, emitting in-toto provenance attestations that record the source commit, the workflow and the materials, and verifying that signature at deploy time so an artifact without valid provenance never runs. It is the same integrity control that a CSPM applies to cloud configuration, moved to the build artifact.
Per-platform hardening
GitHub Actions
Start with the GITHUB_TOKEN read-only by default (permissions: contents: read) and raise privilege job by job only when needed. Pin every third-party action by its full commit SHA, not by tag (uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683), because a tag is mutable and can be rewritten under you, as in the tj-actions case. Restrict which actions are allowed at the organization level, protect production environments with required reviewers, and add StepSecurity Harden-Runner to filter egress traffic and OpenSSF Scorecard to measure repository posture.
GitLab CI
Mark sensitive variables as protected and masked so they are only exposed on protected branches and tags. Scope the CI_JOB_TOKEN with the project allowlist so a leaked token cannot read other repositories. Write your rules carefully so fork-triggered pipelines never inherit trusted jobs, and split the runner fleet by risk level.
Jenkins
Jenkins is historically the most fragile link because of its plugin surface. Patch aggressively: CVE-2024-23897, an arbitrary file read flaw in the CLI, allowed reading keys and could lead to remote code execution. Do not run builds on the controller, use ephemeral agents, isolate credentials per folder with the Credentials Binding plugin, and keep the Script Security sandbox on for Groovy code. The secrets management and SaaS hardening you apply to the rest of the stack apply here too.
Where pipeline security fits
Pipeline hardening is one layer of a broader DevSecOps posture, not the whole of it. The controls above shut down the common PPE, secrets-leakage and runner-abuse paths, but an external validation still pays off: a skilled tester chains a pull_request_target workflow, a loose OIDC condition and a shared runner into an attack path that no single scanner flags. That is exactly what our SSDLC and DevSecOps service does: it embeds security into every phase of the pipeline and validates which findings are genuinely exploitable.
Frequently asked questions
What is poisoned pipeline execution (PPE)?
It is the technique of injecting commands into the pipeline definition, or into the files it invokes, so the CI system runs them with its own privileges and reaches secrets and environments. It splits into direct (modifying the workflow), indirect (injecting into scripts the workflow calls) and public (abusing pipelines triggered by forks). It is risk CICD-SEC-4 in the OWASP Top 10.
Why is OIDC better than storing cloud keys as secrets?
Because it removes the persistent credential. With OIDC the pipeline obtains short-lived temporary credentials at deploy time, bound to the workflow's own identity, instead of a static key that lives forever in the secrets store and leaks the moment it appears in a log. A token that expires in minutes and only works from your repository has almost no value to an attacker.
Can I use self-hosted runners with public repositories?
It is not recommended. A fork can submit a pull request that runs arbitrary code on your self-hosted runner, with access to the network where it lives. If you need them, reserve them for private repositories, make them ephemeral and segregate them by trust level. For public repositories, the provider's managed and isolated runners are the safe option.
How does this differ from software supply-chain security?
Supply-chain security focuses on the dependencies you consume (poisoned packages, dependency confusion, typosquatting). CI/CD pipeline security focuses on the machinery that builds and deploys: workflow permissions, secrets hygiene, runner isolation and artifact integrity. They are complementary, and a serious DevSecOps program covers both.
What does SLSA add to an already hardened pipeline?
Verifiable provenance: the cryptographic guarantee that the deployed artifact came from a specific build, from a specific commit, without tampering along the way. Even if your pipeline is hardened against PPE and secrets leakage, without signed provenance you cannot prove the binary in production is the one your pipeline produced. SLSA and signing with Sigstore close that gap.
Pipeline security with Secra
At Secra we harden and test CI/CD pipelines end to end: workflow and permission review, PPE vector hunting, migration to OIDC, runner isolation and SLSA provenance verification across GitHub Actions, GitLab CI and Jenkins. If you want an external assessment of your build and deployment chain, reach out through contact and we will come back with a first evaluation.
About the author
Secra Solutions team
Ethical hackers with OSCP, OSEP, OSWE, CRTO, CRTL and CARTE certifications, 7+ years of experience in offensive cybersecurity, and authors of CVE-2025-40652 and CVE-2023-3512.

