Skip to main content

Overview

The supplychain component reads Trivy Operator reports and, when its opt-in Grype matcher is enabled, matches SBOMs itself: Trivy’s, plus any registry-attached SBOM when the opt-in supplychain.sources.registry source is on. It posts one payload per image digest and source 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. Findings come from Trivy Operator reports and, when the matcher is enabled, from kguardian’s own Grype matcher. kguardian 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

The controller records which files each container executes and which shared libraries it maps (the runtime inventory). Each retention pass matches those paths to the files the image’s SBOM lists per package and gives every finding, per workload container, one of: Paths are matched as the kernel reports them (symlinks resolved) against the package database’s spelling: /usr/lib/... also matches /lib/... on merged-/usr images, and libz.so.1.2.13 also matches the libz.so.1 soname link, never the libz.so development link. Executed or loaded paths no package owns are kept per image as unowned (a binary copied in outside a package manager). inUseDetail.coverage says how much the state is worth for the package type:
  • file: OS packages (deb, apk, rpm). Per file.
  • static_binary: Go and Rust modules. The SBOM lists the binary as each module’s file, so every module linked into a binary is executed when it runs. That means linked into running code, not that the vulnerable function was reached.
  • interpreted: npm, pip, jar, gem and similar. An interpreter reads them; nothing is mapped or executed, so they are always unknown (reason: language_package).
Other unknown reasons: no_runtime_data (no capture for the container, or the capture coverage signal is not available yet), capture_gap, host_network, probes_missing, libraries_not_tracked (capture ran in exec mode, which cannot vouch for a shared library never being loaded), no_package_files (no SBOM lists the package’s files). When the only file list for a package comes from a node catalog SBOM, three more: sbom_incomplete (the SBOM is not complete, or the package’s file list was cut), platform_mismatch (a node that ran the container reports another platform than the SBOM was cataloged for, or none), interpreted_content (the package also owns interpreted or data files capture cannot see). Clients should show an unrecognised reason as sent. installed_not_observed is only claimed with a coverage signal from the runtime inventory; until the inventory provides one, every finding is executed, loaded or unknown. Unknown never means safe: it is ranked as if loaded. inUse is the same as a boolean: true executed/loaded, false installed_not_observed, null unknown.

Risk tiers

Every finding and CVE row has a tier, most urgent first: “In use” includes unknown. Exposure is the rule in How network exposure is computed. A workload with no observed ingress at all counts as exposed by default, so a KEV finding (or one at or above the EPSS threshold) in a package in use there is P0, not P1: missing data pushes a tier up, never down. Set unknownExposureAsExposed: false to rank it P1 instead. A CVE’s tier is its most urgent over every affected workload container. A CVE row’s tier is null until the retention pass after an upgrade has computed it (“not computed yet”, never a low tier); a tier filter does not match those rows. tierFactors lists what produced a finding’s tier, for example ["in_use:loaded", "kev", "severity:high", "exposed"]. The thresholds are Helm values under broker.supplychain.tiers: epss (default 0.1), unknownExposureAsExposed (default true) and inUseMinWindowHours (default 24, the coverage needed before installed_not_observed).

OpenVEX draft

For a workload, the broker can draft an OpenVEX 0.2.0 document with one not_affected / vulnerable_code_not_in_execute_path statement per finding whose package is installed_not_observed in every container of the workload that runs the image. Each statement is marked as a draft requiring human review and carries the observation window. Nothing else gets a statement: no affected claims, and no statement from missing data. The draft is produced for the workload export bundle’s vex artifact.

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 and score are the highest any source gives, and a fix from any source makes it fixable.
  • KEV and EPSS are per CVE, not per finding: kev, kevDateAdded, epss and epssPercentile on every finding (and on CVE rows) are resolved over every source’s rows for that vulnerability id. kev is true if any source says true, false if one says false and none true, and null only when no source reports it; EPSS is the highest reported. So a Trivy finding (Trivy never reports KEV) shows kev: true when Grype lists the same CVE in KEV, even if the two name the package version differently, and its tier and tierFactors use that. The broker keeps these per-CVE facts in one row per CVE, updated at ingest and rebuilt each retention pass (so a lower EPSS takes effect, and CVEs whose findings were removed drop out, within one interval). 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. An SBOM also carries the exact links of every vulnerability report matched from it (one whose sbomSources names the SBOM’s source, under the SBOM’s digest or the index it belongs to): a registry SBOM is attached to one platform manifest while a node may run another platform of the same index, and the matcher reports under the index, so the SBOM a report came from is served under every running digest the report is. 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); a token without it answers 403. While the broker has no supplychain token at all (auth off, or only the legacy shared token) they answer 503 with Retry-After, so a component that starts before the broker’s scoped auth is in place keeps its payloads queued instead of dropping them; nothing is stored either way.
  • 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, in tier order: P0, then not yet computed (tier: null), then P1, P2, Background. Within a tier, most severe first, then by id. The response says so with "order": "tier"; a broker from before this order omits order and sorts by severity, then id. 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). nextAfter is opaque: pass it back as after unchanged. A cursor from a broker that sorted by severity first (5.CVE-…) is refused with a 400 saying to request the first page again, since continuing it in tier order would skip and repeat rows.
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. {id} is matched case-insensitively, like the vuln_id filter; an id stored in an unusual spelling (not upper, lower or GitHub’s GHSA casing) that was ingested since the last summary rebuild is matched case-insensitively after the next rebuild, within minutes. 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. Kubelet liveness and readiness probes, host-network agents and a NodePort or LoadBalancer Service with externalTrafficPolicy: Cluster (which rewrites outside clients to a node IP) all arrive from node IPs, and the broker cannot tell them apart.
  4. exposedVia names the classes seen (other_namespace, unattributed, public_ip, node). exposed is true when any class other than node is present: node-only ingress is probe traffic as often as it is a NodePort, so it is reported as its own class and never counts as exposure or feeds the P0 tier on its own (exposed: false, exposedVia: ["node"]). exposed is false when INGRESS flows were observed in the window and none came from outside (node peers aside). 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.