CI/CD Security: Your Pipeline Is Part of Your Attack Surface
IntroductionDev EnvironmentClientTransportServerAppDataMore

CI/CD Security: Your Pipeline Is Part of Your Attack Surface

Layer 0 in the HTTP Request Journey

Every time you push code, something builds and deploys it. That something — your CI/CD pipeline — has access to your source code, your cloud credentials, your container registry, your production database connection strings, and possibly the ability to deploy directly to prod. It's automated, it runs on every push, and most developers don't think of it as a security surface until after something goes wrong.

Here's the thing: your pipeline is as much a part of your attack surface as your login page. The difference is that your login page is hardened by default — you think about brute force, password storage, rate limiting. Your pipeline? Probably configured once during project setup and never revisited.

The Codecov breach in 2021 is the clearest example. Attackers compromised a single step in Codecov's Docker image build process. The result: a modified bash script ran inside CI pipelines across thousands of companies, silently collecting environment variables — including credentials for cloud providers, version control systems, and internal services — and sending them to an attacker-controlled server. It ran undetected for months. The scope of the breach was enormous precisely because CI pipelines are trusted by default.


What "CI/CD" Actually Means

Quick orientation for those newer to this: CI (Continuous Integration) is the automated process that runs when you push code — typically linting, tests, security scans. CD (Continuous Deployment) is the automated process that takes a passed build and deploys it somewhere — staging, production, or both.

Together, they remove the manual step of "someone runs the deploy command." That's the point. But automation that touches production needs to be secured like production.

The pipeline, concretely, is a set of instructions (a YAML file in most modern systems) that says: "when code is pushed to this branch, spin up a container, check out the code, run these commands, then do this with the result." GitHub Actions, GitLab CI, CircleCI, and Jenkins all work roughly this way. The container gets environment variables injected — often including credentials — runs your script, and exits.


Secrets in Logs: The Quiet Leak

The most common CI security mistake isn't sophisticated. It looks like this:

echo "Connecting to $DATABASE_URL"

Someone added this line while debugging a connectivity issue, pushed, fixed the problem, and forgot to remove it. DATABASE_URL contains the host, port, database name, username, and password in one string. That line now prints full credentials in a log that anyone with repository read access can see.

CI providers have a mechanism for this: masked secrets. In GitHub Actions, secrets stored in repository settings are automatically redacted in log output — any line matching the secret value gets replaced with ***. In GitLab CI, variables can be marked as masked or protected. These work well, but only for secrets you've deliberately registered in the secrets store.

The failure modes:

Inline variables — credentials defined directly in the workflow YAML file (env: DB_PASSWORD: mypassword) aren't masked. They're just plain text in a file checked into your repository.

Derived values — masking works on exact string matches. If your secret is a JSON blob and you log individual fields parsed from it, those won't be masked.

Error messages — a failed database connection might include the connection string in the exception. If your CI runs migrations and they fail, the stack trace could contain credentials even if you never logged them intentionally.

The practice: store every credential in your CI provider's secrets store. Never define credentials inline in workflow files. When debugging, log explicitly — print the specific, non-sensitive information you need, not entire environment objects.


The Fork PR Problem

GitHub Actions has a behavior that surprises many developers: when someone forks a public repository and opens a pull request, CI runs on their code. For most projects, that's desirable — you want to test contributions before merging them.

The security design here is reasonable by default: GitHub does not expose repository secrets to workflows triggered by pull_request events from forks. An external contributor's PR doesn't get access to your AWS credentials or deployment tokens.

The problem is a different trigger: pull_request_target.

This event was added to solve a real problem — allowing maintainers to run certain actions (labeling PRs, updating a comment) in the context of the base repository, not the fork. The difference from pull_request is subtle but significant: pull_request_target runs in the context of the target repository and does have access to secrets.

The dangerous combination is checking out and executing the PR's code under pull_request_target:

on: pull_request_target
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # checks out the contributor's code
      - run: npm test  # runs that code with your secrets available

This is a documented attack vector. A malicious contributor opens a PR with modified test code that reads and exfiltrates environment variables. Your CI, running under pull_request_target, executes it with full secret access.

The rule: if you use pull_request_target, never check out and execute code from the PR itself in the same job. Keep any code execution that needs secrets on protected branches only.


Pinning Actions and Images

GitHub Actions workflows reference other actions by version:

uses: actions/checkout@v4
uses: aws-actions/configure-aws-credentials@v4

Version tags like v4 are mutable. The repository owner can update what v4 points to at any time. If that action's repository is compromised — which has happened — an attacker can push malicious code to a tag your workflow already trusts. Your pipeline runs it on the next push, with whatever permissions your workflow has.

The fix is pinning to an immutable commit SHA:

uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e831c1e773f580d  # v4

The commit hash can't be retroactively changed. Even if the action repository is compromised and a malicious update is pushed to the tag, your workflow continues running the code you pinned.

This is the same principle covered in Third-Party Libraries — you're pinning a dependency to a specific version. Here the dependency happens to run inside your deployment pipeline with cloud credentials in scope.

The same applies to Docker images. FROM node:20 is mutable — the image changes as patch releases come out. For production builds, pin to a digest:

FROM node:20@sha256:a1b2c3d4...

