Overview
The controller watchesexecve and shared-library mappings per container
and reports the distinct set of files it sees, keyed by workload,
container and image digest. A path is stored once per (workload,
container, digest), not per event, so the inventory grows with what a
workload runs rather than how often it runs it. Processes that were
already running when the controller started are backfilled from /proc
and marked source: "backfill"; a later runtime sighting upgrades them
to ebpf.
Joined with an image’s SBOM on digest and path, this separates packages
an image ships from packages it actually loads. Each path also records
where the file lived when it ran (origin), which is what drift detection
uses to flag binaries the image did not ship.
Enabling it
Off by default until its per-node overhead is measured. Set the chart valuecontroller.runtimeInventory.mode:
Each (container, file) is reported once: the probe deduplicates in the
kernel, so after warm-up a container costs one hash lookup per exec or
executable mapping. Containers already running when the probe attached are
backfilled once from
/proc. Namespaces in
controller.excludedNamespaces and pods annotated
kguardian.dev/runtime-inventory: "off" are not recorded.
An empty inventory means nothing was observed, not that nothing ran: the
feature may be off on that node, the pod may be excluded, or the process
may have run before the controller started and exited before the
backfill.
Origin
When sightings of one path disagree, the most suspicious wins (in the
order
unknown < image < otherFs < deleted < writableLayer <
memfd) and is never downgraded: one replica running the file from its
writable layer is the finding, whatever the others did. deleted,
writableLayer and memfd together are the “unshipped” origins.
A file changed in place while the container runs (an image binary
overwritten, which copies it up into the writable layer, or a file
unlinked and then executed through an open descriptor) is reported again
with its new origin: the probe’s dedup key includes the file’s state.
Not observed: a mapping made executable later with mprotect(PROT_EXEC)
(JIT compilers, some custom loaders), and code an interpreter reads as
data (scripts, .pyc, jars). Only execve and executable mmap are
seen.
Rows not seen for RUNTIME_INVENTORY_RETENTION_DAYS (chart:
broker.runtimeInventory.retentionDays, default 30) are pruned.
GET /workloads/{namespace}/{kind}/{name}/runtime
Requires theread scope when broker auth is on. Returns 200 with an
empty containers list when nothing has been reported for the workload.
coverage lists every (container, digest) of the workload with a
controller heartbeat in the window, with the answer of
kg_runtime_coverage (see Coverage). An empty coverage
means no controller is watching the workload, so an empty inventory says
nothing about what runs.
One containers item per (container, digest): a workload mid-rollout
lists the same container once per image. imageDigest is "" when the
controller could not resolve the digest. Entries are ordered by kind,
then path. pathComplete: false means the kernel-side path walk was cut
short and path is a suffix. truncated is true when more rows exist
than limit.
GET /images/{digest}/runtime
Requires theread scope. The digest must be sha256:<64 hex> or
sha512:<128 hex> (400 otherwise). Returns every path seen running from
the image, aggregated across all workloads that run it.
truncated is true, nextCursor is the after value for the next
page. coverage counts the heartbeating containers running the image in
the window; all zero means nobody is watching.
workloads counts distinct workloads that ran the path. firstSeen and
lastSeen span every workload, and origin is the most suspicious one
seen in any of them.
POST /runtime/executables
Controller ingest; requires theingest scope. The body is a JSON array
of at most 5000 entries (larger batches are refused with 413 and nothing
is written; the body limit is 8 MiB).
kind or
source, an empty or over-4096-byte path, an invalid digest, or a missing
namespace, container or workload is dropped and counted, and the rest of
the batch is stored. An entry with no workload is keyed to its pod
(kind: "Pod"). origin is optional: absent, or a value this broker
does not know, is stored as unknown. Timestamps are UTC; a last_seen in the future is
clamped to the broker’s database clock.
written counts rows inserted or changed. Re-reporting a known path only
rewrites its row when last_seen moved by five minutes or more, or when
the report adds something (an earlier first_seen, an eBPF sighting of a
backfilled path, a complete path, a more suspicious origin), so a
steady-state batch writes nothing.
Coverage
An inventory row proves a file ran. It cannot prove that a file did not run, because a node with the feature off writes nothing. For that, each controller heartbeats every container it tracks every 5 minutes, whether or not anything new ran, and the broker answers “was this watched continuously?” with the SQL functionobserved_since is naive UTC, like every broker timestamp. covered is
a negative claim (“this never ran”) that can end up in a VEX statement,
so every doubt answers false. It is true only when all of these hold for
the workload container and image:
- at least one instance heartbeated within the window;
- every such instance had the exec probe, and the library probe in mode
full(execmode cannot vouch for a library never being loaded); - no instance lost events in the window: the kernel could not queue them, the container hit its 4096-path cap, the broker dropped entries at ingest, or sightings arrived before the pod was known;
- no instance still had entries waiting to reach the broker at its last heartbeat;
- no path of the container and image is incomplete (a truncated path
cannot be matched to the package that owns it), and no instance reported
its inventory incomplete: the
/procbackfill reads at most 4 MiB of a process’smaps, and a container cut short there stays uncovered for as long as it runs; - every such instance is still heartbeating on time (within 3 × interval + 60 s), or ended cleanly;
- every such instance was either captured from its start with no gap
since, or covered without a gap since before the window began. A
container already running when the probe attached counts from its
/procbackfill; - no live pod of the workload runs the container without a fresh heartbeat, for example on a node with the feature off;
- coverage began at least
window_hoursago.
covered is false, and reason is the first failing check in
this order: no_runtime_data, probes_missing, libraries_not_tracked,
events_dropped, events_pending, incomplete_paths, capture_gap.
A late heartbeat or a probe or mode change restarts the gap-free run at
the heartbeat that ended it. A lost event keeps every window containing
it uncovered.
A full dedup map in the kernel loses nothing: it evicts its oldest entry,
and that file is reported again on its next use.
POST /runtime/coverage
Controller heartbeats; requires theingest scope. At most 5000 per post,
counted while parsing (413 above that), 4 MiB body. Invalid entries are dropped and counted.
A heartbeat older than the stored one is ignored, and a container’s row
is frozen once a heartbeat with ended: true arrives.
RUNTIME_INVENTORY_RETENTION_DAYS are pruned.