Every cluster scanner ends up with suppressions. Something gets flagged, the team decides it is acceptable for now, and the finding goes quiet. The interesting question is not whether you can silence a check. It is whether the silence has an end date.
I spent an evening with the KubeBuddy plugin for Headlamp and this turned out to be the part worth writing about.
What KubeBuddy is
A Headlamp plugin that runs a catalog of 137 checks (RBAC, security, networking, workloads, storage) against the active cluster and produces a weighted score.
The architectural choice that matters: nothing is deployed into the cluster. No operator, no CRDs, no agent. It evaluates the resource data Headlamp already reads with your own credentials. Trivy Operator and Kubescape both need in-cluster components to work, which is a deployment, a permission request and, in a client environment, a conversation.
For consulting work that difference is the whole point. I can audit a client cluster without first asking anyone to approve a deployment.
The suppression mechanism
Three annotations on the resource:
kubebuddy.io/ignore-checks
kubebuddy.io/ignore-reason
kubebuddy.io/ignore-until
The claim in the docs is that ignore-until expires. The claim is worth testing, because “expires” can mean several things: the annotation is ignored, the finding comes back, or a warning appears somewhere nobody looks.
Here is the actual code from the plugin bundle, with the minified variable names mapped (ja = ignore-checks, za = ignore-reason, Ga = ignore-until):
// has the deadline passed?
function vl(s){
const e = s.trim();
if (!e) return false;
const t = Date.parse(e);
return Number.isFinite(t) && Date.now() > t;
}
// should this finding be suppressed?
function bl(s, e){
const t = e.sourceAnnotations || {},
n = String(t[ja] || "").trim();
if (!n || !yl(n, s)) return null; // check not in the ignore list
const i = String(t[Ga] || "").trim();
return i && vl(i)
? null // expired, do not suppress
: { reason: String(t[za] || "").trim(), until: i };
}
Three things follow from those fifteen lines:
- The deadline is compared against wall clock time. Past date,
null, and the finding is treated as active again. - Suppressed findings are not deleted. They move into a separate
suppressedFindingsarray, tagged withsuppressionReasonandsuppressionUntil. ignore-checksaccepts a list separated by commas, spaces or semicolons, and*for everything. Case insensitive.
Verifying it on a real cluster
Reading code is not proof. Ten minutes on kind is.
kind create cluster --name kb
npx @kinvolk/headlamp-plugin install \
https://artifacthub.io/packages/headlamp/kubebuddy/kubebuddy-headlamp-plugin \
--folderName ./plugins/kubebuddy
One gotcha that is not in any documentation: Headlamp expects plugins/<name>/main.js, and the CLI creates an extra directory level. Until you flatten it, /plugins returns an empty list and the plugin never shows up in the sidebar.
Then three deployments. One with no annotations as a control, two with exceptions on opposite sides of today:
kubectl create deploy nginx-bad --image=nginx:latest
kubectl create deploy sup-active --image=nginx:latest
kubectl create deploy sup-expired --image=nginx:latest
# exception valid until 2030
kubectl annotate deploy sup-active \
kubebuddy.io/ignore-checks='*' \
kubebuddy.io/ignore-reason='approved by security, ticket SEC-42' \
kubebuddy.io/ignore-until='2030-01-01T00:00:00Z'
# exception expired in 2020
kubectl annotate deploy sup-expired \
kubebuddy.io/ignore-checks='*' \
kubebuddy.io/ignore-reason='temporary waiver' \
kubebuddy.io/ignore-until='2020-01-01T00:00:00Z'
deployment.apps/nginx-bad created
deployment.apps/sup-active created
deployment.apps/sup-expired created
deployment.apps/sup-active annotated
deployment.apps/sup-expired annotated
The scan came back at 87 out of 100 across 146 evaluated checks, 31 failed, with RBAC002 RBAC Overexposure at the top because I had also created a cluster-admin binding to give it something to find.
The card for WRK005 Missing Resource Requests reads 2 findings and 1 suppressed. The list underneath:
| Deployment | In the findings list | Why |
|---|---|---|
nginx-bad | yes | no annotations |
sup-expired | yes | exception expired in 2020 |
sup-active | no, suppressed | exception valid until 2030 |
So the expired one really does come back. Not as a warning in a log somewhere, but in the findings list where you actually look.
And the suppressed one is not thrown away:
{
"resource": "sup-active",
"kind": "Deployment",
"details": "Container nginx missing CPU request, MEMORY request",
"suppressionReason": "approved by security, ticket SEC-42",
"suppressionUntil": "2030-01-01T00:00:00Z",
"link": "/c/kind-kb/deployments/default/sup-active"
}
Same for eight other checks at once, because the annotation used *.
Why this is the part that matters
A suppression without an expiry date is a decision that outlives its reason. The workload gets its requests eventually, the legacy service is decommissioned, the person who approved the exception leaves, and the annotation stays. The scanner keeps quiet about a problem that is no longer accepted by anyone, and the score keeps showing health that does not exist.
No suppression at all is more honest than that. It is annoying, but it does not lie.
An exception with a mandatory reason and a deadline is the only version that stays true: it turns “we accepted this risk” into “we accepted this risk until March”, and after March the system asks again by itself.
What to watch out for
The score moves for the wrong reasons. Any tool with a number on the front page invites people to move the number the cheap way. ignore-checks: '*' on one deployment removed eight findings in a single annotation. If those annotations are not reviewed in pull requests, the metric degrades into self-congratulation within a couple of months.
The scan dies when you leave the page. The plugin registers a beforeunload handler with the message KubeBuddy is still scanning. Leave this page and the scan will stop. This is a deliberate on-demand review tool, not monitoring. Continuous checking needs the CLI in a pipeline.
Plugin coverage is not CLI coverage. No AKS or GKE checks that need cloud APIs, no Prometheus checks, no native Go engine. Diffing a plugin report against a CLI report will produce false differences on checks that simply are not there. Compare by intersecting check IDs.
Score history lives in your browser. The key is kubebuddy:score-history:<cluster> in localStorage, labelled Local cache only in the UI. Different browser, different engineer, cleared site data, and the trend is gone.
Suppressed findings are a counter, not a list. The expanded card shows 1 suppressed with no detail. The reason and the deadline are in the report and the export. If you build a review process around this, review the export, not the screen.
Is the tool itself safe to run
Worth being able to answer this with evidence rather than a link to a marketing page, especially before pointing it at someone else’s cluster.
# mutating requests
grep -oE '"(POST|PUT|PATCH|DELETE)"' main.js | sort | uniq -c
# any network calls of its own
grep -oE 'fetch\(|XMLHttpRequest|axios' main.js | sort | uniq -c
# where it talks to at all
grep -o 'https\?://[a-zA-Z0-9./-]*' main.js | sort -u
The first two return nothing. Reads go through the Headlamp API client, and the bundle has no networking code of its own. The third returns documentation links only. The vendor’s hosted service appears exactly once, as a string inside the generated kubebuddy-config.yaml, in a section that is disabled by default.
So: runs with your permissions, read only, deploys nothing, and has no code path for shipping results anywhere. Three commands, and you can say that to a client with a straight face.
Clean up
kind delete cluster --name kb
Takeaway
If you are choosing a cluster scanner, look past the check count and ask what happens to an exception six months after someone adds it. That answer tells you whether the score on the dashboard will still mean anything by then.
Verified against plugin version 0.5.1, shipping KubeBuddy checks from v0.0.36, on a kind cluster, September 2026.