Container-images scannen met Trivy: in CI én op de host, zonder 400 meldingen per image
Trivy is gratis, snel en vindt alles — en precies dat is het probleem: een gemiddelde image geeft honderden CVE's waarvan de meeste geen fix hebben of in een tool zitten die nooit draait. Dit is de configuratie die we zelf gebruiken: --ignore-unfixed, severity-drempels, een .trivyignore met reden en datum, een CI-gate die alleen op nieuwe kritieke findings breekt, en een nachtelijke scan van wat er écht draait op de host.
Inhoud
trivy image python:3.12 levert op een schone dag zo'n 150 CVE's op. Dat is geen fout van Trivy — de meeste Debian-based images bevatten honderden packages waarvan Debian de kwetsbaarheden kent en bewust nog niet fixt (no-dsa, laag risico). Wie die lijst ongefilterd in een CI-pipeline zet, heeft binnen een week een || true erachter staan en daarmee helemaal geen scanner meer. Dit artikel maakt van Trivy een gate die alleen faalt wanneer het moet, en een nachtelijke controle van wat er op je hosts daadwerkelijk draait.
Stap 1: installeren en één image scannen
# Ubuntu/Debian: officiële repo
sudo apt install -y wget apt-transport-https gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update && sudo apt install -y trivy
trivy --version
# De eerste scan haalt de vulnerability-database (~60 MB) op; daarna is hij lokaal
trivy image --severity HIGH,CRITICAL grafana/grafana:latest
De output is per target: het OS-laagje (alpine 3.24), en dan elk gedetecteerd applicatie-artefact apart — Go-binaries, Node-packages, Python-wheels. Dat onderscheid is belangrijk: een CVE in een Go-binary van een plugin die je niet gebruikt, weegt anders dan een CVE in de OS-libc.
trivy image --quiet --severity HIGH,CRITICAL --format json grafana/grafana:latest \
| jq -r '.Results[] | "\(.Target)\t\(.Type)\t\(.Vulnerabilities|length)"' | column -t -s $'\t'
# grafana/grafana:latest (alpine 3.24.1) alpine 2
# usr/share/grafana/bin/grafana gobinary 3
# usr/share/grafana/data/plugins-bundled/.../postgresql_datasource gobinary 14
Stap 2: de drie filters die de ruis wegnemen
# 1. --ignore-unfixed: alleen CVE's waarvoor de distributie een fix HEEFT
# 2. --severity: drempel; MEDIUM bekijk je wekelijks, niet per build
# 3. --ignorefile: bewust geaccepteerde findings, met reden en vervaldatum
trivy image --ignore-unfixed --severity HIGH,CRITICAL --ignorefile .trivyignore \
ghcr.io/example/shop:2026.09.1
--ignore-unfixed is de belangrijkste. Een CVE zonder fix in de distributie kun je in de image niet oplossen; je kunt hoogstens van base-image wisselen. Dat is een beslissing voor een kwartaal-review, niet voor elke build. Laat de MEDIUM/LOW en de unfixed wél wekelijks in een rapport landen (stap 5), zodat ze niet verdwijnen.
.trivyignore — in de repo, naast de Dockerfile. Trivy leest alleen de CVE-id's, maar de regels ernaast zijn voor mensen en voor de auditor:
# .trivyignore — één regel per geaccepteerde finding, met reden en vervaldatum.
# Herbeoordelen bij elke base-image-wijziging.
CVE-2026-1234 # libxml2 XInclude: niet bereikbaar, we parsen geen externe XML. J. Peeters 2026-09-16, geldig tot 2026-12-31
CVE-2026-5678 # curl in build-stage only, niet in de runtime-image. Zie Dockerfile regel 12. Geldig tot base-image bump
Nog beter dan .trivyignore is een VEX-statement: dat draagt de reden machine-leesbaar mee en kan door meerdere scanners gelezen worden. trivy image --vex vex.json --show-suppressed … toont wat er onderdrukt is en waarom.
Stap 3: de CI-gate — alleen breken op wat nieuw is
De klassieke gate --exit-code 1 --severity CRITICAL faalt de build op elke kritieke finding, ook op degene die er gisteren al was en waar een ticket voor loopt. Dat leidt tot || true. Beter: breek alleen op findings die niet in de vorige succesvolle scan zaten.
# .gitlab-ci.yml (GitHub Actions is equivalent; Woodpecker idem)
image-scan:
stage: test
image: aquasec/trivy:latest
variables:
IMG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
script:
- trivy image --ignore-unfixed --severity HIGH,CRITICAL --ignorefile .trivyignore --format json -o scan.json "$IMG"
- trivy image --ignore-unfixed --severity HIGH,CRITICAL --ignorefile .trivyignore "$IMG" # leesbaar in de log
# Baseline = laatste scan van main, opgeslagen als artifact
- 'curl -sf -o baseline.json "$CI_API_V4_URL/projects/$CI_PROJECT_ID/jobs/artifacts/main/raw/scan.json?job=image-scan" || echo "{}" > baseline.json'
- |
NEW=$(jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' scan.json \
| grep -vxFf <(jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' baseline.json) || true)
if [ -n "$NEW" ]; then echo "NEW HIGH/CRITICAL findings since main:"; echo "$NEW"; exit 1; fi
artifacts:
paths: [scan.json]
expire_in: 90 days
Wat dit doet: een merge request die een nieuwe kritieke kwetsbaarheid introduceert (nieuwe dependency, nieuwe base-image) faalt. Bestaande findings blokkeren niet, maar staan in de log en in het artefact. Op main zelf zet je een aparte, wekelijkse job die alle HIGH/CRITICAL meldt naar een ticket of ntfy — dat is de plek voor de achterstand.
Stap 4: wat draait er écht — de host scannen
De CI scant wat je bouwt. Op de host draait misschien iets anders: een image die drie maanden geleden is gepulld, een latest van een derde partij, een container die iemand handmatig startte. Scan daarom nachtelijk de draaiende containers, op de host zelf:
sudo tee /usr/local/sbin/trivy-running.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Scant elke unieke image van een draaiende container; meldt HIGH/CRITICAL met fix.
NTFY="https://ntfy.example.be/cve"; HOST=$(hostname -s)
D=/var/lib/cve-scan/images; mkdir -p "$D"; OUT="$D/$(date +%F).tsv"; : > "$OUT"
trivy image --download-db-only --quiet 2>/dev/null
for img in $(docker ps --format '{{.Image}}' | sort -u); do
digest=$(docker image inspect --format '{{index .RepoDigests 0}}' "$img" 2>/dev/null | cut -d@ -f2 | cut -c8-19)
trivy image --quiet --ignore-unfixed --severity HIGH,CRITICAL --format json "$img" 2>/dev/null \
| jq -r --arg img "$img" --arg d "$digest" '
.Results[]? | .Target as $t | .Vulnerabilities[]?
| [$img, $d, .Severity, .VulnerabilityID, .PkgName, .InstalledVersion, (.FixedVersion // "-"), $t] | @tsv' >> "$OUT"
done
n=$(wc -l < "$OUT"); c=$(awk -F'\t' '$3=="CRITICAL"' "$OUT" | wc -l)
# Alleen melden wat nieuw is sinds gisteren
prev=$(ls -1 "$D"/*.tsv | grep -v "$(date +%F)" | tail -1)
if [ -n "$prev" ]; then
new=$(comm -13 <(cut -f1,4,5 "$prev" | sort -u) <(cut -f1,4,5 "$OUT" | sort -u))
[ -n "$new" ] && printf 'NEW findings in running images on %s (%s total, %s critical):\n%s\n' "$HOST" "$n" "$c" "$new" \
| curl -s -H "Title: trivy@$HOST" -H "Priority: high" --data-binary @- "$NTFY" >/dev/null
fi
echo "$OUT: $n findings ($c critical)"
EOF
sudo chmod 0755 /usr/local/sbin/trivy-running.sh
echo '45 3 * * * root /usr/local/sbin/trivy-running.sh' | sudo tee /etc/cron.d/trivy-running
De tsv per dag is je historie: "sinds wanneer staat CVE-X open in image Y" is een grep over de map. De ntfy-melding komt alleen bij nieuwe findings — meestal betekent dat: de database van Trivy heeft een nieuwe CVE geleerd voor een package dat je al maanden draait. Dat is precies het moment om te kijken.
Stap 5: het wekelijkse overzicht, inclusief wat je filterde
Wat je in stap 2 wegfiltert, mag niet verdwijnen. Eén keer per week de volledige stand, gesorteerd op wat je kunt fixen:
trivy image --quiet --format json ghcr.io/example/shop:2026.09.1 \
| jq -r '.Results[]?.Vulnerabilities[]? | [.Severity, (if .FixedVersion then "fixable" else "unfixed" end)] | @tsv' \
| sort | uniq -c
# 12 CRITICAL fixable ← deze week
# 3 CRITICAL unfixed ← base-image-beslissing
# 41 HIGH fixable
# 88 HIGH unfixed
# 140 MEDIUM unfixed ← accepteren, documenteren, kwartaal-review
En het antwoord op "fixable" is bijna altijd hetzelfde: rebuild op een verse base-image. FROM python:3.12-slim zonder apt upgrade in de Dockerfile neemt de fixes van de base-image over zodra je opnieuw bouwt — dus bouw wekelijks opnieuw, ook zonder codewijziging.
Valkuilen
latestscannen in CI. Je scant wat er vandaag onder die tag staat, niet wat er in productie draait. Scan de image-digest die je deployt, en pin base-images op digest.- Build-stage-packages in de runtime-image.
gcc,curl,gitin de eindimage geven tientallen CVE's die niets met je app te maken hebben. Multi-stage builds en--no-install-recommendshalveren de lijst. - Trivy-database offline. In een CI-runner zonder internet faalt de DB-update stil en scant Trivy met een oude database.
trivy image --skip-db-updateis bewust; een verouderde DB per ongeluk niet. Controleertrivy --version(toont de DB-datum) in de log. - Secrets en misconfig negeren.
trivy imagescant standaard ook op secrets (API-keys in layers) entrivy configop Dockerfile-fouten (root-user, geen healthcheck). Die findings zijn vaak belangrijker dan de vijftigste libc-CVE. .trivyignorezonder vervaldatum. Een ignore van 2024 op een CVE waarvoor sinds 2025 een exploit bestaat, is een gat dat niemand meer ziet. Elke regel een datum, elk kwartaal een review.
Wat je hiermee nog niet hebt
- Correlatie met de host. Trivy zegt "CVE in image X". Welke container draait die image, op welke poort, internet-facing of niet, met welke andere containers ernaast — dat is wat de prioriteit bepaalt, en het staat niet in het scanrapport.
- Fleet-zicht. Dertig hosts, dertig
/var/lib/cve-scan/images-mappen. "Welke hosts draaien nog image-digest abc…" is ssh-werk. - Bewijs. Een tsv per dag is historie; een auditor wil de scan-datum, de gebruikte DB-versie en de beslissing per finding in één ondertekend document.
Zo doet monsys het
De monsys-agent ontdekt draaiende containers via de Docker-socket en rapporteert per container de image-digest; de hub scant elke unieke digest met Trivy (één keer per digest, niet per host) en koppelt de findings aan de container, de host en de netwerktopologie — een CRITICAL in een container achter poort 443 op een internet-facing host rankt boven dezelfde CRITICAL in een interne batch-job. Base-image-drift ("deze image is gebouwd op een base van 4 maanden oud") is een aparte melding. VEX-statements en geaccepteerde risico's leg je vast bij de finding en verschijnen in het audit pack; de scan-DB-versie en -datum staan bij elk resultaat.
FAQ
Waarom vindt Trivy honderden CVE's in een officiële image?
Omdat de image honderden OS-packages bevat en de distributie voor veel daarvan kwetsbaarheden kent die ze bewust (nog) niet fixt — laag risico, no-dsa. Met --ignore-unfixed blijft over wat je kunt oplossen door opnieuw te bouwen op een verse base-image.
Moet ik de build laten falen op elke CRITICAL?
Nee: dat leidt binnen een week tot || true. Laat een merge request falen op nieuwe HIGH/CRITICAL findings ten opzichte van main, en behandel de bestaande achterstand via een wekelijks rapport en tickets.
Hoe vaak moet ik images opnieuw bouwen?
Wekelijks, ook zonder codewijziging, zodat fixes uit de base-image meekomen. Bij een KEV-vermelding of een kritieke CVE in je runtime (glibc, openssl, node): direct.
Geschreven door het monsys-team — sysadmins die dit dagelijks doen.
Zelf gedaan? Laat monsys het bijhouden.
Alles uit deze how-to draait in monsys als doorlopende check, met historie, alerts en audit-bewijs. 5 servers gratis, EU-gehost in België, geïnstalleerd in 60 seconden.