mirror of
https://github.com/DepthFirstDisclosures/Nginx-Rift.git
synced 2026-05-26 10:20:50 +00:00
add kubernetes nginx rift audit
This commit is contained in:
parent
1a2df3957b
commit
a7e9af4ebf
3 changed files with 708 additions and 0 deletions
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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue