
The One Architecture Decision That Protects Your High-Value Secrets
A pull request runs your tests. It also has access to your AWS credentials, your database password, and your code signing key.
It's Tuesday morning. Your team merges 15 pull requests before lunch. Each one triggers your CI pipeline — tests run, linters check, builds compile. Normal day.
What nobody notices: one of those PRs updated a transitive dependency. That dependency's postinstall script now reads every environment variable in the CI runner and sends them to an external server. Your AWS access keys. Your database connection string. Your code signing key. All gone.
This isn't hypothetical. It's exactly how the Codecov breach worked. It's how the tj-actions/changed-files compromise in March 2025 exfiltrated secrets from thousands of repositories. It's the pattern behind every major CI/CD supply chain attack in the last five years.
And it all comes down to one architectural mistake: your CI pipeline has access to secrets it doesn't need.
The Principle: Two Pipelines, Two Trust Boundaries
The fix isn't a tool. It's an architecture decision. CI and CD must be separate systems with separate credentials and separate trust models.
| Event | Pipeline | Secrets Available | Code Trust |
|---|---|---|---|
| Pull request opened | CI only | contents: read | Untrusted |
| PR from fork | CI only | None | Untrusted |
| Merge queue | CI only | contents: read | Semi-trusted |
| Merged to main | CD only | Full credentials | Trusted — reviewed |
| Production tag | CD only | Full credentials | Trusted — authorized |
CI answers: does this code work? It runs on every pull request, on code that hasn't been reviewed. It should have access to nothing that matters. If a malicious dependency exfiltrates every environment variable during CI, the attacker gets a test API key and nothing else.
CD answers: should this code run in production? It runs only after merge, review, and approval. It has access to cloud credentials, database connections, signing keys. But it only runs trusted code.
The boundary between them is the merge. Code that hasn't been merged doesn't get production credentials. Period.
What This Looks Like in Practice
1. Separate your workflow files
Your CI workflow should declare exactly what it needs — and nothing more:
# ci.yml — runs on PRs, no secrets
name: "CI: Test & Validate"
on:
pull_request:
branches: [ "main" ]
merge_group:
branches: [ "main" ]
permissions:
contents: read # Nothing else. No cloud creds. No deploy tokens.Your CD workflow is a completely separate file with different triggers:
# cd.yml — only runs on merged code
name: "CD: Build & Deploy"
on:
push:
branches: [ main ] # Sandbox deploy
tags:
- "v*.*.*-rc.*" # RC releases → sandbox
- "v*.*.*" # Production releasesPRs cannot trigger CD. Full stop. If your CI and CD share a workflow file, you have a structural vulnerability.
2. Pin every action to a commit SHA
The tj-actions/changed-files compromise worked because repositories referenced the action by a mutable tag. When the attacker gained write access, they moved the v35 tag to point to malicious code. Every repository using that tag immediately executed the compromised version.
# ❌ VULNERABLE — mutable tag can be moved to malicious code - uses: actions/checkout@v6 # ✅ SAFE — immutable commit SHA, audited once - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
The comment tells humans what version it corresponds to. The SHA is what Git actually resolves. No exceptions.
3. Use OIDC federation — eliminate long-lived credentials entirely
The strongest version of CI/CD isolation doesn't just separate secrets — it eliminates them. AWS, GCP, and Azure all support OIDC federation with GitHub Actions. Instead of storing credentials, your pipeline requests a short-lived token scoped to exactly the permissions it needs:
- uses: aws-actions/configure-aws-credentials@8df5847...
with:
role-to-assume: arn:aws:iam::<ACCOUNT>:role/github-deploy
role-session-name: deploy-${{ github.run_id }}
aws-region: us-east-1The IAM role's trust policy restricts which repositories, branches, and environments can assume it. A CI job running on a pull request literally cannot assume the deploy role — the OIDC claim includes the event type, and the trust policy rejects anything that isn't a push to main.
No AWS_ACCESS_KEY_ID in GitHub secrets. No rotation schedule to forget. No credential that can be exfiltrated and used from an attacker's laptop. This is the recommended approach for all cloud credentials.
4. Gate production deploys on authorized actors
Isolation isn't just about secrets. It's about who can trigger deployments. Production deploys should verify the actor against an explicit allowlist:
authorize-production:
steps:
- name: Verify release authorization
run: |
AUTHORIZED_USERS="maintainer-1 maintainer-2 release-bot"
ACTOR="${{ github.actor }}"
if [[ " $AUTHORIZED_USERS " == *" $ACTOR "* ]]; then
echo "✅ Production release authorized for: $ACTOR"
else
echo "❌ Requires authorization from: $AUTHORIZED_USERS"
exit 1
fiTwo humans and one automation bot. The bot can only trigger deploys through a specific release workflow that requires a merged PR. There's no way to skip the chain: PR → review → merge → tag → authorize → deploy.
5. Build your own critical-path tooling
For operations that decide what code gets built and deployed, don't depend on third-party actions:
# Instead of tj-actions/changed-files (compromised March 2025): - name: Detect changed services run: ./scripts/detect-changes.sh
Your own tool, in your own repository, running your own code. No supply chain dependency for the thing that decides what gets deployed.
Isolation Is a Claim. Where's the Proof?
Everything above is architecture. Workflow YAML. Configuration. But how do you know it's actually being followed? How do you know a developer didn't add pull_request_target to a workflow last Tuesday? How do you know an action wasn't re-pinned to a compromised commit?
This is where standards matter. The CNCF Software Supply Chain Best Practices v2 guide recommends a pipeline observer — an independent process that watches your CI/CD execution and creates cryptographic attestations of what actually happened. Not what you configured. What ran.
NIST codified this further in SP 800-204D, "Strategies for the Integration of Software Supply Chain Security in DevSecOps CI/CD Pipelines." It lays out specific strategies for embedding supply chain verification directly into the pipeline — not as an afterthought, but as a first-class security control. The document calls for cryptographic attestation of build steps, policy-based verification gates, and continuous monitoring of pipeline integrity. (Full disclosure: TestifySec contributed to this work.)
A pipeline observer sits alongside your build steps and records:
- What actions executed — were they pinned? From approved sources?
- What secrets were accessed — did a step touch credentials it shouldn't have?
- What artifacts were produced — can you prove the image in production came from this build?
- What the environment looked like — runner identity, git commit, environment variables (names, not values)
Each observation is signed with a short-lived certificate (Fulcio OIDC), timestamped (Sigstore TSA), and stored as an in-toto attestation. The result: a verifiable, tamper-evident record of your pipeline execution that an auditor — or a policy engine — can check after the fact.
This is what tools like cilock do. Wrap your CI steps. Observe what happens. Attest to it cryptographically. Then verify against policy before promoting artifacts. If a step accessed secrets it shouldn't have, the attestation catches it. If an action wasn't pinned, the policy rejects it. If credential patterns appear in stdout, the secretscan attestor flags it.
CI/CD isolation is the architecture. Pipeline observation is the proof. Without the proof, isolation is a configuration you hope nobody changed.
Three Patterns That Will Get You Breached
Anti-pattern 1: pull_request_target with checkout
# ❌ DANGEROUS — runs PR code with base branch secrets
on: pull_request_target
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
# This code is from the PR author.
# It has access to ALL repository secrets.This trigger runs the base branch's workflow with the PR's code. It was designed for labeling PRs. When combined with a checkout of PR code, it gives untrusted code access to every secret. Multiple open source projects have been compromised through this exact pattern.
Anti-pattern 2: shared CI/CD workflow
# ❌ DANGEROUS — secrets available to PR-triggered jobs
on:
pull_request:
push:
branches: [main]
jobs:
test:
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}One workflow file for both CI and CD. Secrets are available to all jobs regardless of trigger. A malicious PR gets your cloud credentials.
Anti-pattern 3: long-lived credentials in GitHub Secrets
If you must store credentials (not OIDC-federated), layer them by sensitivity. Test API keys in CI. Cloud credentials only in CD, fetched from a secrets manager at deploy time, masked in logs. Signing keys in a hardware security module — never in the pipeline at all. But wherever possible, use OIDC federation to eliminate stored credentials entirely.
The Audit Checklist
Run through this for your own pipeline:
- CI workflows trigger on pull_request, never pull_request_target with code checkout
- CD workflows trigger on push to main or tags only
- CI and CD are separate YAML files with separate permission blocks
- All actions pinned to commit SHAs, not mutable tags
- Production credentials are not available in CI jobs
- OIDC federation used instead of long-lived cloud credentials
- Production deploys require an authorized actor check
- GitHub Environments configured with approval gates
- Secrets masked in logs
- Fork PRs cannot access repository secrets
- Critical-path operations use first-party tooling, not third-party actions
If you check all eleven, you're ahead of 95% of engineering organizations. Miss even one, and you have a secret exfiltration path that an attacker will eventually find.
The supply chain attacks that make headlines all exploited the same mistake: trusting the build environment with production credentials. Separate your pipelines. Scope your credentials. Pin your dependencies. Gate your deploys.
Want cryptographic proof that your pipeline actually follows these rules?
cilock is an open-source pipeline observer built on Witness and the in-toto framework. It wraps your CI steps, detects credential leakage, enforces action pinning via Rego policy, and produces signed attestations you can verify before promoting artifacts.
We're at RSAC 2026 — NXT-1, Early Stage Expo. Bring your pipeline. We'll show you exactly where your secrets are exposed.
Or start now at testifysec.com