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-insupplychain.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 isexecutedwhen 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 alwaysunknown(reason: language_package).
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 atier, 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 onenot_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 asource, 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,epssandepssPercentileon every finding (and on CVE rows) are resolved over every source’s rows for that vulnerability id.kevis true if any source says true, false if one says false and none true, andnullonly when no source reports it; EPSS is the highest reported. So a Trivy finding (Trivy never reports KEV) showskev: truewhen Grype lists the same CVE in KEV, even if the two name the package version differently, and its tier andtierFactorsuse 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).fixedVersionslists 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"). grypefindings addkev,kevDateAdded,epssandepssPercentilefrom the Grype DB, and its report names the SBOM(s) it matched (sbomSources). Trivy never sets these, sonullmeans unknown, not “not exploited”.- SBOMs are kept per source and never replace each other. A
registrySBOM is one found attached to the image in its registry, with theattestationit 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. Agrypereport carries the weakest trust among the SBOMs it matched. Unknown values, and a registry SBOM that states none, are stored asattached-unbound. Onlyverifiedmay 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 theplatform_manifestrule.GET /images/{digest}/sbomlists 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_atat 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.
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)
supplychain/README.md. {digest} must equal
image.digest.
- Scoped auth only. These routes need a token with the
supplychainscope (BROKER_TOKEN_SUPPLYCHAIN); a token without it answers403. While the broker has nosupplychaintoken at all (auth off, or only the legacy shared token) they answer503withRetry-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 (408otherwise). A gzip body is inflated into at mostbroker.supplychain.maxDecompressedBytes(default 8 MiB) and refused with413past 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
503withRetry-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 alltotalpages are present, in any order; duplicates are harmless. A newer set drops an older incomplete one. Sets whose pages stop arriving are discarded afterbroker.supplychain.retention.sbomPageTtlSeconds. At most 64 sets (256 MiB) are staged across the cluster; a new set beyond that answers429and counts inkguardian_supplychain_staged_sets_refused_total.
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.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 andsbomTrust 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.
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 samepod_traffic rows network policies
are generated from, with the peer identity the broker stamps at ingest
(see Peer attribution):
- Take up to 50 of the workload’s pods the broker knows (live, or dead but not yet pruned), newest first.
- Take their rows inside the window, matched on pod name and
namespace.
flowsObservedcounts them (either direction) andingressFlowsObservedtheINGRESSones. - Of the
INGRESSrows, 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 withexternalTrafficPolicy: Cluster(which rewrites outside clients to a node IP) all arrive from node IPs, and the broker cannot tell them apart.
exposedVianames the classes seen (other_namespace,unattributed,public_ip,node).exposedistruewhen any class other thannodeis 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 theP0tier on its own (exposed: false, exposedVia: ["node"]).exposedisfalsewhenINGRESSflows were observed in the window and none came from outside (node peers aside). It isnull(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.