From a7e9af4ebfd164bbbf02a9d8324786097f2ddea7 Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Mon, 18 May 2026 22:01:55 +0300 Subject: [PATCH] add kubernetes nginx rift audit --- kube-audit/README.md | 80 ++++++ kube-audit/nginx_rift_k8s_scan.py | 371 +++++++++++++++++++++++++ kube-audit/test_nginx_rift_k8s_scan.py | 257 +++++++++++++++++ 3 files changed, 708 insertions(+) create mode 100644 kube-audit/README.md create mode 100644 kube-audit/nginx_rift_k8s_scan.py create mode 100644 kube-audit/test_nginx_rift_k8s_scan.py diff --git a/kube-audit/README.md b/kube-audit/README.md new file mode 100644 index 0000000..4898356 --- /dev/null +++ b/kube-audit/README.md @@ -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. diff --git a/kube-audit/nginx_rift_k8s_scan.py b/kube-audit/nginx_rift_k8s_scan.py new file mode 100644 index 0000000..bc5a204 --- /dev/null +++ b/kube-audit/nginx_rift_k8s_scan.py @@ -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()) diff --git a/kube-audit/test_nginx_rift_k8s_scan.py b/kube-audit/test_nginx_rift_k8s_scan.py new file mode 100644 index 0000000..b8b35bf --- /dev/null +++ b/kube-audit/test_nginx_rift_k8s_scan.py @@ -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()