Cluster security
Run mlab-agent with k3sec on every node of a Kubernetes cluster and follow it from the workspace: runtime events, eBPF and YARA detections, and the vulnerabilities of the code that really runs.
The Cluster tab of a workspace follows your Kubernetes clusters. On every node, mlab-agent runs k3sec as its tool: k3sec watches the node (Kubernetes events, process executions, outbound connections, YARA detections) and lists the packages of every running image, tagged by what is really loaded in memory. mlab.sh matches them against known vulnerabilities and shows you, per cluster, what to fix first.
k3sec holds no mlab.sh key and opens no connection of its own: the agent of the node owns the identity and the only outbound link, as on any other host.
Linux nodes, x86_64. Tested on k3s; any distribution that runs containerd works the same way. eBPF needs Linux 5.8 or later (the BPF ring buffer).
Set it up
One pod per node, from a DaemonSet: the image ghcr.io/mlab-sh/k3sec holds k3sec, mlab-agent and the default YARA rules.
Get a cluster code. In the workspace, Agents › Enrol an agent › A Kubernetes cluster, with the number of nodes. One code enrols that many agents, one per node, for 24 hours (a host code enrols one agent, for 15 minutes). The dialog also shows the mlab.sh key fingerprint.
Store the code in the cluster:
kubectl create namespace k3sec
kubectl -n k3sec create secret generic k3sec-enroll --from-literal=code=<code>Get the manifest and set your mlab.sh in it:
curl -fsSLO https://raw.githubusercontent.com/mlab-sh/k3sec/main/deploy/kubernetes/k3sec.yamlIn the DaemonSet, set MLAB_SERVER (https://mlab.sh) and uncomment MLAB_FINGERPRINT with the fingerprint from the dialog: a node refuses to enrol if mlab.sh's key does not match it. In the k3sec-policy ConfigMap, set upload_hosts to the upload host the Policy editor shows (inventories and SBOMs go there, nowhere else), and choose what mlab.sh may turn on (see The node policy).
Apply it:
kubectl apply -f k3sec.yaml
kubectl -n k3sec get pods -o wideEach pod enrols its node once, labelled with the node's name. Its keys stay on the node (/var/lib/mlab-agent, owner-only), never in a Kubernetes Secret: a pod that restarts reuses them and does not enrol again. Once every node shows in the Agents tab, the k3sec-enroll Secret can be deleted.
Open the Cluster tab. A cluster appears with the first heartbeat of one of its nodes (within a minute); its id is the UID of the cluster's kube-system namespace.
Updating
Point the DaemonSet at the new image:
kubectl -n k3sec set image ds/k3sec k3sec=ghcr.io/mlab-sh/k3sec:<version>
kubectl -n k3sec rollout status ds/k3secUnlike an agent installed on a host, there is no policy to edit: each pod pins the SHA-256 of the k3sec binary of its own image when it starts. Nodes keep their keys and do not enrol again. Releases are listed on GitHub, with their changes.
What the pods run as
| Kubernetes access | Read-only: events (list, watch), pods (get, list, watch), pod logs (get), nodes (get, list), and the kube-system namespace (its UID is the cluster id). Nothing else. |
| Host access | hostPID (container processes, their files and memory maps) and tracefs, read-only. |
| Capabilities | drop: ALL, then BPF, PERFMON (the eBPF probe), SYS_RESOURCE (BPF maps on older kernels), SYS_PTRACE and DAC_READ_SEARCH (other containers' files). No privileged mode, read-only root filesystem. |
| Network | The agent to mlab.sh (outbound HTTPS) and to the upload host; k3sec to the Kubernetes API only. |
tracefs must be mounted on the node at /sys/kernel/tracing (systemd does it on most distributions). Without it, the node reports its eBPF source as failed and keeps the others running.
The node policy
The k3sec-policy ConfigMap is the agent's local policy on every node: what mlab.sh may ever ask. The workspace chooses within it, never beyond.
enabled = true # kill switch
upload_hosts = ["<upload host from the Policy editor>"]
[tools.k3sec]
path = "/usr/bin/k3sec"
sha256 = "@K3SEC_SHA256@" # filled at start with the image's binary
enabled = true
allowed_ops = ["status", "configure", "events_since", "inventory", "sbom"]
[tools.k3sec.settings]
node = "@NODE_NAME@" # filled at start
allow_k8s_events = true
allow_logs = false # pod logs stay in the cluster, whatever mlab.sh asks
allow_ebpf = true
allow_rules = true
allow_vulns = true
namespaces = [] # empty: all; otherwise mlab.sh can only narrow this list
rulesets = ["/usr/share/k3sec/rules"]
allow_remote_rules = false # YARA rules sent from mlab.shAll settings: k3sec › Settings. After editing the ConfigMap, restart the pods (kubectl -n k3sec rollout restart ds/k3sec).
The Cluster tab
The workspace's Cluster tab lists your clusters, each with its nodes online, images, vulnerabilities in loaded code and known-exploited ones. Click one for its dashboard.
| Part | What it shows |
|---|---|
| Stats | Known-exploited and critical vulnerabilities in loaded code, all vulnerabilities in loaded code, YARA and critical detections in 24 hours, images, nodes online. |
| Leads | What to look at first: detections of the last 24 hours, then images with known-exploited or critical vulnerabilities in loaded code. Red means look now, amber worth a look. |
| Overview | The nodes (state, sources running, versions of k3sec, the agent, the kernel, last heartbeat; silent after 3 minutes without one) and the images to fix first. Inventory again asks every node for a fresh inventory. |
| Vulnerabilities | Vulnerabilities by image, only loaded by default (which also hides the images with nothing vulnerable in memory), with filters for known-exploited only and severity. Click an image for its findings: CVE, severity and CVSS, known-exploited (CISA, EU), package and version, fixed version, reachability. A CVE the distribution rates as not an issue for the package (Debian "unimportant") is not reported. |
| Runtime | Runtime events, newest first, filtered by source, kind, severity, namespace or text; up to 1000 at a time, narrow the filters to go further back. Connection targets and executed binaries carry their reputation from mlab.sh's existing knowledge (no new lookup is made). Connections to 0.0.0.0 or loopback (health checks) are not reported. |
| Inventory | The packages of each image, by reachability, and its SBOM (CycloneDX) to download. |
| Config | What the cluster reports, pushed to every node (below), and what is in force on each one. |
Reachability
Every package of a running image is tagged:
| Tag | Meaning |
|---|---|
| Loaded | A file of the package is mapped in memory by a running process of the container: its code runs. |
| Dormant | Installed in the image, never loaded since the inventory. |
| Unknown | Language dependencies from lockfiles: interpreted code cannot be seen in memory. The modules of a running Go binary are loaded: it is statically linked, so all of them are in memory with it. |
A dormant package that starts running (a shell opened in a container, a debug tool) becomes loaded at its first execution, and its vulnerabilities are reported then.
What the cluster reports
The Config tab chooses, for the whole cluster: the sources (Kubernetes events, pod logs, eBPF events, YARA scanning, inventory and vulnerabilities), the namespaces, the lowest severity reported, and the YARA rule set. Push to the cluster sends it to every node, which applies it at once without restarting. Each node keeps its policy: what it does not allow stays off, and the table below the form shows what each node really runs (for example "pod logs not allowed by the nodes' policy"). Only owners and admins can push a configuration.
Data
| What | Kept | How |
|---|---|---|
| Runtime events | The workspace's data holding period | Expire on their own after it |
| Inventories and findings | Until an image is not reported for the holding period | Encrypted at rest; vulnerabilities matched again every day |
| Nodes | Until silent for the holding period |
Events are numbered by each node. If some are missed (a lost connection), mlab.sh asks the node for them again; what the node no longer holds is counted as lost, never silently dropped.
Limits
- YARA on executions scans the executable. A script is scanned as its interpreter (
/bin/sh), not as its text. A binary that runs for a millisecond is still scanned, through another process of its container. - Nodes running inside containers (k3d, kind, k3s in Docker): the PIDs the kernel reports do not exist in the node's view. Events still reach their pod, and YARA on executions and the dormant to loaded promotion go through another process of the container; YARA on written files sees nothing there. Real nodes and virtual machines are not affected.
- Reachability is a snapshot, taken at each inventory and updated by executions: a library loaded later by a running process shows dormant until the next inventory.
- Binaries no package owns (servers built from source into
/usr/local) are not matched themselves, only the libraries they load. Go binaries are, through their build information.
Troubleshooting
| Symptom | Cause |
|---|---|
| A pod restarts in a loop, "enrol" in its log | The code expired or was used up, or MLAB_FINGERPRINT does not match: get a new cluster code and recreate the Secret. |
| The node shows in Agents, not in Cluster | Wait for the first heartbeat (a minute). Then check the node's log: kubectl -n k3sec logs ds/k3sec. |
| No vulnerabilities, inventories never arrive | upload_hosts is missing or wrong in the ConfigMap: compare with the Policy editor, restart the pods. |
| eBPF source "failed: tracefs not found" | Mount tracefs on the node at /sys/kernel/tracing. |
| A source stays off after a push | The node's policy does not allow it (allow_* in the ConfigMap): the Config tab says so per node. |