""" 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 ;", 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:") 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", ], }