Skip to main content

Overview

The supplychain component reads Trivy Operator reports (and, later, its own Grype matches and registry-attached SBOMs) and posts one payload per image digest to the broker. The broker stores them keyed by digest (never per pod), joins them to the image inventory and serves them to the UI, the assistant and the CLI. kguardian reports what the sources found. It does not scan, and it never blocks a workload. All reads are paginated, capped and charged to the broker’s read-memory budget; they answer 503 with Retry-After rather than a partial result. No list response carries an SBOM document.

”In use” is unknown for now

Findings, CVE rows and exposure rows carry inUse: null and inUseState: "unknown". They are filled when kguardian can tell which packages a workload actually loads. Until then, treat every finding as potentially reachable: unknown never means safe.

Sources

Each payload has a source, one of trivy-operator, grype or registry; anything else is refused. Sources are stored side by side: each payload replaces only its own (digest, source).
  • Findings are deduplicated across sources on (vulnerability id, package name, installed version). Two sources reporting the same thing make one finding with sources: ["grype", "trivy-operator"], never two. Severity, score and EPSS are the highest any source gives, kev is true if any source says so, and a fix from any source makes it fixable. fixedVersions lists every distinct fixed version the sources give, ordered by source; the broker does not pick one, because text order is not version order ("10.1" sorts before "9.2").
  • grype findings add kev, kevDateAdded, epss and epssPercentile from the Grype DB, and its report names the SBOM(s) it matched (sbomSources). Trivy never sets these, so null means unknown, not “not exploited”.
  • SBOMs are kept per source and never replace each other. A registry SBOM is one found attached to the image in its registry, with the attestation it was found through.
  • Every report carries sbomTrust, weakest first: attached-unbound (a bare document attached to the image), unverified (an in-toto statement naming the image, signature not checked), scanned (Trivy Operator’s in-cluster scan), verified. A grype report carries the weakest trust among the SBOMs it matched. Unknown values, and a registry SBOM that states none, are stored as attached-unbound. Only verified may be shown as signed.
  • A BuildKit SBOM for one platform manifest names its index in image.index_digest; it joins to an inventory digest equal to that index by the platform_manifest rule. GET /images/{digest}/sbom lists every source’s SBOM and shows Trivy Operator’s unless you ask for another.

Trust

Whoever holds the supplychain token is trusted to write findings; the broker cannot tell a real scan from a made-up one. What it enforces limits the damage a bad or stolen token can do:
  • the closed set of sources above;
  • scanned_at at most 60 s in the future;
  • different content replaces stored content only from a strictly newer scanned_at, so a payload cannot be pre-dated to win, and an empty payload cannot wipe findings unless it is a newer scan;
  • item caps enforced while parsing, and a global ceiling on half-sent SBOM page sets.
The limits do not stop a token holder from hiding findings: sending a slightly newer (up to 60 s ahead) empty scan, again and again, keeps an image’s findings wiped until the next real rescan replaces it. Treat the supplychain token as a write credential for your vulnerability data, and keep it in the supplychain component only.

How a report is matched to what runs

Trivy Operator reports the digest from the pod spec or the registry, which for a multi-arch image is the index. The kubelet reports the platform manifest it pulled. The broker links each payload to inventory digests with the first rule that matches, and records which one: An exact link retires any tag guess on the same image. Links are refreshed at ingest and by the retention pass (broker.supplychain.retention.intervalSeconds, default 300 s), so a pod that starts after its image was scanned is matched within one interval.

Ingest (supplychain token)

