审查 Nginx 配置的常见安全缺口。
---
name: nginx-config-security-reviewer
description: 审查 Nginx 配置的常见安全缺口。
---
# Nginx 配置安全审查
你是「Nginx 配置安全审查」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。
# Nginx Config Security Reviewer
You review nginx configurations the way a careful ops engineer would before a security audit: find the settings that expose data or weaken TLS, explain each one in plain words, and hand back a corrected config that still serves the same routes. You never guess at files you have not seen, and you never tell the user to reload without `nginx -t`.
## Files in this skill
- `scripts/nginx_review.py` - parses nginx config files into blocks and prints findings with severity, line and fix (Python 3 standard library only)
- `references/hardening-checklist.md` - every check the script runs, why it matters and the fix, plus items to check by hand
- `references/tls-and-headers.md` - TLS protocol and cipher settings, HSTS rollout, security headers and how add_header inheritance works
- `templates/review-report.md` - the report you hand back: summary, findings table, config diff, test commands
- `examples/shop-site.conf` - a small shop config from 2019 with 18 findings (3 HIGH)
- `examples/shop-site-fixed.conf` - the same site after the fixes (no findings apart from the include note)
## Workflow
### 1. Collect the full config
Ask for the output of `sudo nginx -T` (it prints the main file and every included file), or for nginx.conf plus the relevant sites-enabled files. Ask what the site does (static, reverse proxy, PHP), which paths must stay public, the nginx version (`nginx -v`) and whether a CDN or load balancer terminates TLS in front of it. Tell the user to remove private keys or secrets before pasting; certificate paths are fine.
### 2. Run the checker
```
python3 scripts/nginx_review.py examples/shop-site.conf
python3 scripts/nginx_review.py /etc/nginx/nginx.conf /etc/nginx/sites-enabled/shop --json
python3 scripts/nginx_review.py --self-test
```
Exit code 1 means HIGH findings, 2 means the file could not be read or parsed (the error names the line).
### 3. Confirm and add manual checks
Read each finding against the user's context: a CDN in front changes TLS findings, an internal-only admin route may already be firewalled, a `return 404` may already hide a path. Then go through the manual items in `references/hardening-checklist.md` (included files, upstream TLS verification, `if` blocks, PHP handlers, file permissions). Drop false positives and say why.
### 4. Deliver
Fill `templates/review-report.md`: HIGH items first, a minimal diff of the config, and the test steps. Explain the add_header inheritance rule whenever ADD_HEADER_INHERITANCE appears, because it surprises most people. Introduce HSTS with a short max-age first if the site has never used it.
## Example result
For `examples/shop-site.conf`: 3 HIGH (TLS 1.0/1.1 enabled, RC4 and 3DES ciphers, `location /images` with `alias .../images/` allowing `/images../` traversal), 6 MEDIUM (unlimited body size, port 80 serving content instead of redirecting, autoindex on /downloads/, /api/ dropping HSTS and nosniff because it sets its own add_header, unprotected /admin/, `$uri` in a redirect), 8 LOW (server_tokens, dotfiles, missing CSP/Referrer-Policy/X-Frame-Options, proxy headers, no rate limit on /api/auth/login) and 1 INFO. `examples/shop-site-fixed.conf` shows the fixes and passes `nginx -t`.
## Rules
- Quote the exact line and the exact replacement; keep changes minimal and route-preserving.
- Always end with `sudo nginx -t && sudo systemctl reload nginx` and a curl check; never suggest a restart that drops connections when a reload works.
- Do not invent directives: check names against the nginx docs for the user's version (for example `http2 on;` needs 1.25.1+, older versions use `listen 443 ssl http2;`).
- Never ask for or repeat private keys, passwords or htpasswd hashes.
- Treat security headers like CSP as a rollout: suggest `Content-Security-Policy-Report-Only` first for complex apps.
FILE:references/hardening-checklist.md
# Nginx Hardening Checklist
Every code the script prints, why it matters and the fix. Severity is the default; raise or lower it with context.
## HIGH
### OLD_TLS_PROTOCOL
`ssl_protocols` lists SSLv3, TLSv1 or TLSv1.1. These are deprecated (RFC 8996), fail PCI DSS and allow downgrade attacks. Fix: `ssl_protocols TLSv1.2 TLSv1.3;` (the default since nginx 1.23.4, so deleting the line also works on new versions). Only very old clients (Android 4.3, IE on Windows 7 without updates) lose access.
### WEAK_CIPHERS
`ssl_ciphers` allows RC4, DES/3DES (SWEET32), MD5, NULL, EXPORT or anonymous (aNULL/ADH) suites. Entries starting with `!` or `-` are exclusions and are fine. Fix: use the Mozilla "intermediate" list from ssl-config.mozilla.org or remove `ssl_ciphers` to use the OpenSSL default, and set `ssl_prefer_server_ciphers off;` when only modern AEAD suites remain.
### NO_CERTIFICATE
A server listens with `ssl` but no `ssl_certificate` is set in it or in http. nginx refuses to start or fails the handshake. Add `ssl_certificate` (full chain) and `ssl_certificate_key`.
### ALIAS_TRAVERSAL (off-by-slash)
`location /images { alias /var/www/media/images/; }`: a request for `/images../secret.txt` maps to `/var/www/media/images/../secret.txt`, so files one level above the alias are readable (a well-known finding in many audits). Fix: both the location and the alias end with a slash, `location /images/ { alias /var/www/media/images/; }`, or use `root` when the path matches the folder name.
## MEDIUM
### HTTP_NO_REDIRECT
A port-80 server with the same server_name as an HTTPS server serves pages instead of redirecting. Users and cookies stay on plain HTTP. Fix:
```
server {
listen 80;
server_name shop.example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
```
### NO_HSTS
No `Strict-Transport-Security` header on an HTTPS server, so the first request of a session can be downgraded. See `tls-and-headers.md` for the rollout.
### ADD_HEADER_INHERITANCE
nginx inherits `add_header` from the parent level only when the current level has no `add_header` at all. One `add_header Cache-Control no-store;` in `location /api/` drops every security header from the server block for that location. Fix: repeat the headers in that location, or keep them in a snippet (`include snippets/security-headers.conf;`) and include it in every block that has its own add_header. On nginx 1.29.3+ `add_header_inherit merge;` in http makes child blocks add to the parent headers instead of replacing them. `proxy_set_header`, `fastcgi_param` and `proxy_hide_header` follow the same rule.
### AUTOINDEX_ON
Directory listing shows every file name, including backups and exports nobody linked. Turn it off or protect the location.
### ADMIN_OPEN
Paths such as /admin, /phpmyadmin, /actuator, /server-status, /nginx_status or /metrics without allow/deny, auth_basic or auth_request. Fix: `allow 10.20.0.0/16; deny all;` (office or VPN range), or `auth_request` to an SSO service. Basic auth only over HTTPS.
### UNLIMITED_BODY
`client_max_body_size 0` disables the request size limit, making disk and memory exhaustion easy. Keep the default 1m or a real limit in http, and raise it only in the upload location.
### URI_IN_REDIRECT
`$uri` is normalized and URL-decoded, so `%0d%0a` becomes a real line break and an attacker can inject headers (`Set-Cookie`) into the redirect response. Use `$request_uri` (the raw, still-encoded request line) in `return`, `rewrite` targets and `add_header`.
## LOW
### SERVER_TOKENS
The version appears in the `Server` header and on error pages, which helps attackers match known CVEs. `server_tokens off;` in http.
### MISSING_SECURITY_HEADERS
On HTTPS servers: `X-Content-Type-Options nosniff`, `X-Frame-Options` (or CSP `frame-ancestors`), `Content-Security-Policy`, `Referrer-Policy`. Add them with `always` so they are also sent on 4xx/5xx responses.
### SHORT_HSTS
HSTS max-age under 180 days. Fine during rollout; raise it to one year afterwards.
### DOTFILES_EXPOSED
The server serves files but has no rule for hidden files, so `/.git/config`, `/.env` or `/.htpasswd` may be downloadable if they exist under the web root. Fix: `location ~ /\.(?!well-known) { deny all; }` (keeps ACME challenges working).
### NO_RATE_LIMIT
A login, auth or token path without `limit_req`. Add a zone in http and apply it to the location; return 429 with `limit_req_status 429;`.
### PROXY_HEADERS
A `proxy_pass` location that does not send Host, X-Forwarded-For and X-Forwarded-Proto, so the app logs the proxy's IP, builds wrong redirect URLs and may think HTTPS requests are HTTP. Remember the inheritance rule: one `proxy_set_header` in the location replaces all inherited ones.
## INFO
### INCLUDE_NOT_FOLLOWED
The script reads only the files you pass. Ask for `nginx -T` or the included files.
## Check by hand (not automated)
- `proxy_pass https://...` to an upstream on the internet without `proxy_ssl_verify on;` and `proxy_ssl_trusted_certificate`.
- PHP: `location ~ \.php$` must use `try_files $uri =404;` (or `fastcgi_split_path_info` with a check) so uploaded images cannot run as PHP; uploads directories should never pass to PHP.
- `if` inside location with anything other than `return` or `rewrite ... last` ("if is evil").
- `default_server` blocks: an unknown Host should get `return 444;` with a self-signed or `ssl_reject_handshake on;` (1.19.4+) instead of the first site.
- Permissions: nginx workers should not be able to write the web root; private keys `chmod 600`, owned by root.
- Large headers / slow clients: `client_body_timeout`, `client_header_timeout`, `large_client_header_buffers` for public APIs under attack.
- Logging: access logs must not record tokens in query strings; use a custom log_format if needed.
FILE:references/tls-and-headers.md
# TLS and Security Headers
## TLS settings that pass most audits (nginx 1.25+ with OpenSSL 1.1.1 or 3.x)
```
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
```
This is the Mozilla "intermediate" profile. TLS 1.3 suites are not set with ssl_ciphers (OpenSSL handles them). If DHE suites stay, provide `ssl_dhparam` with at least 2048 bits or drop the DHE entries.
OCSP stapling (`ssl_stapling on; ssl_stapling_verify on;`) is optional; Let's Encrypt ended OCSP support in 2025, so do not add it for LE certificates.
HTTP/2: `http2 on;` inside the server on nginx 1.25.1+; older versions use `listen 443 ssl http2;`.
## HSTS rollout
1. Make sure every subdomain works over HTTPS.
2. Start short: `add_header Strict-Transport-Security "max-age=300" always;`
3. After a week without problems: `max-age=31536000; includeSubDomains`.
4. Only add `preload` and submit to hstspreload.org when you are sure; removal takes months.
HSTS on plain HTTP responses is ignored by browsers, so set it on the 443 server.
## Security headers
| Header | Typical value | Notes |
|---|---|---|
| X-Content-Type-Options | `nosniff` | Stops MIME sniffing of uploads |
| X-Frame-Options | `DENY` or `SAMEORIGIN` | Clickjacking; CSP `frame-ancestors` is the modern equivalent |
| Content-Security-Policy | `default-src 'self'` and more | App-specific; roll out with `Content-Security-Policy-Report-Only` |
| Referrer-Policy | `strict-origin-when-cross-origin` | Keeps paths and query strings out of other sites' logs |
| Permissions-Policy | `camera=(), microphone=(), geolocation=()` | Optional |
Add `always` so headers are also sent on error responses.
## The add_header inheritance rule
```
server {
add_header X-Content-Type-Options nosniff always; # A
location / { } # gets A
location /api/ {
add_header Cache-Control no-store; # gets ONLY this one, A is gone
}
}
```
A block inherits add_header from its parent only if it defines none itself. Keep shared headers in a snippet and include it in every block that adds its own. nginx 1.29.3 (October 2025) added `add_header_inherit merge;`, which makes nested blocks add to the inherited headers; check `nginx -v` before suggesting it (Debian 13 ships 1.26).
## Testing
```
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://shop.example.com/ | grep -iE 'strict|x-content|x-frame|content-security|referrer|server:'
curl -sI http://shop.example.com/page?x=1 # expect 301 to https://shop.example.com/page?x=1
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/.git/config # expect 403 or 404
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/images../ # expect 404 after the alias fix
openssl s_client -connect shop.example.com:443 -tls1_1 </dev/null # expect a handshake failure
```
For a full external check: SSL Labs server test or `testssl.sh shop.example.com`.
FILE:templates/review-report.md
# Nginx security review: <site or server name>
**Version:** <nginx 1.26.3, OpenSSL 3.x> **Role:** <static site / reverse proxy to Node on :3000 / PHP-FPM>
**Files reviewed:** <nginx -T output, 2 files> **TLS terminated by:** <nginx / CDN / load balancer>
**Summary:** <one sentence: the most serious problem and what the fix changes>
## Findings
| # | Severity | Code | File:line | Problem | Fix |
|---|---|---|---|---|---|
| 1 | HIGH | <ALIAS_TRAVERSAL> | <nginx.conf:51> | <location /images allows /images../> | <location /images/ { alias .../images/; }> |
| 2 | <MEDIUM> | <...> | <...> | <...> | <...> |
## Not changed (with reason)
- <ADMIN_OPEN on /admin/: already limited by the cloud firewall to the VPN; added deny all anyway? yes/no>
## Config changes
```diff
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_protocols TLSv1.2 TLSv1.3;
```
## Apply and test
1. Back up: `sudo cp -a /etc/nginx /etc/nginx.bak-<date>`
2. Edit, then `sudo nginx -t && sudo systemctl reload nginx`
3. Checks:
- `curl -sI https://<host>/` shows the security headers
- `curl -sI http://<host>/x?y=1` returns 301 to the same path on https
- <path traversal / dotfile / admin checks from the findings>
4. Roll back: restore the backup and reload.
## Follow-ups
- <HSTS: raise max-age to 31536000 after <date>>
- <CSP: move from Report-Only to enforcing after reviewing reports>
FILE:examples/shop-site.conf
# /etc/nginx/nginx.conf for shop.example.com (single-file setup)
# A small online shop: static storefront, product images, a Node.js API on :3000
# and a legacy admin panel. Written in 2019, certificates renewed by certbot.
user www-data;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
sendfile on;
keepalive_timeout 65;
client_max_body_size 0;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers "HIGH:RC4-SHA:DES-CBC3-SHA:!aNULL";
ssl_prefer_server_ciphers on;
server {
listen 80;
server_name shop.example.com www.shop.example.com;
root /var/www/shop;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
server {
listen 443 ssl;
http2 on;
server_name shop.example.com www.shop.example.com;
ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;
root /var/www/shop;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /images {
alias /var/www/shop-media/images/;
}
location /downloads/ {
alias /var/www/shop-media/downloads/;
autoindex on;
}
location /api/ {
add_header Cache-Control "no-store";
proxy_pass http://127.0.0.1:3000;
}
location /api/auth/login {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
}
location /admin/ {
proxy_pass http://127.0.0.1:8080/;
}
location /old-blog {
return 301 https://blog.example.com$uri;
}
}
}
FILE:examples/shop-site-fixed.conf
# shop.example.com after the review: same routes, hardened.
# Shared security headers live in one snippet so locations with their own
# add_header can include it again (add_header is not inherited into a block
# that sets any add_header of its own).
user www-data;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
sendfile on;
keepalive_timeout 65;
server_tokens off;
client_max_body_size 10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
server {
listen 80;
server_name shop.example.com www.shop.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name shop.example.com www.shop.example.com;
ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;
# same lines as /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'self'" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
root /var/www/shop;
index index.html;
location ~ /\.(?!well-known) {
deny all;
}
location / {
try_files $uri $uri/ /index.html;
}
location /images/ {
alias /var/www/shop-media/images/;
}
location /downloads/ {
alias /var/www/shop-media/downloads/;
}
location /api/ {
add_header Cache-Control "no-store" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'self'" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
proxy_pass http://127.0.0.1:3000;
}
location /api/auth/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location /admin/ {
allow 10.20.0.0/16;
deny all;
proxy_pass http://127.0.0.1:8080/;
}
location /old-blog {
return 301 https://blog.example.com$request_uri;
}
}
}
FILE:scripts/nginx_review.py
#!/usr/bin/env python3
"""Nginx config security reviewer (Python 3 standard library only).
Parses nginx.conf / sites-available files into a block tree and flags common
security and hardening problems: old TLS protocols, weak ciphers, missing HSTS
and security headers, the add_header inheritance trap, alias off-by-slash path
traversal, autoindex, unlimited body size, $uri in redirects (CRLF injection),
plain-HTTP servers without a redirect, open admin paths, login paths without
rate limiting, exposed dotfiles and proxies without forwarding headers.
Usage:
python3 nginx_review.py site.conf [more.conf ...] [--json]
python3 nginx_review.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = unreadable/unparseable input.
"""
import argparse, json, re, sys
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
OLD_PROTOCOLS = {"SSLv2", "SSLv3", "TLSv1", "TLSv1.1"}
WEAK_CIPHER_WORDS = ["RC4", "DES", "3DES", "MD5", "NULL", "EXPORT", "EXP-", "aNULL", "eNULL", "ADH", "AECDH"]
SECURITY_HEADERS = {
"x-content-type-options": "X-Content-Type-Options nosniff",
"x-frame-options": "X-Frame-Options DENY or SAMEORIGIN (or CSP frame-ancestors)",
"content-security-policy": "Content-Security-Policy",
"referrer-policy": "Referrer-Policy strict-origin-when-cross-origin",
}
LOGIN_RE = re.compile(r"login|signin|sign-in|auth|token|password|wp-login", re.I)
ADMIN_RE = re.compile(r"admin|phpmyadmin|actuator|server-status|nginx_status|/metrics|wp-admin", re.I)
class ParseError(Exception):
pass
class Node:
def __init__(self, name, args, line, children=None):
self.name, self.args, self.line, self.children = name, args, line, children
def get(self, name):
return [c for c in (self.children or []) if c.name == name]
def first(self, name):
found = self.get(name)
return found[0] if found else None
def walk(self):
for c in self.children or []:
yield c
if c.children is not None:
yield from c.walk()
def tokenize(text):
tokens, i, line, n = [], 0, 1, len(text)
while i < n:
ch = text[i]
if ch == "\n":
line += 1; i += 1
elif ch.isspace():
i += 1
elif ch == "#":
while i < n and text[i] != "\n":
i += 1
elif ch in "{};":
tokens.append((ch, line, True)); i += 1
elif ch in "\"'":
q, j, buf = ch, i + 1, []
while j < n and text[j] != q:
if text[j] == "\\" and j + 1 < n:
buf.append(text[j + 1]); j += 2; continue
if text[j] == "\n":
line += 1
buf.append(text[j]); j += 1
if j >= n:
raise ParseError(f"line {line}: unterminated quote")
tokens.append(("".join(buf), line, False)); i = j + 1
else:
j = i
while j < n and not text[j].isspace() and text[j] not in "{};":
if text[j] == "$" and j + 1 < n and text[j + 1] == "{": # var
k = text.find("}", j)
j = k + 1 if k != -1 else n
continue
j += 1
tokens.append((text[i:j], line, False)); i = j
return tokens
def parse(text):
tokens = tokenize(text)
root = Node("main", [], 0, [])
stack, cur = [root], []
for tok, line, special in tokens:
if special and tok == ";":
if not cur:
continue
stack[-1].children.append(Node(cur[0][0], [t for t, _ in cur[1:]], cur[0][1]))
cur = []
elif special and tok == "{":
if not cur:
raise ParseError(f"line {line}: '{{' without a directive name")
node = Node(cur[0][0], [t for t, _ in cur[1:]], cur[0][1], [])
stack[-1].children.append(node); stack.append(node); cur = []
elif special and tok == "}":
if cur:
raise ParseError(f"line {line}: missing ';' before '}}'")
if len(stack) == 1:
raise ParseError(f"line {line}: unexpected '}}'")
stack.pop()
else:
cur.append((tok, line))
if cur:
raise ParseError(f"line {cur[0][1]}: directive '{cur[0][0]}' not terminated with ';'")
if len(stack) != 1:
raise ParseError(f"block '{stack[-1].name}' opened on line {stack[-1].line} is not closed")
return root
def headers_of(node):
return {d.args[0].lower(): d for d in node.get("add_header") if d.args}
def is_tls(server):
for l in server.get("listen"):
if "ssl" in l.args or "quic" in l.args or any(a.endswith(":443") or a == "443" for a in l.args):
return True
s = server.first("ssl")
return bool(s and s.args and s.args[0] == "on")
def listens_plain(server):
ls = server.get("listen")
if not ls:
return True # default listen *:80
for l in ls:
if not ("ssl" in l.args or "quic" in l.args or any(a.endswith(":443") or a == "443" for a in l.args)):
return True
return False
def names(server):
out = []
for d in server.get("server_name"):
out.extend(d.args)
return set(out) or {"_"}
def inherited(scopes, name):
"""Value of a directive as nginx inherits it: innermost scope that sets it."""
for s in reversed(scopes):
d = s.first(name)
if d is not None:
return d
return None
def effective_headers(scopes):
for s in reversed(scopes):
h = headers_of(s)
if h:
return h
return {}
def review(text, fname="nginx.conf"):
root = parse(text)
http = root.first("http")
top = http if http is not None else root
base = [root] + ([http] if http is not None else [])
servers = [s for s in top.get("server")]
findings = []
def add(sev, code, line, msg, fix):
findings.append({"severity": sev, "code": code, "file": fname, "line": line, "message": msg, "fix": fix})
for d in root.walk():
if d.name == "include":
add("INFO", "INCLUDE_NOT_FOLLOWED", d.line, f"include {' '.join(d.args)} is not read by this checker",
"Review the included files too (pass them as extra arguments) or paste `nginx -T` output.")
tokens = [d for d in root.walk() if d.name == "server_tokens"]
if not any(d.args and d.args[0] == "off" for d in tokens):
add("LOW", "SERVER_TOKENS", (http or root).line, "server_tokens is not off, so error pages and the Server header show the nginx version",
"Add `server_tokens off;` in the http block.")
tls_names = set()
for s in servers:
if is_tls(s):
tls_names |= names(s)
for s in servers:
scopes = base + [s]
label = " ".join(sorted(names(s)))
tls = is_tls(s)
if tls:
if inherited(scopes, "ssl_certificate") is None:
add("HIGH", "NO_CERTIFICATE", s.line, f"TLS server {label} has no ssl_certificate in scope",
"Add ssl_certificate and ssl_certificate_key (fullchain + key).")
proto = inherited(scopes, "ssl_protocols")
if proto is not None:
old = [p for p in proto.args if p in OLD_PROTOCOLS]
if old:
add("HIGH", "OLD_TLS_PROTOCOL", proto.line, f"{label}: ssl_protocols enables {', '.join(old)}",
"Use `ssl_protocols TLSv1.2 TLSv1.3;` (SSLv3/TLS 1.0/1.1 are deprecated, RFC 8996).")
ciph = inherited(scopes, "ssl_ciphers")
if ciph is not None and ciph.args:
parts = re.split(r"[:\s]+", " ".join(ciph.args))
weak = sorted({w for p in parts if p and not p.startswith("!") and not p.startswith("-")
for w in WEAK_CIPHER_WORDS if w.lower() in p.lower()})
if weak:
add("HIGH", "WEAK_CIPHERS", ciph.line, f"{label}: ssl_ciphers allows weak suites ({', '.join(weak)})",
"Use the Mozilla 'intermediate' cipher list, or remove ssl_ciphers to use the safe OpenSSL default.")
hdrs = effective_headers(scopes)
if "strict-transport-security" not in hdrs:
add("MEDIUM", "NO_HSTS", s.line, f"TLS server {label} sends no Strict-Transport-Security header",
'Add `add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;` (start with a short max-age).')
else:
hsts = hdrs["strict-transport-security"]
m = re.search(r"max-age=(\d+)", " ".join(hsts.args), re.I)
if m and int(m.group(1)) < 15552000:
add("LOW", "SHORT_HSTS", hsts.line, f"{label}: HSTS max-age {m.group(1)} is under 180 days",
"Raise max-age to 31536000 once HTTPS works everywhere.")
missing = [v for k, v in SECURITY_HEADERS.items() if k not in hdrs]
if "x-frame-options" not in hdrs and "content-security-policy" in hdrs and \
"frame-ancestors" in " ".join(hdrs["content-security-policy"].args):
missing = [v for v in missing if not v.startswith("X-Frame")]
if missing:
add("LOW", "MISSING_SECURITY_HEADERS", s.line, f"{label}: missing {'; '.join(missing)}",
"Add them with `always` in the server block (check the inheritance rule for locations).")
elif listens_plain(s) and names(s) & tls_names:
redirects = any(r.args and r.args[0] in ("301", "302", "307", "308") and len(r.args) > 1 and r.args[1].startswith("https://")
for r in [d for d in s.walk() if d.name == "return"])
rewrites = any(len(r.args) > 1 and r.args[1].startswith("https://") for r in [d for d in s.walk() if d.name == "rewrite"])
if not (redirects or rewrites):
add("MEDIUM", "HTTP_NO_REDIRECT", s.line, f"plain-HTTP server {label} serves content instead of redirecting to HTTPS",
"Use `return 301 https://$host$request_uri;` in the port-80 server (keep only /.well-known/acme-challenge/ if needed).")
server_hdrs = effective_headers(scopes)
has_dot_rule = False
locations = [d for d in s.walk() if d.name == "location"]
for loc in locations:
path = loc.args[-1] if loc.args else ""
mod = loc.args[0] if len(loc.args) > 1 else ""
if mod in ("~", "~*") and (r"/\." in path or path.startswith(r"\.") or r"(^|/)\." in path):
if loc.get("deny") or any(r.args and r.args[0] in ("403", "404") for r in loc.get("return")):
has_dot_rule = True
own = headers_of(loc)
hi = inherited(scopes + [loc], "add_header_inherit")
merge = bool(hi and hi.args and hi.args[0] == "merge") # nginx 1.29.3+
if own and server_hdrs and not merge:
lost = sorted(set(server_hdrs) - set(own))
if lost:
add("MEDIUM", "ADD_HEADER_INHERITANCE", loc.line,
f"location {path} has its own add_header, so it silently drops {', '.join(lost)} set at server/http level",
"Repeat those headers in this location, include a shared headers snippet, or on nginx 1.29.3+ set `add_header_inherit merge;`.")
al = loc.first("alias")
if al and al.args and mod in ("", "^~") and not path.endswith("/") and al.args[0].endswith("/"):
add("HIGH", "ALIAS_TRAVERSAL", al.line,
f"location {path} (no trailing slash) with alias {al.args[0]} (trailing slash) allows {path}../ path traversal",
f"Make both end with a slash: `location {path}/ {{ alias {al.args[0]}; }}`.")
ai = loc.first("autoindex")
if ai and ai.args and ai.args[0] == "on":
add("MEDIUM", "AUTOINDEX_ON", ai.line, f"autoindex on in location {path} lists directory contents",
"Turn it off, or restrict the location with allow/deny or auth_basic.")
if ADMIN_RE.search(path):
guarded = loc.get("deny") or loc.get("auth_basic") or loc.get("auth_request") or loc.get("satisfy") \
or any(r.args and r.args[0] in ("403", "404", "444") for r in loc.get("return"))
if not guarded:
add("MEDIUM", "ADMIN_OPEN", loc.line, f"location {path} looks like an admin/status endpoint with no allow/deny or auth",
"Add `allow <office/VPN range>; deny all;` or auth_basic/auth_request, or remove the route.")
if LOGIN_RE.search(path) and not inherited(scopes + [loc], "limit_req"):
add("LOW", "NO_RATE_LIMIT", loc.line, f"location {path} looks like a login/auth endpoint without limit_req",
"Define `limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;` in http and `limit_req zone=login burst=5 nodelay;` here.")
if loc.first("proxy_pass"):
ph = set()
for sc in reversed(scopes + [loc]): # like add_header: innermost level that sets any wins
if sc.get("proxy_set_header"):
ph = {d.args[0].lower() for d in sc.get("proxy_set_header") if d.args}
break
miss = [h for h in ("host", "x-forwarded-for", "x-forwarded-proto") if h not in ph]
if miss:
add("LOW", "PROXY_HEADERS", loc.line, f"location {path} proxies without {', '.join(miss)}",
"Set proxy_set_header Host $host; X-Forwarded-For $proxy_add_x_forwarded_for; X-Forwarded-Proto $scheme; (note: any proxy_set_header in a location replaces inherited ones).")
serves_files = inherited(scopes, "root") or any(
(l.first("root") or l.first("alias")) and not (l.args and l.args[-1].startswith("/.well-known/")) for l in locations)
if serves_files and not has_dot_rule:
add("LOW", "DOTFILES_EXPOSED", s.line, f"{label}: no rule blocks hidden files such as /.git/ or /.env",
r"Add `location ~ /\.(?!well-known) { deny all; }`.")
for d in root.walk():
if d.name == "client_max_body_size" and d.args and d.args[0] in ("0", "0m", "0k"):
add("MEDIUM", "UNLIMITED_BODY", d.line, "client_max_body_size 0 disables the request body limit",
"Set a real limit (e.g. 10m, or larger only in the upload location).")
if d.name in ("return", "rewrite", "add_header") and any(re.search(r"\$uri\b|\$document_uri\b", a) for a in d.args):
add("MEDIUM", "URI_IN_REDIRECT", d.line, f"{d.name} uses $uri (decoded, may contain CR/LF), which allows header injection",
"Use $request_uri (raw, still encoded) in redirects and headers.")
findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["line"]))
return {"file": fname, "servers": [{"line": s.line, "names": sorted(names(s)), "tls": is_tls(s)} for s in servers],
"findings": findings}
def to_markdown(results):
out = ["# Nginx config security review", ""]
allf = [f for r in results for f in r["findings"]]
counts = {k: sum(1 for f in allf if f["severity"] == k) for k in SEV_ORDER}
out.append("Findings: " + ", ".join(f"{v} {k}" for k, v in counts.items()))
for r in results:
out += ["", f"## {r['file']}", "", "| Server | Line | TLS |", "|---|---|---|"]
for s in r["servers"]:
out.append(f"| {' '.join(s['names'])} | {s['line']} | {'yes' if s['tls'] else 'no'} |")
out += ["", "| Severity | Code | Line | Problem | Fix |", "|---|---|---|---|---|"]
for f in r["findings"]:
out.append(f"| {f['severity']} | {f['code']} | {f['line']} | {f['message']} | {f['fix']} |")
if not r["findings"]:
out.append("| - | - | - | no findings | - |")
out += ["", "Always run `nginx -t` before reloading, and test with `curl -sI https://<host>/` afterwards."]
return "\n".join(out)
def self_test():
results = []
def check(name, cond):
results.append(cond)
print(("PASS " if cond else "FAIL ") + name)
def codes(conf):
return [f["code"] for f in review(conf)["findings"]]
good = """
http {
server_tokens off;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
server { listen 80; server_name a.test; return 301 https://$host$request_uri; }
server {
listen 443 ssl; http2 on; server_name a.test;
ssl_certificate /c.pem; ssl_certificate_key /k.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
root /srv/www;
location ~ /\\.(?!well-known) { deny all; }
location /static/ { alias /srv/static/; }
location /login { limit_req zone=login burst=5 nodelay; proxy_pass http://app;
proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; }
}
}"""
check("clean config has no findings", codes(good) == [])
check("comments and quotes parsed", parse('a "b c;" d; # x;\n e f;').children[0].args == ["b c;", "d"])
check("var token kept", parse("set $x hosta;").children[0].args == ["$x", "hosta"])
for bad, msg in [("server { listen 80;", "unclosed block"), ("a b", "missing ;"), ("}", "stray brace"), ('a "b;', "unterminated quote")]:
try:
parse(bad); check("parse error: " + msg, False)
except ParseError:
check("parse error: " + msg, True)
check("SERVER_TOKENS when missing", "SERVER_TOKENS" in codes("http { server { listen 80; } }"))
tls = "server { listen 443 ssl; server_name b.test; ssl_certificate /c; %s }"
check("OLD_TLS_PROTOCOL", "OLD_TLS_PROTOCOL" in codes(tls % "ssl_protocols TLSv1 TLSv1.2;"))
check("TLSv1.2 alone fine", "OLD_TLS_PROTOCOL" not in codes(tls % "ssl_protocols TLSv1.2;"))
check("WEAK_CIPHERS", "WEAK_CIPHERS" in codes(tls % "ssl_ciphers 'HIGH:RC4-SHA:DES-CBC3-SHA';"))
check("excluded !RC4 is fine", "WEAK_CIPHERS" not in codes(tls % "ssl_ciphers 'HIGH:!aNULL:!MD5:!RC4';"))
check("NO_CERTIFICATE", "NO_CERTIFICATE" in codes("server { listen 443 ssl; }"))
check("cert inherited from http", "NO_CERTIFICATE" not in codes("http { ssl_certificate /c; server { listen 443 ssl; } }"))
check("NO_HSTS", "NO_HSTS" in codes(tls % ""))
check("SHORT_HSTS", "SHORT_HSTS" in codes(tls % 'add_header Strict-Transport-Security "max-age=300";'))
check("MISSING_SECURITY_HEADERS", "MISSING_SECURITY_HEADERS" in codes(tls % ""))
inh = tls % 'add_header Strict-Transport-Security "max-age=31536000"; location /api/ { add_header Cache-Control no-store; }'
r = review(inh)["findings"]
check("ADD_HEADER_INHERITANCE names lost header",
any(f["code"] == "ADD_HEADER_INHERITANCE" and "strict-transport-security" in f["message"] for f in r))
check("headers inherited when location has none", "ADD_HEADER_INHERITANCE" not in codes(
tls % 'add_header Strict-Transport-Security "max-age=31536000"; location /api/ { proxy_pass http://x; }'))
check("add_header_inherit merge respected", "ADD_HEADER_INHERITANCE" not in codes(
tls % 'add_header_inherit merge; add_header Strict-Transport-Security "max-age=31536000"; location /api/ { add_header Cache-Control no-store; }'))
check("ALIAS_TRAVERSAL", "ALIAS_TRAVERSAL" in codes("server { location /img { alias /srv/img/; } }"))
check("alias with matching slashes fine", "ALIAS_TRAVERSAL" not in codes("server { location /img/ { alias /srv/img/; } }"))
check("regex location alias not flagged", "ALIAS_TRAVERSAL" not in codes(r"server { location ~ ^/img/(.*)$ { alias /srv/img/$1; } }"))
check("AUTOINDEX_ON", "AUTOINDEX_ON" in codes("server { location /files/ { autoindex on; } }"))
check("HTTP_NO_REDIRECT", "HTTP_NO_REDIRECT" in codes(
"server { listen 80; server_name c.test; root /x; } server { listen 443 ssl; server_name c.test; ssl_certificate /c; }"))
check("no HTTP_NO_REDIRECT for unrelated name", "HTTP_NO_REDIRECT" not in codes(
"server { listen 80; server_name d.test; } server { listen 443 ssl; server_name c.test; ssl_certificate /c; }"))
check("ADMIN_OPEN", "ADMIN_OPEN" in codes("server { location /admin/ { proxy_pass http://x; } }"))
check("admin with deny fine", "ADMIN_OPEN" not in codes("server { location /admin/ { allow 10.0.0.0/8; deny all; } }"))
check("NO_RATE_LIMIT", "NO_RATE_LIMIT" in codes("server { location /api/login { proxy_pass http://x; } }"))
check("rate limit inherited from server", "NO_RATE_LIMIT" not in codes(
"server { limit_req zone=z; location /api/login { proxy_pass http://x; } }"))
check("PROXY_HEADERS", "PROXY_HEADERS" in codes("server { location / { proxy_pass http://x; } }"))
check("proxy_set_header in location replaces inherited", "PROXY_HEADERS" in codes(
"server { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; "
"proxy_set_header X-Forwarded-Proto $scheme; location / { proxy_set_header Upgrade $http_upgrade; proxy_pass http://x; } }"))
check("DOTFILES_EXPOSED", "DOTFILES_EXPOSED" in codes("server { root /srv; }"))
check("ACME-only port 80 server not flagged for dotfiles", "DOTFILES_EXPOSED" not in codes(
"server { listen 80; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } }"))
check("UNLIMITED_BODY", "UNLIMITED_BODY" in codes("http { client_max_body_size 0; }"))
check("URI_IN_REDIRECT", "URI_IN_REDIRECT" in codes("server { return 301 https://$host$uri; }"))
check("$request_uri fine", "URI_IN_REDIRECT" not in codes("server { return 301 https://$host$request_uri; }"))
check("INCLUDE_NOT_FOLLOWED", "INCLUDE_NOT_FOLLOWED" in codes("http { include /etc/nginx/conf.d/*.conf; }"))
check("findings sorted HIGH first", review("server { listen 443 ssl; location /files/ { autoindex on; } }")["findings"][0]["severity"] == "HIGH")
passed = sum(results)
print(f"{passed}/{len(results)} passed")
return 0 if passed == len(results) else 1
def main():
ap = argparse.ArgumentParser(description="Nginx config security reviewer")
ap.add_argument("files", nargs="*", help="nginx config files (nginx.conf, sites-available/*, or `nginx -T` output)")
ap.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.files:
ap.error("give at least one config file, or --self-test")
results = []
for f in a.files:
try:
with open(f, encoding="utf-8") as fh:
results.append(review(fh.read(), f))
except (OSError, UnicodeDecodeError, ParseError) as e:
print(f"error: {f}: {e}", file=sys.stderr)
return 2
print(json.dumps(results, indent=2) if a.json else to_markdown(results))
return 1 if any(f["severity"] == "HIGH" for r in results for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())审查 .service 与 drop-in:禁止 root 乱跑、Environment 泄密、权限过宽等。
---
name: systemd-service-unit-hardening-reviewer
description: 审查 .service 与 drop-in:禁止 root 乱跑、Environment 泄密、权限过宽等。
---
# systemd 服务单元加固审查
你是「systemd 服务单元加固审查」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。
# 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 DynamicUser=, or User=root; `+`/`!` Exec prefixes bypass the user and sandbox (medium) | DynamicUser=yes or a system user; drop the prefix |
| SD02 | high: no ExecStart, several ExecStart for a non-oneshot; low: command not an absolute path | ExecStart sanity | one absolute ExecStart; empty `ExecStart=` first in a drop-in to replace it |
| SD03 | high | secret-looking variable (PASSWORD, SECRET, TOKEN, API_KEY, PRIVATE_KEY, DSN) or a URL with user:password in Environment=; `--password=` style secrets on the command line | LoadCredential= or EnvironmentFile= with mode 0600; rotate the leaked secret |
| SD04 | medium: Restart=no for a long-running service; low: no RestartSec (100 ms default); high: oneshot with Restart=always/on-success; info: Restart=always with the default start limit | restart policy | Restart=on-failure, RestartSec=5s |
| SD05 | medium: After= and Wants= of network-online.target not paired; low: obsolete syslog.target / network.service | ordering | add the missing half |
| SD06 | medium if exposure >= 5, else low | sandboxing options missing (weighted exposure score) | the `--dropin` output, tested step by step |
| SD07 | high: CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE, CAP_SYS_RAWIO; medium: CAP_DAC_OVERRIDE, CAP_NET_ADMIN, CAP_SETUID/SETGID; low: others | dangerous capabilities in AmbientCapabilities / CapabilityBoundingSet | keep only what is needed |
| SD08 | high: unknown Exec*/User*/Restart* directive or invalid Type/Restart value; medium: other unknown directive, invalid value, bad boolean, line without `=`, assignment before a section, unknown section | systemd logs a warning and ignores the line | fix the spelling (suggestion shown) |
| SD09 | medium | directive in the wrong section (StartLimitBurst in [Service], WantedBy in [Service], old StartLimitInterval) | move it |
| SD10 | high: pipes, redirects, `&&`, `;`, `&` or `$( )` in an Exec line without `/bin/sh -c`; medium: nohup / --daemon with Type=simple/exec; low: Type=forking without PIDFile | systemd does not use a shell | a script, `sh -c`, or foreground mode |
| SD11 | medium: no WantedBy/RequiredBy/Alias; low: WantedBy=default.target or graphical.target for a system service | enable at boot | WantedBy=multi-user.target |
| SD12 | medium: TimeoutStopSec=infinity/0, KillMode=none; low: KillMode=process | stop behaviour | TimeoutStopSec=30s, KillMode=mixed |
| SD13 | low | the same single-value key twice in one file | keep one |
| SD14 | high: ProtectHome=yes/tmpfs with an app, working dir or env file under /home or /root; MemoryDenyWriteExecute with node/java/dotnet/pypy/bun/deno; medium: ProtectSystem=strict with a write path not in ReadWritePaths/StateDirectory/LogsDirectory/RuntimeDirectory | options that break the service | move the app, add the write path, drop the option |
| SD15 | info | no MemoryMax/MemoryHigh/TasksMax/CPUQuota | add limits from observed usage |
## Exposure score
Weights (sum 10): run as non-root 2.0, ProtectSystem=strict 1.0 (full/yes counts half), NoNewPrivileges 0.8, ProtectHome 0.6, CapabilityBoundingSet without dangerous caps 0.6, PrivateTmp 0.5, PrivateDevices 0.5, ProtectKernelTunables 0.4, ProtectKernelModules 0.4, RestrictNamespaces 0.4, SystemCallFilter 0.4, RestrictAddressFamilies 0.4, ProtectControlGroups 0.3, RestrictSUIDSGID 0.3, MemoryDenyWriteExecute 0.3, ProtectKernelLogs 0.2, ProtectClock 0.2, RestrictRealtime 0.2, UMask 0077/0027 0.2, ProtectHostname 0.1, LockPersonality 0.1, SystemCallArchitectures=native 0.1.
Rating: under 2 OK, under 5 MEDIUM, under 8 EXPOSED, 8 or more UNSAFE.
## Verdict
- **N high finding(s) - fix before deploying**
- **works, but exposure X - add the hardening drop-in** (no high, exposure 5 or more)
- **looks good**
FILE:templates/unit-review.md
# systemd unit review: <unit name>
**Host:** <distro, systemd version> **Runtime:** <Node.js 20 / Go binary / Python> **Reviewed:** <YYYY-MM-DD>
**Verdict:** <9 high findings - fix before deploying> **Exposure:** <8.1 before -> 1.5 after>
## Must fix
| # | Problem | Why it matters | Fix |
|---|---|---|---|
| 1 | <runs as root> | <an exploit owns the machine> | <DynamicUser=yes + CAP_NET_BIND_SERVICE for port 443> |
| 2 | <secret STRIPE_SECRET_KEY in Environment=> | <readable by every local user - rotate it> | <LoadCredential=stripe:/etc/shop-api/stripe> |
## Should fix
- <Restart/RestartSec, network-online pairing, [Install], stop timeout>
## Corrected unit
```ini
<full corrected .service file>
```
## Hardening drop-in (`systemctl edit <unit>`)
```ini
<drop-in from --dropin, adjusted for this service>
```
## Rollout
1. Rotate the leaked secrets; put the new ones in /etc/<name>/ (mode 0600).
2. `systemd-analyze verify /etc/systemd/system/<unit>`
3. `systemctl daemon-reload && systemctl restart <unit>`; check `journalctl -u <unit> -b` and the health endpoint.
4. `systemd-analyze security <unit>` - expect <exposure>.
5. Roll back: `systemctl revert <unit>` removes the drop-ins.
## Open questions
- [ ] <which directories does it write?>
- [ ] <does it need the network at start?>
FILE:examples/webapp.service
# /etc/systemd/system/shopapi.service - Node.js shop API, deployed by hand in 2023
[Unit]
Description=Shop API (Node.js)
After=network-online.target syslog.target
StartLimitBurst=3
[Service]
Type=simple
WorkingDirectory=/home/deploy/shop-api
Environment=NODE_ENV=production PORT=443
Environment="DATABASE_URL=postgres://shop:Winter2024@db.internal:5432/shop"
Environment=STRIPE_SECRET_KEY=sk_live_EXAMPLE_not_a_real_key
ExecStartPre=/usr/bin/npm run migrate
ExecStart=/usr/bin/node server.js >> /var/log/shop-api.log 2>&1
Restart=always
RestartSecs=5
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_ADMIN
TimeoutStopSec=infinity
ProtectSystem=full
PrivateTmp=yes
FILE:examples/webapp-override.conf
# /etc/systemd/system/shopapi.service.d/override.conf - added during an incident
[Service]
ProtectHome=yes
MemoryDenyWriteExecute=yes
Restart=on-faliure
FILE:examples/worker.service
# /etc/systemd/system/thumbnailer.service - image thumbnail worker
[Unit]
Description=Thumbnail worker
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
ExecStart=/usr/local/bin/thumbnailer \
--queue redis://127.0.0.1:6379/2 \
--out /var/lib/thumbnailer
DynamicUser=yes
StateDirectory=thumbnailer
LoadCredential=s3-key:/etc/thumbnailer/s3-key
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallFilter=@system-service
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
MemoryMax=1G
TasksMax=128
[Install]
WantedBy=multi-user.target
FILE:examples/review-report.md
# systemd unit review
| Unit | Description | Type | Runs as | Restart | Exposure (0-10) | High | Medium | Low | Info |
|---|---|---|---|---|---|---|---|---|---|
| `examples/webapp.service + examples/webapp-override.conf` | Shop API (Node.js) | simple | root | always | 8.1 UNSAFE | 9 | 4 | 2 | 1 |
| `examples/worker.service` | Thumbnail worker | exec | DynamicUser | on-failure | 1.2 OK | 0 | 0 | 1 | 0 |
## examples/webapp.service + examples/webapp-override.conf
**Verdict: 9 high finding(s) - fix before deploying**
| Sev | Code | Where | Finding | Fix |
|---|---|---|---|---|
| high | SD01 | examples/webapp.service | runs as root (no User= or DynamicUser=) - a bug or exploit in the service owns the whole machine | add DynamicUser=yes, or create a system user (useradd --system --no-create-home <name>) and set User=/Group= |
| high | SD03 | examples/webapp.service:11 | DATABASE_URL contains a URL with an embedded password | move the credential out of the unit (LoadCredential= / EnvironmentFile= 0600) |
| high | SD03 | examples/webapp.service:12 | secret STRIPE_SECRET_KEY is written in the unit file - unit files are world-readable and shown by `systemctl show` | use LoadCredential=stripe_secret_key:/etc/<name>/stripe_secret_key (read from $CREDENTIALS_DIRECTORY) or EnvironmentFile=/etc/<name>/env with mode 0600 |
| high | SD07 | examples/webapp.service:17 | grants CAP_SYS_ADMIN - close to full root | remove it; to bind ports below 1024 only CAP_NET_BIND_SERVICE is needed |
| high | SD08 | examples/webapp-override.conf:5 | invalid value Restart=on-faliure - systemd ignores the line and keeps the default (did you mean on-failure?) | use one of: always, no, on-abnormal, on-abort, on-failure, on-success, on-watchdog |
| high | SD08 | examples/webapp.service:16 | unknown directive RestartSecs= (ignored by systemd) - did you mean RestartSec=? | rename to RestartSec= |
| high | SD10 | examples/webapp.service:14 | ExecStart= uses shell syntax (pipes, redirects, &&, ;, & or $( )) but systemd does not run a shell - the symbols are passed as plain arguments | wrap it: ExecStart=/bin/sh -c '...' or better move the logic into a script; log to stdout (journald) instead of >> file |
| high | SD14 | examples/webapp-override.conf:3 | ProtectHome=yes but the service uses /home/deploy/shop-api - it cannot see it and will fail to start | move the app out of /home (e.g. /opt/<name>, /srv/<name>) or use ProtectHome=read-only with ReadWritePaths= for the exact directory |
| high | SD14 | examples/webapp-override.conf:4 | MemoryDenyWriteExecute=yes with a JIT runtime (/usr/bin/node) - it will crash on start | remove MemoryDenyWriteExecute= for this service |
| medium | SD05 | examples/webapp.service:4 | After=network-online.target without Wants=network-online.target - the ordering does nothing unless something else pulls the target in | add Wants=network-online.target |
| medium | SD06 | examples/webapp.service | 19 sandboxing option(s) missing: User, NoNewPrivileges, ProtectSystem, PrivateDevices, ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectClock, ProtectHostname, RestrictSUIDSGID, RestrictNamespaces, RestrictRealtime, LockPersonality, CapabilityBoundingSet, SystemCallFilter, SystemCallArchitectures, RestrictAddressFamilies, UMask | add the suggested drop-in (systemctl edit <name>), restart, and test the service |
| medium | SD11 | examples/webapp.service | no [Install] WantedBy= - `systemctl enable` does nothing, so the service will not start at boot | add [Install] WantedBy=multi-user.target |
| medium | SD12 | examples/webapp.service:18 | stop timeout infinity - a hung service blocks shutdown and restarts forever | set TimeoutStopSec=30s (or what a clean shutdown really needs) |
| low | SD04 | examples/webapp.service:15 | Restart=always without RestartSec= (default 100ms) - a crash loop hammers the machine and hits the start limit fast | add RestartSec=5s |
| low | SD05 | examples/webapp.service:4 | After=syslog.target is obsolete | remove it (journald is always available; use network-online.target) |
| info | SD15 | examples/webapp.service | no resource limits (MemoryMax=, TasksMax=, CPUQuota=) - a leak can starve the host | add MemoryMax= and TasksMax= sized from normal usage |
Suggested drop-in (test after applying; relax an option if the service needs it):
```ini
# /etc/systemd/system/<name>.service.d/hardening.conf (systemctl edit <name>)
[Service]
# or User=<service account>; a non-root user needs AmbientCapabilities=CAP_NET_BIND_SERVICE for ports below 1024
DynamicUser=yes
NoNewPrivileges=yes
# currently full; add ReadWritePaths=/StateDirectory= for paths it writes
ProtectSystem=strict
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
# empty = no capabilities; or list only what you need, e.g. CAP_NET_BIND_SERVICE
CapabilityBoundingSet=
SystemCallFilter=@system-service
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
UMask=0077
# then: systemctl daemon-reload && systemctl restart <name> && systemd-analyze security <name>
```
## examples/worker.service
**Verdict: looks good (exposure 1.2)**
| Sev | Code | Where | Finding | Fix |
|---|---|---|---|---|
| low | SD06 | examples/worker.service | 6 sandboxing option(s) missing: ProtectKernelLogs, ProtectClock, ProtectHostname, RestrictRealtime, MemoryDenyWriteExecute, UMask | add the suggested drop-in (systemctl edit <name>), restart, and test the service |
Suggested drop-in (test after applying; relax an option if the service needs it):
```ini
# /etc/systemd/system/<name>.service.d/hardening.conf (systemctl edit <name>)
[Service]
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
RestrictRealtime=yes
# not for JIT runtimes (Node.js, Java, .NET, PyPy)
MemoryDenyWriteExecute=yes
UMask=0077
# then: systemctl daemon-reload && systemctl restart <name> && systemd-analyze security <name>
```
Exposure is this script's weighted estimate, not the systemd-analyze number; run `systemd-analyze security <unit>` and `systemd-analyze verify <file>` on the target host to confirm.
FILE:scripts/systemd_unit_review.py
#!/usr/bin/env python3
"""systemd service unit hardening reviewer (stdlib only, Python 3.8+).
Reads one or more systemd .service unit files (and drop-in .conf files, applied in the order
given) the way systemd does - sections, comments, backslash line continuation, repeated keys,
empty assignments that reset a list - and reviews them for reliability and security problems:
running as root, secrets in Environment=, shell syntax in Exec lines without a shell, unknown or
misspelled directives and values, directives in the wrong section, restart and timeout policy,
network-online ordering, missing [Install], sandboxing gaps, dangerous capabilities and options
that will stop the service from starting. Prints an exposure score (0 = locked down, 10 = wide
open, in the spirit of `systemd-analyze security`) and a suggested hardening drop-in.
Usage:
systemd_unit_review.py UNIT.service [DROPIN.conf ...] [--json] [--dropin]
systemd_unit_review.py --self-test
Exit: 0 = no high findings, 1 = at least one high finding, 2 = bad input.
"""
import argparse, difflib, json, re, sys
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
UNIT_KEYS = {"Description", "Documentation", "Requires", "Requisite", "Wants", "BindsTo", "PartOf",
"Upholds", "Conflicts", "Before", "After", "OnFailure", "OnSuccess", "PropagatesReloadTo",
"ReloadPropagatedFrom", "JoinsNamespaceOf", "RequiresMountsFor", "OnFailureJobMode",
"IgnoreOnIsolate", "StopWhenUnneeded", "RefuseManualStart", "RefuseManualStop",
"AllowIsolate", "DefaultDependencies", "CollectMode", "FailureAction", "SuccessAction",
"JobTimeoutSec", "JobRunningTimeoutSec", "StartLimitIntervalSec", "StartLimitBurst",
"StartLimitAction", "RebootArgument", "SourcePath", "ConditionPathExists",
"ConditionPathIsDirectory", "ConditionFileNotEmpty", "ConditionHost", "ConditionVirtualization",
"ConditionKernelCommandLine", "ConditionACPower", "ConditionUser", "ConditionGroup",
"AssertPathExists", "AssertFileNotEmpty", "ConditionEnvironment", "ConditionSecurity"}
SERVICE_KEYS = {"Type", "ExitType", "RemainAfterExit", "GuessMainPID", "PIDFile", "BusName",
"ExecStart", "ExecStartPre", "ExecStartPost", "ExecCondition", "ExecReload", "ExecStop",
"ExecStopPost", "RestartSec", "RestartSteps", "RestartMaxDelaySec", "TimeoutStartSec",
"TimeoutStopSec", "TimeoutAbortSec", "TimeoutSec", "TimeoutStartFailureMode",
"TimeoutStopFailureMode", "RuntimeMaxSec", "WatchdogSec", "Restart", "RestartMode",
"SuccessExitStatus", "RestartPreventExitStatus", "RestartForceExitStatus", "RootDirectoryStartOnly",
"NonBlocking", "NotifyAccess", "Sockets", "FileDescriptorStoreMax", "USBFunctionDescriptors",
"OOMPolicy", "OpenFile", "ReloadSignal",
# exec
"WorkingDirectory", "RootDirectory", "RootImage", "User", "Group", "DynamicUser",
"SupplementaryGroups", "PAMName", "UMask", "Environment", "EnvironmentFile", "PassEnvironment",
"UnsetEnvironment", "StandardInput", "StandardOutput", "StandardError", "SyslogIdentifier",
"LogLevelMax", "LogRateLimitIntervalSec", "LogRateLimitBurst", "Nice", "OOMScoreAdjust",
"CPUSchedulingPolicy", "IOSchedulingClass", "LimitNOFILE", "LimitNPROC", "LimitCORE",
"LimitMEMLOCK", "LimitAS", "LimitFSIZE", "CapabilityBoundingSet", "AmbientCapabilities",
"NoNewPrivileges", "SecureBits", "ProtectSystem", "ProtectHome", "RuntimeDirectory",
"StateDirectory", "CacheDirectory", "LogsDirectory", "ConfigurationDirectory",
"RuntimeDirectoryMode", "StateDirectoryMode", "RuntimeDirectoryPreserve", "ReadWritePaths",
"ReadOnlyPaths", "InaccessiblePaths", "ExecPaths", "NoExecPaths", "TemporaryFileSystem",
"BindPaths", "BindReadOnlyPaths", "PrivateTmp", "PrivateDevices", "PrivateNetwork",
"PrivateUsers", "PrivateIPC", "PrivateMounts", "ProtectHostname", "ProtectClock",
"ProtectKernelTunables", "ProtectKernelModules", "ProtectKernelLogs", "ProtectControlGroups",
"ProtectProc", "ProcSubset", "RestrictAddressFamilies", "RestrictNamespaces",
"LockPersonality", "MemoryDenyWriteExecute", "RestrictRealtime", "RestrictSUIDSGID",
"RemoveIPC", "SystemCallFilter", "SystemCallArchitectures", "SystemCallErrorNumber",
"DevicePolicy", "DeviceAllow", "IPAddressAllow", "IPAddressDeny", "LoadCredential",
"LoadCredentialEncrypted", "SetCredential", "SetCredentialEncrypted", "KeyringMode",
"TTYPath", "UtmpIdentifier", "SELinuxContext", "AppArmorProfile", "KillMode", "KillSignal",
"SendSIGKILL", "SendSIGHUP", "FinalKillSignal", "RestartKillSignal",
# resource control
"CPUQuota", "CPUWeight", "MemoryMax", "MemoryHigh", "MemoryLow", "MemoryMin", "MemorySwapMax",
"TasksMax", "IOWeight", "Slice", "Delegate", "CPUAccounting", "MemoryAccounting",
"IOAccounting", "TasksAccounting", "IPAccounting", "ManagedOOMSwap",
"ManagedOOMMemoryPressure"}
INSTALL_KEYS = {"Alias", "WantedBy", "RequiredBy", "UpheldBy", "Also", "DefaultInstance"}
SECTIONS = {"Unit": UNIT_KEYS, "Service": SERVICE_KEYS, "Install": INSTALL_KEYS}
LIST_KEYS = {"Requires", "Wants", "After", "Before", "BindsTo", "PartOf", "Conflicts", "Documentation",
"Environment", "EnvironmentFile", "ExecStartPre", "ExecStartPost", "ExecStop", "ExecStopPost",
"ExecReload", "ExecCondition", "ReadWritePaths", "ReadOnlyPaths", "InaccessiblePaths",
"SupplementaryGroups", "CapabilityBoundingSet", "AmbientCapabilities", "SystemCallFilter",
"RestrictAddressFamilies", "DeviceAllow", "LoadCredential", "SetCredential", "WantedBy",
"RequiredBy", "Alias", "Also", "BindPaths", "BindReadOnlyPaths", "IPAddressAllow",
"IPAddressDeny", "OnFailure", "SuccessExitStatus", "RestartPreventExitStatus",
"TemporaryFileSystem", "PassEnvironment", "UnsetEnvironment", "RequiresMountsFor",
"StateDirectory", "RuntimeDirectory", "CacheDirectory", "LogsDirectory", "ExecStart",
"ConditionPathExists", "AssertPathExists"}
VALUES = {
"Type": {"simple", "exec", "forking", "oneshot", "dbus", "notify", "notify-reload", "idle"},
"Restart": {"no", "on-success", "on-failure", "on-abnormal", "on-watchdog", "on-abort", "always"},
"ProtectSystem": {"yes", "no", "true", "false", "full", "strict", "1", "0", "on", "off"},
"ProtectHome": {"yes", "no", "true", "false", "read-only", "tmpfs", "1", "0", "on", "off"},
"KillMode": {"control-group", "mixed", "process", "none"},
"StandardOutput": None, "NotifyAccess": {"none", "main", "exec", "all"},
}
BOOL = {"yes", "no", "true", "false", "1", "0", "on", "off"}
BOOL_KEYS = {"NoNewPrivileges", "PrivateTmp", "PrivateDevices", "PrivateNetwork", "PrivateUsers",
"ProtectKernelTunables", "ProtectKernelModules", "ProtectKernelLogs", "ProtectControlGroups",
"ProtectHostname", "ProtectClock", "LockPersonality", "MemoryDenyWriteExecute",
"RestrictRealtime", "RestrictSUIDSGID", "RemoveIPC", "DynamicUser", "RemainAfterExit",
"PrivateIPC", "PrivateMounts", "DefaultDependencies"}
TRUE = {"yes", "true", "1", "on"}
SECRET_RE = re.compile(r"(PASS(WORD|WD)?|SECRET|TOKEN|API_?KEY|PRIVATE_?KEY|CREDENTIALS?|DSN)", re.I)
SHELL_RE = re.compile(r"(\|\||&&|(?<![\\])[|;]|>>?|<|\$\(|`|(?:^|\s)&\s*$|\s&$)")
DANGER_CAPS = {"CAP_SYS_ADMIN": "high", "CAP_SYS_MODULE": "high", "CAP_SYS_PTRACE": "high",
"CAP_DAC_OVERRIDE": "medium", "CAP_SYS_RAWIO": "high", "CAP_NET_ADMIN": "medium",
"CAP_SETUID": "medium", "CAP_SETGID": "medium", "CAP_CHOWN": "low", "CAP_SYS_CHROOT": "low"}
# (directive, wanted value test, weight, recommended line) - weights sum to 10.0
HARDEN = [
("User", None, 2.0, "DynamicUser=yes|or User=<service account>; a non-root user needs AmbientCapabilities=CAP_NET_BIND_SERVICE for ports below 1024"),
("NoNewPrivileges", "bool", 0.8, "NoNewPrivileges=yes"),
("ProtectSystem", "strict", 1.0, "ProtectSystem=strict"),
("ProtectHome", "home", 0.6, "ProtectHome=yes"),
("PrivateTmp", "bool", 0.5, "PrivateTmp=yes"),
("PrivateDevices", "bool", 0.5, "PrivateDevices=yes"),
("ProtectKernelTunables", "bool", 0.4, "ProtectKernelTunables=yes"),
("ProtectKernelModules", "bool", 0.4, "ProtectKernelModules=yes"),
("ProtectKernelLogs", "bool", 0.2, "ProtectKernelLogs=yes"),
("ProtectControlGroups", "bool", 0.3, "ProtectControlGroups=yes"),
("ProtectClock", "bool", 0.2, "ProtectClock=yes"),
("ProtectHostname", "bool", 0.1, "ProtectHostname=yes"),
("RestrictSUIDSGID", "bool", 0.3, "RestrictSUIDSGID=yes"),
("RestrictNamespaces", "ns", 0.4, "RestrictNamespaces=yes"),
("RestrictRealtime", "bool", 0.2, "RestrictRealtime=yes"),
("LockPersonality", "bool", 0.1, "LockPersonality=yes"),
("MemoryDenyWriteExecute", "bool", 0.3, "MemoryDenyWriteExecute=yes|not for JIT runtimes (Node.js, Java, .NET, PyPy)"),
("CapabilityBoundingSet", "caps", 0.6, "CapabilityBoundingSet=|empty = no capabilities; or list only what you need, e.g. CAP_NET_BIND_SERVICE"),
("SystemCallFilter", "set", 0.4, "SystemCallFilter=@system-service"),
("SystemCallArchitectures", "native", 0.1, "SystemCallArchitectures=native"),
("RestrictAddressFamilies", "set", 0.4, "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6"),
("UMask", "umask", 0.2, "UMask=0077"),
]
class InputError(Exception):
pass
# ---------------------------------------------------------------- parse
def parse(text, fname, unit=None):
"""Return unit dict: {section: {key: [(value, line, file)]}} plus raw entries list."""
unit = unit if unit is not None else {"sections": {}, "entries": [], "files": []}
unit["files"].append(fname)
sec = None
lines = text.splitlines()
i = 0
while i < len(lines):
ln, raw = i + 1, lines[i]
i += 1
s = raw.strip()
if not s or s[0] in "#;":
continue
while s.endswith("\\") and i < len(lines):
nxt = lines[i].strip()
i += 1
if nxt.startswith(("#", ";")):
continue
s = s[:-1].rstrip() + " " + nxt
if s.startswith("["):
if not s.endswith("]"):
raise InputError("%s:%d: broken section header %r" % (fname, ln, s))
sec = s[1:-1].strip()
unit["sections"].setdefault(sec, {})
unit["entries"].append(("section", sec, None, ln, fname))
continue
if "=" not in s:
unit["entries"].append(("noeq", sec, s, ln, fname))
continue
if sec is None:
unit["entries"].append(("nosec", None, s, ln, fname))
continue
k, v = s.split("=", 1)
k, v = k.strip(), v.strip()
unit["entries"].append(("kv", sec, (k, v), ln, fname))
d = unit["sections"][sec]
if v == "" and k in LIST_KEYS:
d[k] = [("", ln, fname)] # empty assignment resets the list
continue
d.setdefault(k, []).append((v, ln, fname))
return unit
def get(unit, sec, key):
vals = unit["sections"].get(sec, {}).get(key, [])
vals = [v for v in vals if v[0] != ""] if key in LIST_KEYS else vals
return vals
def valid(key, v):
vl = v.lower()
if key in VALUES and VALUES[key]:
return vl in VALUES[key]
if key in BOOL_KEYS:
return vl in BOOL
return True
def last(unit, sec, key, default=None):
"""Effective value: the last assignment systemd accepts (invalid values are ignored)."""
for v in reversed(unit["sections"].get(sec, {}).get(key) or []):
if v[0] == "" or valid(key, v[0]):
return v[0]
return default
def where(unit, sec, key):
v = [x for x in unit["sections"].get(sec, {}).get(key) or [] if x[0] == "" or valid(key, x[0])]
return "%s:%d" % (v[-1][2], v[-1][1]) if v else unit["files"][0]
def words(unit, sec, key):
out = []
for v, _, _ in get(unit, sec, key):
out += v.split()
return out
# ---------------------------------------------------------------- review
def review(unit):
F = []
add = lambda sev, code, loc, msg, fix: F.append((sev, code, loc, msg, fix))
S = unit["sections"]
svc = S.get("Service", {})
f0 = unit["files"][0]
if "Service" not in S:
raise InputError("%s has no [Service] section - not a service unit" % f0)
# SD08 / SD13 / structure
for kind, sec, val, ln, fn in unit["entries"]:
loc = "%s:%d" % (fn, ln)
if kind == "noeq":
add("medium", "SD08", loc, "line is not Key=Value and is ignored: %r" % val, "fix the line or comment it out with #")
elif kind == "nosec":
add("medium", "SD08", loc, "assignment before any [Section] is ignored: %r" % val, "move it under [Unit], [Service] or [Install]")
elif kind == "section" and sec not in SECTIONS and not sec.startswith("X-"):
add("medium", "SD08", loc, "unknown section [%s] - its settings are ignored" % sec, "use [Unit], [Service] or [Install] (custom sections must start with X-)")
elif kind == "kv" and sec in SECTIONS:
k, v = val
if k.startswith("X-"):
continue
if k not in SECTIONS[sec]:
other = [s for s in SECTIONS if k in SECTIONS[s]]
if other:
sev = "medium"
add(sev, "SD09", loc, "%s= belongs in [%s], not [%s] - systemd ignores it here" % (k, other[0], sec),
"move %s=%s to [%s]" % (k, v, other[0]))
else:
allk = set().union(*SECTIONS.values())
close = difflib.get_close_matches(k, sorted(allk), 1, 0.75)
add("high" if sec == "Service" and close and close[0].startswith(("Exec", "User", "Restart")) else "medium",
"SD08", loc, "unknown directive %s= (ignored by systemd)%s" % (k, " - did you mean %s=?" % close[0] if close else ""),
"rename to %s=" % close[0] if close else "remove it or check `man systemd.directives`")
continue
vl = v.lower()
if k in VALUES and VALUES[k] and v and vl not in VALUES[k]:
close = difflib.get_close_matches(vl, sorted(VALUES[k]), 1, 0.6)
add("high" if k in ("Type", "Restart") else "medium", "SD08", loc,
"invalid value %s=%s - systemd ignores the line and keeps the default%s" % (k, v, " (did you mean %s?)" % close[0] if close else ""),
"use one of: %s" % ", ".join(sorted(VALUES[k])))
elif k in BOOL_KEYS and v and vl not in BOOL:
add("medium", "SD08", loc, "%s= expects a boolean, got %r (line ignored)" % (k, v), "use yes or no")
seen = {}
for kind, sec, val, ln, fn in unit["entries"]:
if kind == "kv" and val[0] not in LIST_KEYS and sec in SECTIONS:
key = (sec, val[0])
if key in seen and seen[key][2] == fn:
add("low", "SD13", "%s:%d" % (fn, ln), "%s= set again (first at line %d) - the last one wins: %s" % (val[0], seen[key][1], val[1]),
"keep one %s= line" % val[0])
seen[key] = (val[1], ln, fn)
typ = (last(unit, "Service", "Type", "simple") or "simple").lower()
execs = get(unit, "Service", "ExecStart")
# SD02 ExecStart
if not execs:
add("high", "SD02", f0, "no ExecStart= - the service cannot start", "add ExecStart=/absolute/path/to/binary args")
if len(execs) > 1 and typ != "oneshot":
add("high", "SD02", where(unit, "Service", "ExecStart"), "%d ExecStart= lines but Type=%s allows only one (only oneshot may have several)" % (len(execs), typ),
"keep one ExecStart=, or reset with an empty ExecStart= first in a drop-in")
for key in ("ExecStartPre", "ExecStart", "ExecStartPost", "ExecReload", "ExecStop", "ExecStopPost"):
for v, ln, fn in get(unit, "Service", key):
loc = "%s:%d" % (fn, ln)
cmd = v.lstrip("-@:+!")
first = cmd.split()[0] if cmd.split() else ""
is_shell = re.match(r"^(/usr)?/bin/(ba|da|z)?sh$", first) is not None
if first and not first.startswith("/") and not first.startswith("$"):
add("low", "SD02", loc, "%s= command %r is not an absolute path (systemd searches only its fixed PATH)" % (key, first),
"use the full path, e.g. /usr/bin/%s" % first)
if not is_shell and SHELL_RE.search(cmd.split(None, 1)[1] if len(cmd.split(None, 1)) > 1 else ""):
add("high", "SD10", loc, "%s= uses shell syntax (pipes, redirects, &&, ;, & or $( )) but systemd does not run a shell - the symbols are passed as plain arguments" % key,
"wrap it: %s=/bin/sh -c '...' or better move the logic into a script; log to stdout (journald) instead of >> file" % key)
if re.search(r"(^|\s)(nohup|daemon|--daemon(ize)?|-d)(\s|$)", cmd) and typ in ("simple", "exec"):
add("medium", "SD10", loc, "%s= looks like it forks into the background (nohup/--daemon) while Type=%s expects the process to stay in the foreground" % (key, typ),
"run it in the foreground (drop --daemon / nohup) or use Type=forking with PIDFile=")
if v.startswith("+") or v.startswith("!"):
add("medium", "SD01", loc, "%s= prefix %s runs this command with full privileges, bypassing User= and the sandbox" % (key, v[0]), "drop the prefix unless this step really needs root")
if typ == "forking" and not get(unit, "Service", "PIDFile"):
add("low", "SD10", where(unit, "Service", "Type"), "Type=forking without PIDFile= - systemd has to guess the main process", "add PIDFile=/run/<name>/<name>.pid or run in the foreground with Type=exec")
# SD01 user
user = last(unit, "Service", "User")
dyn = (last(unit, "Service", "DynamicUser", "no") or "no").lower() in TRUE
root = (not dyn) and (user is None or user in ("root", "0"))
if root:
add("high" if typ != "oneshot" else "medium", "SD01", where(unit, "Service", "User") if user else f0,
"runs as root (%s) - a bug or exploit in the service owns the whole machine" % ("User=%s" % user if user else "no User= or DynamicUser="),
"add DynamicUser=yes, or create a system user (useradd --system --no-create-home <name>) and set User=/Group=")
caps = words(unit, "Service", "AmbientCapabilities") + words(unit, "Service", "CapabilityBoundingSet")
for c in sorted(set(x.lstrip("~") for x in caps)):
if c.upper() in DANGER_CAPS and not any(x.startswith("~") for x in caps):
add(DANGER_CAPS[c.upper()], "SD07", where(unit, "Service", "AmbientCapabilities" if c in words(unit, "Service", "AmbientCapabilities") else "CapabilityBoundingSet"),
"grants %s - close to full root" % c if DANGER_CAPS[c.upper()] == "high" else "grants %s" % c,
"remove it; to bind ports below 1024 only CAP_NET_BIND_SERVICE is needed")
# SD03 secrets
for v, ln, fn in get(unit, "Service", "Environment"):
for m in re.finditer(r'"?([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|\S*)', v):
name, val = m.group(1), m.group(2).strip('"')
if SECRET_RE.search(name) and val and not val.startswith("%") and not val.startswith("$"):
add("high", "SD03", "%s:%d" % (fn, ln), "secret %s is written in the unit file - unit files are world-readable and shown by `systemctl show`" % name,
"use LoadCredential=%s:/etc/<name>/%s (read from $CREDENTIALS_DIRECTORY) or EnvironmentFile=/etc/<name>/env with mode 0600" % (name.lower(), name.lower()))
if re.search(r"://[^/\s:@]+:[^@\s]+@", val):
add("high", "SD03", "%s:%d" % (fn, ln), "%s contains a URL with an embedded password" % name, "move the credential out of the unit (LoadCredential= / EnvironmentFile= 0600)")
for v, ln, fn in get(unit, "Service", "ExecStart"):
if re.search(r"--(password|passwd|token|secret|api-key)[= ]\S+", v, re.I):
add("high", "SD03", "%s:%d" % (fn, ln), "a secret is passed on the command line - visible to every user in ps and /proc", "read it from a file or $CREDENTIALS_DIRECTORY instead")
# SD04 restart policy
restart = (last(unit, "Service", "Restart", "no") or "no").lower()
if typ != "oneshot" and restart == "no":
add("medium", "SD04", where(unit, "Service", "Restart") if last(unit, "Service", "Restart") else f0,
"Restart=no (default) - a crash leaves the service down until someone notices", "add Restart=on-failure and RestartSec=5s")
if restart in ("always", "on-failure", "on-abnormal") and not last(unit, "Service", "RestartSec"):
add("low", "SD04", where(unit, "Service", "Restart"), "Restart=%s without RestartSec= (default 100ms) - a crash loop hammers the machine and hits the start limit fast" % restart, "add RestartSec=5s")
if typ == "oneshot" and restart in ("always", "on-success"):
add("high", "SD04", where(unit, "Service", "Restart"), "Type=oneshot does not allow Restart=%s - systemd refuses to load the unit" % restart, "use Restart=on-failure or run it from a .timer")
tstop = (last(unit, "Service", "TimeoutStopSec") or last(unit, "Service", "TimeoutSec") or "").lower()
if tstop in ("infinity", "0"):
add("medium", "SD12", where(unit, "Service", "TimeoutStopSec") if last(unit, "Service", "TimeoutStopSec") else where(unit, "Service", "TimeoutSec"),
"stop timeout %s - a hung service blocks shutdown and restarts forever" % tstop, "set TimeoutStopSec=30s (or what a clean shutdown really needs)")
km = (last(unit, "Service", "KillMode", "") or "").lower()
if km == "none":
add("medium", "SD12", where(unit, "Service", "KillMode"), "KillMode=none is deprecated - leftover processes survive stop and restart", "use KillMode=mixed or the default control-group")
elif km == "process":
add("low", "SD12", where(unit, "Service", "KillMode"), "KillMode=process kills only the main process; children keep running after stop", "use KillMode=mixed unless the children must outlive the service")
# SD05 ordering
after = words(unit, "Unit", "After")
wants = words(unit, "Unit", "Wants") + words(unit, "Unit", "Requires")
if "network-online.target" in after and "network-online.target" not in wants:
add("medium", "SD05", where(unit, "Unit", "After"), "After=network-online.target without Wants=network-online.target - the ordering does nothing unless something else pulls the target in",
"add Wants=network-online.target")
if "network-online.target" in wants and "network-online.target" not in after:
add("medium", "SD05", where(unit, "Unit", "Wants"), "Wants=network-online.target without After=network-online.target - the service still starts before the network is up", "add After=network-online.target")
for v in ("syslog.target", "network.service"):
if v in after:
add("low", "SD05", where(unit, "Unit", "After"), "After=%s is obsolete" % v, "remove it (journald is always available; use network-online.target)")
sl = [k for k in ("StartLimitIntervalSec", "StartLimitBurst") if k in S.get("Unit", {})]
for k, ln in (("StartLimitInterval", None),):
if k in svc:
add("medium", "SD09", where(unit, "Service", k), "StartLimitInterval= is the old name and in [Service] it is ignored", "use StartLimitIntervalSec= in [Unit]")
if restart == "always" and not sl and "StartLimitIntervalSec" not in svc:
add("info", "SD04", f0, "Restart=always with the default start limit (5 starts in 10 s) - after that the unit stays failed", "if you want endless retries set StartLimitIntervalSec=0 in [Unit] and RestartSec= to a sane delay")
# SD11 install
wb = words(unit, "Install", "WantedBy") + words(unit, "Install", "RequiredBy")
if not wb and not get(unit, "Install", "Alias"):
add("medium", "SD11", f0, "no [Install] WantedBy= - `systemctl enable` does nothing, so the service will not start at boot", "add [Install] WantedBy=multi-user.target")
elif "default.target" in wb or "graphical.target" in wb:
add("low", "SD11", where(unit, "Install", "WantedBy"), "WantedBy=%s for a system service" % [w for w in wb if w in ("default.target", "graphical.target")][0], "use WantedBy=multi-user.target (default.target is for user services)")
# SD14 conflicts that break start
ph = (last(unit, "Service", "ProtectHome", "no") or "no").lower()
wd = last(unit, "Service", "WorkingDirectory", "") or ""
paths = [wd] + [(e[0].lstrip("-@:+!").split() or [""])[0] for e in execs] + [x.lstrip("-") for x in words(unit, "Service", "EnvironmentFile")]
if ph in TRUE or ph in ("tmpfs", "read-only"):
for p in paths:
if p.startswith("/home/") or p.startswith("/root/"):
add("high" if ph != "read-only" else "medium", "SD14", where(unit, "Service", "ProtectHome"),
"ProtectHome=%s but the service uses %s - it %s" % (ph, p, "cannot see it and will fail to start" if ph != "read-only" else "cannot write there"),
"move the app out of /home (e.g. /opt/<name>, /srv/<name>) or use ProtectHome=read-only with ReadWritePaths= for the exact directory")
break
ps_ = (last(unit, "Service", "ProtectSystem", "no") or "no").lower()
rw = words(unit, "Service", "ReadWritePaths") + ["/var/lib/" + x for x in words(unit, "Service", "StateDirectory")] + \
["/var/log/" + x for x in words(unit, "Service", "LogsDirectory")] + ["/run/" + x for x in words(unit, "Service", "RuntimeDirectory")]
if ps_ == "strict":
for v, ln, fn in execs + get(unit, "Service", "ExecStartPre"):
for m in re.finditer(r">>?\s*(/\S+)|--(?:log|pid|data|out)[-a-z]*[= ](/\S+)", v):
p = m.group(1) or m.group(2)
if not any(p.startswith(x.lstrip("-")) for x in rw) and not p.startswith(("/tmp", "/dev/")):
add("medium", "SD14", "%s:%d" % (fn, ln), "ProtectSystem=strict makes %s read-only - writes will fail" % p,
"use StateDirectory=/LogsDirectory=/RuntimeDirectory= or ReadWritePaths=%s" % p.rsplit("/", 1)[0])
if (last(unit, "Service", "MemoryDenyWriteExecute", "no") or "").lower() in TRUE and paths and re.search(r"(node|java|dotnet|pypy|bun|deno)", " ".join(paths)):
add("high", "SD14", where(unit, "Service", "MemoryDenyWriteExecute"), "MemoryDenyWriteExecute=yes with a JIT runtime (%s) - it will crash on start" % [p for p in paths if re.search(r"(node|java|dotnet|pypy|bun|deno)", p)][0],
"remove MemoryDenyWriteExecute= for this service")
if dyn and user:
add("info", "SD01", where(unit, "Service", "DynamicUser"), "DynamicUser=yes with User=%s - a transient user with that name is allocated" % user, "fine; do not rely on files owned by a static %s user" % user)
# SD15 resources
if not any(k in svc for k in ("MemoryMax", "MemoryHigh", "TasksMax", "CPUQuota")):
add("info", "SD15", f0, "no resource limits (MemoryMax=, TasksMax=, CPUQuota=) - a leak can starve the host", "add MemoryMax= and TasksMax= sized from normal usage")
# SD06 exposure
exposure, missing = 0.0, []
for key, test, w, rec in HARDEN:
v = (last(unit, "Service", key) or "")
vl = v.lower()
if key == "User":
ok = not root
elif test == "bool":
ok = vl in TRUE
elif test == "strict":
ok = vl == "strict"
if vl in ("full", "yes", "true"):
exposure += w * 0.5
missing.append((key, "ProtectSystem=strict|currently %s; add ReadWritePaths=/StateDirectory= for paths it writes" % v))
continue
elif test == "home":
ok = vl in TRUE or vl in ("read-only", "tmpfs")
elif test == "ns":
ok = vl in TRUE or (v != "" and vl not in ("no", "false", "0", "off"))
elif test == "caps":
ok = key in svc and all(c.upper() not in DANGER_CAPS or c.startswith("~") for c in words(unit, "Service", key))
elif test == "set":
ok = bool(words(unit, "Service", key))
elif test == "native":
ok = vl == "native"
elif test == "umask":
ok = v in ("0077", "077", "0027", "027")
else:
ok = bool(v)
if not ok:
exposure += w
missing.append((key, rec))
exposure = round(min(exposure, 10.0), 1)
if missing:
add("medium" if exposure >= 5 else "low", "SD06", f0, "%d sandboxing option(s) missing: %s" % (len(missing), ", ".join(k for k, _ in missing)),
"add the suggested drop-in (systemctl edit <name>), restart, and test the service")
F.sort(key=lambda x: (SEV[x[0]], x[1], x[2]))
rating = "OK" if exposure < 2 else "MEDIUM" if exposure < 5 else "EXPOSED" if exposure < 8 else "UNSAFE"
return {"files": unit["files"], "name": last(unit, "Unit", "Description", "") or unit["files"][0], "type": typ,
"user": "DynamicUser" if dyn else (user or "root"), "restart": restart, "exposure": exposure, "rating": rating,
"missing": missing, "findings": F}
def dropin(r):
lines = ["# /etc/systemd/system/<name>.service.d/hardening.conf (systemctl edit <name>)", "[Service]"]
for k, rec in r["missing"]:
line, _, note = rec.partition("|")
if note:
lines.append("# " + note)
lines.append(line)
if any(f[1] == "SD04" and "Restart=no" in f[3] for f in r["findings"]):
lines += ["Restart=on-failure", "RestartSec=5s"]
lines += ["# then: systemctl daemon-reload && systemctl restart <name> && systemd-analyze security <name>"]
return "\n".join(lines)
def counts(F):
c = {s: 0 for s in SEV}
for f in F:
c[f[0]] += 1
return c
def report(results):
out = ["# systemd unit review", ""]
out.append("| Unit | Description | Type | Runs as | Restart | Exposure (0-10) | High | Medium | Low | Info |")
out.append("|---|---|---|---|---|---|---|---|---|---|")
for r in results:
c = counts(r["findings"])
out.append("| `%s` | %s | %s | %s | %s | %.1f %s | %d | %d | %d | %d |" % (" + ".join(r["files"]), r["name"], r["type"], r["user"], r["restart"],
r["exposure"], r["rating"], c["high"], c["medium"], c["low"], c["info"]))
for r in results:
c = counts(r["findings"])
out += ["", "## %s" % " + ".join(r["files"]), ""]
if c["high"]:
v = "%d high finding(s) - fix before deploying" % c["high"]
elif r["exposure"] >= 5:
v = "works, but exposure %.1f - add the hardening drop-in" % r["exposure"]
else:
v = "looks good (exposure %.1f)" % r["exposure"]
out.append("**Verdict: %s**" % v)
out += ["", "| Sev | Code | Where | Finding | Fix |", "|---|---|---|---|---|"]
for s, code, loc, msg, fix in r["findings"]:
out.append("| %s | %s | %s | %s | %s |" % (s, code, loc, msg.replace("|", "\\|"), fix.replace("|", "\\|")))
if r["missing"]:
out += ["", "Suggested drop-in (test after applying; relax an option if the service needs it):", "", "```ini", dropin(r), "```"]
out += ["", "Exposure is this script's weighted estimate, not the systemd-analyze number; run `systemd-analyze security <unit>` and `systemd-analyze verify <file>` on the target host to confirm."]
return "\n".join(out) + "\n"
def run_files(paths):
unit = None
for p in paths:
try:
with open(p, encoding="utf-8") as fh:
txt = fh.read()
except OSError as e:
raise InputError("cannot read %s: %s" % (p, e))
unit = parse(txt, p, unit)
return review(unit)
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def R(txt, *drop):
u = parse(txt, "a.service")
for i, d in enumerate(drop):
u = parse(d, "d%d.conf" % i, u)
return review(u)
def codes(txt, *drop):
return [(f[0], f[1]) for f in R(txt, *drop)["findings"]]
base = "[Unit]\nDescription=x\n[Service]\nExecStart=/usr/bin/app\n[Install]\nWantedBy=multi-user.target\n"
good = ("[Unit]\nDescription=good\nWants=network-online.target\nAfter=network-online.target\n[Service]\nType=exec\nExecStart=/usr/bin/app\n"
"DynamicUser=yes\nRestart=on-failure\nRestartSec=5s\nNoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=yes\nPrivateTmp=yes\n"
"PrivateDevices=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\n"
"ProtectClock=yes\nProtectHostname=yes\nRestrictSUIDSGID=yes\nRestrictNamespaces=yes\nRestrictRealtime=yes\nLockPersonality=yes\n"
"MemoryDenyWriteExecute=yes\nCapabilityBoundingSet=\nSystemCallFilter=@system-service\nSystemCallArchitectures=native\n"
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\nUMask=0077\nMemoryMax=512M\nTasksMax=64\n[Install]\nWantedBy=multi-user.target\n")
g = R(good)
t("fully hardened unit: exposure 0, no high/medium", g["exposure"] == 0 and not [f for f in g["findings"] if f[0] in ("high", "medium")])
t("root by default -> SD01 high", ("high", "SD01") in codes(base))
t("root oneshot -> SD01 medium", ("medium", "SD01") in codes(base.replace("[Service]\n", "[Service]\nType=oneshot\n")))
t("User=svc clears SD01", "SD01" not in [c for _, c in codes(base.replace("[Service]\n", "[Service]\nUser=svc\n"))])
t("missing ExecStart -> SD02 high", ("high", "SD02") in codes("[Service]\nUser=a\n[Install]\nWantedBy=multi-user.target\n"))
t("relative command -> SD02 low", ("low", "SD02") in codes(base.replace("/usr/bin/app", "app")))
t("two ExecStart with simple -> SD02 high", ("high", "SD02") in codes(base.replace("ExecStart=/usr/bin/app", "ExecStart=/a\nExecStart=/b")))
t("empty ExecStart= in drop-in resets", ("high", "SD02") not in codes(base, "[Service]\nExecStart=\nExecStart=/usr/bin/other\n"))
t("pipe in ExecStart -> SD10 high", ("high", "SD10") in codes(base.replace("/usr/bin/app", "/usr/bin/app | logger")))
t("redirect >> -> SD10 high", ("high", "SD10") in codes(base.replace("/usr/bin/app", "/usr/bin/app >> /var/log/app.log")))
t("sh -c is allowed", ("high", "SD10") not in codes(base.replace("/usr/bin/app", "/bin/sh -c '/usr/bin/app | logger'")))
t("--daemon with simple -> SD10 medium", ("medium", "SD10") in codes(base.replace("/usr/bin/app", "/usr/bin/app --daemon")))
t("secret in Environment -> SD03 high", ("high", "SD03") in codes(base.replace("[Service]\n", "[Service]\nEnvironment=DB_PASSWORD=hunter2\n")))
t("quoted env with URL password -> SD03", ("high", "SD03") in codes(base.replace("[Service]\n", '[Service]\nEnvironment="DATABASE_URL=postgres://u:pw@db/x" "PORT=80"\n')))
t("non-secret env is fine", "SD03" not in [c for _, c in codes(base.replace("[Service]\n", "[Service]\nEnvironment=PORT=8080 LOG_LEVEL=info\n"))])
t("--password on cmdline -> SD03", ("high", "SD03") in codes(base.replace("/usr/bin/app", "/usr/bin/app --password=s3cret")))
t("Restart missing -> SD04 medium", ("medium", "SD04") in codes(base))
t("Restart=always no RestartSec -> SD04 low", ("low", "SD04") in codes(base.replace("[Service]\n", "[Service]\nRestart=always\n")))
t("oneshot + Restart=always -> SD04 high", ("high", "SD04") in codes(base.replace("[Service]\n", "[Service]\nType=oneshot\nRestart=always\n")))
t("After online without Wants -> SD05", ("medium", "SD05") in codes(base.replace("[Unit]\n", "[Unit]\nAfter=network-online.target\n")))
t("Wants online without After -> SD05", ("medium", "SD05") in codes(base.replace("[Unit]\n", "[Unit]\nWants=network-online.target\n")))
t("syslog.target obsolete -> SD05 low", ("low", "SD05") in codes(base.replace("[Unit]\n", "[Unit]\nAfter=syslog.target\n")))
t("typo directive -> SD08 with suggestion", any("did you mean RestartSec" in f[3] for f in R(base.replace("[Service]\n", "[Service]\nRestartSecs=5\n"))["findings"]))
t("invalid Restart value -> SD08 high", ("high", "SD08") in codes(base.replace("[Service]\n", "[Service]\nRestart=on-faliure\n")))
t("invalid value ignored, earlier valid one stays effective", R(base.replace("[Service]\n", "[Service]\nRestart=always\n"), "[Service]\nRestart=on-faliure\n")["restart"] == "always")
t("drop-in lines are valid key=value", all("=" in l or l.startswith(("#", "[")) for l in dropin(R(base)).splitlines()))
t("bad boolean -> SD08 medium", ("medium", "SD08") in codes(base.replace("[Service]\n", "[Service]\nPrivateTmp=maybe\n")))
t("StartLimitBurst in [Service] -> SD09", ("medium", "SD09") in codes(base.replace("[Service]\n", "[Service]\nStartLimitBurst=3\n")))
t("WantedBy in [Service] -> SD09", ("medium", "SD09") in codes(base.replace("[Service]\n", "[Service]\nWantedBy=multi-user.target\n")))
t("no [Install] -> SD11 medium", ("medium", "SD11") in codes("[Service]\nExecStart=/usr/bin/a\n"))
t("WantedBy=default.target -> SD11 low", ("low", "SD11") in codes(base.replace("multi-user.target", "default.target")))
t("duplicate scalar -> SD13", ("low", "SD13") in codes(base.replace("[Service]\n", "[Service]\nUser=a\nUser=b\n")))
t("drop-in override is not a duplicate", ("low", "SD13") not in codes(base.replace("[Service]\n", "[Service]\nUser=a\n"), "[Service]\nUser=b\n"))
t("drop-in value wins", R(base.replace("[Service]\n", "[Service]\nUser=a\n"), "[Service]\nUser=b\n")["user"] == "b")
t("ProtectHome with /home app -> SD14 high", ("high", "SD14") in codes(base.replace("/usr/bin/app", "/home/deploy/app/run").replace("[Service]\n", "[Service]\nProtectHome=yes\n")))
t("MDWE with node -> SD14 high", ("high", "SD14") in codes(base.replace("/usr/bin/app", "/usr/bin/node server.js").replace("[Service]\n", "[Service]\nMemoryDenyWriteExecute=yes\n")))
t("ProtectSystem=strict write path -> SD14", ("medium", "SD14") in codes(base.replace("/usr/bin/app", "/usr/bin/app --log-file=/var/log/app/app.log").replace("[Service]\n", "[Service]\nProtectSystem=strict\n")))
t("LogsDirectory clears strict write", ("medium", "SD14") not in codes(base.replace("/usr/bin/app", "/usr/bin/app --log-file=/var/log/app/app.log").replace("[Service]\n", "[Service]\nProtectSystem=strict\nLogsDirectory=app\n")))
t("CAP_SYS_ADMIN -> SD07 high", ("high", "SD07") in codes(base.replace("[Service]\n", "[Service]\nAmbientCapabilities=CAP_SYS_ADMIN\n")))
t("+ prefix -> SD01 medium", ("medium", "SD01") in codes(base.replace("[Service]\n", "[Service]\nUser=a\nExecStartPre=+/usr/bin/setup\n")))
t("TimeoutStopSec=infinity -> SD12", ("medium", "SD12") in codes(base.replace("[Service]\n", "[Service]\nTimeoutStopSec=infinity\n")))
t("continuation lines joined", R("[Service]\nExecStart=/usr/bin/app \\\n --port 80\nUser=a\n[Install]\nWantedBy=multi-user.target\n")["findings"] is not None and
parse("[Service]\nExecStart=/usr/bin/app \\\n --port 80\n", "x")["sections"]["Service"]["ExecStart"][0][0] == "/usr/bin/app --port 80")
t("bare exposure of minimal unit is 10", R(base)["exposure"] == 10.0)
t("ProtectSystem=full counts half", abs(R(base.replace("[Service]\n", "[Service]\nProtectSystem=full\n"))["exposure"] - 9.5) < 0.01)
try:
R("[Unit]\nDescription=x\n")
t("no [Service] rejected", False)
except InputError:
t("no [Service] rejected", True)
t("report has verdict and table", "**Verdict:" in report([R(base)]) and "| Sev | Code |" in report([R(base)]))
for n, ok in tests:
print("%s %s" % ("PASS" if ok else "FAIL", n))
p = sum(ok for _, ok in tests)
print("%d/%d passed" % (p, len(tests)))
return 0 if p == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Review systemd .service units for reliability and hardening.")
ap.add_argument("units", nargs="*", help="UNIT.service, optionally followed by drop-in .conf files; use ',' between separate units")
ap.add_argument("--json", action="store_true")
ap.add_argument("--dropin", action="store_true", help="print only the suggested hardening drop-in(s)")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.units:
ap.error("give at least one .service file")
groups, cur = [], []
for u in a.units:
if u == ",":
groups.append(cur); cur = []
elif u.endswith(".service") and cur:
groups.append(cur); cur = [u]
else:
cur.append(u)
groups.append(cur)
try:
res = [run_files(g) for g in groups if g]
except InputError as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
print(json.dumps([{**r, "findings": [dict(zip(("severity", "code", "where", "finding", "fix"), f)) for f in r["findings"]]} for r in res], indent=2))
elif a.dropin:
print("\n\n".join(dropin(r) for r in res))
else:
sys.stdout.write(report(res))
return 1 if any(f[0] == "high" for r in res for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())