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 infoGIT_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.