Hysn Logo
Back to Blogs
Practical DevSecOps Resources

How to Secure a CI/CD Pipeline: 9 Controls That Block Supply Chain Attacks

Varun KumarVarun Kumar 9 min read 20 May 2026
CAISP vs AAIR

Securing a CI/CD pipeline means understanding what attackers actually target. Your pipeline has network access, cloud credentials, and the ability to deploy to production. That makes it one of the highest-value targets in your entire environment. Most teams bolt on a secrets scanner at the end and call it done. That's not security. That's checkbox theatre.

The real work is: eliminating static credentials, locking down runner permissions, validating every third-party action you execute, and signing artifacts so you know what you're deploying. SolarWinds attackers compromised a build system and shipped malware to 18,000 customers. Codecov's bash uploader was tampered with and leaked CI secrets from hundreds of companies. These aren't theoretical. They happened because pipelines had no integrity controls.

Here's what you actually need to do.

What Attackers Actually Target First

Before you build controls, know what attackers look for the moment they get a foothold in or adjacent to a pipeline.

The first thing they look for is credentials in runner environment variables. GitHub Actions exposes all secrets as environment variables in the shell process running your job. If an attacker can inject code that executes in your runner context, they print the environment and grab every credential in it. The mitigation isn't just limiting secrets. It's limiting what code can execute in your pipeline to code you've reviewed. SHA-pinned actions, reviewed dependencies, no curl | bash install patterns.

The second target is the pipeline's cloud identity. If your runner has an IAM role attached (EC2 instance profile, Workload Identity on GKE, managed identity on Azure), an attacker with code execution on the runner hits the cloud metadata endpoint at 169.254.169.254 and gets those credentials. They don't need your secrets. They need the network-accessible credential your runner inherits from its host. Scope that IAM role to exactly what the pipeline needs. Nothing broader.

The third target is your artifact registry write access. If an attacker gets credentials that can push to your container registry, they swap images. Your CD pipeline pulls and deploys the attacker's image without knowing the build process was never involved. Separate build credentials from deploy credentials. The job that pushes images should not be the same identity that reads them in production.

The fourth target is branch protection. If your main branch isn't protected, an attacker with repo write access can push directly to main, trigger your pipeline, and get code deployed to production with no review. Branch protection rules and required reviewers are pipeline security controls, not just code quality controls.

The Real Attack Surface

Most teams think about secrets in environment variables. That's one vector. The full picture is worse.

Compromised third-party actions are the supply chain risk most teams ignore. When you write uses: some-org/some-action@v2 in GitHub Actions, you're executing arbitrary code with your pipeline's credentials. v2 is a mutable tag. It can point to different code tomorrow than it does today. The tj-actions/changed-files incident in 2025 proved this: a single compromised action printed CI secrets from thousands of repos.

Overprivileged runners are everywhere. A runner that can push to any branch and assume any IAM role is a lateral movement nightmare if an attacker gets code execution. I've seen runners with AdministratorAccess in AWS "just to make things easier." That's a breach waiting to happen.

Artifact integrity gaps mean you can't verify that what you built is what you're deploying. Without signing, an attacker with write access to your artifact registry can swap binaries.

Pipeline-as-code injection is underrated. If your pipeline reads input from PR titles or commit messages and passes them to shell commands, that's a command injection vector.

Certified DevSecOps Professional

Build secure CI/CD pipelines with SCA, SAST & DAST in 100+ labs.

VIEW COURSE
Certified DevSecOps Professional

Secrets Management in Pipelines

Static long-lived credentials are the root of most pipeline security incidents. The fix is OIDC token exchange.

OIDC (OpenID Connect) is an authentication protocol that lets your CI provider prove its identity to a cloud provider, so the pipeline gets short-lived credentials instead of storing a permanent secret.

