Skip to content

fix(kubescape): stop reporting vulnerability_count 0 for every image - #76

Open
slashben wants to merge 1 commit into
kagent-dev:mainfrom
slashben:fix/kubescape-vulnerability-count
Open

fix(kubescape): stop reporting vulnerability_count 0 for every image#76
slashben wants to merge 1 commit into
kagent-dev:mainfrom
slashben:fix/kubescape-vulnerability-count

Conversation

@slashben

@slashben slashben commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What's wrong

kubescape_list_vulnerability_manifests returns "vulnerability_count": 0 for every image, in every cluster, always. An agent that lists images to find vulnerable ones is told everything is clean.

On the test cluster, nginx:1.14.0 has 466 CVEs (76 Critical, 133 High). The tool returned:

{"image_tag": "docker.io/library/nginx:1.14.0",
 "manifest_name": "docker.io-library-nginx-1.14.0-e34030",
 "vulnerability_count": 0}

An agent asked for the cluster's critical CVEs answered, in 5.5s:

No CVEs detected across all container images. Critical: 0, High: 0, Medium: 0, Low: 0.

The agent did nothing wrong. It listed the images, saw every count at zero, and stopped. The data lied to it.

Why it happens

A little background, since it's specific to how Kubescape stores data.

Kubescape's in-cluster scanner writes results into Kubernetes-style objects. This provider reads them. The object for an image's CVEs (VulnerabilityManifest) holds the full CVE list in spec.payload.matches — for one image that's a 2.7 MB object.

Because those payloads are huge, Kubescape's API server omits spec.payload.matches when you LIST objects and only includes it when you GET a single one. That's sensible server behaviour. But the provider computed the count from the listed objects:

"vulnerability_count": len(manifest.Spec.Payload.Matches),

On a LIST, Matches is always nil. len(nil) is 0. The field never measured anything.

Confirmed on two separate clusters — every listed manifest reports matches=nil, while a GET of the same object returns 466.

The fix

Delete the field. The count isn't knowable from a LIST response, so it's removed rather than corrected: an absent field can't be mistaken for a measured zero.

The tool description now states that this tool is an index, that it does not report vulnerability counts, that an entry appearing in it says nothing about whether the image is clean, and that kubescape_list_vulnerabilities is where counts come from. Without that, an agent reads absence of data as absence of risk — the same false negative in a different form. A test asserts the description names that tool, so this half can't silently regress.

kubescape_list_vulnerabilities already returns a correct severity breakdown, so the drill-down path exists today.

Alternative considered

Kubescape also stores pre-aggregated summary objects that do carry counts. I didn't use them here: I couldn't confirm they're populated on every install type, and the mapping from summary back to image manifest turned out to be unreliable. Wiring a security-critical count to that without proof seemed worse than removing a field that is definitively always wrong. (Those objects are used, with verification, in #77.)

Risk / compatibility

  • One field removed from one tool's output. Consumers reading vulnerability_count will no longer find it — but it was always 0, so nothing loses real information.
  • No new dependencies, no new API calls, no behaviour change anywhere else.

Testing

go test ./pkg/kubescape/... — 51 pass, no cluster needed. go build, go vet, golangci-lint, and go test -tags=test ./pkg/... ./internal/... (730 pass) all clean.

Both tests were written first and observed failing.

One caveat worth stating plainly: the fake clientset can't reproduce this bug. Fakes return whatever you seed them with, so they always show a populated payload on LIST. That's part of why this survived — and why the unit tests here assert the field is absent rather than trying to recreate the server behaviour.

Ticket

None.

list_vulnerability_manifests reported "vulnerability_count": 0 for every
manifest in every cluster, always. The count came from

    len(manifest.Spec.Payload.Matches)

but the Kubescape aggregated API strips spec.payload.matches on LIST and
serves it only on GET -- sensibly, the payloads are megabytes. Matches is
therefore nil on every listed object, and len(nil) is 0.

This is a silent false negative on a security question. Measured on a live
cluster, docker.io/library/nginx:1.14.0 has 466 CVE matches (76 Critical,
133 High, 99 Medium, 56 Low, 102 Negligible, confirmed by GET on the same
manifest), and the tool returned:

    {"image_tag": "docker.io/library/nginx:1.14.0",
     "manifest_name": "docker.io-library-nginx-1.14.0-e34030",
     "vulnerability_count": 0}

An agent asked for the cluster's most critical CVEs answered "No CVEs
detected across all container images ... your cluster's container images
currently have no known CVEs". It behaved correctly given its input: it
listed the manifests, saw every count at 0, and had no reason to drill in.
The data lied to it.

The count is not knowable from a LIST response, so it is removed rather
than corrected. An absent field cannot be mistaken for a measured zero.

Removing it is only safe if the agent is told where counts come from, so
the tool description now states that this is an index which does not report
vulnerability counts, that an entry appearing here says nothing about
whether the image is clean, and that kubescape_list_vulnerabilities returns
counts and severities for a given manifest. Without that, absence of data
reads as absence of risk -- the same false negative in a different costume.

Signed-off-by: Ben Hirschberg <ben@armosec.io>

Docs-exempt: bug fix; no existing doc describes the list response shape
Signed-off-by: Ben <ben@armosec.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant