Home/Blog

DevSecOps Pipeline: How to Add Security to CI/CD

How to build a DevSecOps pipeline: which scans run where, what should block a merge, a working GitHub Actions example, and how to secure the CI runner itself.

DevSecOps Pipeline: How to Add Security to CI/CD

A DevSecOps pipeline runs each security check where its problem is cheapest to fix: secrets scanning before commit and on every push, static analysis, dependency and infrastructure-as-code scanning on every pull request, image scanning and signing at build, and dynamic testing against a deployed staging environment. Only high-confidence findings introduced by the change should block a merge; everything else becomes a ticket with an owner and a deadline. The pipeline is also a target in its own right, so pinning actions, scoping tokens and replacing stored cloud keys with OIDC belong in the same plan.

Key takeaways

  • Put each check at the earliest stage that can run it reliably. Fast checks on every pull request, slow and stateful checks on a schedule or against staging.
  • Block only on new, high-confidence findings. Baseline everything that already exists and burn it down separately.
  • Fewer, tuned tools beat a wall of scanners. A check developers learn to ignore is worse than no check, because it looks like coverage.
  • The CI runner holds your deploy credentials. Least-privilege tokens, SHA-pinned actions and OIDC federation matter as much as any scanner.
  • Secrets that leak are compromised immediately. Rotate first, rewrite history second.
  • Key security SLAs on confirmed severity, not raw CVSS from a feed, and route every finding to one place.

Where each check belongs

StageCheckExample toolsBlocks?Runs on
Developer machineSecrets, security lintGitleaks pre-commit hook, Bandit, gosec, eslint-plugin-securityLocally onlyCommit
PushSecretsGitHub push protection, GitleaksYes, for verified secretsEvery push
Pull requestSASTSemgrep, Opengrep, CodeQLNew high-confidence findingsEvery PR
Pull requestDependencies (SCA)Trivy, OSV-Scanner, Dependabot, RenovateFixable criticals in production depsEvery PR
Pull requestIaCCheckov, Trivy config, KICSPublic exposure, missing encryption on data storesPRs touching infra
BuildImage scan, SBOM, signingTrivy, Grype, Syft, CosignFixable criticalsMain branch builds
StagingDASTOWASP ZAP, NucleiRarely; triage firstAfter deploy, nightly
ClusterAdmissionKyverno, OPA GatekeeperUnsigned or unscanned imagesEvery deploy
Loading diagram...

The ordering principle is simple. Cheap, deterministic checks run first, on every change, and give an answer in the time it takes to make a coffee. Expensive or flaky checks run later, where a failure does not hold up every developer on the team.

The blocking policy

Most DevSecOps programs fail on this decision, not on tool choice. Block too much and developers find the bypass. Block too little and the pipeline is decoration.

FindingBlock the merge?Why
Verified live secret in the diffYesIt is already compromised once pushed
New SAST finding from a tuned, high-confidence ruleYesIntroduced by this change, cheap to fix now
New SAST finding from a broad or experimental ruleNo, comment on the PRPrecision too low to stop work
Critical vulnerability with a fix, in a production dependencyYesA one-line upgrade, usually
Vulnerability with no fix availableNo, ticket with expiryBlocking cannot make a fix appear
Vulnerability in a dev-only dependencyNoDifferent exposure, lower priority
IaC creating public storage or open admin portsYesDirect path to an incident
Pre-existing findings in the baselineNoSeparate burn-down, not this author's problem

The word "new" is doing the work. Semgrep's --baseline-commit, CodeQL's pull request diffing and most SCA tools can report only what the change introduced. Without that, the first developer to touch an old file inherits every historical finding in it, and the team learns that the security check is something to route around.

A working pipeline in GitHub Actions

This workflow runs the pull request checks inside a pinned toolbox image (built by the platform team, with Gitleaks, Semgrep and Trivy installed) and builds, scans, signs and attests the image on pushes to main. In a real repository every uses: line is pinned to a full commit SHA and the toolbox image to a digest; PINNED_SHA and PINNED_DIGEST stand in for those values here.

name: security
on:
  pull_request:
  push:
    branches: [main]
 
permissions:
  contents: read
 
