Security used to live at the end of our pipeline. A dedicated security review gate, scheduled quarterly, staffed by a team of two. The result was predictable: mountains of deferred findings, developers who had context-switched away three sprints ago, and fixes that introduced regressions because no one remembered why the original code was written that way.
We changed that. Over six months, we embedded SAST, DAST, and SCA scanning directly into every merge request. Here is what we learned.
Why “Shift Left” Is More Than a Slogan
The earlier a vulnerability is caught, the cheaper it is to fix. IBM’s System Sciences Institute puts the cost multiplier at 100× between development and post-production. That number feels abstract until you spend two weeks backporting a patch to three production environments while a customer’s SOC is watching your logs.
Shifting left means developers own security findings in the same workflow where they own test failures. No separate ticket queue. No waiting for a security team bottleneck. Just a red pipeline that tells you what is broken and why.
The Three Layers of Pipeline Security
1. SAST — Static Analysis in Every MR
Static Application Security Testing analyses source code without executing it. We use GitLab’s built-in SAST (powered by Semgrep under the hood) for our Python, Go, and TypeScript services.
include: - template: Security/SAST.gitlab-ci.yml
sast: variables: SAST_EXCLUDED_PATHS: "tests/, docs/" SAST_SEVERITY_THRESHOLD: "medium" rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHThe key configuration decision is SAST_SEVERITY_THRESHOLD. We block MRs on critical and high findings, but surface medium as warnings. Blocking on medium immediately triggers alert fatigue — engineers start ignoring the scanner.
For infrastructure-as-code, we layer in Checkov for Terraform files:
checkov: stage: sast image: bridgecrew/checkov:latest script: - checkov -d infrastructure/ --framework terraform --output cli --soft-fail-on MEDIUM rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" changes: - infrastructure/**/*2. SCA — Dependency Vulnerability Scanning
Software Composition Analysis catches vulnerabilities in your third-party dependencies. This is where the volume is — the average Node.js application has 1,000+ transitive dependencies.
include: - template: Security/Dependency-Scanning.gitlab-ci.yml
dependency_scanning: variables: DS_EXCLUDED_ANALYZERS: "bundler-audit" DS_MAX_DEPTH: 5We complement this with Trivy for container image scanning at build time:
trivy-scan: stage: security image: aquasec/trivy:latest script: - trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA allow_failure: false--ignore-unfixed is critical here. Without it, you’ll fail builds on CVEs where no patched base image exists yet — which means every build fails until upstream publishes a fix that may be months away.
3. DAST — Dynamic Testing Against a Live Environment
Dynamic Application Security Testing runs against a running instance of your application. It catches things SAST misses: authentication bypass, injections in runtime-evaluated code, misconfigurations in response headers.
We deploy to a review environment on every MR (Cloudflare Pages preview URLs) and then point DAST at it:
include: - template: DAST.gitlab-ci.yml
dast: stage: dast variables: DAST_WEBSITE: $REVIEW_APP_URL DAST_FULL_SCAN_ENABLED: "false" # baseline scan on MRs DAST_ZAP_USE_AJAX_SPIDER: "true" environment: name: review/$CI_COMMIT_REF_SLUG url: $REVIEW_APP_URL rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"We use baseline scans (passive only) on MRs and full active scans nightly against staging. Full scans against a running app can create noise (or actual damage if the app is destructive by nature), so limit active scanning to environments built for it.
Handling False Positives Without Breaking Morale
The number one reason security scanning fails in practice is false positive fatigue. Engineers who see 40 findings on a UI library import — none of them exploitable in their context — will start adding # nosec comments as muscle memory.
Our approach:
- Triage weekly — a 30-minute weekly review of new findings in GitLab’s vulnerability dashboard. Anything confirmed false positive gets dismissed with a comment explaining why.
- Baseline exceptions — for accepted risks (e.g., a CVE in a test-only dependency), use GitLab’s vulnerability management to mark it
acceptedwith a rationale and expiry date. - Keep the block list narrow — only
criticalandhighexploitable findings block merge. Everything else informs but does not stop.
Results After Six Months
| Metric | Before | After |
|---|---|---|
| Mean time to detect (MTTD) | 73 days | 4 hours |
| Critical vulns reaching staging | 18/quarter | 2/quarter |
| Security review meetings | 4/year | 0 |
| Developer security tickets (open) | 140 | 23 |
The security review meetings going to zero is the outcome I’m most proud of. Not because security became unimportant, but because it became routine — part of the same feedback loop as a failing unit test.
What’s Next
We’re currently rolling out gitleaks for secrets detection (pre-receive hooks + CI stage) and evaluating IAST agents for our Python services. I’ll cover both in follow-up posts.
If you’re just getting started, the minimum viable pipeline security stack is:
- Semgrep SAST (free, open-source rules)
- Trivy for dependencies + container images
- OWASP ZAP baseline scan for DAST
All three are free, all three integrate cleanly with GitLab CI. Ship that, tune the thresholds for your context, and you’ll be ahead of 90% of teams your size.