fix(observability): take every capability off Alloy (#188) - #192
Merged
Conversation
Alloy held `privileged: true`, the Docker socket, and a read-only mount of the whole host filesystem. Measured before changing anything: uid 0, CapEff 000001ffffffffff, and ~/.config/sops/age/keys.txt readable straight off /rootfs — the key every secret in this repository decrypts with, and the one SECURITY.md says file permissions alone protect. They did not protect it from this container. `privileged: true` was never what made cAdvisor work. `cgroup: host` is, and that is not a privilege. #62 already proved privileged was not sufficient — it gave one series, id="/", until the cgroup namespace was fixed. This proves it was not necessary either: with cap_drop ALL the exporter returns the same 895 series, the same seven container names, the same eleven ids, the same node_filesystem mountpoints. The key is then denied, because root without DAC_OVERRIDE is subject to a 0600 like anyone else. The cost is six series, measured rather than assumed: node_rapl_*_joules_total and node_cpu_{core,package}_throttles_total read root-only sysfs, and node_exporter drops them silently — no collector failure, no log line. Nothing in this repository references any of the six. That silence is exactly why the number is written into compose.yaml rather than left to be discovered. Two things had to be given back, and finding them is the whole risk of this change: - /var/log/auth.log and /var/log/syslog are syslog:adm 0640 and root owns neither, so dropping DAC_OVERRIDE takes out both file sources and most of security.rules.yaml with them. group_add on the owning group restores exactly those. cap_add: [DAC_READ_SEARCH] would too, and would hand back the age key — tested both ways, which is the only reason to trust the choice. - alloy-data is 0770 473:473, so the agent could not create its own WAL directory and crash-looped on `mkdir /var/lib/alloy/data: permission denied` before reading a line of config. That is what privileged had been hiding. Joining the image's own group fixes it. The log gid is rendered from /var/log/syslog rather than hardcoded to 4, because compose.yaml and config.alloy deploy identically to every monitored host and the owning group is a property of the distribution. /var/lib/docker/containers is gone. Nothing read it: loki.source.docker streams over the socket, config.alloy names the path only to exclude it from the filesystem collector, and cAdvisor was measured producing identical series without it. It is root-only and holds every container's logs, so an unread mount of it was pure reach. Also corrects a comment that asserted a security property the file did not have: the syslog port note claimed the container "does not run as root". It ran as uid 0 with every capability. The claim is true now, and for the reason it now gives. What this does not close is the Docker socket, and the docs now say so instead of implying otherwise. `:ro` there applies to the socket file, not the API, and that API can create a container with / mounted read-write — root on the host and the age key with it. Putting it behind an allowlisting proxy is tracked separately; this is defence in depth, not a closed door. Verified on the host against a baseline captured before any change: seven container names before and after, node series 669 -> 663 (the six above), auth.log and syslog markers written with `logger` and found in Loki, journal 97 lines/hr against 82 before, docker logs 688 lines/3m, 13 targets up, 39 rules healthy, no restarts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #188.
Alloy held
privileged: true, the Docker socket, and a read-only mount of thewhole host filesystem. Measured before changing anything:
That is the key every secret in this repository decrypts with, and the one
SECURITY.mdsays file permissions alone protect. They did not protect it fromthis container.
privileged: truewas never doing the job it was credited withcgroup: hostis what makes cAdvisor see the host's cgroups, and that is not aprivilege. #62 already showed privileged was not sufficient — it gave one
series,
id="/", until the cgroup namespace was fixed. This change shows it wasnot necessary either.
Tested on a throwaway container first, so the live agent was never the
experiment:
--privileged(before)--cap-drop ALL+ no-new-privileges65534)Root is kept: dropping to a non-root uid reproduces the #62 symptom exactly.
Dropping capabilities does not.
Cost: six series, and nothing uses them
node_rapl_{core,package,uncore}_joules_totalandnode_cpu_{core,package}_throttles_totalread root-only sysfs.node_exporter drops them silently — no collector failure, no log line. Zero
references anywhere in the repo. The number is written into
compose.yamlprecisely because the loss is silent.
Two things had to be given back — finding them is the whole risk
auth.logandsyslogaresyslog:adm 0640and root owns neither, sodropping
DAC_OVERRIDEtakes out both file sources and most ofsecurity.rules.yaml.group_addon the owning group restores exactly those.cap_add: [DAC_READ_SEARCH]would restore them too — and hand back the agekey. Tested both ways; that is the only reason to trust the choice.
alloy-datais0770 473:473, so the agent could not create its own WALdirectory and crash-looped on
mkdir /var/lib/alloy/data: permission deniedbefore reading a line of config. That is what privileged had been hiding.
Joining the image's own group fixes it.
The log gid is rendered from
/var/log/syslograther than hardcoded to 4,because
compose.yamlandconfig.alloydeploy identically to every monitoredhost and the owning group is a property of the distribution.
/var/lib/docker/containersis goneNothing read it:
loki.source.dockerstreams over the socket,config.alloynames the path only to exclude it from the filesystem collector, and cAdvisor
was measured producing identical series without it. Root-only, holds every
container's logs, so an unread mount of it was pure reach.
A comment that asserted a property the file did not have
The syslog-port note claimed the container "does not run as root". It ran as uid
0 with every capability including
NET_BIND_SERVICE. The claim is true now, andfor the reason it now gives.
What this does not close
The Docker socket.
:rothere applies to the socket file, not the API, andthat API can create a container with
/mounted read-write — root on the hostand the age key with it. The docs now say this plainly instead of listing the
read-only socket as a mitigation. An allowlisting proxy is #193.
This is defence in depth, not a closed door.
Verified on the host, against a baseline captured before any change
node_filesystemmountpoints/ /boot /boot/efi …CapEff 0000000000000000auth.logloggermarker written and found in Lokisyslogloggermarker written and found in Lokimake validategreen.container_cpu_cfs_periods_totalis absent both beforeand after — that is #185, not this.
One thing found while verifying and filed rather than fixed here: Alloy is
delivering ~1.5% of the host journal (#194). Unchanged by this PR — 82 lines/hr
before, 97 after — but it is the #62 shape and worth its own look.
🤖 Generated with Claude Code