Your CI/CD Security Wiring Diagram: SAST, SCA, IaC, SBOM, and Signatures Without Killing Throughput
What to scan, where to gate, and how to keep builds fast. Real configs, metrics, and rollout playbooks that won’t blow up your developer experience.
Security that’s slow will get bypassed. Security that’s fast becomes culture.Back to all posts
The problem isn’t tools; it’s wiring and incentives
If you’ve ever dropped a scanner into CI and watched lead time double, you know the movie. Two sprints later, the team quietly flips it to “warn.” I’ve seen this at a fintech on GitLab, a B2B SaaS on Actions, and a Fortune 500 on Jenkins. Same smell: security bolted on, not integrated.
What actually works is a clear wiring diagram, hard gates only where they’re cheap and credible, and metrics that keep leadership honest. Below is the wiring I deploy at GitPlumbers when we’re asked to “add security without killing velocity.”
- Fast on PR: secrets, SAST, IaC (under 90s total)
- Heavier on merge to main: SCA, container scan, SBOM, sign
- Gate at deploy: policy + signature verification
- Post-deploy/nightly: DAST, repo-wide baseline jobs
Keep PRs fast, put heavy lifting after merge, and enforce at deploy like you enforce tests.
The reference flow: where each scan belongs
Think checkpoints, not a monolith.
Commit/PR
- Secrets scanning:
gitleaksor native SCM (GitHub secret scanning) on diff - SAST:
semgreporcodeqlwith incremental scope - IaC:
checkov/tfsecon changed*.tf,k8s/*.yaml, Helm charts - Gate: fail on new Critical/High; warn on Medium until baseline is clean
- Secrets scanning:
Build and containerize (on merge)
- SCA: lockfile scan (e.g.,
trivy fsor Snyk) plusdockerimage layers - Container scan:
trivy imageorgrypeon the built image - SBOM:
syftto CycloneDX or SPDX; attach to artifact - Sign:
cosign signwith keyless or KMS; push provenance (SLSA provenance attestation)
- SCA: lockfile scan (e.g.,
Pre-deploy
- Policy check:
conftest(OPA) against SBOM and k8s manifests (e.g., block:latest, CVSS >= 9) - Signature verification:
cosign verifyin the CD controller (e.g.,Kyverno,Policy Controller)
- Policy check:
Post-deploy / nightly
- DAST: OWASP ZAP baseline on staging or review env
- Repo-wide baseline: full SAST/SCA to ratchet debt without blocking PRs
This gets you credible protection where it matters, and speed where devs live.
A concrete GitHub Actions pipeline (fast PRs, heavier merges)
Below is a minimal but real Actions setup. Same logic ports to GitLab or Jenkins.
ame: ci-security
on:
pull_request:
branches: ["*"]
paths-ignore: ["**/*.md", "docs/**"]
push:
branches: ["main"]
jobs:
pr-fast-scans:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions: { security-events: write, contents: read }
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Cache tools
uses: actions/cache@v4
with:
path: ~/.cache/semgrep
key: ${{ runner.os }}-semgrep-${{ hashFiles('**/*.yml') }}
- name: Secret scan (gitleaks)
uses: zricethezav/gitleaks-action@v2
with:
args: "detect --source . --no-git --redact --baseline-path .gitleaks.baseline.json --exit-code 1"
- name: SAST (Semgrep incremental)
uses: returntocorp/semgrep-action@v1
with:
config: p/owasp-top-ten
generateSarif: true
auditOn: pull_request
baselineRef: origin/main
- name: IaC scan (Checkov)
uses: bridgecrewio/checkov-action@v12
with:
directory: .
soft_fail: false
framework: terraform,kubernetes,helm
build-and-scan:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions: { contents: read, packages: write, id-token: write }
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
echo "IMAGE=ghcr.io/${{ github.repository }}:${{ github.sha }}" >> $GITHUB_ENV
- name: SBOM (Syft CycloneDX)
uses: anchore/sbom-action@v0
with:
image: ${{ env.IMAGE }}
format: cyclonedx-json
artifact-name: sbom.cdx.json
- name: Container scan (Trivy)
uses: aquasecurity/trivy-action@0.20.0
with:
image-ref: ${{ env.IMAGE }}
format: table
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: 1
- name: Produce provenance (SLSA)
uses: slsa-framework/slsa-github-generator@v2
with:
artifact_path: .
- name: Sign image (Cosign keyless)
env:
COSIGN_EXPERIMENTAL: "true"
run: |
cosign sign ${{ env.IMAGE }}
- name: Push image
run: docker push ${{ env.IMAGE }}
policy-gate:
needs: build-and-scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Policy (Conftest on k8s manifests)
run: |
conftest test deploy/k8s --policy policy/ --input yamlAdd verification in your CD path. For Argo CD or Flux, enforce signature verification and basic policies.
# kyverno ClusterPolicy example
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: require-sigstore
match:
any:
- resources:
kinds: [Deployment]
verifyImages:
- imageReferences: ["ghcr.io/your-org/*"]
attestors:
- entries:
- keyless:
issuer: https://token.actions.githubusercontent.com
subject: "repo:your-org/your-repo:ref:refs/heads/main"GitLab and Jenkins snippets (because not everyone’s on Actions)
GitLab CI: SAST/Secrets/IaC on PR, heavy on default branch.
stages: ["test","build","security","deploy"]
variables:
TRIVY_EXIT_CODE: 1
pr_scans:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- gitleaks detect --no-git --exit-code 1
- semgrep --config p/owasp-top-ten --error --baseline-ref origin/main
- checkov -d . --framework terraform,kubernetes,helm
container_scan:
stage: security
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code $TRIVY_EXIT_CODE $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- syft packages -o cyclonedx-json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA > sbom.cdx.json
- cosign sign $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
artifacts:
paths: [sbom.cdx.json]Jenkinsfile: keep it parallel and cache aggressively.
pipeline {
agent any
stages {
stage('PR Scans') {
when { changeRequest() }
parallel {
stage('Secrets') { steps { sh 'gitleaks detect --no-git --exit-code 1' } }
stage('SAST') { steps { sh 'semgrep --config p/owasp-top-ten --error --baseline-ref origin/main' } }
stage('IaC') { steps { sh 'checkov -d . --framework terraform,kubernetes,helm' } }
}
}
stage('Build+Scan') {
when { branch 'main' }
steps {
sh 'docker build -t $IMAGE .'
sh 'trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 $IMAGE'
sh 'syft $IMAGE -o cyclonedx-json > sbom.cdx.json'
sh 'cosign sign $IMAGE'
}
}
}
}Gates, thresholds, and waivers that won’t become permanent exceptions
You need teeth, but not fangs that bite the wrong people.
- Severity gates:
- PRs: fail on new
Critical(immediately), warn onHighfor first 30 days; then ratchet to fail onHigh. - Main: fail build on
Critical,High; allowMediumwith SLA and auto-ticket.
- PRs: fail on new
- Time-bound waivers:
- Store waivers in repo as code with expiry and owner. Example
conftestinput:
- Store waivers in repo as code with expiry and owner. Example
package waivers
default allow = false
allow {
input.finding.id == "CVE-2023-12345"
input.repo == "acme/service-a"
time.now_ns() < time.add(time.now_ns(), 14*24*60*60*1000000000) # 14 days
input.owner == "team-payments"
}- Break-glass:
- Allow merge with label
security-break-glassonly if an incident number is linked, and send an audit event to Slack/SEIM.
- Allow merge with label
- Pinning and image hygiene:
- Pin base images (
python:3.11.7-slim), block:latestin policy, and rebuild weekly for patched layers.
- Pin base images (
This keeps pressure where it belongs: on fixing real risk, not arguing with the scanner.
Keep it fast and sane: tuning for signal, not noise
- Incremental scanning:
semgrep --baseline-ref origin/mainandcodeql database analyze --sarif --threads 4only on changed files.
- Caching and parallelization:
- Cache rule downloads and language packs; parallelize secrets/SAST/IaC on PRs. Target < 90s PR budget.
- De-duplication and SARIF:
- Emit
SARIFfor SAST/SCA and centralize in the code hosting security tab. Merge duplicates across tools.
- Emit
- Vulnerability sources:
- For containers, use distro feeds (e.g.,
trivy --ignore-unfixedto avoid churn) and prefer slim images to cut CVE surface.
- For containers, use distro feeds (e.g.,
- Baselines for legacy code:
- Establish a baseline report today; only fail on “new” findings. Tackle old debt via backlog with owners and SLAs.
- False positive management:
- Use
semgrepautofixand--disable-nosempatterns sparingly; every suppression requires an issue link and expiry.
- Use
- IaC context:
- Enrich scans with cloud context (e.g.,
checkov --skip-check CKV_AWS_79only if an org-wide guardrail exists via SCP).
- Enrich scans with cloud context (e.g.,
Metrics that prove it’s working (and when it isn’t)
Executives love big numbers; engineers love truthful ones. Track both.
- Lead time impact: median PR scan duration, target < 90s; build+scan on main < 8 min.
- Defect flow:
- Findings per KLOC (new only), trend down week over week.
- Remediation MTTR: Critical < 3 days, High < 14 days, Medium < 45 days.
- Gate effectiveness: percent of merges blocked for valid reasons vs reverted waivers.
- Coverage: percent of repos with SBOM + signed artifacts; percent of deploys with signature verified.
- Noise: false positive rate (< 10%) measured via suppression with evidence links.
Quick and dirty export for GitHub to a dashboard:
# Count open critical vulns from code scanning
gh api -X GET repos/:owner/:repo/code-scanning/alerts --paginate \
| jq '[.[] | select(.rule.severity=="critical" and .state=="open")] | length'
# Average PR security job duration
gh run list --event pull_request --json duration,status | jq '[.[] | select(.status=="completed") | .duration] | add/length'Wire these into Prometheus/Grafana, or push to Datadog as custom metrics. Set SLOs and page yourselves when MTTR breaches.
A 30/60/90-day rollout that survives contact with reality
Day 0–30: visibility, no surprises
- Turn on PR scans (secrets, SAST, IaC) in warn-only except Critical. Establish baselines. Cache and parallelize.
- Nightly SCA and container scans on main. Publish SBOMs. No gates yet except on Criticals.
- Metrics: capture durations, findings, and MTTR starting today.
Day 31–60: add teeth
- Fail on High in PRs. Enforce container scan gates on main. Introduce signature verification in staging.
- Implement waivers with expiry and owner. Start policy-as-code for
:latestand privilege blocks. - Metrics: show MTTR improvements; track gate-induced failures vs. false positives.
Day 61–90: production-grade
- Enforce signature verification in prod. Add DAST in pre-prod nightly. Ratchet to fail on Medium in sensitive services.
- Integrate SBOM diffing in CD; block if new Criticals appear between build and deploy.
- Metrics: coverage > 90% repos, PR scan p95 < 120s, Critical MTTR < 3 days.
If any metric blows up, roll back severity gates one notch and fix tooling noise before re-enforcing.
Gotchas I keep seeing (so you can dodge them)
- Monorepos: path filters are mandatory or your PR scans will be 10 minutes. Use CODEOWNERS to scope ownership and waivers.
- Private registries/air-gapped: pre-warm scanners and CVE DBs in the runner; mirror feeds; consider Grype+Syft offline modes.
- Third-party services: your CD nodes need egress to OIDC issuers for keyless signing; otherwise use KMS-backed keys.
- Alpine-based images: busybox/glibc differences can produce noisy CVEs; consider
-slimDebian or Wolfi for cleaner feeds. - Java builds:
mvn -DskipTestsstill downloads half the internet; cache local m2 or use GitHub Actions dependency caching. - Helm and Kustomize: template before scanning IaC or you’ll miss runtime values.
helm templateand feed the result toconftest. - License scanning: legal will ask. Add SPDX/CycloneDX licenses in SBOM and auto-fail on copyleft in closed-source services.
The goal is not zero findings. The goal is zero surprise risk at deploy time, with predictable, fast feedback for developers.
Key takeaways
- Place fast, cheap scans (secrets, SAST, IaC) on pull requests; run heavier scans (container, SBOM, DAST) on merge and nightly.
- Gate on severity with expirations and waivers, not permanent allowlists. Break-glass only with audit trail.
- Keep scans sub-90s per stage via caching, incremental scope, and parallelization. Don’t punish developers for doing the right thing.
- Emit SBOMs by default and sign artifacts. Verify signatures at deploy. Treat supply chain as part of the build, not an afterthought.
- Track remediation speed, not just counts. Set SLAs by severity and measure MTTR for vulns and misconfigurations.
- Roll out in 30/60/90 days: start with visibility, then enforce on criticals, then ratchet to highs as noise drops.
Implementation checklist
- Decide scan placement: pre-commit (optional), PR (secrets/SAST/IaC), merge (SCA/container/SBOM), pre-deploy (policy/signature), post-deploy (DAST).
- Pick tools: Gitleaks, Semgrep/CodeQL, Checkov/tfsec, Trivy/Grype, Syft (CycloneDX), Cosign, OPA/Conftest, ZAP.
- Set thresholds and waivers: fail on criticals immediately, highs within 7–14 days, everything else tracked with backlog and ownership.
- Implement caching and path filters for sub-90s PR scans. Use baselines to avoid re-litigating legacy noise.
- Publish results as SARIF and metrics to your observability. Build dashboards (pass rate, MTTR, scan duration, false positive rate).
- Add artifact signing and verify at deploy. Reject unsigned or tampered images by policy.
Questions we hear from teams
- What’s the minimal viable stack if I have to start this week?
- PR: Gitleaks (secrets), Semgrep (SAST), Checkov (IaC). Merge to main: Trivy (SCA+image), Syft (SBOM), Cosign (sign). Deploy: Kyverno or Conftest for policy, verify signatures. Nightly: ZAP for DAST. This gets you 80% in under two weeks.
- How do I keep PR scans under 90 seconds?
- Use path filters to only scan changed code, cache rule downloads, run secrets/SAST/IaC in parallel, and skip markdown/docs. Semgrep baseline + Checkov targeted frameworks usually keeps under 90s on medium repos.
- What about false positives?
- Baseline first, fail only on new findings, and require waivers with expiry and an owner for any suppression. Track false positive rate and fix rules that cause churn before ratcheting gates.
- Do I need both SCA and container image scanning?
- Yes. SCA on lockfiles catches app deps; image scanning catches base OS packages and supply chain issues introduced by the build. They overlap but miss different classes of vulns.
- How do I prove to compliance that we’re covered?
- Show SBOMs attached to each build, signed artifacts, policy enforcement logs (Kyverno/OPA), and MTTR metrics by severity. Map to frameworks (SOC2/ISO) with evidence links from your CI logs and dashboards.
- What if we’re on Jenkins with self-hosted runners only?
- Pre-warm containers with scanner binaries and CVE DBs, schedule nightly updates, and mirror feeds. Use shared libraries to standardize stages and keep PR scans parallel.
Ready to modernize your codebase?
Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.
