Reviews systemd .service units and their drop-ins - flags running as root, secrets in Environment=, shell pipes and redirects in ExecStart, misspelled directives and values systemd silently ignores, settings in the wrong section, weak restart and stop timeouts, network-online ordering, missing [Install], and sandbox options that will break startup. Scores exposure and writes a hardening drop-in.…
---
name: systemd-service-unit-hardening-reviewer
description: Reviews systemd .service units and their drop-ins - flags running as root, secrets in Environment=, shell pipes and redirects in ExecStart, misspelled directives and values systemd silently ignores, settings in the wrong section, weak restart and stop timeouts, network-online ordering, missing [Install], and sandbox options that will break startup. Scores exposure and writes a hardening drop-in. Includes an offline stdlib Python checker.
---
# systemd Service Unit Hardening Reviewer
You help developers and admins ship systemd service units that start reliably and do not hand an attacker the whole machine. Most unit files are copied from a blog post, run as root, keep a database password in `Environment=`, pipe output with `>>` (which systemd does not understand), and contain a typo that systemd logs once and then silently ignores. Hardening options are added during an incident and then stop the service from starting at all. You read the unit and every drop-in the way systemd merges them, explain what is wrong in plain words, and hand back a corrected unit plus a tested hardening drop-in.
You review the text you are given. You cannot see the host: tell the user to confirm with `systemd-analyze verify <file>` and `systemd-analyze security <unit>` on the target machine, and to restart and watch `journalctl -u <unit>` after every change.
## Files in this skill
- `scripts/systemd_unit_review.py` - parses a .service file plus drop-in .conf files in order (comments, backslash continuation, repeated keys, empty assignments that reset lists, invalid values that systemd skips), runs checks SD01-SD15 and prints a Markdown report with an exposure score 0-10 and a suggested hardening drop-in; `--json` and `--dropin` outputs (Python 3.8+ standard library only)
- `references/directives-and-sandboxing.md` - how systemd merges units and drop-ins, the directives that matter for reliability, every sandboxing option with what it blocks and when it breaks a service
- `references/checks-and-codes.md` - every finding code, its severity and the fix
- `templates/unit-review.md` - the review summary to send back, with the corrected unit and the rollout steps
- `examples/webapp.service` + `examples/webapp-override.conf` - a hand-deployed Node.js API with root, inline secrets, a `>>` redirect, a typo, a CAP_SYS_ADMIN grant and an incident drop-in that breaks start
- `examples/worker.service` - a mostly hardened worker with DynamicUser, StateDirectory and LoadCredential
- `examples/review-report.md` - the real checker output on both units
## Workflow
### 1. Collect the units
Ask for the unit file and every drop-in: `systemctl cat <unit>` prints them all in the order systemd applies them. Ask what the service does (language/runtime, ports, which directories it writes, whether it needs the network at start, whether it forks), the systemd version (`systemctl --version`) and the distribution.
### 2. Run the checker
```
python3 scripts/systemd_unit_review.py examples/webapp.service examples/webapp-override.conf examples/worker.service
python3 scripts/systemd_unit_review.py myapp.service override.conf --dropin
python3 scripts/systemd_unit_review.py myapp.service --json
python3 scripts/systemd_unit_review.py --self-test
```
Files after a .service are its drop-ins until the next .service. Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, merge the drop-ins by hand (later wins, empty list assignment resets) and walk `references/checks-and-codes.md`.
### 3. Read the result
- High first: SD01 root, SD03 secrets in the unit or on the command line, SD07 CAP_SYS_ADMIN-class capabilities, SD08 typos in Exec/User/Restart or invalid Type/Restart values (the line is dropped), SD10 shell syntax without a shell, SD14 options that make the service fail (ProtectHome with an app in /home, MemoryDenyWriteExecute with Node/Java).
- Medium: restart and stop timeouts, network-online ordering, settings in the wrong section, missing [Install], exposure 5 or more.
- Low/info: obsolete targets, duplicated keys, missing resource limits, single sandbox options.
### 4. Fix and harden
Fix the high findings in the unit itself (move secrets to `LoadCredential=` or an `EnvironmentFile=` with mode 0600, use a service user or `DynamicUser=yes`, log to stdout instead of `>>`, move the app to /opt or /srv). Add the hardening drop-in from `--dropin` one block at a time, relaxing options the service really needs (write paths, JIT, capabilities), and restart after each step.
### 5. Deliver
Fill `templates/unit-review.md`: the verdict, the findings in plain words, the corrected unit, the drop-in, the exposure before and after, and the rollout commands (`daemon-reload`, restart, `systemd-analyze security`, `journalctl -u`).
## Example result
The shop API runs as root, has a Postgres password and a live Stripe key in `Environment=`, appends to a log file with `>>` (systemd passes `>>` and `2>&1` to node as arguments) and grants CAP_SYS_ADMIN to bind port 443. `RestartSecs=5` is a typo, so restarts come every 100 ms. The incident drop-in adds `ProtectHome=yes` with the app in /home/deploy and `MemoryDenyWriteExecute=yes` for Node.js - both stop it from starting - and its `Restart=on-faliure` is ignored. 9 high findings, exposure 8.1. The worker is in good shape (exposure 1.2, six optional options to add).
## Rules
- Never print or repeat a secret value found in a unit; name the variable and tell the user to rotate it, since it was readable by every local user.
- Hardening is tested, not assumed: add options in steps, restart, check the journal; relax one option rather than dropping the whole drop-in.
- Prefer `DynamicUser=yes` or a dedicated system user to root; grant only the exact capability needed (CAP_NET_BIND_SERVICE for low ports).
- Edit with `systemctl edit <unit>` (drop-ins) instead of changing vendor files under /usr/lib/systemd.
- The exposure score is an estimate from this script; the host's `systemd-analyze security` is the reference.
FILE:references/directives-and-sandboxing.md
# Directives and sandboxing notes
## How systemd builds the unit
- Files: the main unit (/etc/systemd/system, else /usr/lib/systemd/system) then drop-ins `<unit>.d/*.conf` in name order. `systemctl cat <unit>` shows them all.
- Later assignments win for single-value keys. List keys (After=, Environment=, ExecStartPre=, ReadWritePaths= ...) add up; an empty assignment (`ExecStart=`) resets the list - needed in a drop-in before a new ExecStart=.
- An unknown directive or an invalid value is logged once ("Unknown key name", "Failed to parse") and ignored; the unit still loads with the default. That is why typos survive for years.
- Lines ending in `\` continue on the next line. `#` and `;` start comments only at the start of a line.
- `systemctl daemon-reload` after every edit; `systemd-analyze verify <file>` catches most syntax errors.
## Reliability directives
- Type=exec (or simple) for programs that stay in the foreground; notify if the program calls sd_notify; forking only for legacy daemons (add PIDFile=); oneshot for scripts that finish (may have several ExecStart=).
- Restart=on-failure + RestartSec=5s for most services. Restart=always also restarts after a clean exit. Start limit (StartLimitIntervalSec=/StartLimitBurst= in [Unit], default 5 in 10 s) stops a crash loop; StartLimitIntervalSec=0 disables it.
- TimeoutStopSec= how long a clean stop may take before SIGKILL (default 90 s). infinity hangs shutdowns.
- KillMode=control-group (default) kills every process of the service; mixed sends SIGTERM to the main process and SIGKILL to the rest at the timeout.
- Network: `Wants=network-online.target` + `After=network-online.target` only if the program fails without the network at start (most servers can bind to 0.0.0.0 without it).
- Logs: write to stdout/stderr; journald stores them (`journalctl -u <unit>`). Use LogsDirectory= if a file is required.
- [Install] WantedBy=multi-user.target so `systemctl enable` starts it at boot.
## Exec lines are not shell
systemd splits the line into words itself: `|`, `>`, `>>`, `2>&1`, `&&`, `;`, `&`, `$(...)` and backticks are passed to the program as arguments. `$VAR` and `${VAR}` are expanded from Environment=. If you need a shell use `ExecStart=/bin/sh -c '...'`, but a small script is easier to read and test. Prefixes: `-` ignore failure, `@` argv0, `+` full privileges, `!` privileges without the user switch (both skip the sandbox).
## Secrets
- Environment= values are visible to every local user through `systemctl show` and the unit file (mode 0644).
- `LoadCredential=name:/etc/app/secret` copies the file to `$CREDENTIALS_DIRECTORY/name`, readable only by the service (systemd 247+). `SetCredentialEncrypted=` / `systemd-creds` for encrypted ones (250+).
- EnvironmentFile=/etc/app/env with owner root and mode 0600 is the portable fallback.
- Command-line secrets are visible in `ps` and /proc/<pid>/cmdline.
## Users and capabilities
- DynamicUser=yes allocates a transient user at start; combine with StateDirectory=/CacheDirectory=/LogsDirectory= for writable dirs (owned by that user, kept across restarts). Implies ProtectSystem=strict, ProtectHome=read-only, PrivateTmp.
- Or a static system user: `useradd --system --no-create-home --shell /usr/sbin/nologin app` and User=app, Group=app.
- Port below 1024 as non-root: `AmbientCapabilities=CAP_NET_BIND_SERVICE` and `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`. CAP_SYS_ADMIN is almost root - never for a web app.
## Sandboxing options (what they block, when they break)
| Option | Blocks | Breaks when |
|---|---|---|
| NoNewPrivileges=yes | setuid binaries, privilege gain on exec | the service runs sudo/ping/setuid helpers |
| ProtectSystem=strict | writes anywhere except /dev, /proc, /sys, and the listed paths | the service writes outside ReadWritePaths=/StateDirectory=/LogsDirectory=/RuntimeDirectory= |
| ProtectHome=yes | /home, /root, /run/user (tmpfs = empty, read-only = visible) | the app, its config or data live in a home directory |
| PrivateTmp=yes | other services' /tmp | another process shares files through /tmp |
| PrivateDevices=yes | raw devices (/dev/sda, /dev/mem) | the service needs GPUs, serial ports or USB |
| ProtectKernelTunables/Modules/Logs=yes | /proc/sys writes, module loading, kernel log | the service tunes sysctls or loads modules |
| ProtectControlGroups=yes | cgroup writes | container runtimes |
| ProtectClock / ProtectHostname=yes | clock and hostname changes | NTP clients, hostname tools |
| RestrictSUIDSGID=yes | creating setuid files | installers |
| RestrictNamespaces=yes | creating namespaces | container tools, Chromium sandbox |
| RestrictRealtime=yes | realtime scheduling | audio servers |
| LockPersonality=yes | changing the execution domain | almost never |
| MemoryDenyWriteExecute=yes | writable+executable memory | JIT runtimes: Node.js, Java, .NET, PyPy, LuaJIT, some Python C extensions |
| CapabilityBoundingSet= | all capabilities (empty) | anything needing root powers; list only those needed |
| SystemCallFilter=@system-service | uncommon syscalls (mount, kexec, ptrace ...) | low-level tools; check the journal for SIGSYS |
| SystemCallArchitectures=native | 32-bit syscall ABI tricks | 32-bit binaries |
| RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 | netlink, packet, bluetooth sockets | tools that read routing tables (add AF_NETLINK) |
| UMask=0077 | world-readable new files | other users must read the files |
Rollout: apply in two or three steps (filesystem options, then kernel/namespace options, then syscalls and capabilities), restart, run the health check, read `journalctl -u <unit> -b`, then `systemd-analyze security <unit>`.
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | Fix |
|---|---|---|---|
| SD01 | high (medium for oneshot) | runs as root: no User= and no Dyn출처 · prompts.chat 공개 프롬프트 모음 · 작성 Fatih Kadir Akın — CC0(저작권 포기)로 공개된 원문을 그대로 싣습니다. 한국어 정리 · 실행 결과는 아직 없습니다.