With GitHub Actions and AWS, you configure an IAM OIDC identity provider and a role with a trust policy scoped to your repo. The pipeline gets a token that expires when the job ends. No AWS_ACCESS_KEY_ID stored anywhere.

python
permissions: id-token: write contents: readsteps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole aws-region: us-east-1

That replaces a stored IAM key. Use the same pattern for GCP Workload Identity Federation and Azure federated credentials.

For secrets you can't replace with OIDC (third-party API keys, signing certs), use HashiCorp Vault with the JWT auth method, or your cloud provider's native secret manager. Vault lets you define short-lived dynamic credentials for databases and cloud resources. A pipeline grabs a Postgres password that expires in 15 minutes. If it leaks, it's already dead.

Never store secrets in environment variables at the repo or org level if you can avoid it. GitHub Actions secrets are better than plaintext env vars, but they still show up in debug logs if someone sets ACTIONS_STEP_DEBUG=true. Use SOPS with KMS encryption for secrets in your repo configs. The private key never leaves KMS.

For secret detection, detect-secrets in pre-commit and gitleaks as a CI step catch most cases before they land in the main branch.

Pipeline Permissions and Least Privilege

The GITHUB_TOKEN in GitHub Actions has a default permission set that's too broad for most jobs. Set it to read-only at the workflow level and grant specific permissions per job.

yaml
permissions: read-alljobs: build: permissions: contents: read packages: write # only if pushing to GHCR test: permissions: contents: read security-scan: permissions: contents: read security-events: write # required for uploading SARIF results to GitHub Code Scanning deploy: permissions: id-token: write # OIDC token exchange with cloud provider contents: read

This is more granular than what most teams ship. The security-events: write permission is what lets tools like Semgrep and CodeQL upload SARIF results to GitHub's code scanning dashboard. Without it, you get scan output in the CI log but not integrated into PR comments and the security tab. The id-token: write in the deploy job is what enables keyless OIDC authentication with AWS, GCP, or Azure. Grant it only in jobs that actually need cloud access.

One more scope worth knowing: pull-requests: write lets a job comment on PRs. Only the job that posts scan results needs this. Your build and test jobs don't. Granting it at the workflow level means every job can comment, which is more exposure than you want.

For self-hosted runners, network segmentation is your next layer of defense. Runners that deploy to production should live in a separate network segment with outbound access scoped to your artifact registry, your Kubernetes API, and nothing else. An attacker who compromises a runner with unrestricted egress can exfiltrate secrets or reach internal services that your pipeline was never meant to reach.

For runners, don't use hosted runners for anything touching production credentials if you can self-host with tighter network controls. If you use GitHub-hosted runners, scope IAM roles narrowly. One role per pipeline, scoped to the minimum S3 bucket or ECS cluster that job touches.

Runner isolation matters too. In Jenkins, don't share agents across projects with different trust levels. An agent running a public contributor's PR should not share an executor with a job that has production database access.

Dependency and Supply Chain Controls

Pin actions by commit SHA, not by tag. Tags are mutable. SHAs are not.

bash
# Baduses: actions/checkout@v4# Gooduses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Yes, this is annoying to maintain. Use Dependabot to open PRs when action versions update. Review the diff. Merge the SHA bump. This is the minimum viable supply chain control.

For your own dependencies, generate an SBOM (Software Bill of Materials) on every build. Syft works well here: syft . -o spdx-json > sbom.spdx.json. Feed it to Grype for vulnerability matching. This gives you an inventory of what's in your artifact and flags known CVEs before you ship.

Artifact Integrity with Cosign and SLSA

Cosign is a tool from the Sigstore project that signs container images and other artifacts using ephemeral keys tied to your CI identity via OIDC.

Here's the complete signing workflow in a GitHub Actions job:

