CIS_Benchmarks/nginx_audit/checks.py
2026-06-18 20:47:15 +03:00

1724 lines
75 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
CIS NGINX Benchmark v3.0.0 — all 44 check implementations.
Each public function matches the pattern:
def check_X_Y_Z(executor) -> CheckResult
The function runs one or more shell commands via *executor*, evaluates the
output, and returns a CheckResult with status PASS / FAIL / MANUAL / ERROR.
MANUAL is returned when the check requires human judgment (site-specific
expected values, ambiguous output, etc.). The command output is still
collected so the reviewer only has to read data, not re-run commands.
"""
from __future__ import annotations
import re
from typing import List, Optional
from .executor import LocalExecutor, SSHExecutor
from .models import CheckResult, PASS, FAIL, MANUAL, ERROR
# Type alias used through the module
Executor = LocalExecutor | SSHExecutor
# ── helpers ──────────────────────────────────────────────────────────────────
def _run(executor: Executor, cmd: str) -> tuple[str, int]:
return executor.run(cmd)
def _ok(check_id: str, title: str, profiles: List[str],
actual: str, expected: str, commands: List[str],
remediation: str, notes: str = "") -> CheckResult:
return CheckResult(check_id, title, profiles, PASS,
actual, expected, commands, remediation, notes)
def _fail(check_id: str, title: str, profiles: List[str],
actual: str, expected: str, commands: List[str],
remediation: str, notes: str = "") -> CheckResult:
return CheckResult(check_id, title, profiles, FAIL,
actual, expected, commands, remediation, notes)
def _manual(check_id: str, title: str, profiles: List[str],
actual: str, expected: str, commands: List[str],
remediation: str, notes: str = "") -> CheckResult:
return CheckResult(check_id, title, profiles, MANUAL,
actual, expected, commands, remediation, notes)
def _error(check_id: str, title: str, profiles: List[str],
msg: str, commands: List[str], remediation: str) -> CheckResult:
return CheckResult(check_id, title, profiles, ERROR,
msg, "N/A – check could not run", commands, remediation)
def _parse_timeout(value_str: str) -> Optional[int]:
"""Convert NGINX timeout strings like '10', '10s', '1m' to seconds."""
m = re.match(r"(\d+)\s*([smh]?)", value_str.strip().lower())
if not m:
return None
n, unit = int(m.group(1)), m.group(2)
return n * {"s": 1, "m": 60, "h": 3600, "": 1}[unit]
def _octal_to_int(s: str) -> Optional[int]:
try:
return int(s, 8)
except (ValueError, TypeError):
return None
# ── Section 1 — Initial Setup ────────────────────────────────────────────────
PROFILES_L1_ALL = [
"Level 1 - Webserver", "Level 1 - Proxy", "Level 1 - Loadbalancer"
]
REMEDIATIONS = {
"1.1.1": (
"Install or upgrade NGINX to version 1.28.0 or later from "
"https://nginx.org/en/linux_packages.html"
),
"1.2.1": (
"Configure package manager to point to the official NGINX repository "
"or your OS vendor's trusted repository."
),
"1.2.2": (
"Run the OS package manager update command (e.g. `dnf update nginx` "
"or `apt upgrade nginx`) to install the latest available version."
),
"2.1.1": (
"Remove unused dynamic modules from the NGINX configuration by deleting "
"or commenting out the relevant `load_module` directives."
),
"2.2.1": (
"Add `user nginx;` (or another dedicated non-root account) to nginx.conf "
"and ensure the account is not root."
),
"2.2.2": (
"Lock the NGINX service account: `passwd -l nginx` (RHEL) or "
"`usermod -L nginx` (Debian/Ubuntu)."
),
"2.2.3": (
"Set the NGINX service account shell to an invalid login shell: "
"`usermod -s /sbin/nologin nginx`."
),
"2.3.1": (
"Change ownership of all NGINX config files and directories to root: "
"`chown -R root:root /etc/nginx`."
),
"2.3.2": (
"Restrict permissions: directories to 750 or less, files to 640 or less. "
"Example: `find /etc/nginx -type d -exec chmod 750 {} +; "
"find /etc/nginx -type f -exec chmod 640 {} +`."
),
"2.3.3": (
"Set PID file ownership and permissions: "
"`chown root:root /run/nginx.pid && chmod 644 /run/nginx.pid`."
),
"2.4.1": (
"Review all `listen` directives and remove any that expose unauthorized ports."
),
"2.4.2": (
"Add a default_server block that rejects unknown host names:\n"
"server {\n listen 443 ssl default_server;\n ssl_reject_handshake on;\n}"
),
"2.4.3": (
"Add `keepalive_timeout 10;` to the http block in nginx.conf."
),
"2.4.4": (
"Add `send_timeout 10;` to the http or server block in nginx.conf."
),
"2.5.1": (
"Add `server_tokens off;` to the http block in nginx.conf."
),
"2.5.2": (
"Create custom error pages that do not contain any NGINX branding, "
"and reference them with `error_page 404 /custom_404.html;`."
),
"2.5.3": (
"Add a location block to deny access to hidden files:\n"
"location ~ /\\. { deny all; return 404; }"
),
"2.5.4": (
"Add `proxy_hide_header Server; proxy_hide_header X-Powered-By;` "
"to the server or location block."
),
"3.1": (
"Define a detailed log format and reference it in access_log:\n"
'log_format main \'$remote_addr - $remote_user [$time_local] "$request" ...\''
),
"3.2": (
"Ensure access_log is enabled and points to a valid log file:\n"
"`access_log /var/log/nginx/access.log main;`"
),
"3.3": (
"Set `error_log /var/log/nginx/error.log info;` in nginx.conf."
),
"3.4": (
"Add to your proxy location block:\n"
"proxy_set_header X-Real-IP $remote_addr;\n"
"proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
),
"4.1.1": (
"Add an HTTP server block that redirects to HTTPS:\n"
"server { listen 80; return 301 https://$host$request_uri; }"
),
"4.1.2": (
"Install a certificate from a trusted CA and set ssl_certificate / "
"ssl_certificate_key paths in your server block."
),
"4.1.3": (
"Restrict private key permissions: "
"`find /etc/nginx -name '*.key' -exec chmod 400 {} +`."
),
"4.1.4": (
"Set `ssl_protocols TLSv1.2 TLSv1.3;` and remove any older protocols."
),
"4.1.5": (
"Set a strong cipher suite, e.g.:\n"
"ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...;\n"
"ssl_prefer_server_ciphers on;"
),
"4.1.6": (
"Generate a strong DH parameter file:\n"
"`openssl dhparam -out /etc/nginx/dhparam.pem 4096`\n"
"Then add `ssl_dhparam /etc/nginx/dhparam.pem;` to nginx.conf."
),
"4.1.7": (
"Enable OCSP stapling:\n"
"ssl_stapling on;\nssl_stapling_verify on;\n"
"ssl_trusted_certificate /path/to/chain.pem;\nresolver 8.8.8.8 valid=300s;"
),
"4.1.8": (
"Add HSTS header:\n"
'add_header Strict-Transport-Security "max-age=63072000; '
'includeSubDomains; preload" always;'
),
"4.1.9": (
"Add client certificate for upstream:\n"
"proxy_ssl_certificate /etc/nginx/client.crt;\n"
"proxy_ssl_certificate_key /etc/nginx/client.key;"
),
"4.1.10": (
"Enable upstream certificate verification:\n"
"proxy_ssl_verify on;\n"
"proxy_ssl_trusted_certificate /etc/nginx/trusted_ca.crt;"
),
"4.1.11": (
"Configure session resumption:\n"
"ssl_session_cache shared:SSL:10m;\n"
"ssl_session_timeout 1d;\n"
"ssl_session_tickets off;"
),
"4.1.12": (
"Enable HTTP/3 with QUIC:\n"
"listen 443 quic reuseport;\n"
'add_header Alt-Svc \'h3=":443"; ma=86400\';'
),
"5.1.1": (
"Add allow/deny rules inside your server or location blocks to restrict "
"access to trusted IP ranges. Deny all other traffic by default."
),
"5.1.2": (
"Restrict allowed HTTP methods using limit_except or a request_method check:\n"
"limit_except GET HEAD POST { deny all; }"
),
"5.2.1": (
"Set client timeouts in the http block:\n"
"client_header_timeout 10;\nclient_body_timeout 10;"
),
"5.2.2": (
"Set a maximum request body size in the http block:\n"
"client_max_body_size 1m;"
),
"5.2.3": (
"Set large_client_header_buffers in the http block:\n"
"large_client_header_buffers 2 1k;"
),
"5.2.4": (
"Add connection limiting to the http block:\n"
"limit_conn_zone $binary_remote_addr zone=addr:10m;\n"
"limit_conn addr 10;"
),
"5.2.5": (
"Add rate limiting to the http block:\n"
"limit_req_zone $binary_remote_addr zone=req:10m rate=10r/s;\n"
"limit_req zone=req burst=20 nodelay;"
),
"5.3.1": (
'Add `add_header X-Content-Type-Options "nosniff" always;` '
"to the server block."
),
"5.3.2": (
"Add a Content-Security-Policy header appropriate to your application:\n"
'add_header Content-Security-Policy "default-src \'self\';" always;'
),
"5.3.3": (
"Add a Referrer-Policy header:\n"
'add_header Referrer-Policy "strict-origin-when-cross-origin" always;'
),
}
# ── 1.1.1 ────────────────────────────────────────────────────────────────────
def check_1_1_1(executor: Executor) -> CheckResult:
cid, title = "1.1.1", "Ensure NGINX is installed"
profiles = PROFILES_L1_ALL
cmd = "nginx -V 2>&1"
out, _ = _run(executor, cmd)
# Extract version string
m = re.search(r"nginx/(\d+)\.(\d+)\.(\d+)", out)
if not m:
return _fail(cid, title, profiles,
actual="NGINX not found or version unreadable",
expected="nginx/1.28.0 or later installed",
commands=[cmd],
remediation=REMEDIATIONS[cid])
major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3))
version_str = f"nginx/{m.group(1)}.{m.group(2)}.{m.group(3)}"
min_version = (1, 28, 0)
if (major, minor, patch) >= min_version:
return _ok(cid, title, profiles,
actual=version_str,
expected="nginx/1.28.0 or later",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=version_str,
expected="nginx/1.28.0 or later",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 1.2.1 ────────────────────────────────────────────────────────────────────
def check_1_2_1(executor: Executor) -> CheckResult:
cid, title = "1.2.1", "Ensure package manager repositories are properly configured"
profiles = PROFILES_L1_ALL
# OS-agnostic: try both RPM and DEB
cmds = [
"dnf repolist -v 2>/dev/null | grep -i nginx || true",
"apt-cache policy nginx 2>/dev/null || true",
]
outputs = []
for cmd in cmds:
out, _ = _run(executor, cmd)
if out:
outputs.append(out)
combined = "\n".join(outputs).strip() or "(no output)"
return _manual(cid, title, profiles,
actual=combined,
expected="Repository URL points to nginx.org or OS vendor's trusted domain; repo is enabled",
commands=cmds,
remediation=REMEDIATIONS[cid],
notes="Verify the repo URL is trusted and the repo is enabled.")
# ── 1.2.2 ────────────────────────────────────────────────────────────────────
def check_1_2_2(executor: Executor) -> CheckResult:
cid, title = "1.2.2", "Ensure the latest software package is installed"
profiles = PROFILES_L1_ALL
cmds = [
"dnf info nginx 2>/dev/null | grep -E '(Version|Available)' || true",
"apt-cache policy nginx 2>/dev/null | grep -E '(Installed|Candidate)' || true",
]
outputs = []
for cmd in cmds:
out, _ = _run(executor, cmd)
if out:
outputs.append(out)
combined = "\n".join(outputs).strip() or "(no output)"
return _manual(cid, title, profiles,
actual=combined,
expected="Installed version matches the latest available version",
commands=cmds,
remediation=REMEDIATIONS[cid],
notes="Compare Installed vs Candidate/Available to confirm up-to-date.")
# ── 2.1.1 ────────────────────────────────────────────────────────────────────
def check_2_1_1(executor: Executor) -> CheckResult:
cid, title = "2.1.1", "Ensure only required dynamic modules are loaded"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep 'load_module'"
out, _ = _run(executor, cmd)
if not out.strip():
return _ok(cid, title, profiles,
actual="No dynamic modules loaded",
expected="Only required dynamic modules loaded (none is acceptable)",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _manual(cid, title, profiles,
actual=out,
expected="Each listed module must be required for the application",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes="Review each load_module line and remove any that are not needed.")
# ── 2.2.1 ────────────────────────────────────────────────────────────────────
def check_2_2_1(executor: Executor) -> CheckResult:
cid, title = "2.2.1", "Ensure NGINX is run using a non-privileged, dedicated service account"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i '^user'"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="No 'user' directive found – NGINX may run as the launching user or nobody",
expected="user <non-root dedicated account>;",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# Extract user name from "user nginx;" or "user www-data nginx;"
m = re.match(r"user\s+(\S+)", out.strip())
nginx_user = m.group(1).rstrip(";") if m else out.strip()
if nginx_user.lower() in ("root", "0"):
return _fail(cid, title, profiles,
actual=f"user directive: {out.strip()} → running as root",
expected="Non-root dedicated service account",
commands=[cmd],
remediation=REMEDIATIONS[cid])
return _ok(cid, title, profiles,
actual=f"user directive: {out.strip()}",
expected="Non-root dedicated service account",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 2.2.2 ────────────────────────────────────────────────────────────────────
def check_2_2_2(executor: Executor) -> CheckResult:
cid, title = "2.2.2", "Ensure the NGINX service account is locked"
profiles = PROFILES_L1_ALL
cmd_user = "nginx -T 2>/dev/null | grep -i '^user'"
out_user, _ = _run(executor, cmd_user)
m = re.match(r"user\s+(\S+)", out_user.strip())
nginx_user = m.group(1).rstrip(";") if m else "nginx"
cmd_lock = f"passwd -S {nginx_user} 2>&1"
out_lock, _ = _run(executor, cmd_lock)
commands = [cmd_user, cmd_lock]
if not out_lock.strip() or "unknown" in out_lock.lower():
return _error(cid, title, profiles,
msg=f"Could not determine lock status for '{nginx_user}': {out_lock}",
commands=commands, remediation=REMEDIATIONS[cid])
# RHEL shows "LK" or "L"; Debian/Ubuntu shows "L" field in a status line
locked = bool(re.search(r"\bL\b|\bLK\b", out_lock))
status_label = f"passwd -S {nginx_user}: {out_lock}"
if locked:
return _ok(cid, title, profiles,
actual=status_label,
expected=f"Account '{nginx_user}' is locked (L or LK in passwd -S output)",
commands=commands,
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=status_label,
expected=f"Account '{nginx_user}' is locked (L or LK in passwd -S output)",
commands=commands,
remediation=REMEDIATIONS[cid])
# ── 2.2.3 ────────────────────────────────────────────────────────────────────
def check_2_2_3(executor: Executor) -> CheckResult:
cid, title = "2.2.3", "Ensure the NGINX service account has an invalid shell"
profiles = PROFILES_L1_ALL
cmd_user = "nginx -T 2>/dev/null | grep -i '^user'"
out_user, _ = _run(executor, cmd_user)
m = re.match(r"user\s+(\S+)", out_user.strip())
nginx_user = m.group(1).rstrip(";") if m else "nginx"
cmd_shell = f"getent passwd {nginx_user} 2>&1"
out_shell, _ = _run(executor, cmd_shell)
commands = [cmd_user, cmd_shell]
if not out_shell.strip():
return _error(cid, title, profiles,
msg=f"User '{nginx_user}' not found in passwd database.",
commands=commands, remediation=REMEDIATIONS[cid])
# passwd entry: name:x:uid:gid:comment:home:shell
parts = out_shell.strip().split(":")
shell = parts[-1] if parts else ""
invalid_shells = ("/sbin/nologin", "/bin/false", "/usr/sbin/nologin",
"/dev/null", "/bin/nologin")
if shell in invalid_shells:
return _ok(cid, title, profiles,
actual=f"Shell for '{nginx_user}': {shell}",
expected="Non-login shell (/sbin/nologin, /bin/false, etc.)",
commands=commands,
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=f"Shell for '{nginx_user}': {shell}",
expected="Non-login shell (/sbin/nologin, /bin/false, etc.)",
commands=commands,
remediation=REMEDIATIONS[cid])
# ── 2.3.1 ────────────────────────────────────────────────────────────────────
def check_2_3_1(executor: Executor) -> CheckResult:
cid, title = "2.3.1", "Ensure NGINX directories and files are owned by root"
profiles = PROFILES_L1_ALL
cmd = (
r"find /etc/nginx -name '*' \( -not -user root -o -not -group root \)"
r" -exec ls -ld {} \; 2>/dev/null"
)
out, _ = _run(executor, cmd)
if not out.strip():
return _ok(cid, title, profiles,
actual="All files and directories in /etc/nginx are owned by root:root",
expected="All files owned by root:root",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=f"Files/dirs NOT owned by root:root:\n{out}",
expected="All files owned by root:root",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 2.3.2 ────────────────────────────────────────────────────────────────────
def check_2_3_2(executor: Executor) -> CheckResult:
cid, title = "2.3.2", "Ensure access to NGINX directories and files is restricted"
profiles = PROFILES_L1_ALL
cmd_dirs = r'find /etc/nginx -type d -exec stat -Lc "%n %a" {} + 2>/dev/null'
cmd_files = r'find /etc/nginx -type f -exec stat -Lc "%n %a" {} + 2>/dev/null'
out_dirs, _ = _run(executor, cmd_dirs)
out_files, _ = _run(executor, cmd_files)
commands = [cmd_dirs, cmd_files]
bad = []
# Directories: max 750 (rwxr-x---)
for line in out_dirs.splitlines():
parts = line.rsplit(" ", 1)
if len(parts) == 2:
perm = _octal_to_int(parts[1])
if perm is not None and perm > 0o750:
bad.append(f"DIR perm={parts[1]}: {parts[0]}")
# Files: max 640 (rw-r-----)
for line in out_files.splitlines():
parts = line.rsplit(" ", 1)
if len(parts) == 2:
perm = _octal_to_int(parts[1])
if perm is not None and perm > 0o640:
bad.append(f"FILE perm={parts[1]}: {parts[0]}")
if not bad:
return _ok(cid, title, profiles,
actual="All directories ≤ 750, all files ≤ 640",
expected="Directories max 750, files max 640",
commands=commands,
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual="Over-permissive entries found:\n" + "\n".join(bad),
expected="Directories max 750, files max 640",
commands=commands,
remediation=REMEDIATIONS[cid])
# ── 2.3.3 ────────────────────────────────────────────────────────────────────
def check_2_3_3(executor: Executor) -> CheckResult:
cid, title = "2.3.3", "Ensure the NGINX process ID (PID) file is secured"
profiles = PROFILES_L1_ALL
cmds = [
r'stat -Lc "%U:%G %a" /run/nginx.pid 2>/dev/null',
r'stat -Lc "%U:%G %a" /var/run/nginx.pid 2>/dev/null',
]
out = ""
for cmd in cmds:
out, _ = _run(executor, cmd)
if out.strip():
break
if not out.strip():
# PID file may not exist if NGINX is not running – mark as manual
return _manual(cid, title, profiles,
actual="PID file not found (NGINX may not be running)",
expected="root:root with permissions 644 or stricter",
commands=cmds,
remediation=REMEDIATIONS[cid],
notes="Start NGINX and re-run this check.")
# Expected: owner root:root, perms 644 or stricter (<=644)
m = re.match(r"(\S+:\S+)\s+(\d+)", out.strip())
if not m:
return _error(cid, title, profiles,
msg=f"Could not parse stat output: {out}",
commands=cmds, remediation=REMEDIATIONS[cid])
owner, perm_str = m.group(1), m.group(2)
perm = _octal_to_int(perm_str)
issues = []
if owner != "root:root":
issues.append(f"owner is {owner}, expected root:root")
if perm is not None and perm > 0o644:
issues.append(f"permissions {perm_str} are too permissive (max 644)")
actual = f"owner={owner}, permissions={perm_str}"
if issues:
return _fail(cid, title, profiles,
actual=actual,
expected="root:root with permissions 644 or stricter",
commands=cmds,
remediation=REMEDIATIONS[cid])
else:
return _ok(cid, title, profiles,
actual=actual,
expected="root:root with permissions 644 or stricter",
commands=cmds,
remediation=REMEDIATIONS[cid])
# ── 2.4.1 ────────────────────────────────────────────────────────────────────
def check_2_4_1(executor: Executor) -> CheckResult:
cid, title = "2.4.1", "Ensure NGINX only listens for network connections on authorized ports"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -E '\\blisten\\b'"
out, _ = _run(executor, cmd)
return _manual(cid, title, profiles,
actual=out.strip() or "(no listen directives found)",
expected="Only authorized ports (typically 80, 443) should be listed",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes="Review each listen directive and confirm all ports are authorized.")
# ── 2.4.2 ────────────────────────────────────────────────────────────────────
def check_2_4_2(executor: Executor) -> CheckResult:
cid, title = "2.4.2", "Ensure requests for unknown host names are rejected"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -Ei 'listen.*default_server|ssl_reject_handshake'"
out, _ = _run(executor, cmd)
has_default = "default_server" in out.lower()
has_reject = "ssl_reject_handshake" in out.lower()
if has_default or has_reject:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="A default_server block with ssl_reject_handshake on is present",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=out.strip() or "(neither default_server nor ssl_reject_handshake found)",
expected="A default_server block with ssl_reject_handshake on is present",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 2.4.3 ────────────────────────────────────────────────────────────────────
def check_2_4_3(executor: Executor) -> CheckResult:
cid, title = "2.4.3", "Ensure keepalive_timeout is 10 seconds or less, but not 0"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i keepalive_timeout"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="keepalive_timeout not set (default is 75s)",
expected="keepalive_timeout between 1 and 10 (seconds)",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# Find value – take last occurrence (most specific context wins in NGINX)
values = re.findall(r"keepalive_timeout\s+(\S+)", out)
if not values:
return _error(cid, title, profiles,
msg=f"Could not parse keepalive_timeout value from: {out}",
commands=[cmd], remediation=REMEDIATIONS[cid])
raw = values[-1].rstrip(";")
seconds = _parse_timeout(raw)
if seconds is None:
return _error(cid, title, profiles,
msg=f"Unrecognized timeout format: {raw}",
commands=[cmd], remediation=REMEDIATIONS[cid])
if 1 <= seconds <= 10:
return _ok(cid, title, profiles,
actual=f"keepalive_timeout = {raw} ({seconds}s)",
expected="1 to 10 seconds",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=f"keepalive_timeout = {raw} ({seconds}s)",
expected="1 to 10 seconds",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 2.4.4 ────────────────────────────────────────────────────────────────────
def check_2_4_4(executor: Executor) -> CheckResult:
cid, title = "2.4.4", "Ensure send_timeout is set to 10 seconds or less, but not 0"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i send_timeout"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="send_timeout not set (default is 60s)",
expected="send_timeout between 1 and 10 seconds",
commands=[cmd],
remediation=REMEDIATIONS[cid])
values = re.findall(r"send_timeout\s+(\S+)", out)
if not values:
return _error(cid, title, profiles,
msg=f"Could not parse send_timeout value from: {out}",
commands=[cmd], remediation=REMEDIATIONS[cid])
raw = values[-1].rstrip(";")
seconds = _parse_timeout(raw)
if seconds is None:
return _error(cid, title, profiles,
msg=f"Unrecognized timeout format: {raw}",
commands=[cmd], remediation=REMEDIATIONS[cid])
if 1 <= seconds <= 10:
return _ok(cid, title, profiles,
actual=f"send_timeout = {raw} ({seconds}s)",
expected="1 to 10 seconds",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=f"send_timeout = {raw} ({seconds}s)",
expected="1 to 10 seconds",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 2.5.1 ────────────────────────────────────────────────────────────────────
def check_2_5_1(executor: Executor) -> CheckResult:
cid, title = "2.5.1", "Ensure server_tokens directive is set to `off`"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i server_tokens"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="server_tokens not set (default is 'on')",
expected="server_tokens off;",
commands=[cmd],
remediation=REMEDIATIONS[cid])
if re.search(r"server_tokens\s+off", out, re.IGNORECASE):
return _ok(cid, title, profiles,
actual=out.strip(),
expected="server_tokens off;",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=out.strip(),
expected="server_tokens off;",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 2.5.2 ────────────────────────────────────────────────────────────────────
def check_2_5_2(executor: Executor) -> CheckResult:
cid, title = "2.5.2", "Ensure default error and index.html pages do not reference NGINX"
profiles = PROFILES_L1_ALL
cmd_pages = "nginx -T 2>/dev/null | grep -i error_page"
cmd_curl = "curl -sk http://127.0.0.1/non-existent-page 2>&1 | grep -i nginx | head -5"
out_pages, _ = _run(executor, cmd_pages)
out_curl, _ = _run(executor, cmd_curl)
commands = [cmd_pages, cmd_curl]
if out_curl.strip():
return _fail(cid, title, profiles,
actual=f"NGINX branding found in error page response:\n{out_curl}",
expected="Error pages contain no NGINX version information",
commands=commands,
remediation=REMEDIATIONS[cid])
else:
return _ok(cid, title, profiles,
actual="No NGINX branding detected in 404 error response",
expected="Error pages contain no NGINX version information",
commands=commands,
remediation=REMEDIATIONS[cid])
# ── 2.5.3 ────────────────────────────────────────────────────────────────────
def check_2_5_3(executor: Executor) -> CheckResult:
cid, title = "2.5.3", "Ensure hidden file serving is disabled"
profiles = PROFILES_L1_ALL
cmd = r"nginx -T 2>/dev/null | grep -A2 'location.*\\\\.'"
out, _ = _run(executor, cmd)
if re.search(r"deny\s+all", out, re.IGNORECASE):
return _ok(cid, title, profiles,
actual=out.strip(),
expected="location ~/\\. block with 'deny all' present",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=out.strip() or "(no hidden-file location block found)",
expected="location ~/\\. block with 'deny all' present",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 2.5.4 ────────────────────────────────────────────────────────────────────
PROFILES_PROXY_LB_L1 = ["Level 1 - Proxy", "Level 1 - Loadbalancer"]
def check_2_5_4(executor: Executor) -> CheckResult:
cid, title = "2.5.4", "Ensure the NGINX reverse proxy does not enable information disclosure"
profiles = PROFILES_PROXY_LB_L1
cmd = "nginx -T 2>/dev/null | grep -Ei '(proxy|fastcgi)_hide_header'"
out, _ = _run(executor, cmd)
hides_server = bool(re.search(r"hide_header\s+Server", out, re.IGNORECASE))
hides_xpb = bool(re.search(r"hide_header\s+X-Powered-By", out, re.IGNORECASE))
if hides_server and hides_xpb:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="proxy_hide_header Server and proxy_hide_header X-Powered-By both present",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
missing = []
if not hides_server:
missing.append("proxy_hide_header Server")
if not hides_xpb:
missing.append("proxy_hide_header X-Powered-By")
return _fail(cid, title, profiles,
actual=out.strip() or "(no hide_header directives found)",
expected="proxy_hide_header Server and proxy_hide_header X-Powered-By both present",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Missing: {', '.join(missing)}")
# ── 3.1 ──────────────────────────────────────────────────────────────────────
def check_3_1(executor: Executor) -> CheckResult:
cid, title = "3.1", "Ensure detailed logging is enabled"
profiles = PROFILES_L1_ALL
cmd_fmt = "nginx -T 2>/dev/null | grep -i log_format"
cmd_acc = "nginx -T 2>/dev/null | grep -i access_log"
out_fmt, _ = _run(executor, cmd_fmt)
out_acc, _ = _run(executor, cmd_acc)
commands = [cmd_fmt, cmd_acc]
has_format = bool(out_fmt.strip())
has_access = bool(out_acc.strip())
if has_format and has_access:
return _ok(cid, title, profiles,
actual=f"log_format defined: YES\naccess_log directive: {out_acc.strip()[:200]}",
expected="log_format defined and referenced in access_log",
commands=commands,
remediation=REMEDIATIONS[cid])
else:
missing = []
if not has_format:
missing.append("log_format not defined")
if not has_access:
missing.append("access_log not configured")
return _fail(cid, title, profiles,
actual="\n".join(missing),
expected="log_format defined and referenced in access_log",
commands=commands,
remediation=REMEDIATIONS[cid])
# ── 3.2 ──────────────────────────────────────────────────────────────────────
def check_3_2(executor: Executor) -> CheckResult:
cid, title = "3.2", "Ensure access logging is enabled"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i access_log"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="access_log directive not found",
expected="access_log enabled (not 'off')",
commands=[cmd],
remediation=REMEDIATIONS[cid])
if re.search(r"access_log\s+off", out, re.IGNORECASE):
return _fail(cid, title, profiles,
actual=out.strip(),
expected="access_log enabled (not 'off')",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="access_log enabled (not 'off')",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 3.3 ──────────────────────────────────────────────────────────────────────
def check_3_3(executor: Executor) -> CheckResult:
cid, title = "3.3", "Ensure error logging is enabled and set to the info logging level"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i error_log"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="error_log directive not found",
expected="error_log at 'info' level or more verbose",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# Log levels from most verbose to least: debug, info, notice, warn, error, crit, alert, emerg
verbose_levels = {"debug", "info", "notice", "warn"}
m = re.search(r"error_log\s+\S+\s+(\w+)", out)
if m:
level = m.group(1).lower()
if level in verbose_levels:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="error_log at 'info' level or more verbose",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=out.strip(),
expected="error_log at 'info' level or more verbose",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
# error_log with no level specified defaults to 'error' – not sufficient
return _fail(cid, title, profiles,
actual=out.strip() + " (no log level specified – defaults to 'error')",
expected="error_log at 'info' level or more verbose",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 3.4 ──────────────────────────────────────────────────────────────────────
def check_3_4(executor: Executor) -> CheckResult:
cid, title = "3.4", "Ensure proxies pass source IP information"
profiles = PROFILES_PROXY_LB_L1
cmd = "nginx -T 2>/dev/null | grep -E 'proxy_set_header.*(X-Real-IP|X-Forwarded-For)'"
out, _ = _run(executor, cmd)
has_real_ip = bool(re.search(r"X-Real-IP", out, re.IGNORECASE))
has_forwarded = bool(re.search(r"X-Forwarded-For", out, re.IGNORECASE))
if has_real_ip and has_forwarded:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="proxy_set_header X-Real-IP and X-Forwarded-For both set",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
missing = []
if not has_real_ip:
missing.append("X-Real-IP")
if not has_forwarded:
missing.append("X-Forwarded-For")
return _fail(cid, title, profiles,
actual=out.strip() or "(no proxy_set_header for IP headers found)",
expected="proxy_set_header X-Real-IP and X-Forwarded-For both set",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Missing headers: {', '.join(missing)}")
# ── 4.1.1 ────────────────────────────────────────────────────────────────────
def check_4_1_1(executor: Executor) -> CheckResult:
cid, title = "4.1.1", "Ensure HTTP is redirected to HTTPS"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -A10 'listen 80'"
out, _ = _run(executor, cmd)
if re.search(r"return\s+30[12]\s+https", out, re.IGNORECASE):
return _ok(cid, title, profiles,
actual=out.strip()[:400],
expected="HTTP server block contains 'return 301/302 https://' redirect",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=out.strip()[:400] or "(no listen 80 block or redirect found)",
expected="HTTP server block contains 'return 301/302 https://' redirect",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 4.1.2 ────────────────────────────────────────────────────────────────────
def check_4_1_2(executor: Executor) -> CheckResult:
cid, title = "4.1.2", "Ensure a trusted certificate and trust chain is installed"
profiles = PROFILES_L1_ALL
cmd_cert = "grep -ir ssl_certificate /etc/nginx/ 2>/dev/null | grep -v ssl_certificate_key"
out_cert, _ = _run(executor, cmd_cert)
commands = [cmd_cert]
return _manual(cid, title, profiles,
actual=out_cert.strip() or "(no ssl_certificate directive found)",
expected="ssl_certificate points to a valid cert from a trusted CA with complete chain",
commands=commands,
remediation=REMEDIATIONS[cid],
notes="Verify the certificate path, expiry date, and that the full chain is included.")
# ── 4.1.3 ────────────────────────────────────────────────────────────────────
def check_4_1_3(executor: Executor) -> CheckResult:
cid, title = "4.1.3", "Ensure private key permissions are restricted"
profiles = PROFILES_L1_ALL
cmd = r"find /etc/nginx/ -name '*.key' -exec stat -Lc '%n %a' {} + 2>/dev/null"
out, _ = _run(executor, cmd)
if not out.strip():
return _manual(cid, title, profiles,
actual="No .key files found under /etc/nginx/",
expected="Private key files with permissions 400 or 600",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes="Key files may be stored outside /etc/nginx. Verify manually.")
bad = []
for line in out.strip().splitlines():
parts = line.rsplit(" ", 1)
if len(parts) == 2:
perm = _octal_to_int(parts[1])
if perm is not None and perm not in (0o400, 0o600):
bad.append(f"perm={parts[1]}: {parts[0]}")
if not bad:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="Private key files with permissions 400 or 600",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual="Incorrect permissions:\n" + "\n".join(bad),
expected="Private key files with permissions 400 or 600",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 4.1.4 ────────────────────────────────────────────────────────────────────
def check_4_1_4(executor: Executor) -> CheckResult:
cid, title = "4.1.4", "Ensure only modern TLS protocols are used"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i ssl_protocols"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="ssl_protocols not set (NGINX default may include older protocols)",
expected="ssl_protocols TLSv1.2 TLSv1.3; (no SSLv2/3, TLSv1.0, TLSv1.1)",
commands=[cmd],
remediation=REMEDIATIONS[cid])
weak = re.findall(r"(SSLv2|SSLv3|TLSv1\.0|TLSv1\b(?!\.)|TLSv1\.1)", out, re.IGNORECASE)
if weak:
return _fail(cid, title, profiles,
actual=out.strip(),
expected="ssl_protocols TLSv1.2 TLSv1.3; only",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Weak/deprecated protocols found: {', '.join(set(weak))}")
else:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="ssl_protocols TLSv1.2 TLSv1.3; only",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 4.1.5 ────────────────────────────────────────────────────────────────────
WEAK_CIPHERS = [
"RC4", "DES", "3DES", "MD5", "NULL", "EXPORT", "ANON",
"ADH", "AECDH", "SHA1", "RC2", "IDEA",
]
def check_4_1_5(executor: Executor) -> CheckResult:
cid, title = "4.1.5", "Disable weak ciphers"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i ssl_ciphers"
out, _ = _run(executor, cmd)
if not out.strip():
return _manual(cid, title, profiles,
actual="ssl_ciphers not explicitly set (using OpenSSL defaults)",
expected="Strong cipher suite without RC4, DES, MD5, NULL, EXPORT, etc.",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes="Review the compiled OpenSSL defaults or explicitly set ssl_ciphers.")
found_weak = [c for c in WEAK_CIPHERS if re.search(c, out, re.IGNORECASE)]
if found_weak:
return _fail(cid, title, profiles,
actual=out.strip(),
expected="No RC4, DES, 3DES, MD5, NULL, EXPORT, or ANON ciphers",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Weak cipher patterns found: {', '.join(found_weak)}")
else:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="No RC4, DES, 3DES, MD5, NULL, EXPORT, or ANON ciphers",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 4.1.6 ────────────────────────────────────────────────────────────────────
def check_4_1_6(executor: Executor) -> CheckResult:
cid, title = "4.1.6", "Ensure awareness of TLS 1.3 new Diffie-Hellman parameters"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i ssl_dhparam"
out, _ = _run(executor, cmd)
return _manual(cid, title, profiles,
actual=out.strip() or "(ssl_dhparam not set)",
expected="For TLS 1.2: ssl_dhparam pointing to a 2048-bit+ DH param file; "
"TLS 1.3 uses ephemeral key exchange (no dhparam needed)",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes="If TLS 1.2 is in use, ensure a strong DH param file is configured.")
# ── 4.1.7 ────────────────────────────────────────────────────────────────────
def check_4_1_7(executor: Executor) -> CheckResult:
cid, title = "4.1.7", "Ensure Online Certificate Status Protocol (OCSP) stapling is enabled"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -Ei '^\\s*(ssl_stapling|ssl_stapling_verify|ssl_trusted_certificate|resolver)'"
out, _ = _run(executor, cmd)
has_stapling = bool(re.search(r"ssl_stapling\s+on", out, re.IGNORECASE))
has_verify = bool(re.search(r"ssl_stapling_verify\s+on", out, re.IGNORECASE))
has_resolver = bool(re.search(r"resolver\s+", out, re.IGNORECASE))
if has_stapling and has_verify and has_resolver:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="ssl_stapling on; ssl_stapling_verify on; resolver configured",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
missing = []
if not has_stapling:
missing.append("ssl_stapling on")
if not has_verify:
missing.append("ssl_stapling_verify on")
if not has_resolver:
missing.append("resolver")
return _fail(cid, title, profiles,
actual=out.strip() or "(none of the required OCSP directives found)",
expected="ssl_stapling on; ssl_stapling_verify on; resolver configured",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Missing: {', '.join(missing)}")
# ── 4.1.8 ────────────────────────────────────────────────────────────────────
MIN_HSTS_AGE = 63072000 # 2 years in seconds
def check_4_1_8(executor: Executor) -> CheckResult:
cid, title = "4.1.8", "Ensure HTTP Strict Transport Security (HSTS) is enabled"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -i Strict-Transport-Security"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="HSTS header not configured",
expected=f'add_header Strict-Transport-Security "max-age={MIN_HSTS_AGE}; includeSubDomains" always;',
commands=[cmd],
remediation=REMEDIATIONS[cid])
m = re.search(r"max-age=(\d+)", out, re.IGNORECASE)
if m:
age = int(m.group(1))
if age >= MIN_HSTS_AGE:
return _ok(cid, title, profiles,
actual=out.strip(),
expected=f"max-age >= {MIN_HSTS_AGE} (2 years)",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=out.strip(),
expected=f"max-age >= {MIN_HSTS_AGE} (2 years)",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"max-age={age} is below the recommended minimum of {MIN_HSTS_AGE}.")
else:
return _fail(cid, title, profiles,
actual=out.strip(),
expected=f"max-age >= {MIN_HSTS_AGE} (2 years)",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 4.1.9 ────────────────────────────────────────────────────────────────────
def check_4_1_9(executor: Executor) -> CheckResult:
cid, title = "4.1.9", "Ensure upstream server traffic is authenticated with a client certificate"
profiles = PROFILES_PROXY_LB_L1
cmd = "nginx -T 2>/dev/null | grep -E '^\\s*(proxy_ssl_certificate|proxy_ssl_certificate_key)'"
out, _ = _run(executor, cmd)
has_cert = bool(re.search(r"proxy_ssl_certificate\s+", out))
has_key = bool(re.search(r"proxy_ssl_certificate_key\s+", out))
if has_cert and has_key:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="proxy_ssl_certificate and proxy_ssl_certificate_key both configured",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
missing = []
if not has_cert:
missing.append("proxy_ssl_certificate")
if not has_key:
missing.append("proxy_ssl_certificate_key")
return _fail(cid, title, profiles,
actual=out.strip() or "(neither directive found)",
expected="proxy_ssl_certificate and proxy_ssl_certificate_key both configured",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Missing: {', '.join(missing)}")
# ── 4.1.10 ───────────────────────────────────────────────────────────────────
PROFILES_PROXY_LB_L2 = ["Level 2 - Proxy", "Level 2 - Loadbalancer"]
def check_4_1_10(executor: Executor) -> CheckResult:
cid, title = "4.1.10", "Ensure the upstream traffic server certificate is trusted"
profiles = PROFILES_PROXY_LB_L2
cmd = "nginx -T 2>/dev/null | grep -E '^\\s*(proxy_ssl_verify|proxy_ssl_trusted_certificate|proxy_ssl_name)'"
out, _ = _run(executor, cmd)
has_verify = bool(re.search(r"proxy_ssl_verify\s+on", out, re.IGNORECASE))
has_trusted = bool(re.search(r"proxy_ssl_trusted_certificate\s+", out))
if has_verify and has_trusted:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="proxy_ssl_verify on and proxy_ssl_trusted_certificate configured",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
missing = []
if not has_verify:
missing.append("proxy_ssl_verify on")
if not has_trusted:
missing.append("proxy_ssl_trusted_certificate")
return _fail(cid, title, profiles,
actual=out.strip() or "(neither directive found)",
expected="proxy_ssl_verify on and proxy_ssl_trusted_certificate configured",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Missing: {', '.join(missing)}")
# ── 4.1.11 ───────────────────────────────────────────────────────────────────
def check_4_1_11(executor: Executor) -> CheckResult:
cid, title = "4.1.11", "Ensure Secure Session Resumption is Enabled"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -E '^\\s*(ssl_session_cache|ssl_session_timeout|ssl_session_tickets)'"
out, _ = _run(executor, cmd)
has_cache = bool(re.search(r"ssl_session_cache\s+shared:", out, re.IGNORECASE))
has_tickets_off = bool(re.search(r"ssl_session_tickets\s+off", out, re.IGNORECASE))
has_timeout = bool(re.search(r"ssl_session_timeout\s+", out))
if has_cache and has_tickets_off and has_timeout:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="ssl_session_cache shared:SSL:Xm; ssl_session_timeout set; ssl_session_tickets off",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
missing = []
if not has_cache:
missing.append("ssl_session_cache shared:SSL:<size>")
if not has_timeout:
missing.append("ssl_session_timeout")
if not has_tickets_off:
missing.append("ssl_session_tickets off")
return _fail(cid, title, profiles,
actual=out.strip() or "(no session resumption directives found)",
expected="ssl_session_cache shared:SSL:Xm; ssl_session_timeout set; ssl_session_tickets off",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Missing/incorrect: {', '.join(missing)}")
# ── 4.1.12 ───────────────────────────────────────────────────────────────────
PROFILES_WS_L2 = ["Level 2 - Webserver"]
def check_4_1_12(executor: Executor) -> CheckResult:
cid, title = "4.1.12", "Ensure HTTP/3.0 is used"
profiles = PROFILES_WS_L2
cmd = "nginx -T 2>/dev/null | grep -E '^\\s*(listen.*quic|listen.*http3|add_header.*Alt-Svc)'"
out, _ = _run(executor, cmd)
has_quic = bool(re.search(r"listen.*quic", out, re.IGNORECASE))
has_alt_svc = bool(re.search(r"Alt-Svc", out, re.IGNORECASE))
if has_quic and has_alt_svc:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="listen ... quic; and add_header Alt-Svc directive present",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
missing = []
if not has_quic:
missing.append("listen 443 quic")
if not has_alt_svc:
missing.append("add_header Alt-Svc")
return _fail(cid, title, profiles,
actual=out.strip() or "(no HTTP/3 configuration found)",
expected="listen ... quic; and add_header Alt-Svc directive present",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Missing: {', '.join(missing)}")
# ── 5.1.1 ────────────────────────────────────────────────────────────────────
PROFILES_ALL_L2 = [
"Level 2 - Webserver", "Level 2 - Proxy", "Level 2 - Loadbalancer"
]
def check_5_1_1(executor: Executor) -> CheckResult:
cid, title = "5.1.1", "Ensure allow and deny filters limit access to specific IP addresses"
profiles = PROFILES_ALL_L2
cmd = "nginx -T 2>/dev/null | grep -E '^\\s*(allow|deny)'"
out, _ = _run(executor, cmd)
return _manual(cid, title, profiles,
actual=out.strip() or "(no allow/deny directives found)",
expected="allow/deny rules restricting access to trusted IP ranges; 'deny all' as default",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes="Verify that IP ranges are appropriately restricted for your environment.")
# ── 5.1.2 ────────────────────────────────────────────────────────────────────
def check_5_1_2(executor: Executor) -> CheckResult:
cid, title = "5.1.2", "Ensure only approved HTTP methods are allowed"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -E '(\\$request_method|limit_except)'"
out, _ = _run(executor, cmd)
if out.strip():
return _ok(cid, title, profiles,
actual=out.strip(),
expected="HTTP method restriction via limit_except or $request_method conditional",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual="(no HTTP method restriction found)",
expected="HTTP method restriction via limit_except or $request_method conditional",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 5.2.1 ────────────────────────────────────────────────────────────────────
def check_5_2_1(executor: Executor) -> CheckResult:
cid, title = "5.2.1", "Ensure timeout values for reading the client header and body are set correctly"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -E '^\\s*(client_header_timeout|client_body_timeout)'"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="Neither client_header_timeout nor client_body_timeout is set (defaults: 60s each)",
expected="client_header_timeout and client_body_timeout both set to 10s or less",
commands=[cmd],
remediation=REMEDIATIONS[cid])
bad = []
for line in out.splitlines():
m = re.match(r"\s*(client_(?:header|body)_timeout)\s+(\S+)", line)
if m:
directive, raw = m.group(1), m.group(2).rstrip(";")
secs = _parse_timeout(raw)
if secs is not None and secs > 10:
bad.append(f"{directive} = {raw} ({secs}s) — exceeds 10s")
if not bad:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="client_header_timeout and client_body_timeout ≤ 10s",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual="\n".join(bad),
expected="client_header_timeout and client_body_timeout ≤ 10s",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 5.2.2 ────────────────────────────────────────────────────────────────────
def check_5_2_2(executor: Executor) -> CheckResult:
cid, title = "5.2.2", "Ensure the maximum request body size is set correctly"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep -E '^\\s*client_max_body_size'"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="client_max_body_size not set (default: 1m; may allow large uploads)",
expected="client_max_body_size explicitly configured",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="client_max_body_size explicitly configured",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 5.2.3 ────────────────────────────────────────────────────────────────────
def check_5_2_3(executor: Executor) -> CheckResult:
cid, title = "5.2.3", "Ensure the maximum buffer size for URIs is defined"
profiles = PROFILES_L1_ALL
cmd = "nginx -T 2>/dev/null | grep large_client_header_buffers"
out, _ = _run(executor, cmd)
if not out.strip():
return _fail(cid, title, profiles,
actual="large_client_header_buffers not set (default: 4 8k)",
expected="large_client_header_buffers explicitly configured (e.g. 2 1k)",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="large_client_header_buffers explicitly configured",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 5.2.4 ────────────────────────────────────────────────────────────────────
def check_5_2_4(executor: Executor) -> CheckResult:
cid, title = "5.2.4", "Ensure the number of connections per IP address is limited"
profiles = PROFILES_ALL_L2
cmd = "nginx -T 2>/dev/null | grep -E '^\\s*(limit_conn_zone|limit_conn)'"
out, _ = _run(executor, cmd)
has_zone = bool(re.search(r"limit_conn_zone\s+", out))
has_conn = bool(re.search(r"\blimit_conn\s+", out))
if has_zone and has_conn:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="limit_conn_zone and limit_conn both configured",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
missing = []
if not has_zone:
missing.append("limit_conn_zone")
if not has_conn:
missing.append("limit_conn")
return _fail(cid, title, profiles,
actual=out.strip() or "(neither limit_conn_zone nor limit_conn found)",
expected="limit_conn_zone and limit_conn both configured",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Missing: {', '.join(missing)}")
# ── 5.2.5 ────────────────────────────────────────────────────────────────────
def check_5_2_5(executor: Executor) -> CheckResult:
cid, title = "5.2.5", "Ensure rate limits by IP address are set"
profiles = PROFILES_ALL_L2
cmd = "nginx -T 2>/dev/null | grep -E '^\\s*(limit_req_zone|limit_req)'"
out, _ = _run(executor, cmd)
has_zone = bool(re.search(r"limit_req_zone\s+", out))
has_req = bool(re.search(r"\blimit_req\s+", out))
if has_zone and has_req:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="limit_req_zone and limit_req both configured",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
missing = []
if not has_zone:
missing.append("limit_req_zone")
if not has_req:
missing.append("limit_req")
return _fail(cid, title, profiles,
actual=out.strip() or "(neither limit_req_zone nor limit_req found)",
expected="limit_req_zone and limit_req both configured",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes=f"Missing: {', '.join(missing)}")
# ── 5.3.1 ────────────────────────────────────────────────────────────────────
PROFILES_WS_L1 = ["Level 1 - Webserver"]
def check_5_3_1(executor: Executor) -> CheckResult:
cid, title = "5.3.1", "Ensure X-Content-Type-Options header is configured and enabled"
profiles = PROFILES_WS_L1
cmd = "nginx -T 2>/dev/null | grep -i X-Content-Type-Options"
out, _ = _run(executor, cmd)
if re.search(r"nosniff", out, re.IGNORECASE):
return _ok(cid, title, profiles,
actual=out.strip(),
expected='add_header X-Content-Type-Options "nosniff" always;',
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=out.strip() or "(X-Content-Type-Options header not configured)",
expected='add_header X-Content-Type-Options "nosniff" always;',
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 5.3.2 ────────────────────────────────────────────────────────────────────
def check_5_3_2(executor: Executor) -> CheckResult:
cid, title = "5.3.2", "Ensure that Content Security Policy (CSP) is enabled and configured properly"
profiles = PROFILES_WS_L2
cmd = "nginx -T 2>/dev/null | grep -i Content-Security-Policy"
out, _ = _run(executor, cmd)
if out.strip():
return _manual(cid, title, profiles,
actual=out.strip(),
expected="Content-Security-Policy header with appropriate directives for the application",
commands=[cmd],
remediation=REMEDIATIONS[cid],
notes="CSP is site-specific. Verify the policy is appropriate and restrictive.")
else:
return _fail(cid, title, profiles,
actual="Content-Security-Policy header not configured",
expected="Content-Security-Policy header present with appropriate directives",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── 5.3.3 ────────────────────────────────────────────────────────────────────
def check_5_3_3(executor: Executor) -> CheckResult:
cid, title = "5.3.3", "Ensure the Referrer Policy is enabled and configured properly"
profiles = PROFILES_WS_L2
cmd = "nginx -T 2>/dev/null | grep -i Referrer-Policy"
out, _ = _run(executor, cmd)
valid_policies = {
"no-referrer", "no-referrer-when-downgrade", "origin",
"origin-when-cross-origin", "same-origin", "strict-origin",
"strict-origin-when-cross-origin", "unsafe-url",
}
if out.strip():
found_policy = re.search(
r"(" + "|".join(valid_policies) + r")", out, re.IGNORECASE
)
if found_policy:
return _ok(cid, title, profiles,
actual=out.strip(),
expected="Referrer-Policy header with a valid policy value",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual=out.strip(),
expected="Referrer-Policy header with a recognized policy value",
commands=[cmd],
remediation=REMEDIATIONS[cid])
else:
return _fail(cid, title, profiles,
actual="Referrer-Policy header not configured",
expected="Referrer-Policy header with a valid policy value",
commands=[cmd],
remediation=REMEDIATIONS[cid])
# ── Registry: map check_id → function ────────────────────────────────────────
ALL_CHECKS = {
"1.1.1": check_1_1_1,
"1.2.1": check_1_2_1,
"1.2.2": check_1_2_2,
"2.1.1": check_2_1_1,
"2.2.1": check_2_2_1,
"2.2.2": check_2_2_2,
"2.2.3": check_2_2_3,
"2.3.1": check_2_3_1,
"2.3.2": check_2_3_2,
"2.3.3": check_2_3_3,
"2.4.1": check_2_4_1,
"2.4.2": check_2_4_2,
"2.4.3": check_2_4_3,
"2.4.4": check_2_4_4,
"2.5.1": check_2_5_1,
"2.5.2": check_2_5_2,
"2.5.3": check_2_5_3,
"2.5.4": check_2_5_4,
"3.1": check_3_1,
"3.2": check_3_2,
"3.3": check_3_3,
"3.4": check_3_4,
"4.1.1": check_4_1_1,
"4.1.2": check_4_1_2,
"4.1.3": check_4_1_3,
"4.1.4": check_4_1_4,
"4.1.5": check_4_1_5,
"4.1.6": check_4_1_6,
"4.1.7": check_4_1_7,
"4.1.8": check_4_1_8,
"4.1.9": check_4_1_9,
"4.1.10": check_4_1_10,
"4.1.11": check_4_1_11,
"4.1.12": check_4_1_12,
"5.1.1": check_5_1_1,
"5.1.2": check_5_1_2,
"5.2.1": check_5_2_1,
"5.2.2": check_5_2_2,
"5.2.3": check_5_2_3,
"5.2.4": check_5_2_4,
"5.2.5": check_5_2_5,
"5.3.1": check_5_3_1,
"5.3.2": check_5_3_2,
"5.3.3": check_5_3_3,
}
# Profile → set of applicable check IDs
PROFILE_CHECKS: dict[str, list[str]] = {
"Level 1 - Webserver": [
"1.1.1","1.2.1","1.2.2","2.1.1","2.2.1","2.2.2","2.2.3",
"2.3.1","2.3.2","2.3.3","2.4.1","2.4.2","2.4.3","2.4.4",
"2.5.1","2.5.2","2.5.3","3.1","3.2","3.3","4.1.1","4.1.2",
"4.1.3","4.1.4","4.1.5","4.1.6","4.1.7","4.1.8","4.1.11",
"5.1.2","5.2.1","5.2.2","5.2.3","5.3.1",
],
"Level 1 - Proxy": [
"1.1.1","1.2.1","1.2.2","2.1.1","2.2.1","2.2.2","2.2.3",
"2.3.1","2.3.2","2.3.3","2.4.1","2.4.2","2.4.3","2.4.4",
"2.5.1","2.5.2","2.5.3","2.5.4","3.1","3.2","3.3","3.4",
"4.1.1","4.1.2","4.1.3","4.1.4","4.1.5","4.1.6","4.1.7",
"4.1.8","4.1.9","4.1.11","5.1.2","5.2.1","5.2.2","5.2.3",
],
"Level 1 - Loadbalancer": [
"1.1.1","1.2.1","1.2.2","2.1.1","2.2.1","2.2.2","2.2.3",
"2.3.1","2.3.2","2.3.3","2.4.1","2.4.2","2.4.3","2.4.4",
"2.5.1","2.5.2","2.5.3","2.5.4","3.1","3.2","3.3","3.4",
"4.1.1","4.1.2","4.1.3","4.1.4","4.1.5","4.1.6","4.1.7",
"4.1.8","4.1.9","4.1.11","5.1.2","5.2.1","5.2.2","5.2.3",
],
"Level 2 - Webserver": [
"1.1.1","1.2.1","1.2.2","2.1.1","2.2.1","2.2.2","2.2.3",
"2.3.1","2.3.2","2.3.3","2.4.1","2.4.2","2.4.3","2.4.4",
"2.5.1","2.5.2","2.5.3","3.1","3.2","3.3","4.1.1","4.1.2",
"4.1.3","4.1.4","4.1.5","4.1.6","4.1.7","4.1.8","4.1.11",
"4.1.12","5.1.1","5.1.2","5.2.1","5.2.2","5.2.3","5.2.4",
"5.2.5","5.3.1","5.3.2","5.3.3",
],
"Level 2 - Proxy": [
"1.1.1","1.2.1","1.2.2","2.1.1","2.2.1","2.2.2","2.2.3",
"2.3.1","2.3.2","2.3.3","2.4.1","2.4.2","2.4.3","2.4.4",
"2.5.1","2.5.2","2.5.3","2.5.4","3.1","3.2","3.3","3.4",
"4.1.1","4.1.2","4.1.3","4.1.4","4.1.5","4.1.6","4.1.7",
"4.1.8","4.1.9","4.1.10","4.1.11","5.1.1","5.1.2","5.2.1",
"5.2.2","5.2.3","5.2.4","5.2.5",
],
"Level 2 - Loadbalancer": [
"1.1.1","1.2.1","1.2.2","2.1.1","2.2.1","2.2.2","2.2.3",
"2.3.1","2.3.2","2.3.3","2.4.1","2.4.2","2.4.3","2.4.4",
"2.5.1","2.5.2","2.5.3","2.5.4","3.1","3.2","3.3","3.4",
"4.1.1","4.1.2","4.1.3","4.1.4","4.1.5","4.1.6","4.1.7",
"4.1.8","4.1.9","4.1.10","4.1.11","5.1.1","5.1.2","5.2.1",
"5.2.2","5.2.3","5.2.4","5.2.5",
],
}