Why this is worth reading: A generated service can pass a smoke test, log “ok”, and still bind a port, keep a capability, or mount a home directory that its unit file never declared. This article turns that risk into a repeatable audit: extract the declared capability surface from the service definition, observe the surface the process actually gets, then diff the two. It uses systemd as the enforcement point and ends with a small script you can run on a minimal host.
Start with a candidate, not a trust statement
You are most likely to see the mismatch when a free model produces a plausible service definition instead of a patch. The unit file looks reasonable, but the model may leave User=root, omit ProtectSystem, or set CapabilityBoundingSet without understanding what the spawned interpreter still receives.
A practical way to produce such a candidate is MonkeyCode's free model access, paired with its free server option as the observation host. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Use that server only as the place where you observe the service, not as the reason to skip a static review.
Before you run anything, define a fixture that is small enough to keep the audit honest:
[Unit]
Description=Demo capability fixture
[Service]
User=demo
Group=demo
ExecStart=/usr/bin/python3 -m http.server 8080 --bind 127.0.0.1 --directory /var/lib/demo
StateDirectory=demo
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
ReadWritePaths=/var/lib/demo
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=yes
RestrictAddressFamilies=AF_UNIX AF_INET
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
MemoryDenyWriteExecute=yes
This is not the generated service you would productionize. You use it as a template to compare what the generated unit claims against what the process receives.
Extract the declared surface first
You start from the service definition itself. Look for the settings that define the capability and filesystem surface:
-
User=andGroup= CapabilityBoundingSet=AmbientCapabilities=-
ProtectSystem=,ProtectHome=, andReadWritePaths= -
PrivateTmp=andPrivateDevices= RestrictAddressFamilies=NoNewPrivileges=
You can print the effective unit values without reading the file manually:
systemctl show demo.service -p User -p Group -p CapabilityBoundingSet -p AmbientCapabilities -p NoNewPrivileges
If the output is blank, be specific. An empty CapabilityBoundingSet= means no bounding capabilities, but an empty AmbientCapabilities= is not the same as a missing field. Record both as exact strings rather than treating blank as safe.
You can also run:
systemd-analyze security demo.service
The score is useful as a triage signal, not as truth. A score can drop from one weak default even when the other settings are strong. Use it to locate gaps, then verify those gaps manually.
Observe the process after it starts
You then start the fixture and read the process from /proc:
systemctl start demo.service
pid="$(systemctl show -p MainPID --value demo.service)"
grep -E '^(Uid|Gid|Cap(Inh|Prm|Eff|Bnd|Amb))' "/proc/$pid/status"
The CapEff line tells you which capabilities are currently effective. An empty bit mask is reassuring:
capsh --decode=0000000000000000
Next you check the listening socket the unit did not declare explicitly:
ss -ltnp | grep "pid=$pid"
You expect 127.0.0.1:8080 from the fixture. If the output shows 0.0.0.0:8080, the service is reachable on every interface, which is a behavior that should be rejected or made explicit before you expose the port.
You also inspect mount exposure, because a service can read or write paths that its unit file never mentioned:
findmnt --task "$pid" -o TARGET,SOURCE,FSTYPE,OPTIONS
A key check is whether /home, /var, or / appear with rw when the declared policy says they should be ro or hidden behind a namespace.
Diff the declared surface against the observed surface
The audit is only useful when you compare the two sets deliberately. The table below is the smallest decision surface I use.
| Signal | Declared | Observed | Action |
|---|---|---|---|
User= |
demo |
root or empty for root |
Reject the generated unit |
CapEff |
empty | non-empty | Decode with capsh --decode; drop capabilities or lower the port if only CAP_NET_BIND_SERVICE is present |
| Listening address | 127.0.0.1 |
0.0.0.0 |
Reject or explicitly bind to a loopback or internal address |
/home mount |
read-only | rw |
Tighten ProtectHome= or use a mount namespace |
PrivateTmp= |
yes |
no tmp isolation signal |
Confirm the unit actually loaded and the process belongs to the expected cgroup |
You turn those checks into a repeatable script by reading the same data sources programmatically:
#!/usr/bin/env bash
set -euo pipefail
unit="${1:?usage: capability_surface_diff.sh demo.service}"
pid="$(systemctl show -p MainPID --value "$unit")"
test "$pid" != 0 || { echo "no main pid for $unit" >&2; exit 1; }
echo "### Declared hardening"
systemctl show "$unit" -p User -p CapabilityBoundingSet -p AmbientCapabilities -p NoNewPrivileges
echo "### Observed process status"
grep -E '^(Uid|Gid|Cap(Inh|Prm|Eff|Bnd|Amb))' "/proc/$pid/status"
echo "### Listening sockets"
ss -ltnp | grep "pid=$pid" || true
echo "### Mount exposure"
findmnt --task "$pid" -o TARGET,SOURCE,FSTYPE,OPTIONS
echo "### systemd-analyze security"
systemd-analyze security "$unit"
This script is deliberately small so you can read it before running it on the free server. It does not change anything; it only prints declared and observed state. On a non-systemd host, you can adapt the /proc and ss checks and skip the systemctl and systemd-analyze parts.
Limitations and who should not use this approach
This audit catches mismatches after the service starts, so it is not a replacement for a deny-by-default policy or for refusing to run a candidate until its unit file has been reviewed. A Python or Node process can open a new file descriptor after your check, so a point-in-time /proc read is not a proof of maximum privilege. If the service spawns workers, check the cgroup rather than only MainPID:
systemd-cgls -u demo.service
You should not use this approach as your only control for regulated workloads, multi-tenant services, or code that has not had a static review. In those cases, put the generated service behind a stronger sandbox, remove network access until the declared surface is accepted, and use a VM or container runtime rather than a shared host. The method is most useful for generated services that are nearly harmless but need one final check before they receive a port or a persistent directory.
You can therefore run this audit on a candidate from MonkeyCode's free model access, observe it on the free server option, and treat the printed diff as a draft review artifact rather than as proof. The value is not the score; it is the moment you stop reading the generated YAML as permission and start reading /proc as evidence.
Top comments (0)