Skip to content

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.