A full teardown of how we hardened grafana/grafana:13.0.3 — what we deleted, what we proved harmless, and how to reproduce every step. 54 CVEs down to 17 (−68%): 37 physically removed, 17 proven unreachable with evidence.
Trivy reports 54 vulnerabilities, 29 of them HIGH, for grafana/grafana:13.0.3 — the current stable release at the time of writing. The reflexive fixes don't work here: upgrading to 13.1.0 makes things worse (83 findings), and no amount of apk upgrade touches CVEs baked into a Go binary. So this became a two-part job.
The split falls out of one question: which findings live in Grafana itself, and which came along with the Alpine base layer? It turns out 24 of the 29 HIGH are outdated curl/libcurl packages that Grafana never touches at runtime — those can simply be deleted. The rest sit in Go dependencies compiled into the Grafana binary: they can't be removed, but it can be proven that the vulnerable code is unreachable in the way Grafana uses those dependencies.
The end state: 54 → 17 (−68%). Roughly 37 vulnerabilities are physically gone (packages removed), and each of the 17 that remain has an unreachability proof attached. Below is the full walkthrough — Dockerfile, reproduction commands, and the parts worth stealing for your own images. The setup:
| Parameter | Value |
|---|---|
| Base image | grafana/grafana:13.0.3 |
| Hardened image | grafana-hardened:13.0.3 |
| Analysis date | 2026-07-09 |
| Tooling | Trivy 0.71.2 · Go 1.26.4 · govulncheck v1.5.0 · Syft 1.45.1 · cosign 2.4.3 |
Scan before hardening
The official image was scanned with Trivy 0.71.2, vulnerability scanner only:
trivy image --scanners vuln --format json \ --output grafana-13.0.3.json grafana/grafana:13.0.3
Trivy detected Alpine 3.24.1 and three target types: OS packages (alpine), static Go binaries (gobinary) and frontend-plugin npm manifests (node-pkg). The picture across both image variants:
| Image | Total | High | Medium | Low | Fixable |
|---|---|---|---|---|---|
grafana:13.0.3 | 54 | 29 | 15 | 3 | 52 / 54 |
grafana:13.0.3-slim | 48 | 27 | 14 | 3 | 47 / 48 |
Almost everything is fixable (52 of 54) — a reliable sign that most of it goes away by upgrading or removing packages. Breaking findings down by source shows where the pain actually lives:
| Package | HIGH | Nature |
|---|---|---|
curl / libcurl | 12 + 12 | Alpine OS package, fix available in 8.21.0-r0 |
grafana/tempo | 2 | vendored Go dependency |
prometheus | 1 | vendored Go dependency |
stdlib (Go) | 2 | Go toolchain |
openfga | — | Go dependency (2 MED, 1 LOW) |
Diagnosis: 24 of 29 HIGH are curl/libcurl (the same 12 CVEs counted once per package), not Grafana code. That's the biggest slice, and it's exactly what hardening removes.
grafana/tempo as vulnerable by comparing the pseudo-version v1.5.1-0.2025… against release tags 2.x. But Grafana vendors Tempo by commit from its main branch, not by release — so “1.5.1 < 2.8.4” formally reads as “vulnerable” while the version mapping is meaningless. The real verdict comes from linkage analysis, not version math.13.1.0 line (a.k.a. latest) carries 83 vulnerabilities versus 54, because it ships a second Go binary built on an older toolchain (go1.25.7 → +13 HIGH in stdlib). 13.0.3 was chosen as the hardening base deliberately.Hardening the image
First: verify the packages really are unused
Deleting packages blindly is a reliable way to break a container. So before touching the Dockerfile, every assumption was checked against the live image:
ldd $(which grafana)→ “not a valid dynamic program”: the Grafana server is a static Go binary and needs no system.solibraries./run.shnever callscurl/wget(grep comes back empty) — plugin installs go throughgrafana cli, which uses Go's own HTTP client.bashstays:/run.shhas a#!/bin/bashshebang and is the entrypoint.- No setuid/setgid binaries (
find / -perm /6000is empty), and the default user is non-root (uid 472).
Conclusion: curl, wget, libcurl, c-ares can be removed, not merely patched.
The Dockerfile
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
USER root
# 1. Patch every outdated Alpine package.
# 2. Fully remove curl/wget/libcurl/c-ares: the Grafana server is a
# static Go binary and /run.sh never calls them. bash stays.
RUN apk update \
&& apk upgrade --no-cache \
&& apk del --no-cache curl wget libcurl c-ares \
&& rm -rf /var/cache/apk/*
USER grafanaA pleasant side effect: apk del also swept out curl's transitive dependencies — libpsl, nghttp2-libs, zstd-libs, brotli-libs, libidn2, libunistring. Less attack surface for free. The build:
docker build --build-arg BASE_IMAGE=grafana/grafana:13.0.3 \ -f Dockerfile.hardened -t grafana-hardened:13.0.3 .
Runtime hardening
The image is only half the job. The container runs with runtime restrictions (verified: Grafana starts and is fully functional under all of them):
docker run -d --name grafana-hardened-1303 -p 3003:3000 \ --read-only \ --tmpfs /tmp --tmpfs /var/lib/grafana --tmpfs /var/log/grafana \ --cap-drop ALL \ --security-opt no-new-privileges:true \ -e GF_SECURITY_ADMIN_USER=admin -e GF_SECURITY_ADMIN_PASSWORD=*** \ grafana-hardened:13.0.3
| Measure | Effect |
|---|---|
--read-only | read-only rootfs; writes land in tmpfs only |
--cap-drop ALL | all Linux capabilities dropped |
no-new-privileges | privilege escalation blocked |
| non-root (uid 472) | the process never runs as root (default) |
Verifying nothing broke
Any hardening pass has to end with a functional check. A 13-check smoke test covered health, auth, datasource CRUD with health and query, dashboards, the plugin catalog, service accounts and tokens, the alerting provisioning API, internal metrics and frontend settings. The score: 13/13 PASS on the baseline and 13/13 PASS on the hardened container with read-only rootfs and dropped capabilities.
--read-only and --cap-drop ALL happily break apps that write outside tmpfs or need a capability. The test must exercise real user flows (CRUD, queries, provisioning) — not just /health.The results
| Image | Before | After | Δ | HIGH after |
|---|---|---|---|---|
grafana:13.0.3 | 54 | 17 | −68% | 5 |
grafana:13.0.3-slim | 48 | 11 | −77% | 3 |
Every OS-level vulnerability is gone — removed, not patched: apk info in the hardened image lists no curl, libcurl, c-ares, nghttp2 or brotli. The remaining 17 findings split across two Go binaries with different toolchains — a detail that matters for everything that follows:
| Binary | Go | Symbols | Findings |
|---|---|---|---|
bin/grafana | 1.26.4 | not stripped (453,732 symbols) | tempo ×2, prometheus ×3, openfga ×3, stdlib ×2, x/crypto |
plugins/elasticsearch | 1.26.3 | stripped (gopclntab only) | stdlib ×5, x/crypto |
Why the rest gets proven, not patched
The 17 remaining findings can't be fixed at the Dockerfile level: the vulnerable code is compiled into Grafana's static binary. A real fix needs an upstream Grafana release built with updated dependencies (tempo 2.10.3, prometheus 0.311.3) and the Go 1.26.5 toolchain — which doesn't exist yet as of the analysis date (even the 2026-07-09 nightlies ship the same unpatched dependencies). With no immediate fix available, the only honest path is to prove the vulnerable code unreachable and accept the residual risk on that basis.
Two properties, four methods
The whole Go-binary analysis rests on two facts: (1) dead-code elimination — the Go linker drops functions that are never statically called, so a missing symbol means guaranteed unreachability; and (2) “symbol present” ≠ “symbol called”. That yields four methods, in increasing order of rigor:
| Method | What it proves | Applies to |
|---|---|---|
| M1 · go tool nm | package/symbol absent ⇒ unreachable | main binary (not stripped) |
| M2 · govulncheck binary | vulnerable symbol is present | both binaries (works on stripped) |
| M3 · govulncheck source | true call graph: is the symbol ever called | ES plugin (sources available) |
| M4 · runtime | endpoint/feature genuinely absent or off | live hardened container |
Trivy and govulncheck count different things
Don't be alarmed when the numbers disagree — the tools look at different layers.
| Scanner | Principle | Finds in the main binary |
|---|---|---|
| Trivy | SBOM package versions ↔ CVE database | 17 findings |
| govulncheck | dependency graph + symbol reachability | 351 OSV in the graph; only 10 with a symbol present |
“Trivy found 17” and “govulncheck found 351” is not a contradiction — they're different slices. govulncheck digs deeper (it sees hundreds of transitive CVEs), but the vast majority are unreachable and get filtered automatically by symbol analysis. Use both: Trivy for the inventory, govulncheck for reachability.
Why binary mode alone isn't enough: the x509 example
For CVE-2026-27145 (crypto/x509) in the plugin, the symbols Certificate.Verify / VerifyHostname are present — binary mode says “reachable”. Source mode proves the opposite: they are import-only, never called from the plugin's reachable code (they exist only as transitive baggage of net/http/crypto/tls). Had the analysis stopped at binary mode, the verdict would have been wrong. That's exactly why source mode was used for the plugin — it settles these disagreements in favor of the truth.
Unreachability proofs
Each finding class got the strongest method available; where possible, methods cross-confirm each other.
Tempo — DoS and S3 key disclosure (HIGH)
Both CVEs live in Tempo's HTTP server. go tool nm shows that only protobuf types (the trace/TraceQL data model) and pkg/pool are linked in; Tempo's server packages (config, status, query-frontend) are absent. Runtime confirms it: GET /status/config → 404. The vulnerable server isn't in the binary — unconditionally unreachable.
Prometheus — client_secret disclosure and XSS (HIGH/MED)
All three CVEs are in Prometheus's web server/UI. Checking for the host component — nm | grep -c prometheus/prometheus/web → 0; the endpoints /-/config, /api/v1/status/config, /graph all return 404. Grafana uses Prometheus as a PromQL/config library; the web server isn't linked. Unconditionally unreachable.
OpenFGA — Zanzana is off (MED/LOW)
OpenFGA ships inside Grafana as the experimental Zanzana authorization engine. Its OIDC authenticator isn't linked at all (nm → 0) — CVE-2026-55689 is unconditionally unreachable. Two more CVEs (iterator cache, MySQL) are linked, but on the live container no zanzana feature toggle is enabled (the default), so the OpenFGA server never starts and the code never enters the execution path. Conditionally unreachable (condition: zanzana stays off).
stdlib in the Elasticsearch plugin — proven by call graph (HIGH/MED)
The plugin is built with go1.26.3 (vulnerable by version) and stripped, so source mode was used: the plugin sources were cloned at the exact commit and govulncheck -mode=source produced the true call graph. All four stdlib CVEs (x509, mime, textproto, os.Root) came back import-only: the symbols exist as transitive baggage of net/http/crypto/tls but are never called from the plugin's reachable code. A call-graph proof beats any data-flow reasoning.
ECH — the weakest claim (UNK)
CVE-2026-42505 concerns de-anonymizing ECH handshakes. Source mode showed tls.Dialer.Dial is reachable (CheckHealth → net/http → SOCKS5 → tls.Dialer.Dial). But only the ECH branch is vulnerable — an option that requires explicitly setting EncryptedClientHelloConfigList, which Grafana never configures. Conditionally unreachable — and honestly the weakest link in the whole analysis: the TLS symbol is in the execution path, and the justification is configurational, not “code not present”.
openpgp — the package simply isn't there
GO-2026-5932 covers the abandoned x/crypto/openpgp (no fix will ever exist). The package isn't linked into either binary (nm/strings → 0). Unconditionally unreachable.
The full verdict matrix
| CVE | Sev | Binary | Method | Verdict |
|---|---|---|---|---|
| CVE-2026-21728 | HIGH | main | M1+M4 | Unreachable |
| CVE-2026-28377 | HIGH | main | M1+M4 | Unreachable |
| CVE-2026-42151 | HIGH | main | M1+M4 | Unreachable |
| CVE-2026-40179 | MED | main | M1+M4 | Unreachable |
| CVE-2026-44903 | MED | main | M1+M4 | Unreachable |
| CVE-2026-55689 | MED | main | M1 | Unreachable |
| CVE-2026-48096 | MED | main | M1+M4 | Conditional (§A) |
| CVE-2026-55170 | LOW | main | M1+M4 | Conditional (§A) |
| CVE-2026-42505 | UNK | both | M3+cfg | Conditional (§B) |
| CVE-2026-39822 | UNK | main | M2 | Unreachable (symbol absent) |
| CVE-2026-39822 | UNK | plugin | M3 | Unreachable (import-only) |
| CVE-2026-27145 | HIGH | plugin | M3 | Unreachable |
| CVE-2026-42504 | HIGH | plugin | M3 | Unreachable |
| CVE-2026-42507 | MED | plugin | M3 | Unreachable |
| GO-2026-5932 | UNK | both | M1 | Unreachable (not linked) |
Three verdicts are conditional
Eleven of the fourteen unique CVEs are unconditionally unreachable — the code isn't linked, or a call graph proves it's never invoked. Three hold only under configuration preconditions, and keeping those preconditions true is on whoever operates the deployment:
| Id | Condition | If violated |
|---|---|---|
| §A | The zanzana feature flag stays off (default) | The OpenFGA server starts → its authorization code enters the execution path |
| §B | ECH is never configured | The vulnerable ECH branch becomes executable |
| §C | No untrusted PKCS#12 input is processed | go-pkcs12 reachability needs a follow-up investigation |
zanzana is disabled and ECH is not configured). Otherwise a VEX suppression will quietly mask a vulnerability that has become real.“Fixed” ≠ “unreachable”
This distinction is the crux of interpreting the results honestly. The OS vulnerabilities are actually fixed — the packages no longer exist. The 17 Go findings are NOT fixed: the code is still there; only its unreachability has been proven.
| Category | What happened | Check |
|---|---|---|
| OS vulnerabilities (~37) | ✅ actually removed — packages deleted | apk info |
| 17 Go vulnerabilities | ❌ not removed — code is compiled in | go version -m |
Practical consequences: scanners will keep flagging them (that's what VEX is for); calling them “fixed” would be wrong — the correct phrasing is “unreachable in this configuration”; and if the reachability analysis is mistaken, the vulnerability is live. Hence the emphasis on reproducibility and independent review.
VEX: the machine-readable verdict
All verdicts were exported as OpenVEX 0.2.0. One trap cost the first attempt:
products[].@id = the image's root PURL, subcomponents[].@id = the vulnerable package's PURL. Only then did 17 become 0. Lesson: generate VEX from the PURLs of the specific scanner, or it will silently do nothing.trivy image --vex openvex-trivy.json \ --show-suppressed grafana-hardened:13.0.3 # Without VEX: Total: 17 (HIGH: 5, MEDIUM: 5, LOW: 1, UNKNOWN: 6) # With VEX: Total: 0 + Suppressed Vulnerabilities (17)
All 17 findings move to the Suppressed section with a not_affected status, a justification, and a pointer to the VEX source — nothing is hidden, everything is traceable.
Suppressed section appears only in table output; the JSON format has no such section. For machine processing, parse the non-VEX report and the VEX document separately.How much to trust the VEX
| Tier | Basis | Confidence |
|---|---|---|
| A — dispositive | code/symbol not linked (M1) + runtime 404 | Highest: what isn't in the binary can't execute |
| B — call-graph proven | source-mode import-only (M3) | High |
| C — conditional | depends on configuration (§A/§B); ECH imprecise | Requires enforcing the conditions |
Honest weaknesses that reduce confidence: VEX ≠ remediation (the code stays); the ECH justification is imprecise (tls.Dial is provably in the execution path); a coverage gap — go-pkcs12 (GO-2026-5052) is invisible to Trivy and absent from the VEX, so “Trivy+VEX = 0” ≠ “zero vulnerabilities”; and it's self-attestation — the VEX was written by the same party that ran the analysis, so an independent reviewer is required.
Supply chain
To make the artifact trustworthy, an SBOM was generated (Syft, SPDX 2.3 + CycloneDX 1.6, 704 components) along with a cosign signature over the digest and an in-toto SBOM attestation. Verification passes with the public key (exit 0), and the negative control holds — a foreign key is rejected (exit 12).
Scanner pitfalls (the cheat sheet)
The most valuable takeaway from this work is the list of places where a scanner quietly misleads you. All in one table:
| Pitfall | How to avoid it |
|---|---|
| Tempo's false version comparison (pseudo-version vs release tag) | Verify reachability via linkage (M1), not version math |
| Prometheus version mismatch (v0.306.0 in binary vs v0.305.3 in Trivy) | Don't trust scanner versions blindly; verdicts come from linkage |
| Two binaries, two Go versions (1.26.4 and 1.26.3) | Scope stdlib CVEs per binary; never merge them |
| The main binary is also stdlib-vulnerable (fix only in 1.26.5) | Don't assume “latest toolchain = no stdlib CVEs” |
| Trivy (17) vs govulncheck (351) — different coverage | Use both: Trivy for inventory, govulncheck for reachability |
| binary-mode marks module-level OSV falsely “CALLED” | Resolve via M1 (linkage) or M3 (source) |
| binary-mode ≠ source-mode (x509: symbol present, never called) | For a precise verdict use source mode where sources exist |
| Stripped plugin: go tool nm returns 0 | Use govulncheck (reads pclntab) and strings, not nm |
| VEX matches on PURLs, not paths | Generate VEX from the specific scanner's PURLs |
| --show-suppressed appears in table output only, not JSON | For machines, parse the non-VEX report + VEX separately |
| Coverage gap: go-pkcs12 (absent from Trivy and the VEX) | “Trivy+VEX = 0” ≠ “zero vulnerabilities”; cross-check with govulncheck |
| Conditional VEX statuses (zanzana, ECH) | Enforce zanzana=off and no ECH automatically |
| VEX ≠ remediation (the code stays in the binary) | Never say “fixed” about the residual 17 |
| Point-in-time: CVE databases change daily | Re-run the analysis whenever databases or the image change |
Takeaways
- Use the hardened image (17 residual findings, all with proofs) over the base 13.0.3 (54) — and especially over 13.1.0 (83).
- Enforce conditions §A/§B automatically: a guard check that
zanzanais off and ECH is not configured. - Wire VEX into CI (
trivy --vex) with--show-suppressed— a green report that stays transparent. - Watch upstream Grafana: a release on Go 1.26.5 with updated tempo/prometheus will fix the residue physically — then the VEX entries can be retired.
- Take
go-pkcs12(§C) to a definite verdict. - Set up independent review and formal risk acceptance (separation of duties).
- For production supply chains — keyless signing + Rekor + SLSA provenance from CI.
The short version: hardening isn't “run the scanner until it shows zero”. It's two honest operations — physically remove everything the workload doesn't need, and attach evidence to whatever remains. The first shrinks the attack surface; the second turns a red report into a decision you can defend in front of an auditor.
Get the Hardening Playbook for your AI agentFree
The exact prompts and steps we used — hand them to your AI agent and reproduce this hardening on your own image.