1724 lines
75 KiB
Python
1724 lines
75 KiB
Python
"""
|
||
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",
|
||
],
|
||
}
|