Security Gates¶
A security gate is a checkpoint in the pipeline that decides whether code or an artifact is allowed to proceed — to merge, to be released, or to be deployed — based on security criteria. Gates are how shift-left scanning translates into actual risk reduction: a finding that never blocks anything rarely gets fixed.
The idea is simple: catch problems before they hit production. A vulnerability found during a pull request costs minutes to fix. The same vulnerability found in production is an incident, potentially a breach, and weeks of cleanup.
The hard part is not adding gates; it is designing gates that reduce risk without destroying developer flow. Gates that are too noisy or too strict get disabled, ignored, or bypassed.
Principles for effective gates¶
- Gate on risk, not raw counts — block on severity, exploitability, and reachability, not on the total number of findings. A gate that blocks on "any medium CVE in any transitive dependency" will be overridden within a week.
- Baseline existing issues — hold teams accountable for new risk they introduce, not the entire backlog of legacy debt at once. Use a known-good baseline snapshot and block only on findings that did not exist in the baseline.
- Fail fast and clearly — when a gate blocks, tell the developer exactly what, where, and how to fix it. A gate that says "build failed" with no guidance is friction without value.
- Provide a path forward — support documented, time-boxed risk acceptance / exceptions with an owner, rather than forcing developers to silently disable checks.
- Tune relentlessly — high false-positive rates are the fastest way to lose developer trust; treat noise as a bug in the gate, not a developer compliance problem.
Where to place gates¶
+-----------------------------------------------------------------------------+
| CI/CD Pipeline Security Gates |
+-------------+-------------+-------------+-------------+---------------------+
| Pre-Commit | Build | Test | Release | Deploy |
+-------------+-------------+-------------+-------------+---------------------+
| - Secrets | - SAST | - DAST | - Image | - Admission |
| scanning | - SCA | - IAST | signing | controllers |
| - Linting | - Container | - Pentest | - SBOM | - Runtime |
| - Hooks | scanning | (staged) | generation| policies |
| | - IaC scan | | - Artifact | - Network |
| | | | attestation| policies |
+-------------+-------------+-------------+-------------+---------------------+
| Stage | Typical gate | Failure mode |
|---|---|---|
| Pre-commit / IDE | Secrets, linting, fast SAST (advisory) | Advisory only — educate, don't block here |
| Pull request | SAST, SCA, IaC — block new high/critical | Block merge; surface in PR comment with fix guidance |
| Build | Full scans, container scan, SBOM generation | Block artifact promotion from build stage |
| Release | Signed artifacts, provenance, no unresolved criticals | Block publishing to production registry |
| Deploy | Admission control: only signed, policy-compliant artifacts | Block workload from running in cluster |
# Example: GitHub Actions gate using Semgrep — blocks on new findings only
# (the returntocorp/semgrep-action wrapper is deprecated; run `semgrep ci` in the official image)
semgrep:
runs-on: ubuntu-latest
container:
image: semgrep/semgrep # pin to a version tag or digest in production
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history is needed to diff against the base branch
- name: Semgrep SAST gate
run: semgrep ci --config p/owasp-top-ten --config p/secrets
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
SEMGREP_BASELINE_REF: origin/${{ github.base_ref }} # diff-aware: new findings only
Defining thresholds and policy¶
Decide what blocks before wiring up tools¶
Before adding scanners, decide what blocks a deployment versus what only logs a warning. A common starting point:
| Severity | Action | Example threshold |
|---|---|---|
| Critical | Block deployment | 0 allowed |
| High | Block deployment | 0 allowed |
| Medium | Warn, require approval | 5 or fewer allowed |
| Low | Warn only | No limit (track) |
Centralize gate policy¶
Keep gate policies in a single, version-controlled config so every team knows exactly what is enforced:
# .security-gates.yaml
version: "1.0"
gates:
sast:
enabled: true
fail_on:
critical: true
high: true
medium: false
tools:
- semgrep
- codeql
sca:
enabled: true
fail_on:
critical: true
high: true
max_age_days: 30 # Fail if dependencies older than 30 days
license_policy:
denied:
- GPL-3.0
- AGPL-3.0
container:
enabled: true
fail_on:
critical: true
high: true
base_image_policy:
allowed_registries:
- gcr.io
- docker.io/library
max_age_days: 90
secrets:
enabled: true
fail_on_any: true
iac:
enabled: true
fail_on:
critical: true
high: true
frameworks:
- terraform
- kubernetes
- dockerfile
Roll out gradually¶
Don't flip everything to "block" on day one — developers will get frustrated and the gates will be disabled. Start soft and tighten over a few sprints:
# Phase 1: Warn only (Week 1-2)
security_gate_mode: "warn"
# Phase 2: Block critical only (Week 3-4)
security_gate_mode: "block_critical"
# Phase 3: Block critical and high (Week 5+)
security_gate_mode: "block_critical_high"
End-to-end pipeline examples¶
The examples below wire several gates into a single pipeline. They use per-tool JSON output and simple threshold checks; see Normalizing multi-scanner output for a more robust, SARIF-based approach once you have more than a couple of scanners.
GitHub Actions¶
# .github/workflows/security-gates.yaml
# Third-party actions are pinned to release tags for readability; in production,
# pin to a full commit SHA (e.g. `uses: owner/action@<sha> # vX.Y.Z`) so a
# compromised or moved tag cannot change what your gate runs.
name: Security Gates Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
# Gate 1: Secret Scanning
secrets-gate:
name: "Secrets Gate"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# No base/head overrides: the action derives the commit range from the
# push or pull_request event itself. Hard-coding the default branch as
# base makes base == HEAD on pushes to that branch, which TruffleHog rejects.
- name: TruffleHog Secret Scan
uses: trufflesecurity/trufflehog@v3.97.9
with:
path: ./
extra_args: --only-verified
- name: Gitleaks Scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gate 2: SAST Gate
sast-gate:
name: "SAST Gate"
runs-on: ubuntu-latest
permissions:
contents: read # checkout (required in private repos once permissions are restricted)
actions: read # CodeQL upload processing
security-events: write # upload SARIF to code scanning
steps:
- uses: actions/checkout@v4
# CodeQL uploads findings to GitHub code scanning but does NOT fail this job.
# Its results are enforced separately via a code-scanning merge-protection
# rule / ruleset ("Code scanning results" with a severity threshold). That
# protects merges only — the job below is what gates this pipeline.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript, python # Adjust based on your languages
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
- name: Run Semgrep
run: |
python3 -m pip install --quiet semgrep
semgrep scan \
--config p/security-audit \
--config p/secrets \
--config p/owasp-top-ten \
--json --output semgrep.json .
- name: Check SAST Results
run: |
# Fail closed: a missing report means the scanner did not run.
if [ ! -f semgrep.json ]; then
echo "SAST Gate ERROR: semgrep.json not produced"
exit 1
fi
CRITICAL=$(jq '[.results[] | select(.extra.severity == "ERROR")] | length' semgrep.json)
if [ "$CRITICAL" -gt 0 ]; then
echo "SAST Gate FAILED: $CRITICAL high/critical findings"
jq '.results[] | select(.extra.severity == "ERROR") | {rule: .check_id, file: .path, line: .start.line}' semgrep.json
exit 1
fi
echo "SAST Gate PASSED"
# Gate 3: SCA Gate (Dependency Scanning)
sca-gate:
name: "SCA Gate"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy SCA Scan
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'json'
output: 'trivy-sca-results.json'
vuln-type: 'library'
severity: 'CRITICAL,HIGH'
- name: Evaluate SCA Gate
run: |
if [ ! -f trivy-sca-results.json ]; then
echo "SCA Gate ERROR: trivy-sca-results.json not produced"
exit 1
fi
CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-sca-results.json)
HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-sca-results.json)
echo "SCA Results: Critical=$CRITICAL, High=$HIGH"
# Policy: zero critical AND zero high (matches the threshold table above)
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
echo "SCA Gate FAILED: $CRITICAL critical, $HIGH high vulnerabilities found"
jq '.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL" or .Severity == "HIGH") | {Package: .PkgName, Version: .InstalledVersion, CVE: .VulnerabilityID, Severity: .Severity, Title: .Title}' trivy-sca-results.json
exit 1
fi
echo "SCA Gate PASSED"
- name: License Compliance Check
run: |
# pip-licenses inventories the packages installed in the current
# environment, so install the project's *locked* dependencies into an
# isolated venv first — otherwise only the reporting tool is inventoried.
python3 -m venv .venv-licenses
. .venv-licenses/bin/activate
pip install --quiet -r requirements.txt # or: pip-sync, poetry install --only main, etc.
pip install --quiet pip-licenses
pip-licenses --format=json --output-file=licenses.json
# Check for denied licenses. pip-licenses reports the package's *native*
# classifier/metadata name (e.g. "GNU General Public License v3 (GPLv3)",
# "GNU Affero General Public License v3", "GPL-3.0-only"), not a normalized
# SPDX id, so match the family rather than an exact SPDX string. The
# pattern below catches GPL/AGPL in both spellings and deliberately
# excludes LGPL. For strict SPDX-based policy use a lockfile/SBOM license
# scanner (e.g. `trivy fs --scanners license`, ScanCode, or Dependency-Track).
DENY_RE='(^|[^L])GPL|Affero|General Public License v[23]'
DENIED=$(jq --arg re "$DENY_RE" '[.[] | select(.License | test($re; "i") and (test("Lesser|LGPL"; "i") | not))] | length' licenses.json)
if [ "$DENIED" -gt 0 ]; then
echo "License Gate FAILED: Found $DENIED packages with denied licenses"
jq --arg re "$DENY_RE" '.[] | select(.License | test($re; "i") and (test("Lesser|LGPL"; "i") | not))' licenses.json
exit 1
fi
echo "License Gate PASSED"
# Gate 4: Container Security Gate
container-gate:
name: "Container Gate"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Docker image names must be lowercase; `github.repository` keeps the
# owner/repo casing, so use a fixed local tag for the build-and-scan step.
- name: Build Container Image
run: docker build -t app-under-test:${{ github.sha }} .
- name: Run Trivy Container Scan
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: 'app-under-test:${{ github.sha }}'
format: 'json'
output: 'trivy-container-results.json'
severity: 'CRITICAL,HIGH,MEDIUM'
- name: Evaluate Container Gate
run: |
if [ ! -f trivy-container-results.json ]; then
echo "Container Gate ERROR: trivy-container-results.json not produced"
exit 1
fi
CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-container-results.json)
HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-container-results.json)
echo "Container Scan Results: Critical=$CRITICAL, High=$HIGH"
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
echo "Container Gate FAILED: $CRITICAL critical, $HIGH high vulnerabilities"
exit 1
fi
echo "Container Gate PASSED"
- name: Dockerfile Best Practices (Hadolint)
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
failure-threshold: error
# Gate 5: IaC Security Gate
iac-gate:
name: "IaC Gate"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Checkov IaC Scan
uses: bridgecrewio/checkov-action@v12.3128.0
with:
directory: .
framework: terraform,kubernetes,dockerfile
output_format: json
# Checkov treats this as a *directory* and writes results_json.json inside it
output_file_path: checkov-results
soft_fail: true # defer the pass/fail decision to the evaluator below
- name: Evaluate IaC Gate
run: |
REPORT=checkov-results/results_json.json
# Fail closed: no report means Checkov did not run or crashed.
if [ ! -f "$REPORT" ]; then
echo "IaC Gate ERROR: $REPORT not produced"
exit 1
fi
# Open-source Checkov output carries no severity field (severity requires
# the Prisma Cloud platform), so gate on failed checks directly. Multi-framework
# runs emit an *array* of per-framework reports; single runs emit one object —
# handle both. Use .checkov.yaml `skip-check:` for documented exceptions.
FAILED=$(jq '[.. | objects | select(has("failed_checks")) | .failed_checks[]?] | length' "$REPORT") || {
echo "IaC Gate ERROR: malformed Checkov report"; exit 1; }
echo "IaC Scan Results: Failed Checks=$FAILED"
if [ "$FAILED" -gt 0 ]; then
echo "IaC Gate FAILED: $FAILED policy violations"
jq -r '.. | objects | select(has("failed_checks")) | .failed_checks[]? | "\(.check_id) \(.file_path):\(.file_line_range[0]) \(.check_name)"' "$REPORT"
exit 1
fi
echo "IaC Gate PASSED"
# Final Gate: Aggregate Results
security-gate-summary:
name: "Security Gate Summary"
needs: [secrets-gate, sast-gate, sca-gate, container-gate, iac-gate]
runs-on: ubuntu-latest
if: always()
steps:
- name: Check Gate Results
run: |
echo "## Security Gate Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ needs.secrets-gate.result }}" == "success" ]; then
echo "Secrets Gate: PASSED" >> $GITHUB_STEP_SUMMARY
else
echo "Secrets Gate: FAILED" >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ needs.sast-gate.result }}" == "success" ]; then
echo "SAST Gate: PASSED" >> $GITHUB_STEP_SUMMARY
else
echo "SAST Gate: FAILED" >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ needs.sca-gate.result }}" == "success" ]; then
echo "SCA Gate: PASSED" >> $GITHUB_STEP_SUMMARY
else
echo "SCA Gate: FAILED" >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ needs.container-gate.result }}" == "success" ]; then
echo "Container Gate: PASSED" >> $GITHUB_STEP_SUMMARY
else
echo "Container Gate: FAILED" >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ needs.iac-gate.result }}" == "success" ]; then
echo "IaC Gate: PASSED" >> $GITHUB_STEP_SUMMARY
else
echo "IaC Gate: FAILED" >> $GITHUB_STEP_SUMMARY
fi
- name: Fail if Any Gate Did Not Succeed
# Treat cancelled/skipped like failure: an unfinished scan must not leave
# this required check green.
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')
run: |
echo "One or more security gates did not succeed. Blocking deployment."
exit 1
GitLab CI¶
# .gitlab-ci.yml
# Scanner images use floating tags (`:latest`, `:debug`) for brevity; in
# production pin each `image:` to a version tag or digest (`image@sha256:...`).
stages:
- build
- security-scan
- security-gate
- deploy
variables:
SECURITY_GATE_CRITICAL_THRESHOLD: 0
SECURITY_GATE_HIGH_THRESHOLD: 0
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# Shared rules: every job that the gate depends on must run in the same
# pipelines as the gate, otherwise GitLab cannot create the pipeline
# ("job needs a job that does not exist").
.gate-rules:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# Build and push the image first so the container scan has something to scan
build-image:
stage: build
extends: .gate-rules
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
# Kaniko does not read CI_REGISTRY_USER/PASSWORD on its own — write a Docker
# auth config from the job's registry credentials before pushing.
- mkdir -p /kaniko/.docker
- |
AUTH=$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64 | tr -d '\n')
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$CI_REGISTRY" "$AUTH" > /kaniko/.docker/config.json
- /kaniko/executor --context "$CI_PROJECT_DIR" --dockerfile "$CI_PROJECT_DIR/Dockerfile" --destination "$IMAGE"
# Secret Scanning Gate
secrets-scan:
stage: security-scan
extends: .gate-rules
variables:
GIT_DEPTH: 0 # full history: a shallow clone would hide secrets in older commits
image:
name: trufflesecurity/trufflehog:latest
entrypoint: [""] # the image's entrypoint is the CLI itself; clear it so the runner's shell works
script:
# --fail exits 183 when verified secrets are found. TruffleHog prints the raw
# credential value in BOTH its JSON and plain-text output, so never let result
# stdout reach the job log. Capture it outside the workspace (not an artifact)
# and surface only safe metadata (count + detector names).
- |
RC=0
trufflehog git file://. --only-verified --fail --json > /tmp/trufflehog.jsonl || RC=$?
case "$RC" in
0) echo "Secrets Gate PASSED" ;;
183) echo "Secrets Gate FAILED: $(wc -l < /tmp/trufflehog.jsonl) verified secret(s) found"
echo "Detectors: $(grep -o '"DetectorName":"[^"]*"' /tmp/trufflehog.jsonl | cut -d'"' -f4 | sort -u | tr '\n' ' ')"
echo "Values withheld from logs. Run 'trufflehog git file://. --only-verified' locally for locations."
exit 1 ;;
*) echo "Secrets Gate ERROR: trufflehog exited $RC"; exit 1 ;;
esac
# SAST Gate
sast-scan:
stage: security-scan
extends: .gate-rules
image: semgrep/semgrep
script:
- semgrep scan --config=p/security-audit --config=p/owasp-top-ten --json -o semgrep-report.json .
artifacts:
# Native scanner JSON is kept as a plain artifact for the jq evaluator below.
# If you also want findings in GitLab's Security Dashboard, emit a *separate*
# GitLab-schema report (semgrep --gitlab-sast) and declare that under reports.sast.
paths:
- semgrep-report.json
# SCA Gate — dependency vulnerabilities in lockfiles/manifests
sca-scan:
stage: security-scan
extends: .gate-rules
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy fs --scanners vuln --format json --output trivy-sca-report.json .
artifacts:
paths:
- trivy-sca-report.json
# IaC Gate — Terraform / Kubernetes / Dockerfile misconfigurations
iac-scan:
stage: security-scan
extends: .gate-rules
image:
name: bridgecrew/checkov:latest
entrypoint: [""]
script:
# --soft-fail defers pass/fail to the evaluator; --output-file-path is a
# directory and Checkov writes results_json.json inside it.
- checkov -d . --framework terraform,kubernetes,dockerfile -o json --output-file-path checkov-results --soft-fail
artifacts:
paths:
- checkov-results/results_json.json
# Container Scanning Gate
container-scan:
stage: security-scan
extends: .gate-rules
needs: [build-image]
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
# This job runs in its own container with no registry session; pass the job's
# registry credentials so Trivy can pull the (private) image it just built.
- TRIVY_USERNAME="$CI_REGISTRY_USER" TRIVY_PASSWORD="$CI_REGISTRY_PASSWORD" trivy image --format json --output trivy-report.json "$IMAGE"
artifacts:
# Same as above: for the Security Dashboard, additionally generate
# `--format template --template "@contrib/gitlab.tpl"` under reports.container_scanning.
paths:
- trivy-report.json
# Security Gate Evaluation
security-gate:
stage: security-gate
extends: .gate-rules
image: alpine:latest
needs:
- secrets-scan
- sast-scan
- sca-scan
- iac-scan
- container-scan
before_script:
- apk add --no-cache jq
script:
- |
echo "Evaluating Security Gates..."
# Fail closed: missing reports mean a scanner did not run.
for f in semgrep-report.json trivy-sca-report.json checkov-results/results_json.json trivy-report.json; do
if [ ! -f "$f" ]; then echo "Gate ERROR: $f missing"; exit 1; fi
done
# SAST: Semgrep ERROR == high/critical, WARNING == medium
SAST_CRITICAL=$(jq '[.results[]? | select(.extra.severity == "ERROR")] | length' semgrep-report.json)
if [ "$SAST_CRITICAL" -gt "$SECURITY_GATE_CRITICAL_THRESHOLD" ]; then
echo "SAST Gate Failed: $SAST_CRITICAL high/critical issues found"
exit 1
fi
# SCA: same Trivy JSON shape as the container report
SCA_CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-sca-report.json)
SCA_HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-sca-report.json)
if [ "$SCA_CRITICAL" -gt "$SECURITY_GATE_CRITICAL_THRESHOLD" ] || [ "$SCA_HIGH" -gt "$SECURITY_GATE_HIGH_THRESHOLD" ]; then
echo "SCA Gate Failed: $SCA_CRITICAL critical, $SCA_HIGH high dependency vulnerabilities"
exit 1
fi
# IaC: OSS Checkov has no severity field — gate on failed checks (array or object report)
IAC_FAILED=$(jq '[.. | objects | select(has("failed_checks")) | .failed_checks[]?] | length' checkov-results/results_json.json)
if [ "$IAC_FAILED" -gt 0 ]; then
echo "IaC Gate Failed: $IAC_FAILED policy violations"
exit 1
fi
# Container: check both thresholds
CONTAINER_CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-report.json)
CONTAINER_HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-report.json)
if [ "$CONTAINER_CRITICAL" -gt "$SECURITY_GATE_CRITICAL_THRESHOLD" ]; then
echo "Container Gate Failed: $CONTAINER_CRITICAL critical vulnerabilities"
exit 1
fi
if [ "$CONTAINER_HIGH" -gt "$SECURITY_GATE_HIGH_THRESHOLD" ]; then
echo "Container Gate Failed: $CONTAINER_HIGH high vulnerabilities (threshold $SECURITY_GATE_HIGH_THRESHOLD)"
exit 1
fi
echo "All Security Gates Passed"
Interpreting gate results¶
A good gate summary tells the developer what failed, where, and what to do next:
Security Gate Summary
========================
+----------------+--------+---------+
| Gate | Status | Issues |
+----------------+--------+---------+
| Secrets | PASS | 0 |
| SAST | PASS | 3 (low) |
| SCA | WARN | 2 (med) |
| Container | PASS | 0 |
| IaC | FAIL | 1 (crit)|
+----------------+--------+---------+
Pipeline blocked: IaC gate failed
- CKV_AWS_21: S3 bucket has public access enabled
- File: terraform/s3.tf:15
Action Required: Fix the critical IaC issue before merge.
When a gate fails: check the CI logs (most tools show exactly what is wrong and where), fix it or file an exception if it is a false positive, then re-run the pipeline.
Normalizing multi-scanner output into a single gate decision¶
Real pipelines rarely run a single scanner. A typical SCA stage, for example, might run both an application dependency scanner (e.g., OSV-Scanner) and a container/SBOM scanner (e.g., Trivy) to get broader vulnerability database coverage. Each tool produces its own result, its own exit code semantics, and its own idea of "severity". Without normalization, you end up with N independent pass/fail signals instead of one gate decision, and inconsistent exit code handling across tools becomes a silent source of false negatives.
Standardize on SARIF as the common interface¶
Most modern scanners can emit SARIF (Static Analysis Results Interchange Format, an OASIS standard). SARIF's security-severity property (under each rule's properties) is typically populated with a CVSS-like score, which gives you a single numeric field to gate on regardless of which tool produced the finding.
# Read every SARIF file produced by the pipeline's scanners and
# return the single highest security-severity score found across all of them.
import json
import logging
def max_severity(sarif_paths: list[str]) -> float:
max_score = 0.0
for path in sarif_paths:
try:
with open(path) as f:
sarif = json.load(f, strict=False)
except (json.JSONDecodeError, FileNotFoundError) as e:
logging.error(f"Failed to parse SARIF file {path}: {e}")
return -1.0 # Explicitly signal a tool ERROR state
for run in sarif.get("runs", []):
rules = run.get("tool", {}).get("driver", {}).get("rules", [])
severities = {
r.get("id"): r.get("properties", {}).get("security-severity")
for r in rules if r.get("id")
}
for result in run.get("results", []):
score = severities.get(result.get("ruleId"))
if score is not None:
try:
max_score = max(max_score, float(score))
except ValueError:
logging.warning(f"Invalid security-severity value: {score}")
continue
return max_score
Map the score to a gate decision¶
def gate_status(score: float) -> str:
if score < 0.0:
return "ERROR" # tool crashed or output is malformed
if score >= 8.0:
return "FAILED" # block the pipeline
if score >= 5.0:
return "WARNING" # log only, do not block
return "PASSED"
| Status | Meaning | Blocks the pipeline? |
|---|---|---|
PASSED |
Highest score across all scanners is below 5.0 | No |
WARNING |
Highest score is 5.0 to 7.9 | No (logged only) |
FAILED |
Highest score is 8.0 or above | Yes |
ERROR |
A scanner crashed, subprocess failed, or produced malformed SARIF | Yes |
The thresholds above (5.0, 8.0) are examples, not a prescribed standard. Each organization should set its own thresholds based on its risk tolerance, the criticality of the affected system, and its remediation capacity.
Treating "scanner crashed" as its own ERROR state, distinct from FAILED, matters: a gate that only checks whether anything failed on severity will silently pass a pipeline where a scanner never actually ran.
The exit code trap¶
Do not assume a non-zero exit code always means vulnerabilities were found, or that zero always means the scan is clean. Exit code semantics differ per tool and must be normalized individually. For example, OSV-Scanner uses exit code 1 to mean "scan completed, vulnerabilities were found," not a tool failure. Treating that as a pipeline error would incorrectly flag every scan with findings as broken, rather than letting the SARIF based gate decide pass, warn, or fail on its own terms:
import subprocess
def run_osv_scanner(cmd: list[str]) -> int:
exit_code = subprocess.run(cmd).returncode
if exit_code == 1:
# OSV-Scanner returns 1 when vulnerabilities are found, not a crash.
# The real pass/warn/fail decision comes later, from the SARIF scores.
return 0
return exit_code
Each scanner's documentation should be checked individually for this distinction before wiring it into a gate. Some tools, like Trivy by default, exit 0 regardless of findings, so any non-zero exit from them is a genuine tool failure.
Enforcing the gate in CI¶
Once the orchestrator has parsed the SARIF files and determined the final severity score, it must translate that decision into a pipeline action. In CI/CD environments (like GitHub Actions, GitLab CI, or Jenkins), this is achieved by exiting the orchestrator script with a non-zero exit code to block the merge or deployment.
import sys
def enforce_pipeline_gate(status: str):
"""
Halts the CI pipeline if the status is FAILED or ERROR.
"""
if status in ("FAILED", "ERROR"):
# Writing to stderr ensures CI systems prominently display the failure reason
print(f"::error::Security gate {status}. Halting pipeline.", file=sys.stderr)
sys.exit(1)
print(f"Security gate {status}. Pipeline may proceed.")
sys.exit(0)
Exception and risk-acceptance process¶
No gate system survives contact with reality without an exception process. Without one, developers disable gates rather than deal with blocked pipelines. A sound process:
- Raise an exception request — the developer (or team) documents the finding, explains why immediate remediation is not feasible (e.g., no fix available, legacy library), and proposes a compensating control.
- Approve with a risk owner — a security engineer or application security lead reviews and approves; the approver takes ownership of the accepted risk.
- Time-box every exception — set a hard expiry (e.g., 30 or 90 days). The gate re-engages automatically when the exception expires. Indefinite exceptions are not exceptions — they are hidden vulnerabilities.
- Track exceptions centrally — maintain a register (in a vulnerability management tool or ticketing system) so accepted risks are visible to leadership, not hidden in
.semgrepignorefiles. - Review on a cadence — include open exceptions in sprint planning and quarterly security reviews so they get addressed, not forgotten. Report exception counts and age to leadership as a risk indicator.
Exceptions as code¶
Where the exception register lives in the repository (in addition to, not instead of, a central register), make every entry carry a reason, an approver, a tracking ticket, and an expiry so the gate can re-engage automatically:
# .security-exceptions.yaml
exceptions:
- id: "CVE-2023-12345"
reason: "False positive - not applicable to our usage"
approved_by: "security-team"
jira_ticket: "SEC-118"
expires: "2024-06-01"
- id: "semgrep-rule-xyz"
reason: "Accepted risk - compensating controls in place"
approved_by: "security-team"
jira_ticket: "SEC-123"
expires: "2024-03-15"
Emergency bypass (use sparingly)¶
Sometimes production is down and the fix has to ship. Provide an explicit, audited bypass path — never a silent continue-on-error. The authorization must come from the platform, not from a variable the caller sets: a pipeline variable like APPROVED_BY=alice proves nothing, since whoever triggers the job can type any name. Use a protected environment with a restricted deployer list and deployment approvals, so the approving identity is recorded by GitLab/GitHub itself. Require a tracking ticket so the gate is re-enabled and the finding is remediated:
# Emergency bypass — GitLab example
# Authorization is enforced by the *protected environment* "production":
# Settings > CI/CD > Protected environments: allowed to deploy = release-managers,
# required approvals = 1 from @security-team.
# Only members of those groups can play the job, and the approver's identity is
# recorded on the deployment — not taken from a caller-supplied variable.
deploy-production-bypass:
stage: deploy
# `needs` turns this into a DAG job: it depends only on the built artifact, so a
# failed scanner or security-gate job does not skip it (without `needs`, a failure
# in an earlier stage would skip the whole deploy stage and the bypass could never run).
needs: [build-image]
environment:
name: production
deployment_tier: production
rules:
# Same pipeline condition as .gate-rules (so build-image is guaranteed to exist),
# restricted to the default branch — production is never deployed from an MR
# pipeline — and only when a tracking ticket is supplied. Manual play only.
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $SECURITY_BYPASS == "true" && $BYPASS_TICKET =~ /^SEC-[0-9]+$/
when: manual
allow_failure: false
script:
- echo "Security gate bypassed under $BYPASS_TICKET by $GITLAB_USER_LOGIN (approved via protected environment)"
- ./deploy.sh "$IMAGE"
On GitHub Actions the equivalent is a job bound to environment: production with required reviewers configured on that environment; the reviewer's approval is logged in the deployment history.
Smarter prioritization¶
Mature programs increasingly drive gates from correlated, contextual risk rather than isolated scanner output. Application Security Posture Management (ASPM) aggregates findings across tools, deduplicates them, and adds code-to-runtime context so gates block on what is genuinely exploitable and reachable in production — keeping signal high and friction low.
The evolution of gate intelligence:
| Generation | What drives the gate |
|---|---|
| 1st | Raw count of findings above a severity threshold |
| 2nd | New findings since baseline, filtered by severity |
| 3rd | CVSS + EPSS + KEV — exploitability-weighted severity |
| 4th | Reachability + runtime exposure + ASPM correlation, with VEX statements suppressing findings already assessed as not affected |
Common pitfalls and anti-patterns¶
- Gates without feedback — a gate that blocks with no explanation sends developers to Google. Every blocked gate must link to the finding, the affected code, and remediation guidance.
- Gates configured but not enforced — required CI status checks must be enabled in branch protection rules; otherwise developers merge without them.
- Relying on a single scanner — no scanner catches everything. Layer multiple tools and normalize their output into one decision rather than trusting one vendor's view of severity.
- Slow gates — scans add time. Run them in parallel, cache databases and dependencies, and keep PR-stage scans diff-aware, or developers will route around the slow pipeline.
- Exception process that requires security team approval for every finding — creates a bottleneck and incentivizes teams to avoid scanning. Delegate tier-2 and tier-3 exception approvals to risk owners within the product team.
- No visibility into exception trends — if the exception register is growing every sprint, that is a systemic problem. Track exception counts, ages, and owners as board-level metrics.
- Unaudited bypasses — if someone needs an emergency bypass, make sure there is a ticket tracking who approved it and when it expires.
- Gating on a scanner you have not pinned — a scanner or its CI action pulled by mutable tag is itself a supply-chain risk (the March 2026 compromise of the
trivy-actiontags is an example). Pin gate tooling to versions or digests, as described in CI/CD Pipeline Security. - Suppression via comments in source code —
# nosec,// NOSONAR, and.semgrepignoresuppressions are invisible in most dashboards. Require that suppressions be tracked in the central exception register.
Maturity progression¶
Starter — Enable SAST and SCA scans in CI. Run in advisory mode (report, don't block) for two sprints to establish baseline. Then block on new criticals.
Intermediate — Gate pull requests on new high/critical SAST and SCA findings. Enforce a formal exception process. Track exceptions in DefectDojo or Jira. Add container scan gates at the build stage.
Advanced — Drive gates from ASPM-correlated, reachability-enriched findings. Fully automate exception expiry. Measure false positive rate per scanner and tune quarterly. Report gate compliance and exception trends to engineering leadership monthly.
Tools¶
| Category | Examples |
|---|---|
| Vulnerability tracking & gate integration | DefectDojo (open source), OWASP Dependency-Track (open source), Archery |
| Scan orchestration | SecureCodeBox (open source, Kubernetes-native) |
| Policy-as-code gate enforcement | OPA/Conftest, Rego policies in CI, Kyverno and OPA Gatekeeper (Kubernetes) |
| CI/CD native gates | GitHub branch protection + required status checks, GitLab merge request approvals, Jenkins Quality Gates |
| Commercial platforms with policy gates | Snyk, Checkmarx, Veracode |
| ASPM (correlated gate decisions) | Apiiro, Arnica, Ox Security, Cycode — see ASPM |
| Exception / risk-acceptance tracking | Jira (security issue type), ServiceNow, DefectDojo risk acceptance workflow |
Metrics and KPIs¶
| Metric | Target |
|---|---|
| % of PRs passing security gates without exception | > 95% |
| Open exceptions older than 90 days | 0 |
| Gate false positive rate | < 15% per tool |
| Findings that escape to production | Decreasing quarter-over-quarter |
| Mean time from gate block to resolution | < 3 business days (critical) |