yaml
jobs: build-and-sign: permissions: contents: read packages: write id-token: write # required for keyless Cosign signing steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Build and push image id: build uses: docker/build-push-action@v5 with: push: true tags: ghcr.io/yourorg/yourapp:sha-${{ github.sha }} - name: Install Cosign uses: sigstore/cosign-installer@v3 - name: Sign the container image run: | cosign sign --yes \ ghcr.io/yourorg/yourapp@${{ steps.build.outputs.digest }}

Signing by digest rather than by tag is intentional. If you sign the tag and the tag gets reassigned, the signature no longer matches what you think you signed. Signing the digest guarantees the signature is tied to the exact bytes you built.

The verification step belongs in your deployment pipeline, before the image is pulled:

python
cosign verify \ --certificate-identity-regexp="https://github.com/yourorg/yourapp/.github/workflows/build.yml" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ ghcr.io/yourorg/yourapp@sha256:abc123...# If verification fails, the exit code is non-zero and your deploy job stops

In Kubernetes, enforce this at admission with a policy controller. Kyverno and OPA Gatekeeper both support Cosign verification policies. A Kyverno ClusterPolicy can block any pod from starting if its image doesn't have a valid Cosign signature from your pipeline identity. That's the real enforcement point: not "we sign images" but "we can't run unsigned images."

Keyless signing means you don't manage a signing key. The signature is tied to the GitHub Actions OIDC token for that build. Verification confirms the image was built by your pipeline, not modified after the fact.

SLSA (Supply chain Levels for Software Artifacts) is a framework with four levels. Most teams are at level 0. Getting to SLSA 2 requires a hosted build service that generates signed provenance. GitHub's slsa-framework/slsa-github-generator handles this without much setup.

Security Gates in the Pipeline

Most tutorials stop here, which is exactly where things go wrong. You need to know where in the pipeline each tool fires.

On pull request (PR open/update):

  • Semgrep for SAST (language-aware, low false-positive rate when tuned)
  • detect-secrets or gitleaks for secrets scanning
  • Trivy for container image CVE scanning
  • Checkov for IaC misconfigs

On merge to main:

  • Full SAST scan (Semgrep or SonarQube)
  • SCA with SBOM generation
  • Artifact signing with Cosign
  • SLSA provenance generation

Pre-deploy to staging/production:

  • Cosign verification (don't deploy unsigned images)
  • Policy check: does this image have a clean Trivy scan? Use OPA/Rego or Kyverno for this in Kubernetes.

In GitLab CI, use rules and needs to wire this into merge request pipelines. In Jenkins, use shared libraries so security gates are enforced consistently across teams.

The goal isn't to slow down deployments. It's to make failures loud and early. A SAST finding on a PR is a 5-minute fix. The same finding in production is an incident.

If you want to go deeper on building and auditing these controls in a structured way, the Certified DevSecOps Professional program covers exactly this, with hands-on lab environments.

FAQ

Run fast, targeted scans (secrets detection, SAST on changed files) on every PR. Full scans on merge. Scanning everything on every PR adds latency without proportional benefit. Tune your tooling to fail fast on high-severity findings only.

Use git filter-repo to remove them, rotate the credentials immediately regardless, and treat the history as compromised. Removal from history doesn't mean no one has the secret. Run truffleHog against the full history to identify everything that leaked before you start rotating.

For most pipeline operations GITHUB_TOKEN is sufficient. Use it with minimal permissions. A separate service account (PAT or machine user) is only justified if you need cross-repo access or actions the GITHUB_TOKEN can't perform, like triggering workflows in other repos.

Pin all third-party actions by SHA, add detect-secrets as a pre-commit hook, set permissions: read-all at the workflow level and grant specific write permissions per job, and configure OIDC-based cloud auth to replace stored credentials. Those four changes eliminate the most common vectors.

Varun Kumar

Varun Kumar

Security Research Writer

Varun is a Security Research Writer specializing in DevSecOps, AI Security, and cloud-native security. He takes complex security topics and makes them straightforward. His articles provide security professionals with practical, research-backed insights they can actually use.

Related articles

All blogs →