Tetragon¶
Tetragon watches processes at the kernel, with eBPF,
on every node. Trivy says what an image could do; Tetragon records what a
process did: every exec and exit in a pod, and, where a TracingPolicy
says so, file opens and credential changes. It is the only thing here that
sees a shell spawned in a running container.
At a glance¶
| Namespace | kube-system |
| Depends on | Kernel BTF at /sys/kernel/btf/vmlinux, which Flatcar ships; Logging for the events, Monitoring for the alerts |
| If it is down | No runtime record and no runtime alerts; nothing else notices |
| Health check | kubectl get tracingpolicies -o wide; TetragonPolicyNotLoaded and TetragonEventsLost otherwise |
| Dashboard | Tetragon: agent health, event rates, policy hits by binary, and the latest policy hits, shells and execs read from Loki |
| Files | payload/platform/tetragon/: the chart, the policies, prometheusrule.yaml and a dashboard written for this cluster, since upstream ships none |
Configuration¶
Cilium's own chart, from helm.cilium.io; Tetragon does not depend on Cilium
and runs privileged on the host network by the chart's default.
| Setting | Why |
|---|---|
| Export to stdout | The chart runs a sidecar that tails Tetragon's JSON file to stdout, so Alloy collects it as any container log: {namespace="kube-system", container="export-stdout"} |
exportDenyList |
Exec and exit events from the host and from the platform's own kube-system pods, selected by label, stay out of the export; they are the bulk of the volume and the least interesting. Any other pod in kube-system is exported, because a stolen write role lands its privileged pod there. Policy hits still pass, which is the point of the event_set field |
enableProcessCred |
Capabilities and privileges_changed on every exec, so a setuid binary or a file-capability escalation is visible without a policy |
Two ServiceMonitors |
tetragon_policy_events_total is what the alert reads; tetragon_tracingpolicy_loaded and the ring-buffer counters are its health |
| Memory limit | 2.5x the measured working set of the busiest agent with the policy library loaded, per the platform rule; the dashboard's first row shows the current worst node against it |
Policies¶
tracingpolicies.yaml carries the cluster-scoped TracingPolicy objects,
adapted from the upstream policy library.
All post at most one event per minute per selector, and most watch containers
only, since the host has the node journal; sensitive-file-access and the
signature hook of kernel-module-load record the host too. Every hook carries a
message and one of upstream's observability.* tags, so an event says
what it is without a lookup here.
| Policy | Hook | Fires on | Alerted |
|---|---|---|---|
sensitive-file-access |
security_file_permission |
A read of a control-plane or etcd key, the encryption config, an admin kubeconfig, etcd's data or a kubelet key, matched by the end of the path so a hostPath mounted anywhere counts, unless the reader is a control-plane binary, the kubelet, kubeadm or kube-vip on the host network, or the path is under /run/cilium/state/, where Cilium's route reconciler keeps its own .wal; the alert leaves out etcdctl in the etcd backup and defrag Jobs, by namespace and Job name; a read of /etc/shadow on the host by anything but PAM's /sbin/unix_chkpwd, which every login and sudo runs (in a container any read is PAM in the image, so only writes count there); any write, host or container, to /etc/passwd, /etc/shadow, /etc/group, /etc/sudoers, /etc/ld.so.preload, or under /etc/sudoers.d/, /etc/cron.d/ and /root/.ssh/ |
Yes: TetragonSensitiveFileAccess |
process-creds-changed |
commit_creds |
A container process that moved into another mount, pid, network or user namespace after its exec, except runc; the alert leaves out the cilium DaemonSet in kube-system and all of rook-ceph, whose init containers and OSD prepare Jobs nsenter into the host by design, and /proc/self/exe in the arc-jobs workflow pods, which is buildah re-executing itself into its user namespace. The hook runs on every execve and fork; the selector keeps the escape signal. Upstream's matchCapabilityChanges is not used: it matches a change in either direction, so every entrypoint that drops root fires it |
Yes: TetragonCredentialsEscalated |
exec-from-writable-path |
security_bprm_check |
A container executing a file under /tmp/, /var/tmp/, /dev/shm/, /run/, /var/run/, /shared/, /controller/ or /plugins/, except the two binaries an init container copies there and the scripts s6-overlay writes under /run/s6* and /run/service/ at start |
Yes: TetragonExecFromWritablePath |
library-from-writable-path |
security_mmap_file |
The same paths mapped with PROT_EXEC, which is how a dropped shared library loads, except the native libraries runtimes unpack under /tmp on every start: Alloy's Snowflake driver (/tmp/gosnowflake-cgo*) and zstd-jni in Dependency-Track (/tmp/libzstd-jni-*) |
Yes: TetragonLibraryFromWritablePath |
privileges-raise, privileges-raise-gid, privileges-raise-gid-argocd |
The __sys_set*uid and __sys_set*gid family |
Any setuid or setgid to root inside a container. A record: root re-asserting root is routine (logrotate), and a setuid binary that actually raises privileges is TetragonPrivilegedExec. runc, which sets the ids on every container start, is dropped in the kernel. The redis-ha busybox calls setgid on every applet, so the gid hooks leave out pods labelled app: redis-ha in argocd only; one podSelector cannot say "this label in that namespace", hence two gid policies |
No |
bpf-program-load |
bpf_check |
Any BPF program load from a container; the alert leaves out cilium and kube-system |
Yes: TetragonBpfProgramLoaded |
kernel-module-load |
security_kernel_module_request, security_kernel_read_file, find_module_sections |
A module requested by name or read from a container, minus the net-pf-2-proto-262 and net-pf-10-proto-262 requests Go binaries and haproxy make when they probe MPTCP at start and the net-pf-31 one Home Assistant makes when it looks for a Bluetooth adapter; the alert leaves out rook-ceph, whose CSI plugin loads rbd after a boot. The third hook records every load's signature check, host included: Flatcar signs its modules but does not enforce it (CONFIG_MODULE_SIG without _FORCE) |
Yes: TetragonKernelModuleLoaded; TetragonUnsignedKernelModule from Loki |
dns-outside-cluster |
ip_output |
A port 53 packet from a container to anything but the kube-dns ClusterIP or the pod range, which is a hard-coded resolver in an image, a tunnel or exfiltration. The pod range is left out because it holds each node's Cilium router address, the pod's default gateway, which Home Assistant's dhcp integration asks for PTR records at start. The hook sees TCP and UDP alike, so musl's unconnected sends count too; the alert leaves out kube-system, where CoreDNS forwards upstream, and cert-manager, whose DNS-01 propagation check asks the zone's authoritative servers |
Yes: TetragonDnsOutsideCluster |
mount-in-container |
security_sb_mount |
Any mount from a container except by runc, which mounts the rootfs, proc and sysfs on every start. Escapes start with a mount of the host's disk, cgroup or proc; the alert leaves out rook-ceph, cilium and kube-system, which mount by design |
Yes: TetragonMountInContainer |
ptrace-in-container |
security_ptrace_access_check |
An attach-mode access to another process from a container: ptrace, process_vm_writev, /proc/<pid>/mem. The read mode ps uses is left alone |
Yes: TetragonPtraceInContainer |
kill-unprivileged-user-namespace |
create_user_ns |
A user namespace created without CAP_SYS_ADMIN in a container, killed, except in arc-jobs. The only policy in enforce mode |
Yes: TetragonUserNamespaceCreated |
unprivileged-user-namespace-arc-jobs |
create_user_ns |
The same in arc-jobs, recorded: buildah builds the images there in a user namespace, see GitHub runners |
No; every image build would fire it |
egress-outside-cluster |
security_socket_connect |
An IPv4 connect(), TCP or UDP, from a container to anything outside the pod, service and site ranges |
No; ACME, S3, the Trivy database and Home Assistant all do this routinely |
The allow list in the first policy is the set of things that read key material
in steady state, found by listing what fired. Add a binary there only after
confirming it in the export; a person running cat on a node is exactly what
the policy is for. A binary path is only trusted together with the host
network namespace: any image can ship /usr/local/bin/etcd, but only a
privileged namespace can run it with hostNetwork. The exemption stays in the
kernel rather than on pod labels, because the podSelector pitfall below would
miss a pod that reads a key the moment it starts; exemptions by namespace and
workload go in the alert instead.
The third policy is the runtime half of an image check: nothing in an image
lives on those paths, so a binary executing from one was written after the
container started. An emptyDir can be mounted anywhere, so the policy names
the mounts this cluster puts binaries into rather than every emptyDir;
kubectl get pods -A -o json lists the rest. A readOnlyRootFilesystem
pod makes the check complete, since then a new binary has nowhere else to
land. The offline half, every binary Tetragon saw executed diffed against
the image's file list, needs no policy: query Loki by image and binary and
resolve each path in crane export <image> - | tar t.
Four more detections from the same library need no policy, because the exec
event already carries what they look for: sudo, a setuid or file-capability
binary raising privileges, a fileless exec and a deleted binary. They are
Loki alerts. Upstream's sshd policy is covered by
NodeSshLogin from the node journal.
Usage¶
The dashboard is the summary; the queries below are what its Loki panels run.
# Everything a policy caught
{namespace="kube-system", container="export-stdout"} | json | process_kprobe_policy_name!=""
# Every exec in one namespace, with arguments
{namespace="kube-system", container="export-stdout"} | json | process_exec_process_pod_namespace="home-assistant"
| line_format "{{.process_exec_process_binary}} {{.process_exec_process_arguments}}"
The tetra CLI in the agent pod does the same live:
kubectl -n kube-system exec ds/tetragon -c tetragon -- tetra getevents -o compact --namespaces home-assistant
Health check¶
kubectl -n kube-system get ds tetragon
kubectl get tracingpolicies -o wide # STATE should be enabled
Enforcement¶
Every policy but one only records. kill-unprivileged-user-namespace
carries policy-mode: enforce and a Sigkill, because nothing here
creates user namespaces and most kernel escalation chains start with one.
It stays scoped to containers: a sysctl would cover the host too, where
systemd's PrivateUsers= needs them. The one exception is arc-jobs,
where buildah needs one for every image build; its podSelector excludes
that namespace, at the price of the first seconds of every container
(see Pitfalls), and a monitor-only twin records the events there.
The limits, both checked on the running nodes: Override and enforcers
need CONFIG_FUNCTION_ERROR_INJECTION, which Flatcar's kernel does not
set, and BPF LSM hooks need bpf in the lsm= boot argument, which
Flatcar does not pass. That leaves Sigkill, which upstream warns does
not stop an operation already in flight. A policy without policy-mode
loads in monitor mode and its kill actions are elided, so an enforcing
policy has to say so.
Pitfalls¶
A podSelector misses a pod's first seconds
The policy filter learns pods from the API and maps their cgroups; a
process that runs the moment a container starts is not yet selected,
and the test pod's unshare went unrecorded by the filtered policy
while the unfiltered one killed it. Put the hook an alert depends on in
a policy without a podSelector, and do exclusions in PromQL.
A first hit is a new series, and increase() of one sample is zero
tetragon_policy_events_total has a pod label, so the first hit from a
pod creates a counter series inside the alert window and increase()
reports nothing until the second scrape moves it. Every policy alert
therefore also matches a series absent one window earlier, with
unless ... offset, and holds with keep_firing_for, because the
agent drops a pod's series when the pod
exits and a one-shot pod would otherwise flap. Copy that shape for a
new alert, and test it with a pod that fires once, not with one that
keeps firing.
A policy that does not load is silent
A TracingPolicy naming a kernel function this kernel does not export sits in state error and watches nothing. TetragonPolicyNotLoaded fires after its for; check kubectl get tracingpolicies -o wide after every Flatcar release that moves the kernel.