Finding SHAs manually is tedious. The pin-github-actions tool can automate converting tags to SHAs in your workflow files.


Scoped Tokens and OIDC: The Credential Model

The most common CI credential setup: one powerful token that can do everything — push images, deploy to staging, deploy to production, modify infrastructure. It's stored in a single repository secret. It works.

It's also a single point of failure. If that credential leaks through a log, a compromised action, or a misconfigured workflow, the attacker has access to your entire deployment chain.

The alternative is scoping credentials to their actual job:

  • Test jobs don't need deploy access. If tests only run against an in-memory database and pull a base image, the credential doesn't need registry write access or cloud permissions.
  • Staging deploys don't need production access. Environment-level secrets in GitHub Actions let you define credentials scoped to specific environments — production credentials aren't available to workflows that don't explicitly target the production environment.
  • Production deploys can require human approval. GitHub Actions environment protection rules let you require a named reviewer to approve before a workflow proceeds with production credentials. Automated deploy on merge to main; production credentials gated behind an approval step.

The modern approach goes further: OIDC (OpenID Connect) eliminates stored credentials entirely for cloud provider access. Instead of a static access key stored in CI secrets, your pipeline requests a short-lived token from GitHub's identity provider and presents it to AWS, GCP, or Azure to assume a specific role. The token is valid for minutes. Nothing long-lived is stored anywhere.

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@e3dd6a429...
    with:
      role-to-assume: arn:aws:iam::123456789012:role/ci-deploy-staging
      aws-region: eu-west-1

The role assumed is logged in CloudTrail. The token expires automatically. There's nothing to rotate. For new projects with AWS, GCP, or Azure, OIDC is worth setting up from the start.


Branch Protection and Deploy Gates

Your CI/CD pipeline is only as secure as the branches it trusts. If anyone can push directly to main and trigger a production deploy, the pipeline's security model doesn't matter much.

Branch protection rules (available in GitHub, GitLab, Bitbucket) let you require:

  • Pull request reviews — changes to protected branches require one or more approvals before merge
  • Status checks — specified workflows (tests, security scans) must pass before a PR can merge
  • No direct pushes — even repository admins can't bypass the PR process

For production deployments specifically, environment protection rules add a gate: a named reviewer must approve before the workflow proceeds with production credentials. This separates the automated part ("tests pass, merge to main") from the deliberate part ("someone consciously approved deploying this to production").

The practical setup: main is a protected branch. Merging requires an approved PR and passing CI. Deploying to production requires an additional approval step in the workflow. Production secrets are only available to workflows that pass that gate.


Self-Hosted Runners: More Control, More Responsibility

Most teams use GitHub-hosted or GitLab-hosted runners — clean VMs spun up for each job, torn down afterward. Some teams run their own compute for CI: cheaper for high-volume workloads, easier to put inside a VPC.

Self-hosted runners introduce risks that managed runners don't have:

Persistent state — a managed runner is a clean VM each time. A self-hosted runner might carry state between jobs — files left on disk, cached credentials, modified environments. One job could theoretically read artifacts left by another.

Lateral movement — a compromised workflow running on a self-hosted runner inside your VPC has network access to internal services. A managed runner on GitHub's infrastructure doesn't.

If you run self-hosted runners, the key mitigations are isolation (each job gets a fresh environment, ideally a fresh container or VM), and network segmentation (runners shouldn't have unrestricted access to internal services — grant only what CI specifically needs).

For public repositories, don't use self-hosted runners at all. A malicious PR would run on your infrastructure.


Audit Trail: Knowing What Actually Got Deployed

Security incidents are often discovered because the evidence was there but nobody was watching. A deployment audit trail — who deployed what, when, to which environment — is that evidence.

At minimum:

  • Tie deploys to commits — every deploy should reference the exact commit SHA being deployed. This makes it trivial to determine what's running in production at any given moment.
  • Surface deploy events somewhere visible — CI logs are useful but often buried. A Slack notification or deployment tracking dashboard makes unusual deploys noticeable.
  • Alert on unexpected activity — a production deploy at 3am is worth a notification. So is a deploy from a branch that isn't main.

Cloud provider audit logs (AWS CloudTrail, GCP Cloud Audit Logs) capture every API call made by CI credentials — including which role did what, from which IP, at what time. This connects to the broader monitoring topic: you want a clear picture of what normal looks like so that abnormal is detectable.


Quick Checklist

  • Credentials stored in CI provider's secrets store, not inline in workflow YAML
  • No environment variables logged (directly or via error output)
  • pull_request_target reviewed — if used, untrusted code is not checked out and executed with secret access
  • Actions pinned to commit SHAs rather than mutable version tags
  • Docker base images pinned to digest for production builds
  • Separate secrets per environment (test, staging, production)
  • Production environment has required reviewers
  • OIDC configured for cloud provider access where possible
  • main and release branches protected — no direct pushes, required reviews
  • Deploy events logged and linked to commit SHAs

The pipeline sits right between your development environment and your users. Securing your laptop, running git hooks, scanning dependencies — the Securing Your Development Environment and Third-Party Libraries posts cover that ground. This is what comes after you push: the automated system that takes your code the rest of the way to production. It deserves the same care.


Sources: GitHub Actions Security Hardening, Codecov Security Update, OWASP Top 10 CI/CD Security Risks