jobs:
  pr-checks:
    runs-on: ubuntu-latest
    container: ghcr.io/acme/security-toolbox@sha256:PINNED_DIGEST
    env:
      BASE: origin/${{ github.base_ref || 'main' }}
    steps:
      - uses: actions/checkout@PINNED_SHA
        with:
          fetch-depth: 0
          persist-credentials: false
 
      - name: Secrets in new commits
        run: gitleaks git --redact --log-opts="$BASE..HEAD" .
 
      - name: SAST, new findings only
        run: semgrep scan --config p/default --config .semgrep/ --error --baseline-commit "$BASE"
 
      - name: Vulnerable dependencies
        run: trivy fs --scanners vuln --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 .
 
      - name: IaC misconfiguration
        run: trivy config --severity CRITICAL,HIGH --exit-code 1 infra/
 
  image:
    if: github.event_name == 'push'
    needs: pr-checks
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write   # keyless signing through Sigstore
    steps:
      - uses: actions/checkout@PINNED_SHA
        with:
          persist-credentials: false
      - name: Build, scan, push, SBOM, sign
        env:
          IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # trivy, syft and cosign are installed by a pinned setup step, omitted here
          echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
          docker build -t "$IMAGE" .
          trivy image --severity CRITICAL --ignore-unfixed --exit-code 1 "$IMAGE"
          docker push "$IMAGE"
          REF=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE")
          syft "$REF" -o spdx-json > sbom.spdx.json
          cosign sign --yes "$REF"
          cosign attest --yes --type spdxjson --predicate sbom.spdx.json "$REF"

A few choices here are deliberate. Permissions default to read-only at the top and widen only in the job that needs them. persist-credentials: false keeps the checkout token out of the workspace, where any later step (including a compromised dependency's install script) could read it. The image is scanned before it is pushed, and it is signed by digest, never by tag, because tags move. For the wider GitHub Actions setup (caching, matrix builds, environments), see our CI/CD with GitHub Actions guide.

Secrets: prevent, detect, rotate

Secret scanning is the highest-value, lowest-noise check in the pipeline. Run it in three places: a pre-commit hook on developer machines (fast feedback, easily skipped), push protection on the platform (GitHub offers it through Secret Protection for private repositories), and a CI job as the backstop that cannot be skipped.

When a secret does land in a repository, the order of operations matters:

  1. Rotate the credential immediately. Assume it is compromised the moment it was pushed. Clones, forks, CI caches and mirrors already have it.
  2. Check whether it was used. Look at the provider's audit log for the key (CloudTrail, the SaaS vendor's access log) for the window it was exposed.
  3. Then clean history with git filter-repo if the repository is shared widely. History cleanup is hygiene, not remediation.

Verification matters for noise. Scanners such as TruffleHog can test whether a detected credential is live, which lets you block hard on verified secrets and merely warn on high-entropy strings that might be test fixtures.

The structural fix is to have fewer secrets to leak. Cloud credentials in CI should come from OIDC federation, not stored keys. On AWS, the trust policy should pin the repository and environment, not just the organization:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:acme/api:environment:production"
        }
      }
    }
  ]
}

