DevOps Multi-Cloud

Building a DevSecOps Pipeline: Wiring SAST, SCA, Secrets, and IaC Scanning with Risk-Based Gates

Most “DevSecOps” rollouts fail the same way: someone bolts five scanners onto the pipeline, sets every gate to fail-on-anything, and within a week developers are commenting out the security stage to ship. The goal is not maximum scanning. It is catching exploitable risk early while keeping the build green for everything else. This guide wires SAST, SCA, secret detection, and IaC scanning into CI with gates calibrated to severity and reachability, then closes the loop with aggregation and metrics.

1. Shift-left without shift-pain: where each scan belongs

Not every scan belongs in the inner loop. The cost of a finding rises the later you catch it, but so does the cost of a false finding interrupting flow. Place each control where its signal-to-noise ratio is best.

Scan Pre-commit PR / CI Main / nightly Blocking by default?
Secret detection Yes (fast, deterministic) Yes (diff scan) Yes (full history) Yes
SAST (CodeQL/Semgrep) Lightweight rules only Yes (diff-aware) Full database build High/critical only
SCA (Trivy/Grype) No Yes (lockfile diff) Yes (full SBOM) Reachable high/critical
IaC (Checkov/tfsec) Optional Yes Yes High/critical only

The principle: deterministic, sub-second checks (secrets, lint-grade Semgrep) run pre-commit. Anything that needs a build, a dependency graph, or a database (CodeQL) runs in CI on the pull request. Expensive full-history and full-image scans run nightly on the default branch where latency does not block a human.

Callout: A gate that blocks a PR must produce a finding the author can act on today. If the only fix is upgrading a transitive dependency with no patched version, that is a tracked exception, not a red build. Conflating “risk exists” with “this PR is the place to fix it” is the fastest way to lose developer trust.

2. Static analysis with Semgrep and CodeQL

Run two layers. Semgrep gives fast, customizable, diff-aware results on every PR. CodeQL gives deeper dataflow analysis (taint tracking across functions) on a schedule and on the default branch.

Semgrep in CI, scoped to the diff so you only flag code the PR actually touches:

# .github/workflows/semgrep.yml
name: semgrep
on:
  pull_request: {}
jobs:
  semgrep:
    runs-on: ubuntu-latest
    container:
      image: semgrep/semgrep
    steps:
      - uses: actions/checkout@v4
      - name: Diff-aware scan
        run: semgrep ci --sarif --output=semgrep.sarif
        env:
          SEMGREP_BASELINE_REF: ${{ github.event.pull_request.base.sha }}
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: semgrep.sarif

semgrep ci automatically scans only changed lines when a baseline ref is set, which is the single biggest false-positive reducer for legacy codebases. New rules apply to new code; the existing backlog does not light up every build.

For tuning, prefer suppressing at the source over disabling rules globally. An inline # nosemgrep: rule-id (with a comment justifying it) is reviewable in the diff; deleting a rule from config hides risk for the whole org silently.

CodeQL runs the heavy analysis. Use the default setup for most repos, or a custom workflow when you need specific query packs:

# .github/workflows/codeql.yml
name: codeql
on:
  push:
    branches: [main]
  schedule:
    - cron: "0 3 * * 1"
jobs:
  analyze:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
    strategy:
      matrix:
        language: [javascript-typescript, python]
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with:
          languages: ${{ matrix.language }}
          queries: security-extended
      - uses: github/codeql-action/autobuild@v3
      - uses: github/codeql-action/analyze@v3

The security-extended suite adds higher-recall queries at the cost of more findings; start with the default query set, prove the program works, then opt into security-extended once triage is healthy.

3. Dependency and SCA scanning with Trivy and Grype

SCA is where noise goes to spawn. A typical Node or Python project pulls thousands of transitive packages, and a raw CVE list will report hundreds of “criticals” you can do nothing about. Two filters tame this: severity and reachability.

Scan the filesystem (lockfiles) in the PR, and the built image nightly. Trivy reads package-lock.json, go.sum, requirements.txt, and friends directly:

# Fail the build only on fixable high/critical, output SARIF for the dashboard
trivy fs \
  --scanners vuln \
  --severity HIGH,CRITICAL \
  --ignore-unfixed \
  --exit-code 1 \
  --format sarif \
  --output trivy.sarif \
  .

--ignore-unfixed is the flag that matters most for sanity: it drops vulnerabilities with no released fix, so you only gate on things a developer can actually remediate by bumping a version. Unfixed criticals still get recorded (run a second non-blocking scan without the flag), but they become tracked work, not a blocked merge.

