← All writing
· ~7 min read · CI/CD, Security, DevSecOps

CI/CD security gates that don't slow the team down

Most security gates in CI/CD pipelines fail one of two tests: they're so noisy nobody reads them, or they're so slow developers route around them. Here's the version I actually keep in production.

The honest reason security gates get ignored isn't culture — it's UX. A 12-minute scan that yells about a CVSS 4.3 issue in a transitive dev dependency is not a security control, it's a bikeshed generator. The first job is to build something engineers don't actively hate.

The three rules

  1. Fast or fail-soft. Anything over ~90 seconds runs in parallel or post-merge — never in front of a human waiting to ship a hotfix.
  2. Actionable or silent. If the finding doesn't tell the developer exactly what to change, it doesn't belong in the PR. Send it to the backlog instead.
  3. One owner per finding. "Security team" is not an owner. CODEOWNERS file, repo or service, name attached. No exceptions.

The pipeline shape

This is the GitHub Actions layout I default to. GitLab CI maps 1:1.

name: ci
on: [pull_request, push]
permissions:
  id-token: write   # OIDC for AWS, no static keys
  contents: read
  security-events: write
jobs:
  lint-and-test:    # ~1–2 min
  sast:             # Semgrep, ~30s
  dependency-scan:  # OSV-Scanner or Trivy fs, ~20s
  build-image:      # depends on lint-and-test
  scan-image:       # Trivy/Grype on the built image, ~30s
  sbom:             # Syft, attached to release
  dast:             # ZAP baseline, only on main, ~5 min
  deploy:           # OIDC-assumed role, only on main

SAST without the noise

Default Semgrep rulesets are fine to start, but they will fire on things your team will never fix. After 2 weeks, do a triage round:

The point: a rule that always passes is noise; a rule that always fails gets disabled. Aim for ~80% pass on first commit, ~95% on second commit.

Dependency scanning

Two scanners are better than one — they disagree, and the disagreement is signal.

Block PRs on HIGH+ with a known fix version. Everything else goes to a weekly Renovate-driven batch. Don't block on issues you can't fix.

Container scanning

Scan the image you actually ship, not "what the Dockerfile probably builds". Run it after docker build in the same job. Cache the vulnerability DB between runs — first-pull is 30s, subsequent 3s.

Use a baseline file (.trivyignore) but require an expiry date on every ignore. CI fails when an ignore expires. The compromise: developers can mute findings, but only temporarily.

SBOM as a deliverable

Generate an SBOM (Syft, SPDX or CycloneDX) on every release build. Attach it to the OCI image as a referrer (Cosign attestation) or to the GitHub Release. You'll thank yourself the first time a Log4Shell-shaped event hits and you can answer "which builds shipped this?" in 30 seconds instead of three days.

DAST: don't run it in PRs

ZAP baseline scans take 3–10 minutes and produce mostly the same findings every time. Run them post-merge on main, with results uploaded to GitHub Code Scanning. Page on a new finding only — diff-driven, not absolute.

OIDC and the death of static keys

If a single thing on this list mattered: kill long-lived cloud credentials in CI. Use OIDC trust between GitHub Actions / GitLab and your cloud IAM. Configure the trust policy with branch and repo conditions, not just aud=sts.amazonaws.com.

{
  "StringLike": {
    "token.actions.githubusercontent.com:sub":
      "repo:org/repo:ref:refs/heads/main"
  },
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
  }
}

The measure of success

Three numbers I track for any pipeline I touch:

None of this is exotic. The hard part is having the discipline to remove rules that don't pay rent, and the credibility to add the ones that do.

If you'd like a hand wiring up a pipeline like this — get in touch.