Gate Aggregation Scripts
Intro: The moment a pipeline uses more than one security tool, someone has to decide how the results become a single release decision. Gate aggregation scripts solve that problem explicitly: they ingest scanner outputs, normalize the findings, apply policy, and emit a final pass/fail decision that can block merge or release.
What this page includes
- why aggregation is better than scattered fail logic
- bash and Python examples for multi-tool gate decisions
- artifact patterns for GitLab release evidence and human review
- practical rules for exceptions and new-code bias
Working assumptions
- every security tool has a different output format and different noise profile
- release gates should be inspectable and reproducible, not buried in tribal knowledge
Why aggregate at all?
Without aggregation:
- one job fails on any finding,
- another job only warns,
- Sonar waits asynchronously,
- DAST results live in another format,
- release logic becomes inconsistent and hard to explain.
With aggregation:
- jobs still generate native reports,
- a single policy job decides what counts as blocking,
- the pipeline leaves behind one summary artifact for humans and audits.
Pattern: tool jobs are evidence producers, not final judges
A practical flow:
- run Semgrep and Bandit for code findings;
- query SonarQube quality gate status;
- run SCA and DAST;
- collect outputs in JSON, SARIF, XML, or text;
- aggregate them into one summary;
- fail the aggregation job only if policy says the release should stop.
Example GitLab job for aggregation
security_gate_aggregate:
stage: security
image: python:3.12-alpine
needs:
- semgrep_scan
- bandit_scan
- sonar_gate
- dependency_check
- zap_baseline
script:
- python3 snippets/ci/aggregate-security-gate.py
artifacts:
paths:
- security-gate-summary.json
- security-gate-summary.md
expire_in: 30 days
Bash example for a simple threshold gate
#!/usr/bin/env bash
set -euo pipefail
semgrep_file="${1:-semgrep.json}"
bandit_file="${2:-bandit.json}"
critical_count="$(jq '[.results[]? | select(.extra.severity == "ERROR" or .extra.severity == "CRITICAL")] | length' "$semgrep_file")"
high_bandit="$(jq '[.results[]? | select(.issue_severity == "HIGH")] | length' "$bandit_file")"
echo "semgrep_critical=${critical_count}"
echo "bandit_high=${high_bandit}"
if [ "$critical_count" -gt 0 ] || [ "$high_bandit" -gt 0 ]; then
echo "Security gate failed"
exit 1
fi
echo "Security gate passed"
This is good for a small team, but it becomes hard to maintain as soon as exceptions, changed-file logic, or multiple tools are added.
Python example for a richer policy
#!/usr/bin/env python3
import json
from pathlib import Path
SUMMARY = {
"tools": {},
"blocking_reasons": [],
"status": "pass",
}
def load_json(path):
p = Path(path)
if not p.exists():
return {}
return json.loads(p.read_text())
def count_semgrep(data):
results = data.get("results", [])
high = sum(1 for r in results if str(r.get("extra", {}).get("severity", "")).upper() in {"ERROR", "HIGH", "CRITICAL"})
med = sum(1 for r in results if str(r.get("extra", {}).get("severity", "")).upper() == "MEDIUM")
return {"high_or_above": high, "medium": med}
def count_bandit(data):
results = data.get("results", [])
high = sum(1 for r in results if r.get("issue_severity") == "HIGH")
med = sum(1 for r in results if r.get("issue_severity") == "MEDIUM")
return {"high": high, "medium": med}
def sonar_status(path="sonar-gate.json"):
data = load_json(path)
return data.get("projectStatus", {}).get("status", "UNKNOWN")
semgrep = count_semgrep(load_json("semgrep.json"))
bandit = count_bandit(load_json("bandit.json"))
sonar = sonar_status()
SUMMARY["tools"]["semgrep"] = semgrep
SUMMARY["tools"]["bandit"] = bandit
SUMMARY["tools"]["sonar"] = {"status": sonar}
if semgrep["high_or_above"] > 0:
SUMMARY["blocking_reasons"].append("Semgrep high-or-above findings detected")
if bandit["high"] > 0:
SUMMARY["blocking_reasons"].append("Bandit high findings detected")
if sonar not in {"OK", "NONE"}:
SUMMARY["blocking_reasons"].append(f"Sonar quality gate status is {sonar}")
if SUMMARY["blocking_reasons"]:
SUMMARY["status"] = "fail"
Path("security-gate-summary.json").write_text(json.dumps(SUMMARY, indent=2))
Path("security-gate-summary.md").write_text(
"# Security Gate Summary\n\n"
f"- status: **{SUMMARY['status']}**\n"
f"- blocking reasons: {', '.join(SUMMARY['blocking_reasons']) or 'none'}\n"
)
if SUMMARY["status"] == "fail":
raise SystemExit(1)
Policy ideas that age well
Good default rules:
- block new high/critical issues;
- allow existing debt to remain visible but not silently worsen;
- require explicit review of hotspots;
- allow exceptions only when ticketed and time-bound;
- fail closed when required reports are missing on protected release paths.
Example exception file
exceptions:
- tool: semgrep
rule_id: python.lang.security.audit.subprocess-shell-true
scope: services/legacy-worker/**
reason: "Legacy migration in progress; tracked in SEC-142"
expires_on: "2026-06-30"
- tool: dependency-check
package: "org.example:legacy-xml"
cve: "CVE-2024-99999"
reason: "Fix requires vendor patch; compensating controls documented"
expires_on: "2026-03-31"
The aggregator can read this file and reject expired exceptions automatically.
GitLab job that publishes an MR-friendly summary
security_gate_note:
stage: security
image: alpine:3.20
needs: [security_gate_aggregate]
script:
- apk add --no-cache curl jq
- |
body="$(cat security-gate-summary.md)"
echo "Would post note here with GitLab API or glab CLI"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
Combining DAST and SCA inputs
Example conventions:
zap-report.jsonfor ZAP baseline or full scanburp-report.jsonfor exported Burp Suite resultsdependency-check-report.jsonor XML for SCA
A good aggregator does not need every tool to use the same schema. It only needs a documented adapter for each source.
Release-focused behavior
Use stricter rules for:
- tags that represent releases,
- production-targeting deploy jobs,
- protected branches,
- component or platform repositories that affect many pipelines.
Example:
security_gate_aggregate:
rules:
- if: '$CI_COMMIT_TAG'
variables:
GATE_MODE: release
- if: '$CI_MERGE_REQUEST_ID'
variables:
GATE_MODE: mr
The script can interpret GATE_MODE=release as a stricter threshold.
Practical outputs to preserve
Keep these artifacts:
- normalized JSON summary;
- human-readable markdown summary;
- raw tool reports;
- exception manifest used for the decision;
- version of the policy logic or component that made the decision.
That turns the gate into something a reviewer can actually reconstruct later.
Cross-links
- Security Quality Gates and Release Blocking
- GitLab Release Evidence
- SAST Noise Reduction
- GitLab Mock Interview Pack