Reachability is the next layer. A vulnerable function buried in a dependency you never call is lower risk than one on your hot path. Trivy can be paired with reachability analysis for some ecosystems; Grype plus its SBOM workflow gives a similar second opinion. The pragmatic pattern is to enrich findings with EPSS (exploit prediction) and known-exploited status, then gate hardest on the intersection of high severity, fixable, and reachable or actively exploited:

# Generate an SBOM once, then scan it (decouples build from scan)
syft dir:. -o cyclonedx-json=sbom.json
grype sbom:sbom.json --fail-on high -o sarif > grype.sarif

Callout: Do not gate purely on CVSS. A CVSS 9.8 in a dev-only dependency that never reaches production is noise; a CVSS 7.5 on your auth path that appears in CISA’s Known Exploited Vulnerabilities catalog is an emergency. Severity is an input to risk, not risk itself.

4. Secret detection with gitleaks, hooks, and push protection

Secrets are the one category where you want defense in depth, because a leaked credential is exploitable the instant it lands. Layer three controls: client-side pre-commit, server-side CI, and platform push protection.

Pre-commit catches most secrets before they ever leave the laptop:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks
pip install pre-commit
pre-commit install   # installs the git hook into .git/hooks

CI is the backstop for anyone who skips the hook with --no-verify. Scan the full history on the default branch and the diff on PRs:

# .github/workflows/gitleaks.yml
name: gitleaks
on: [pull_request, push]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # full history so historical leaks are caught
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Finally, enable platform-level push protection so the forge itself rejects a push containing a recognized secret pattern. On GitHub that is secret scanning push protection, enabled at the org or repo level; on GitLab the equivalent is secret push protection. This is the only control that stops the secret from ever reaching the remote.

Critically: detection is step one. Any committed secret is compromised and must be rotated, even after you scrub history, because it existed in a clone, a fork, or a CI log somewhere. Bake rotation into the runbook, not just removal.

5. IaC scanning for Terraform and Kubernetes with Checkov and tfsec

Infrastructure misconfigurations (public S3 buckets, unencrypted disks, 0.0.0.0/0 security groups) are cheap to catch in the plan and expensive to catch in production. Checkov has the broadest policy coverage across Terraform, CloudFormation, Kubernetes, and Helm; tfsec (now folded into the Trivy project) is a fast Terraform-focused complement.

Scan Terraform in CI:

# Checkov over a Terraform directory, SARIF out, soft-fail handled by the gate logic
checkov \
  --directory . \
  --framework terraform \
  --output sarif \
  --output-file-path checkov_results \
  --compact

For higher fidelity, scan the plan rather than raw HCL so conditional and variable-driven resources are evaluated as they will actually deploy:

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
checkov -f tfplan.json --framework terraform_plan -o sarif --output-file-path checkov_plan

Trivy also covers IaC and Kubernetes manifests with a single binary, which is convenient if you already run it for SCA:

trivy config --severity HIGH,CRITICAL --format sarif --output trivy-iac.sarif ./infra

Suppress intentional deviations inline and in version control so they are reviewable. Checkov honors a skip comment directly above the resource:

# checkov:skip=CKV_AWS_18:Access logging handled centrally by the org log archive bucket
resource "aws_s3_bucket" "artifacts" {
  bucket = "kv-build-artifacts"
}

The justification text is mandatory in review: a skip without a reason is a finding in its own right.

6. Risk-based gating: thresholds, allowlists, and time-boxed exceptions

This is the heart of the program. The gate decides what turns a build red. Get it wrong in either direction and you either ship vulnerabilities or train developers to ignore security.

A defensible default policy:

Implement the gate as explicit logic, not as each scanner’s own --exit-code, so the policy lives in one readable place:

  enforce:
    needs: [semgrep, trivy, gitleaks, checkov]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Evaluate risk gate
        run: |
          set -euo pipefail
          fail=0
          for s in semgrep trivy gitleaks checkov; do
            n=$(jq '[.runs[].results[]
              | select(.level=="error")] | length' "artifacts/${s}.sarif" 2>/dev/null || echo 0)
            echo "${s}: ${n} blocking findings"
            fail=$(( fail + n ))
          done
          if [ "$fail" -gt 0 ]; then
            echo "::error::Risk gate failed with ${fail} blocking finding(s)"
            exit 1
          fi

Allowlists and exceptions must be explicit, attributed, and expiring. Encode them as data, not as a forgotten || true:

