mirror of
https://github.com/DepthFirstDisclosures/Nginx-Rift.git
synced 2026-05-26 10:20:50 +00:00
Merge a7e9af4ebf into 1a2df3957b
This commit is contained in:
commit
2c7cd9ab88
3 changed files with 708 additions and 0 deletions
80
kube-audit/README.md
Normal file
80
kube-audit/README.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Kubernetes NGINX Rift Audit
|
||||||
|
|
||||||
|
Read-only Kubernetes scanner for NGINX Rift, CVE-2026-42945. It looks for NGINX
|
||||||
|
containers in running pods, reads their effective NGINX configuration, and
|
||||||
|
reports `rewrite` directives whose replacement contains a literal `?`.
|
||||||
|
|
||||||
|
The scanner uses Python standard library plus the local `kubectl` binary. It
|
||||||
|
does not create, update, or delete Kubernetes resources.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Run against the current kubeconfig context:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/OWNER/REPO/main/kube-audit/nginx_rift_k8s_scan.py \
|
||||||
|
| python3 -
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with an explicit kubeconfig:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/OWNER/REPO/main/kube-audit/nginx_rift_k8s_scan.py \
|
||||||
|
| python3 - --kubeconfig /path/to/kubeconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with an explicit context and JSON output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/OWNER/REPO/main/kube-audit/nginx_rift_k8s_scan.py \
|
||||||
|
| python3 - --context my-context --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## What It Checks
|
||||||
|
|
||||||
|
For each running container, the script tries to find `nginx` or `openresty`.
|
||||||
|
For containers with NGINX, it collects:
|
||||||
|
|
||||||
|
- `nginx -v` version output
|
||||||
|
- effective config via `nginx -T`
|
||||||
|
- live `/etc/nginx/nginx.conf` fallback for `ingress-nginx` controllers when `nginx -T` fails
|
||||||
|
|
||||||
|
It then parses `rewrite` directives and flags replacements containing a literal
|
||||||
|
`?`, for example:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
rewrite ^/api/(.*)$ /internal?migrated=true;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the key NGINX Rift configuration primitive described in the public PoC.
|
||||||
|
The script also reports affected NGINX Open Source versions, but version alone
|
||||||
|
does not prove exploitability; the dangerous rewrite pattern must be present in
|
||||||
|
the active configuration.
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
- `0`: no rewrite replacement containing literal `?` was found
|
||||||
|
- `1`: at least one potential NGINX Rift rewrite trigger was found
|
||||||
|
- `2`: scan failed or completed with partial errors
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```text
|
||||||
|
--kubeconfig PATH kubeconfig path
|
||||||
|
--context NAME kubeconfig context
|
||||||
|
--namespace NAME scan one namespace instead of all namespaces
|
||||||
|
--timeout SECONDS per-kubectl-call timeout, default 20
|
||||||
|
--workers N parallel kubectl exec workers, default 8
|
||||||
|
--json emit JSON report
|
||||||
|
--verbose include per-container details
|
||||||
|
--no-ingress-conf disable /etc/nginx/nginx.conf fallback for ingress-nginx when nginx -T fails
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Permissions
|
||||||
|
|
||||||
|
The current Kubernetes identity needs permission to:
|
||||||
|
|
||||||
|
- list pods
|
||||||
|
- exec into pods
|
||||||
|
|
||||||
|
No write permissions are required.
|
||||||
371
kube-audit/nginx_rift_k8s_scan.py
Normal file
371
kube-audit/nginx_rift_k8s_scan.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only Kubernetes scanner for NGINX Rift (CVE-2026-42945).
|
||||||
|
|
||||||
|
The scanner uses only Python stdlib and the local kubectl binary. It does not
|
||||||
|
create, update, or delete Kubernetes resources; it only runs get and exec calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
AFFECTED_MIN = (0, 6, 27)
|
||||||
|
AFFECTED_MAX = (1, 30, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class RewriteFinding:
|
||||||
|
line: int
|
||||||
|
regex: str
|
||||||
|
replacement: str
|
||||||
|
directive: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class ConfigFindings:
|
||||||
|
lines: int
|
||||||
|
rewrite_total: int
|
||||||
|
set_total: int
|
||||||
|
rewrite_question: list[RewriteFinding]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class ContainerTarget:
|
||||||
|
namespace: str
|
||||||
|
pod: str
|
||||||
|
container: str
|
||||||
|
image: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version_tuple(version_text: str) -> tuple[int, int, int] | None:
|
||||||
|
match = re.search(r"nginx/(\d+)\.(\d+)\.(\d+)", version_text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return tuple(int(part) for part in match.groups())
|
||||||
|
|
||||||
|
|
||||||
|
def is_affected_nginx_version(version_text: str) -> bool:
|
||||||
|
version = parse_version_tuple(version_text)
|
||||||
|
if version is None:
|
||||||
|
return False
|
||||||
|
return AFFECTED_MIN <= version <= AFFECTED_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def scan_nginx_config(config_text: str) -> ConfigFindings:
|
||||||
|
rewrite_total = 0
|
||||||
|
set_total = 0
|
||||||
|
rewrite_question: list[RewriteFinding] = []
|
||||||
|
|
||||||
|
lines = config_text.splitlines()
|
||||||
|
for line_number, raw_line in enumerate(lines, 1):
|
||||||
|
directive = raw_line.strip().rstrip(";")
|
||||||
|
if not directive or directive.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if re.match(r"^set\s+", directive):
|
||||||
|
set_total += 1
|
||||||
|
|
||||||
|
if not re.match(r"^rewrite\s+", directive):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(directive, comments=True, posix=True)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(tokens) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rewrite_total += 1
|
||||||
|
regex = tokens[1]
|
||||||
|
replacement = tokens[2]
|
||||||
|
if "?" in replacement:
|
||||||
|
rewrite_question.append(
|
||||||
|
RewriteFinding(
|
||||||
|
line=line_number,
|
||||||
|
regex=regex,
|
||||||
|
replacement=replacement,
|
||||||
|
directive=directive,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConfigFindings(
|
||||||
|
lines=len(lines),
|
||||||
|
rewrite_total=rewrite_total,
|
||||||
|
set_total=set_total,
|
||||||
|
rewrite_question=rewrite_question,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(args: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
args,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def kubectl_base(args: argparse.Namespace) -> list[str]:
|
||||||
|
command = [args.kubectl]
|
||||||
|
if args.kubeconfig:
|
||||||
|
command.extend(["--kubeconfig", args.kubeconfig])
|
||||||
|
if args.context:
|
||||||
|
command.extend(["--context", args.context])
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def kubectl_exec_base(args: argparse.Namespace, target: ContainerTarget) -> list[str]:
|
||||||
|
return kubectl_base(args) + [
|
||||||
|
"-n",
|
||||||
|
target.namespace,
|
||||||
|
"exec",
|
||||||
|
target.pod,
|
||||||
|
"-c",
|
||||||
|
target.container,
|
||||||
|
"--",
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_running_containers(args: argparse.Namespace) -> list[ContainerTarget]:
|
||||||
|
command = kubectl_base(args) + ["get", "pods"]
|
||||||
|
if args.namespace:
|
||||||
|
command.extend(["-n", args.namespace])
|
||||||
|
else:
|
||||||
|
command.append("-A")
|
||||||
|
command.extend(["-o", "json"])
|
||||||
|
|
||||||
|
result = run_command(command, args.timeout)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(result.stderr.strip() or "kubectl get pods failed")
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
targets: list[ContainerTarget] = []
|
||||||
|
for item in data.get("items", []):
|
||||||
|
if item.get("status", {}).get("phase") != "Running":
|
||||||
|
continue
|
||||||
|
namespace = item.get("metadata", {}).get("namespace", args.namespace or "default")
|
||||||
|
pod = item.get("metadata", {}).get("name", "")
|
||||||
|
for container in item.get("spec", {}).get("containers", []):
|
||||||
|
targets.append(
|
||||||
|
ContainerTarget(
|
||||||
|
namespace=namespace,
|
||||||
|
pod=pod,
|
||||||
|
container=container.get("name", ""),
|
||||||
|
image=container.get("image", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
def build_discover_nginx_command() -> str:
|
||||||
|
return (
|
||||||
|
"command -v nginx 2>/dev/null "
|
||||||
|
"|| command -v openresty 2>/dev/null "
|
||||||
|
"|| { test -x /usr/local/openresty/nginx/sbin/nginx "
|
||||||
|
"&& printf %s /usr/local/openresty/nginx/sbin/nginx; } "
|
||||||
|
"|| { pid=$(ps -eo pid,args 2>/dev/null "
|
||||||
|
"| awk '/nginx: master/ && !/awk/ {print $1; exit}'); "
|
||||||
|
"test -n \"$pid\" && readlink -f /proc/$pid/exe 2>/dev/null; } "
|
||||||
|
"|| true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_nginx_binary(args: argparse.Namespace, target: ContainerTarget) -> str | None:
|
||||||
|
script = build_discover_nginx_command()
|
||||||
|
result = run_command(kubectl_exec_base(args, target) + [script], args.timeout)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
binary = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
|
||||||
|
return binary or None
|
||||||
|
|
||||||
|
|
||||||
|
def read_nginx_version(args: argparse.Namespace, target: ContainerTarget, binary: str) -> str:
|
||||||
|
result = run_command(
|
||||||
|
kubectl_exec_base(args, target) + [f"{shlex.quote(binary)} -v 2>&1"],
|
||||||
|
args.timeout,
|
||||||
|
)
|
||||||
|
text = (result.stdout + result.stderr).strip()
|
||||||
|
return text.splitlines()[0] if text else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def read_nginx_config(args: argparse.Namespace, target: ContainerTarget, binary: str) -> tuple[str, str]:
|
||||||
|
result = run_command(
|
||||||
|
kubectl_exec_base(args, target) + [f"{shlex.quote(binary)} -T 2>&1"],
|
||||||
|
args.timeout,
|
||||||
|
)
|
||||||
|
config_text = result.stdout + result.stderr
|
||||||
|
if result.returncode == 0 and config_text.strip():
|
||||||
|
return config_text, "nginx -T"
|
||||||
|
|
||||||
|
if args.ingress_conf and (
|
||||||
|
target.namespace == "ingress-nginx" or "ingress-nginx/controller" in target.image
|
||||||
|
):
|
||||||
|
result = run_command(
|
||||||
|
kubectl_exec_base(args, target)
|
||||||
|
+ ["sed -n '1,999999p' /etc/nginx/nginx.conf 2>/dev/null"],
|
||||||
|
args.timeout,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout, "live /etc/nginx/nginx.conf"
|
||||||
|
|
||||||
|
return config_text, "nginx -T"
|
||||||
|
|
||||||
|
|
||||||
|
def scan_container(args: argparse.Namespace, target: ContainerTarget) -> dict[str, Any] | None:
|
||||||
|
binary = discover_nginx_binary(args, target)
|
||||||
|
if not binary:
|
||||||
|
return None
|
||||||
|
|
||||||
|
version = read_nginx_version(args, target, binary)
|
||||||
|
config_text, config_source = read_nginx_config(args, target, binary)
|
||||||
|
findings = scan_nginx_config(config_text)
|
||||||
|
return {
|
||||||
|
"namespace": target.namespace,
|
||||||
|
"pod": target.pod,
|
||||||
|
"container": target.container,
|
||||||
|
"image": target.image,
|
||||||
|
"binary": binary,
|
||||||
|
"version": version,
|
||||||
|
"affected_version": is_affected_nginx_version(version),
|
||||||
|
"config_source": config_source,
|
||||||
|
"lines": findings.lines,
|
||||||
|
"rewrite_total": findings.rewrite_total,
|
||||||
|
"set_total": findings.set_total,
|
||||||
|
"rewrite_question_total": len(findings.rewrite_question),
|
||||||
|
"rewrite_question": [dataclasses.asdict(item) for item in findings.rewrite_question],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_cluster(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
|
targets = load_running_containers(args)
|
||||||
|
scanned: list[dict[str, Any]] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||||
|
future_map = {executor.submit(scan_container, args, target): target for target in targets}
|
||||||
|
for future in concurrent.futures.as_completed(future_map):
|
||||||
|
target = future_map[future]
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
except Exception as exc: # noqa: BLE001 - CLI should report and continue.
|
||||||
|
errors.append(f"{target.namespace}/{target.pod}/{target.container}: {exc}")
|
||||||
|
continue
|
||||||
|
if result:
|
||||||
|
scanned.append(result)
|
||||||
|
|
||||||
|
scanned.sort(key=lambda item: (item["namespace"], item["pod"], item["container"]))
|
||||||
|
trigger_total = sum(item["rewrite_question_total"] for item in scanned)
|
||||||
|
return {
|
||||||
|
"containers_checked": len(targets),
|
||||||
|
"nginx_containers": len(scanned),
|
||||||
|
"affected_version_containers": sum(1 for item in scanned if item["affected_version"]),
|
||||||
|
"total_rewrites": sum(item["rewrite_total"] for item in scanned),
|
||||||
|
"total_sets": sum(item["set_total"] for item in scanned),
|
||||||
|
"rift_rewrite_question_triggers": trigger_total,
|
||||||
|
"containers": scanned,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def print_human_report(report: dict[str, Any], verbose: bool) -> None:
|
||||||
|
print("NGINX Rift Kubernetes Audit")
|
||||||
|
print()
|
||||||
|
print(f"Containers checked: {report['containers_checked']}")
|
||||||
|
print(f"NGINX containers found: {report['nginx_containers']}")
|
||||||
|
print(f"Affected-version containers: {report['affected_version_containers']}")
|
||||||
|
print(f"Total rewrite directives: {report['total_rewrites']}")
|
||||||
|
print(f"Total set directives: {report['total_sets']}")
|
||||||
|
print(f"Rift rewrite '?' triggers: {report['rift_rewrite_question_triggers']}")
|
||||||
|
if report["errors"]:
|
||||||
|
print(f"Scan errors: {len(report['errors'])}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if report["rift_rewrite_question_triggers"]:
|
||||||
|
print("Potential NGINX Rift triggers:")
|
||||||
|
for item in report["containers"]:
|
||||||
|
for finding in item["rewrite_question"]:
|
||||||
|
print(
|
||||||
|
f"- {item['namespace']}/{item['pod']}/{item['container']} "
|
||||||
|
f"line {finding['line']}: {finding['directive']}"
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
print("Verdict: potential CVE-2026-42945 trigger found.")
|
||||||
|
else:
|
||||||
|
print("Verdict: no rewrite replacement containing literal '?' was found.")
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print()
|
||||||
|
print("Scanned NGINX containers:")
|
||||||
|
for item in report["containers"]:
|
||||||
|
affected = "affected-version" if item["affected_version"] else "fixed-or-unknown-version"
|
||||||
|
print(
|
||||||
|
f"- {item['namespace']}/{item['pod']}/{item['container']} "
|
||||||
|
f"{item['version']} {affected} rewrites={item['rewrite_total']} "
|
||||||
|
f"sets={item['set_total']} source={item['config_source']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if report["errors"] and verbose:
|
||||||
|
print()
|
||||||
|
print("Errors:")
|
||||||
|
for error in report["errors"]:
|
||||||
|
print(f"- {error}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Read-only Kubernetes audit for NGINX Rift (CVE-2026-42945)."
|
||||||
|
)
|
||||||
|
parser.add_argument("--kubectl", default="kubectl", help="kubectl binary path")
|
||||||
|
parser.add_argument("--kubeconfig", help="kubeconfig path")
|
||||||
|
parser.add_argument("--context", help="kubeconfig context")
|
||||||
|
parser.add_argument("--namespace", help="scan one namespace instead of all namespaces")
|
||||||
|
parser.add_argument("--timeout", type=int, default=20, help="per-kubectl-call timeout in seconds")
|
||||||
|
parser.add_argument("--workers", type=int, default=8, help="parallel kubectl exec workers")
|
||||||
|
parser.add_argument("--json", action="store_true", help="emit JSON report")
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="include per-container details")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-ingress-conf",
|
||||||
|
dest="ingress_conf",
|
||||||
|
action="store_false",
|
||||||
|
help="disable /etc/nginx/nginx.conf fallback for ingress-nginx when nginx -T fails",
|
||||||
|
)
|
||||||
|
parser.set_defaults(ingress_conf=True)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
report = scan_cluster(args)
|
||||||
|
except Exception as exc: # noqa: BLE001 - CLI top-level reporting.
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(report, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
print_human_report(report, args.verbose)
|
||||||
|
|
||||||
|
if report["rift_rewrite_question_triggers"]:
|
||||||
|
return 1
|
||||||
|
if report["errors"]:
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
257
kube-audit/test_nginx_rift_k8s_scan.py
Normal file
257
kube-audit/test_nginx_rift_k8s_scan.py
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import unittest
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
import nginx_rift_k8s_scan as scan
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def patched_attr(module, name, replacement):
|
||||||
|
original = getattr(module, name)
|
||||||
|
setattr(module, name, replacement)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
setattr(module, name, original)
|
||||||
|
|
||||||
|
|
||||||
|
class RewriteParserTests(unittest.TestCase):
|
||||||
|
def test_detects_public_poc_nginx_config(self):
|
||||||
|
poc_config = pathlib.Path(__file__).resolve().parents[1] / "env" / "nginx.conf"
|
||||||
|
|
||||||
|
findings = scan.scan_nginx_config(poc_config.read_text())
|
||||||
|
|
||||||
|
self.assertEqual(findings.rewrite_total, 1)
|
||||||
|
self.assertEqual(findings.set_total, 1)
|
||||||
|
self.assertEqual(len(findings.rewrite_question), 1)
|
||||||
|
self.assertEqual(findings.rewrite_question[0].line, 40)
|
||||||
|
self.assertEqual(findings.rewrite_question[0].regex, "^/api/(.*)$")
|
||||||
|
self.assertEqual(findings.rewrite_question[0].replacement, "/internal?migrated=true")
|
||||||
|
|
||||||
|
def test_does_not_trigger_when_public_poc_rewrite_question_is_removed(self):
|
||||||
|
poc_config = pathlib.Path(__file__).resolve().parents[1] / "env" / "nginx.conf"
|
||||||
|
safe_config = poc_config.read_text().replace(
|
||||||
|
"/internal?migrated=true",
|
||||||
|
"/internal/migrated-true",
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = scan.scan_nginx_config(safe_config)
|
||||||
|
|
||||||
|
self.assertEqual(findings.rewrite_total, 1)
|
||||||
|
self.assertEqual(findings.set_total, 1)
|
||||||
|
self.assertEqual(findings.rewrite_question, [])
|
||||||
|
|
||||||
|
def test_finds_rewrite_replacement_with_literal_question(self):
|
||||||
|
config = """
|
||||||
|
location ~ ^/api/(.*)$ {
|
||||||
|
rewrite ^/api/(.*)$ /internal?migrated=true;
|
||||||
|
set $original_endpoint $1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
findings = scan.scan_nginx_config(config)
|
||||||
|
|
||||||
|
self.assertEqual(len(findings.rewrite_question), 1)
|
||||||
|
self.assertEqual(findings.rewrite_question[0].line, 3)
|
||||||
|
self.assertEqual(findings.rewrite_question[0].replacement, "/internal?migrated=true")
|
||||||
|
|
||||||
|
def test_ignores_question_mark_in_regex_token(self):
|
||||||
|
config = """
|
||||||
|
rewrite ^/api/(?:v1|v2)/(.*)$ /internal/$1 last;
|
||||||
|
"""
|
||||||
|
|
||||||
|
findings = scan.scan_nginx_config(config)
|
||||||
|
|
||||||
|
self.assertEqual(findings.rewrite_total, 1)
|
||||||
|
self.assertEqual(findings.rewrite_question, [])
|
||||||
|
|
||||||
|
def test_parses_quoted_replacement(self):
|
||||||
|
config = 'rewrite ^/x/(.*)$ "/internal?migrated=true" break;'
|
||||||
|
|
||||||
|
findings = scan.scan_nginx_config(config)
|
||||||
|
|
||||||
|
self.assertEqual(len(findings.rewrite_question), 1)
|
||||||
|
self.assertEqual(findings.rewrite_question[0].replacement, "/internal?migrated=true")
|
||||||
|
|
||||||
|
def test_handles_inline_comments_tabs_and_multiple_rewrites(self):
|
||||||
|
config = """
|
||||||
|
rewrite ^/safe/(.*)$ /safe/$1 last; # regex and replacement are safe
|
||||||
|
rewrite ^/risky/(.*)$ "/internal?from=risky" break; # should be flagged
|
||||||
|
set $captured $1; # counted but not required for the trigger primitive
|
||||||
|
"""
|
||||||
|
|
||||||
|
findings = scan.scan_nginx_config(config)
|
||||||
|
|
||||||
|
self.assertEqual(findings.rewrite_total, 2)
|
||||||
|
self.assertEqual(findings.set_total, 1)
|
||||||
|
self.assertEqual(len(findings.rewrite_question), 1)
|
||||||
|
self.assertEqual(findings.rewrite_question[0].regex, "^/risky/(.*)$")
|
||||||
|
self.assertEqual(findings.rewrite_question[0].replacement, "/internal?from=risky")
|
||||||
|
|
||||||
|
def test_ignores_malformed_rewrite_without_counting_it(self):
|
||||||
|
config = """
|
||||||
|
rewrite ^/missing-replacement;
|
||||||
|
rewrite ^/valid/(.*)$ /valid/$1 last;
|
||||||
|
"""
|
||||||
|
|
||||||
|
findings = scan.scan_nginx_config(config)
|
||||||
|
|
||||||
|
self.assertEqual(findings.rewrite_total, 1)
|
||||||
|
self.assertEqual(findings.rewrite_question, [])
|
||||||
|
|
||||||
|
|
||||||
|
class VersionTests(unittest.TestCase):
|
||||||
|
def test_classifies_affected_open_source_versions(self):
|
||||||
|
self.assertTrue(scan.is_affected_nginx_version("nginx version: nginx/1.29.1"))
|
||||||
|
self.assertTrue(scan.is_affected_nginx_version("nginx version: nginx/1.30.0"))
|
||||||
|
|
||||||
|
def test_classifies_fixed_open_source_versions(self):
|
||||||
|
self.assertFalse(scan.is_affected_nginx_version("nginx version: nginx/1.30.1"))
|
||||||
|
self.assertFalse(scan.is_affected_nginx_version("nginx version: nginx/1.31.0"))
|
||||||
|
|
||||||
|
def test_unknown_version_is_not_marked_affected(self):
|
||||||
|
self.assertFalse(scan.is_affected_nginx_version("not nginx"))
|
||||||
|
|
||||||
|
|
||||||
|
class KubernetesCommandTests(unittest.TestCase):
|
||||||
|
def test_load_running_containers_uses_all_namespaces_by_default(self):
|
||||||
|
args = type("Args", (), {"timeout": 1, "kubectl": "kubectl", "kubeconfig": None, "context": None, "namespace": None})()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run_command(command, timeout):
|
||||||
|
calls.append(command)
|
||||||
|
payload = {"items": []}
|
||||||
|
return type("Result", (), {"returncode": 0, "stdout": __import__("json").dumps(payload), "stderr": ""})()
|
||||||
|
|
||||||
|
with patched_attr(scan, "run_command", fake_run_command):
|
||||||
|
containers = scan.load_running_containers(args)
|
||||||
|
|
||||||
|
self.assertEqual(containers, [])
|
||||||
|
self.assertEqual(calls[0], ["kubectl", "get", "pods", "-A", "-o", "json"])
|
||||||
|
|
||||||
|
def test_load_running_containers_uses_requested_namespace_when_provided(self):
|
||||||
|
args = type("Args", (), {"timeout": 1, "kubectl": "kubectl", "kubeconfig": None, "context": None, "namespace": "apps"})()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run_command(command, timeout):
|
||||||
|
calls.append(command)
|
||||||
|
payload = {"items": []}
|
||||||
|
return type("Result", (), {"returncode": 0, "stdout": __import__("json").dumps(payload), "stderr": ""})()
|
||||||
|
|
||||||
|
with patched_attr(scan, "run_command", fake_run_command):
|
||||||
|
containers = scan.load_running_containers(args)
|
||||||
|
|
||||||
|
self.assertEqual(containers, [])
|
||||||
|
self.assertEqual(calls[0], ["kubectl", "get", "pods", "-n", "apps", "-o", "json"])
|
||||||
|
|
||||||
|
def test_discovery_command_uses_ps_fallback(self):
|
||||||
|
command = scan.build_discover_nginx_command()
|
||||||
|
|
||||||
|
self.assertIn("ps", command)
|
||||||
|
self.assertIn("nginx: master", command)
|
||||||
|
self.assertIn("/proc/$pid/exe", command)
|
||||||
|
|
||||||
|
def test_config_reader_prefers_nginx_t_before_ingress_conf_fallback(self):
|
||||||
|
args = type("Args", (), {"ingress_conf": True, "timeout": 1, "kubectl": "kubectl", "kubeconfig": None, "context": None})()
|
||||||
|
target = scan.ContainerTarget("ingress-nginx", "pod-a", "controller", "registry.k8s.io/ingress-nginx/controller:v1")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run_command(command, timeout):
|
||||||
|
calls.append(command[-1])
|
||||||
|
if "nginx -T" in command[-1]:
|
||||||
|
return type("Result", (), {"returncode": 1, "stdout": "", "stderr": "nginx: [emerg] test\n"})()
|
||||||
|
return type("Result", (), {"returncode": 0, "stdout": "events {}", "stderr": ""})()
|
||||||
|
|
||||||
|
with patched_attr(scan, "run_command", fake_run_command):
|
||||||
|
config, source = scan.read_nginx_config(args, target, "nginx")
|
||||||
|
|
||||||
|
self.assertEqual(source, "live /etc/nginx/nginx.conf")
|
||||||
|
self.assertEqual(config, "events {}")
|
||||||
|
self.assertIn("nginx -T", calls[0])
|
||||||
|
|
||||||
|
def test_config_reader_does_not_use_ingress_fallback_when_nginx_t_succeeds(self):
|
||||||
|
args = type("Args", (), {"ingress_conf": True, "timeout": 1, "kubectl": "kubectl", "kubeconfig": None, "context": None})()
|
||||||
|
target = scan.ContainerTarget("ingress-nginx", "pod-a", "controller", "registry.k8s.io/ingress-nginx/controller:v1")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run_command(command, timeout):
|
||||||
|
calls.append(command[-1])
|
||||||
|
return type("Result", (), {"returncode": 0, "stdout": "rewrite ^/x$ /y last;", "stderr": ""})()
|
||||||
|
|
||||||
|
with patched_attr(scan, "run_command", fake_run_command):
|
||||||
|
config, source = scan.read_nginx_config(args, target, "nginx")
|
||||||
|
|
||||||
|
self.assertEqual(source, "nginx -T")
|
||||||
|
self.assertEqual(config, "rewrite ^/x$ /y last;")
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
def test_config_reader_does_not_use_ingress_fallback_for_regular_container(self):
|
||||||
|
args = type("Args", (), {"ingress_conf": True, "timeout": 1, "kubectl": "kubectl", "kubeconfig": None, "context": None})()
|
||||||
|
target = scan.ContainerTarget("default", "pod-a", "app", "example/app:latest")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run_command(command, timeout):
|
||||||
|
calls.append(command[-1])
|
||||||
|
return type("Result", (), {"returncode": 1, "stdout": "", "stderr": "nginx -T failed"})()
|
||||||
|
|
||||||
|
with patched_attr(scan, "run_command", fake_run_command):
|
||||||
|
config, source = scan.read_nginx_config(args, target, "nginx")
|
||||||
|
|
||||||
|
self.assertEqual(source, "nginx -T")
|
||||||
|
self.assertEqual(config, "nginx -T failed")
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterSummaryTests(unittest.TestCase):
|
||||||
|
def test_scan_cluster_summarizes_triggers_errors_and_affected_versions(self):
|
||||||
|
args = type("Args", (), {"workers": 1})()
|
||||||
|
targets = [
|
||||||
|
scan.ContainerTarget("default", "safe", "nginx", "nginx:1.31"),
|
||||||
|
scan.ContainerTarget("default", "risky", "nginx", "nginx:1.29"),
|
||||||
|
scan.ContainerTarget("default", "broken", "nginx", "nginx:1.29"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def fake_scan_container(_args, target):
|
||||||
|
if target.pod == "broken":
|
||||||
|
raise RuntimeError("exec failed")
|
||||||
|
if target.pod == "risky":
|
||||||
|
return {
|
||||||
|
"namespace": target.namespace,
|
||||||
|
"pod": target.pod,
|
||||||
|
"container": target.container,
|
||||||
|
"affected_version": True,
|
||||||
|
"rewrite_total": 2,
|
||||||
|
"set_total": 1,
|
||||||
|
"rewrite_question_total": 1,
|
||||||
|
"rewrite_question": [{"line": 7, "directive": "rewrite ^/x$ /y?z=1;"}],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"namespace": target.namespace,
|
||||||
|
"pod": target.pod,
|
||||||
|
"container": target.container,
|
||||||
|
"affected_version": False,
|
||||||
|
"rewrite_total": 1,
|
||||||
|
"set_total": 0,
|
||||||
|
"rewrite_question_total": 0,
|
||||||
|
"rewrite_question": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patched_attr(scan, "load_running_containers", lambda _args: targets):
|
||||||
|
with patched_attr(scan, "scan_container", fake_scan_container):
|
||||||
|
report = scan.scan_cluster(args)
|
||||||
|
|
||||||
|
self.assertEqual(report["containers_checked"], 3)
|
||||||
|
self.assertEqual(report["nginx_containers"], 2)
|
||||||
|
self.assertEqual(report["affected_version_containers"], 1)
|
||||||
|
self.assertEqual(report["total_rewrites"], 3)
|
||||||
|
self.assertEqual(report["total_sets"], 1)
|
||||||
|
self.assertEqual(report["rift_rewrite_question_triggers"], 1)
|
||||||
|
self.assertEqual(len(report["errors"]), 1)
|
||||||
|
self.assertIn("default/broken/nginx", report["errors"][0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue