This commit is contained in:
Alexander K 2026-05-18 22:09:47 +03:00 committed by GitHub
commit 2c7cd9ab88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 708 additions and 0 deletions

80
kube-audit/README.md Normal file
View 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.

View 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())

View 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()