# .security/exceptions.yaml
exceptions:
  - id: CVE-2025-12345
    component: example-lib
    reason: "No patched release; not reachable from our entrypoints (see analysis #4821)"
    owner: platform-security
    expires: "2026-08-01"

A nightly job that fails when an exception is past expires is what keeps the allowlist from becoming a graveyard. An exception with no expiry is a permanent hole; the expiry forces a re-decision. Policy-as-code engines (OPA/Conftest) can evaluate this file in the gate so the rules themselves are versioned and testable.

7. Aggregating findings: SARIF, dashboards, and ticket automation

Five tools emitting five report formats is unmanageable. Standardize on SARIF (Static Analysis Results Interchange Format) as the lingua franca; every tool above can emit it, and GitHub’s code scanning ingests it natively:

      - uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy.sarif
          category: trivy-sca   # category keeps tools separate in the UI

Uploaded SARIF populates the repo’s Security tab with deduplicated, line-anchored findings, including fix status across runs so you can see what is new in a PR versus pre-existing. Use a distinct category per tool so one tool’s results never overwrite another’s.

For cross-repo visibility, pull findings into a central system. The pragmatic options are DefectDojo (open-source vulnerability management that imports SARIF and many native formats) or a SIEM/data warehouse fed by the forge’s security API. Automate ticket creation so tracked findings do not rot: a scheduled job queries open code-scanning alerts above a threshold and opens or updates issues, deduplicating on the rule ID plus location so you do not spawn a new ticket every night for the same finding.

Enterprise scenario

A fintech platform team I worked with turned on Trivy SCA across ~140 service repos with --severity HIGH,CRITICAL and --exit-code 1. Within two days every PR in the monorepo-adjacent Node services was red, all pointing at the same culprit: a transitive glibc and OpenSSL chain pulled in through the base image, plus a lodash advisory deep in the build toolchain that had no patched release on their pinned major. Developers did the rational thing and started merging with the security check set to non-required. The gate was technically working and operationally dead.

The fix was two-fold. First, add --ignore-unfixed so only remediable findings could block, and split the scan into a blocking pass (fixable) and a non-blocking inventory pass (everything, uploaded to the Security tab). Second — the part that actually saved it — establish a baseline so the existing backlog stopped lighting up new PRs. Trivy supports this with a baseline file generated on main:

# One-time on main: snapshot current findings as the baseline
trivy fs --severity HIGH,CRITICAL --ignore-unfixed \
  --format json --output .trivy-baseline.json .

# In PR CI: only fail on findings absent from the baseline
trivy fs --severity HIGH,CRITICAL --ignore-unfixed \
  --exit-code 1 --baseline .trivy-baseline.json .

New code was held to the bar; the legacy debt became tracked, time-boxed exceptions with owners and expires dates. PR-blocking findings dropped from hundreds to low single digits, the check went back to required, and the backlog burned down on a schedule instead of in a panic. The lesson: a gate calibrated for a greenfield repo will be routed around the instant you point it at a real codebase with history.

Verify

Confirm each layer actually fires before you trust the green check.

# 1. SCA gate trips on a known-vulnerable lockfile
trivy fs --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .; echo "exit=$?"

# 2. Secret detection catches a planted test credential
printf 'aws_secret_access_key = AKIAIOSFODNN7EXAMPLE\n' > /tmp/leak.tf
gitleaks detect --source /tmp/leak.tf --no-git -v; echo "exit=$?"

# 3. IaC scan flags a deliberately public bucket
checkov -f tfplan.json --framework terraform_plan --compact; echo "exit=$?"

# 4. SARIF parses and contains results
jq '.runs[].results | length' trivy.sarif

Then verify the gate logic itself: open a throwaway PR that introduces one fixable critical dependency and confirm the build goes red, the Security tab shows the finding, and a ticket is created. A gate you have never seen fail is a gate you cannot trust.

Rollout checklist

Measuring the program

A DevSecOps program you cannot measure is a faith-based one. Track at least four metrics:

Pitfalls

The recurring failure modes are predictable. Gating on raw CVSS instead of fixability and reachability buries teams in unactionable criticals. Treating secret removal as remediation while skipping rotation leaves live credentials exposed. Letting allowlists accumulate without expiry turns every exception into a permanent hole. Running expensive full scans on every PR adds minutes of latency that pushes developers to bypass the stage. And scanning without aggregation produces noise no one owns. Start narrow, gate only on high-confidence high-impact findings, prove the loop works end to end, and widen coverage only once triage and MTTR are healthy. Security that developers route around protects nothing.

DevSecOpsSASTSCATrivyPolicy as Code

Comments

Keep Reading