The payload schema is in supplychain/README.md. {digest} must equal image.digest.
  • Scoped auth only. These routes need a token with the supplychain scope (BROKER_TOKEN_SUPPLYCHAIN). With broker auth off, or with only the legacy shared token, they answer 403.
  • Bodies. Content-Encoding: gzip (or none), at most 1 MiB on the wire, received within 30 s (408 otherwise). A gzip body is inflated into at most broker.supplychain.maxDecompressedBytes (default 8 MiB) and refused with 413 past it. Parsing stops each list at its cap: 20 000 findings or 10 000 components per request (413); nested lists keep their first entries. A body is read before it waits for anything, so a slow sender only holds its own connection.
  • One at a time. A single worker inflates, parses and writes, behind a queue of 8 bodies; a full queue answers 503 with Retry-After. The measured worst case is about 62 MiB of heap per ingest.
  • Paged SBOMs. Pages are staged by (digest, source, set_id) and swapped in together once all total pages are present, in any order; duplicates are harmless. A newer set drops an older incomplete one. Sets whose pages stop arriving are discarded after broker.supplychain.retention.sbomPageTtlSeconds. At most 64 sets (256 MiB) are staged across the cluster; a new set beyond that answers 429 and counts in kguardian_supplychain_staged_sets_refused_total.
Other errors: broken gzip or JSON 400; a contract violation (unknown schema_version or source, digest mismatch, scanned_at too far ahead, pages that disagree on total) 422. The response is 200 (202 for a staged page) with a status:

GET /images/{digest}/vulnerabilities

Findings for one inventory digest, deduplicated across sources, most severe first.
An empty reports list means no vulnerability data for this image. That is unknown, not clean. title and primaryUrl are third-party text: render them, never fetch or execute them.

GET /images/{digest}/sbom

SBOM components, paged by id (limit default 100, max 500; after is a component id). reports lists every source’s SBOM with its sbomTrust; report is the one the items come from: ?source=, else Trivy Operator’s, else another scanner’s, and a registry SBOM only when it is the only one. report is null when there is no SBOM.

GET /images/{digest}/sbom/cyclonedx

The same selection as one CycloneDX 1.5 JSON document (download), with the source, join and sbomTrust in metadata.properties. File paths are not included.

GET /vulnerabilities

Every CVE affecting a digest in the inventory, grouped by id, most severe first. It reads a summary the retention pass rebuilds each interval, so it costs the same however many findings there are; computedAt and staleSeconds say how fresh it is (null until the first rebuild).
Counts are distinct images and workloads, so sources agreeing never count twice. weakestJoin is workload_tag when any match behind the counts is by tag only.

GET /vulnerabilities/{id}/exposure

Which images, workloads and namespaces a CVE affects, running or not, and what traffic reached them. window_hours (default 168, max 720) sets how far back flows count. Up to 200 images and 200 workload containers are listed; truncated says when there are more. 404 when no inventory digest is affected.

How network exposure is computed

It is observed traffic, from the same pod_traffic rows network policies are generated from, with the peer identity the broker stamps at ingest (see Peer attribution):
  1. Take up to 50 of the workload’s pods the broker knows (live, or dead but not yet pruned), newest first.
  2. Take their rows inside the window, matched on pod name and namespace. flowsObserved counts them (either direction) and ingressFlowsObserved the INGRESS ones.
  3. Of the INGRESS rows, count distinct peers:
    • ingressFromOtherNamespaces: a pod or service peer in another namespace;
    • ingressFromUnattributedPeers: a peer IP the broker never matched to a pod, service or node (an external client, or an in-cluster pod it could not identify);
    • ingressFromPublicIps: those of the unattributed peers that are internet-routable (not RFC 1918, CGNAT, link-local, ULA, loopback or documentation space);
    • ingressFromNodes: node or host-network peers. A NodePort or LoadBalancer Service with externalTrafficPolicy: Cluster rewrites outside clients to a node IP, so this counts as possible exposure. Kubelet probes arrive from node IPs too; the broker cannot tell them apart.
  4. exposed is true when any of those is non-zero, and exposedVia names which (other_namespace, unattributed, public_ip, node). It is false when INGRESS flows were observed in the window and none came from outside. It is null (unknown) when the broker knows no pods for the workload, or they had no ingress flows in the window, even if they had egress: the controller does not capture inbound UDP, so a UDP-only server shows outbound flows and no ingress. Nothing observed is not the same as nothing reachable.
false means no such ingress was seen in the window, not that none is possible: a Service nobody called this week reads false. The direction of a row is as the controller classified it.