Trivy + SonarQube: security scanning in a GitLab CI pipeline
Published: 2026-04-07
The infra repo runs two flavours of Trivy scan: filesystem (IaC misconfigurations in the repo itself) and a live Kubernetes cluster scan. Results feed into SonarQube as external issues.
Why both scans
| Scan type | What it finds |
|---|---|
trivy fs |
Secrets in git, vulnerable image tags in HelmRelease values, K8s manifest misconfigs |
trivy k8s |
Deployed image vulns, RBAC over-permissions, runtime misconfigurations |
The filesystem scan catches what's in git. The cluster scan catches what's actually running — images that may have been deployed weeks ago before a CVE was published.
Pipeline structure
Security stage is manual-triggered to avoid running on every commit:
yamlstart:security:
stage: validate:security
when: manual
allow_failure: true
script:
- echo "Security checks started"
All security jobs have needs: [start:security] — they only run if the manual trigger fires.
Filesystem scan
yamltrivy:scan-repo:
image: registry.example.com/aquasec/trivy:0.70.0
needs: [start:security]
cache:
key: trivy-db
paths: [.trivy-cache/]
policy: pull-push
script:
- |
trivy fs \
--cache-dir "${TRIVY_CACHE_DIR}" \
--format sarif \
--output trivy.sarif \
--severity HIGH,CRITICAL \
--exit-code 0 \
.
artifacts:
paths: [trivy.sarif]
--exit-code 0 means the job never fails on findings — the goal is visibility in SonarQube, not a hard gate. --format sarif produces a SARIF file that SonarQube's sonar.sarifReportPaths accepts natively.
Kubernetes cluster scan
yamltrivy:k8s:
needs: [start:security]
cache:
key: trivy-db
paths: [.trivy-cache/]
policy: pull-push
script:
- |
trivy k8s \
--format json \
--output "trivy-k8s-infra.json" \
--severity HIGH,CRITICAL \
--context infra-k8s \
all
artifacts:
paths: ["trivy-k8s-*.json"]
Output is JSON, not SARIF — Trivy's k8s scan doesn't produce valid SARIF directly. A conversion script handles the format transformation.
JSON → SonarQube external issues format
yamltrivy:convert-k8s:
needs: [trivy:k8s]
script:
- |
for source_file in trivy-k8s-*.json; do
target_env="${source_file#trivy-k8s-}"
target_env="${target_env%.json}"
python3 scripts/trivy-k8s-json-to-sonar.py \
"$source_file" \
"trivy-k8s-${target_env}-sonar.json"
done
trivy-k8s-json-to-sonar.py transforms Trivy findings into SonarQube external issues format:
json{
"issues": [
{
"engineId": "trivy",
"ruleId": "CVE-2024-1234",
"severity": "CRITICAL",
"type": "VULNERABILITY",
"primaryLocation": {
"message": "nginx:1.25.3 has known RCE vulnerability",
"filePath": "fluxcd/projects/dev/kustomization/hub/patches/nginx.yaml"
}
}
]
}
SonarQube import
yamlsonarqube:trivy:
image: registry.example.com/sonarsource/sonar-scanner-cli:5.0.1
needs: [trivy:scan-repo, trivy:convert-k8s]
variables:
GIT_STRATEGY: clone
GIT_DEPTH: "0" # required for git blame
script:
- |
sonar_k8s_reports=$(ls trivy-k8s-*-sonar.json 2>/dev/null | tr '\n' ',' | sed 's/,$//')
sonar-scanner \
-Dsonar.host.url=${SONAR_HOST_URL} \
-Dsonar.token=${SONAR_TOKEN} \
-Dsonar.projectKey=trivy_infra \
-Dsonar.sources=. \
-Dsonar.python.version=3.12 \
-Dsonar.sarifReportPaths=trivy.sarif \
${sonar_k8s_reports:+-Dsonar.externalIssuesReportPaths=${sonar_k8s_reports}}
GIT_DEPTH: "0" — SonarQube uses git blame to link findings to commits and authors. Shallow clone breaks blame.
The ${sonar_k8s_reports:+...} expansion only appends the flag if non-empty — the job works even if the k8s scan produced no files.
Trivy DB cache
Both Trivy jobs use a GitLab CI cache keyed on trivy-db:
yamlcache:
key: trivy-db
paths: [.trivy-cache/]
policy: pull-push
The vulnerability database is ~300 MiB. Without caching, each scan downloads the full DB from GitHub releases — several minutes on slow connections.
Notification
yamlnotify:security:
when: on_success
needs: [sonarqube:trivy]
script:
# sends Telegram + Mattermost with link to SonarQube dashboard
Sends a message with a direct link to https://sonarqube.example.com/dashboard?id=trivy_infra.
Common issues
Trivy fails: TOOMANYREQUESTS — GitHub API rate limit on DB download. Solution: use GitLab CI cache or a Trivy mirror (--db-repository).
SonarQube missing blame info — GIT_DEPTH was not "0". Issues appear without author.
trivy k8s times out — Use --timeout 30m for large clusters. Default 5 minutes is not enough for 20+ namespaces.
SARIF import fails in SonarQube — Ensure SonarQube version supports SARIF (v9.9+). Older versions need manual issue import.