A sub condition with a wildcard like repo:acme/* lets any workflow in any repository of the organization, including one a contractor just created, assume your production role.

SAST that developers do not mute

Semgrep (and Opengrep, its community fork) and CodeQL are the common choices. Semgrep is fast and easy to write rules for; CodeQL does deeper data-flow analysis and is free for public repositories on GitHub. Either works. What decides success is tuning:

  • Start with a small, high-precision rule set and add rules only when the current set is clean and trusted.
  • Write rules for your own codebase. The generic registry catches generic bugs. The rules that pay off are the ones encoding your team's past incidents and banned patterns.
  • Treat a false positive as a bug in the rule, not a reason for the developer to add an ignore comment. Track ignores per rule and fix or drop rules that attract them.

A custom rule is a few lines of YAML. This one flags shell command execution with a non-literal command in Python:

rules:
  - id: subprocess-shell-true
    languages: [python]
    severity: ERROR
    message: shell=True with a non-literal command allows command injection. Pass a list instead.
    patterns:
      - pattern: subprocess.$FUNC($CMD, ..., shell=True, ...)
      - pattern-not: subprocess.$FUNC("...", ..., shell=True, ...)

Keep custom rules in the repository (.semgrep/ in the workflow above) so changes to them are reviewed like code.

Dependencies: scan, prioritize, slow down

Dependency scanning produces the most findings and the most arguments. Three practices keep it useful.

Prioritize by exploitability, not CVSS alone. A critical in a package your production code never loads is less urgent than a high that is in CISA's Known Exploited Vulnerabilities catalog. Reachability analysis, KEV membership and EPSS scores all help separate "patch tonight" from "next sprint". Be wary of brand-new records too: we walked through a fabricated CVE that scored 9.8 before it was rejected in CVE Slop, including a triage script.

Let new releases age before they merge. Malicious package versions are often caught and pulled within hours or days of publication. A cooldown means you are rarely the first to install one. Renovate supports it directly:

{
  "extends": ["config:recommended"],
  "minimumReleaseAge": "3 days",
  "vulnerabilityAlerts": { "labels": ["security"], "minimumReleaseAge": null },
  "packageRules": [
    { "matchUpdateTypes": ["patch", "minor"], "matchDepTypes": ["devDependencies"], "automerge": true }
  ]
}

Security fixes skip the cooldown; ordinary updates wait.

Install from the lockfile, always. npm ci, pnpm install --frozen-lockfile and their equivalents fail when the lockfile and manifest disagree, instead of quietly resolving something new during the build. Our software supply chain security guide goes deeper on SBOMs, provenance and dependency policy.

IaC and container images

Infrastructure code is scanned like application code. trivy config and Checkov cover Terraform, CloudFormation, Kubernetes manifests, Helm charts and Dockerfiles; tfsec's checks now live in Trivy. Block on the short list that leads straight to incidents (public storage, management ports open to the internet, unencrypted data stores, privileged containers) and report the rest.

For images, enforce a handful of Dockerfile rules and scan the result:

  • Pin base images by digest, not latest, and rebuild on a schedule so base image fixes arrive without a code change.
  • Use multi-stage builds and minimal runtime images (distroless or a slim base) so there is less to patch.
  • Run as a non-root user, with no package manager or shell where you can avoid them.
  • Never COPY secrets or .env files into a layer; layers are forever, even if a later step deletes the file.

Scanning in CI only helps if production refuses what CI did not approve. An admission policy in the cluster (Kyverno's verifyImages or a Gatekeeper constraint) that requires a valid signature from your pipeline's identity closes the loop: an image built on a laptop and pushed by hand does not run.

DAST against staging

Dynamic testing needs a running application, so it belongs after deploy. Two modes work well together:

  • Baseline scan on every deploy to staging or a preview environment. ZAP's baseline scan is passive (it spiders and inspects responses without attacking) and finishes quickly. It catches missing security headers, cookie flags, information leaks and obvious misconfiguration.
  • Authenticated active scan nightly. Give the scanner a test account so it can reach the application behind login, where almost everything interesting lives. Run it against an environment you do not mind it filling with junk data.
docker run --rm -v "$PWD:/zap/wrk" ghcr.io/zaproxy/zaproxy:stable \
  zap-baseline.py -t https://staging.example.com -r zap-baseline.html

Treat DAST findings as triage input, not merge gates. They are noisier than static checks and often describe configuration rather than code.

Secure the pipeline itself

Attackers have noticed that CI runners hold deploy credentials, publish tokens and cloud access, often with weaker monitoring than production. The tj-actions/changed-files compromise in March 2025 repointed the action's version tags at malicious code, so workflows that ran it printed their secrets into build logs. In May 2026, TanStack packages were published with valid provenance after an attacker poisoned a shared Actions cache from a fork pull request; we covered the mechanics in Your CI Runner Is the Target.

The hardening checklist:

  • Default permissions: contents: read at the workflow level, and widen per job. Org-wide, set the default GITHUB_TOKEN to read-only.
  • Pin third-party actions to full commit SHAs, and let Dependabot or Renovate update the pins so pinning does not mean never updating.
  • Never check out and run pull request code in a pull_request_target workflow. It runs with the base repository's context. Use pull_request for untrusted code.
  • Separate release workflows from anything that touches forks, including caches. Do not restore a cache in a release job that untrusted runs could have written.
  • Scope OIDC trust policies to repository and environment, as above, and put production deploys behind a protected environment with required reviewers.
  • Lint the workflows. actionlint catches mistakes; zizmor is a static analyzer for GitHub Actions security issues such as template injection and excessive permissions.
  • Watch runner egress on jobs that hold sensitive credentials. Build steps rarely need to talk to arbitrary hosts.

Managing findings

As tools accumulate, findings scatter across PR comments, dashboards and emails. Put them in one place (DefectDojo, your platform's security tab, or the issue tracker), deduplicate across tools, and route by code owner. Set SLAs per severity, but define severity after triage, not from the raw feed.

When a finding is accepted or proven irrelevant, record the decision with an expiry so it resurfaces later instead of disappearing forever. Trivy supports this in .trivyignore:

#Not reachable: only the offline export script loads it. Revisit when that script is retired.
CVE-2026-NNNNN exp:2026-12-31

What goes wrong in production

  • The scanner's database becomes a single point of failure. Every build downloads a vulnerability database from a public registry. When that registry rate limits you or has an outage, every pipeline fails at once. Cache or mirror the database and decide what a scan should do when it cannot update.
  • A CVE published overnight breaks unrelated pull requests. A dependency scan that fails on any critical in the tree makes Tuesday's PRs fail for a vulnerability nobody touched. Gate PRs on what the diff introduced, and catch newly disclosed issues in existing code with a scheduled scan that opens tickets.
  • Everyone learns the bypass. --no-verify, a "skip-security" label, an admin merge. Bypasses will exist; log them, review them weekly, and treat a rising count as a sign that a rule is wrong.
  • A security bot ships an outage. An auto-merged "security" upgrade with a breaking change reaches production faster than any feature would. Security updates go through the same tests and deploy gates as everything else.
  • Secrets leak through logs, not commits. set -x in a shell step, a verbose CLI flag, or a debug print in a test echoes a token into build logs that many people can read. Masking is best effort; malware that reads runner memory ignores it completely.
  • The pipeline gets slow, so changes get big. If PR checks take long enough that developers batch changes, each change carries more risk. Keep PR checks fast and push heavy scans to merge or schedule.

Frequently Asked Questions

What is a DevSecOps pipeline?

A DevSecOps pipeline is a CI/CD pipeline with automated security checks built into its normal stages: secrets scanning on every push, static analysis and dependency scanning on every pull request, image scanning and signing at build time, and dynamic testing against a deployed environment. Developers get findings in the pull request, while the code is still fresh in their heads. The pipeline itself is also hardened, because CI runners hold the credentials that deploy to production.

What security tools should be in a CI/CD pipeline?

At minimum: a secrets scanner such as Gitleaks or GitHub push protection, a SAST tool such as Semgrep or CodeQL, a dependency scanner such as Trivy, OSV-Scanner or Dependabot, an IaC scanner such as Checkov or Trivy config, and an image scanner if you ship containers. Add DAST with OWASP ZAP against staging once those are stable. Fewer tools that developers trust beat many tools they ignore.

What is the difference between SAST, DAST and SCA?

SAST reads your source code without running it and finds patterns such as injection or unsafe deserialization. SCA checks your third-party dependencies against vulnerability databases. DAST attacks a running application from the outside, which catches configuration and authentication problems that neither of the others can see.

Should security scans block the build?

Only for high-confidence findings that the change itself introduced: a verified secret, a new critical issue from a tuned SAST rule, a fixable critical vulnerability in a production dependency, or infrastructure that exposes data publicly. Everything else should become a tracked ticket with a deadline. Blocking on noisy or pre-existing findings teaches developers to bypass the checks.

How do you keep secrets out of a CI/CD pipeline?

Scan for secrets before commit and on every push, and turn on push protection if your platform offers it. Replace stored cloud keys with OIDC federation so the pipeline gets short-lived credentials scoped to one repository and environment. If a secret does leak, rotate it first and clean the history second, because clones and caches already have a copy.

How do you secure GitHub Actions workflows?

Set default token permissions to read-only and widen them per job, pin third-party actions to full commit SHAs, and never run untrusted pull request code in a pull_request_target workflow. Keep release workflows separate from anything that handles forks, including caches. Use OIDC with tightly scoped trust policies for cloud access and protected environments with required reviewers for production deploys.

Need help building a secure pipeline?

We build pipelines for software we then run, so we tune them for two things at once: catching what matters and staying fast enough that nobody routes around them. Senior engineers set up the checks, the blocking policy and the runner hardening, and if we built it, we're on the pager for it.