Read nginx's October fixes against the binary and the dumped configuration

John Burns

Read nginx’s October fixes against the binary and the dumped configuration

nginx 1.31.6 prints a newer version than nginx 1.30.6. A comparison that asks only whether the version is at least 1.30.6 therefore accepts it. The official nginx.org packages tested for this article do not support that conclusion. nginx -v on the 1.31.6 package printed nginx version: nginx/1.31.6, and the same check on the 1.30.6 package printed nginx version: nginx/1.30.6. The 14 October 2026 fixes are in 1.30.6 and in 1.31.7, not in the mainline releases before 1.31.7. The review below reads the version the binary prints, applies the branch boundary nginx published, and then reads the configuration dump so an included file is not mistaken for an absent condition.

The report says whether the binary is an upstream fixed release, which of the five advisories include that version, whether the binary was built with the HTTP/3 module, and which changelog conditions are visible after includes are put back where nginx uses them. It does not send a request, and a matching directive is not a claim that the vulnerable code can be reached.

What the 14 October release fixed

nginx 1.31.7 and nginx 1.30.6 were released on 14 October 2026. The stable changelog and the mainline changelog describe the same five security fixes, and the security advisories page gives the vulnerable ranges. Those pages do not describe a request that triggers any of the bugs. This review does not add one.

CVE-2026-15176 is a buffer overflow in a worker process when the alias directive is in use, the rewrite directive has changed the request URI, and that URI is then mapped to a file path. The advisories page lists 0.1.29 through 1.31.6 as vulnerable.

CVE-2026-93479 is a buffer overflow while evaluating string expressions with variables. The changelog says it affected document-root calculation and request logging in the HTTP and stream modules. The vulnerable ranges are narrower than the other four: 1.30.0 through 1.30.5, and 1.31.2 through 1.31.6. An older legacy release can sit outside this range and still sit inside another advisory.

CVE-2026-95101 applies when HTTP/3 is in use. A part of an unknown frame might be treated as the request body, which might bypass client_max_body_size or inject data into a proxied request. The listed range is 1.25.0 through 1.31.6.

CVE-2026-97629 applies when TLS session ticket keys are rotated. One of the keys was left uninitialized, which might allow a session to be forged. The listed range is 1.23.2 through 1.31.6. The SSL module documentation says when nginx rotates those keys itself: a shared session cache automatically generates, stores, and periodically rotates ticket keys, since 1.23.2, unless ssl_session_ticket_key is set. ssl_session_tickets defaults to on. ssl_session_cache defaults to none, so a server that never sets a shared cache is not using that automatic rotation.

CVE-2026-97205 is a use-after-free when asynchronous file I/O is in use, in certain configurations, if an SSI page contains include virtual commands. The listed range is 0.8.11 through 1.31.6. aio defaults to off. A configuration review can see aio and ssi. It cannot see whether an HTML file contains include virtual. That limit belongs in the report, not in a guess about files the review never opened.

For all five, the advisories page lists 1.31.7 and later, and 1.30.6 and later, as not vulnerable. nginx 1.31.8 is in the first set. A later 1.30 patch would be in the second. Older branches, including the 1.28 line still offered as a legacy download, are not listed as fixed. A distribution can backport a patch into a package whose nginx -v output still names an older version. The review cannot see a backport. It can stop a numeric comparison from treating 1.31.6 as if it were 1.30.6.

A passing configuration test is a different fact

The same test configuration was given to the 1.30.5 binary and the 1.31.8 binary. Both were official nginx.org builds for Debian trixie, unpacked and executed without replacing a system package. Each nginx -t exited 0 and reported that the syntax was ok and the test was successful. The unfixed binary did not reject alias combined with rewrite, a shared session cache, a QUIC listener, or aio together with ssi. A syntax test is not a list of compiled security fixes.

The packages were checked by SHA-256 before they were unpacked:

d8b4f1855d4455749c3782def671b980e7ca2792a29ce529dd2bbc01a00b27a8  nginx_1.30.5-1~trixie_amd64.deb
8dc9a5da6d62b33aaef20c047760a82c7b18c90100251290ef5f553b9de3bc3c  nginx_1.30.6-1~trixie_amd64.deb
31ddb23f2d490c41e27b14cf04fabe9a518fecf6ee1091ce46724b8e35a136c9  nginx_1.31.6-1~trixie_amd64.deb
4d8088c4beb982f8066062f62bd39bd04952bceddcf752338c652676a126a614  nginx_1.31.8-1~trixie_amd64.deb

Those hashes are for the packages downloaded from nginx.org on 23 October 2026. They are not a signature check. nginx publishes PGP signatures beside the source archives. Use those when you are verifying a source tarball rather than one of these packages. The hashes only tie the version strings below to those four builds.

nginx -V on each of the four binaries included --with-http_v3_module. A built module is not a listener. Version-only runs reported http_v3_module=built when no configuration was supplied. The configuration half of the report is what decides whether HTTP/3 is enabled.

The official package’s own nginx.conf uses include /etc/nginx/conf.d/*.conf inside the http block. That is the same shape the test configuration used: a parent file with an include, and the server blocks in other files. A search that never opens the included files will not see the October conditions even when nginx -T printed them.

Compare the branch, not a single floor

Save the program at the end of this article as review_nginx_october.py. The version half is the part to read before you trust a job that shells out to sort -V or a tuple comparison against 1.30.6. upstream_fixed() accepts a 1.30 version only when the patch level is 6 or greater. Any version above the 1.30 line must be at least 1.31.7. Everything older than 1.30.6 returns false, including 1.28.3 and 1.22.1. naive_numeric_fixed() is printed beside it so the log shows the comparison that accepts 1.31.6.

Python 3.13.5 ran the reviewer against the four unpacked binaries. The split was:

  • 1.30.5: upstream_fixed=no, naive_numeric_fixed=no, and all five advisories in-range.
  • 1.30.6: upstream_fixed=yes, and every advisory fixed-release.
  • 1.31.6: upstream_fixed=no, naive_numeric_fixed=yes, naive_comparison_mismatch=yes, and all five advisories in-range.
  • 1.31.8: upstream_fixed=yes, and every advisory fixed-release.

The packages on hand did not include every boundary, so four additional version strings were passed to --version-text without a binary. nginx/1.31.7 was fixed-release. nginx/1.31.1 was numerically at least 1.30.6, was not an upstream fixed release, and was outside-range for CVE-2026-93479 while remaining in-range for the other four. nginx/1.28.3 was not fixed, was outside the string-expression range, and was inside the other four. nginx/1.22.1 was inside the alias and AIO ranges only. The malformed string nginx/1.31 exited 2 with no nginx MAJOR.MINOR.PATCH version found, before it printed a fixed or unfixed result.

A package revision such as 1.30.6-1~trixie still contains a three-part version, and the parser takes those three numbers. It does not accept nginx/1.31 as 1.31.0. A vendor backport that keeps an older version string stays in range until the package changelog says otherwise.

Put included files back before you search them

nginx -T tests the configuration and dumps it. The help text describes -T as test configuration, dump it, and exit. On the test prefix, the dump did not inline included files at the include directive. The parent file contained include and no server block. Each site file and the SSL snippet appeared later, introduced by a # configuration file line. The official package layout produces the same kind of dump: the parent names a glob, and the included files follow as separate sections.

A review that walked only that parent file reported an unresolved include and no advisory condition. The same dump, after the reviewer spliced each # configuration file section back onto the matching include, reported the conditions in the next section. The splice is the difference between “this file does not mention alias” and “alias is in a file this server includes.”

The include path printed by nginx -T is compared with the section header, including a glob such as conf.d/*.conf. A path that does not match is reported as unresolved-include rather than skipped. Relative includes that resolve to a different header were not tested. Treat that finding as incomplete.

Status lines that begin with nginx: were mixed into the captured output after the last included file. Left in place, the parser stopped with unterminated directive. The reviewer drops that prefix before it parses. Keep the # configuration file headers. The splice uses them.

Read the conditions the changelog names

The configuration half of the report is narrower than a generic hardening scan. It looks for the conditions the 14 October changelog names, plus the shared-cache behavior the SSL module documents for ticket rotation. A hit is a reason to inspect that server. It is not a statement that a request reached the vulnerable code.

For CVE-2026-15176, the reviewer looks for alias and rewrite after includes have been spliced. The rewrite module runs server-level rewrite directives before location selection, and a last flag starts a new location search. A break flag stays in the current location. The test configuration had four pairings, and the report distinguished them:

  • alias and rewrite in the same location produced alias-rewrite=same.invalid same-location.
  • A server-level rewrite with last, plus alias in a location, produced server-rewrite.
  • rewrite ... last in a different location produced rewrite-last-other-location.
  • rewrite ... break in a different location produced not-paired.

A comment that mentioned alias and rewrite did not produce a finding. The server named comment.invalid was absent from the report. not-paired is there so a break in another location is not folded into the same-location case. It is not a clean result for that alias. If the report says not-paired, read the rewrite flags before you ignore the server.

The reviewer does not prove that a given rewrite changes a URI, and it does not evaluate $request_filename. It names the server and the relation between alias and rewrite. It does not invent a request.

CVE-2026-93479 does not get a directive search. The changelog describes string evaluation in document-root calculation and request logging, and the vulnerable window is the version range. A search for $ would flag ordinary logging on a release outside that range, and it would still miss whether the binary is 1.30.5 or 1.30.6.

For CVE-2026-95101, the reviewer reports http3=server-name enabled when a server has quic on a listen directive or http3 set to a value other than off. The test server that used both produced http3=ticket.invalid enabled. A binary built with --with-http_v3_module does not produce that finding unless the dump enables HTTP/3. Check both the module line and the finding. This review does not turn HTTP/3 off. If the version is in range and the finding is present, record either the October release or a change to that listener.

For CVE-2026-97629, the reviewer flags a server whose effective ssl_session_cache contains shared: when session tickets are not off and neither the server nor the enclosing http block sets ssl_session_ticket_key. The shared cache in this test was not written in the server file. It was in an included snippet. The spliced dump still produced ticket-rotation=ticket.invalid shared-cache-without-explicit-key. A server with the same shared cache and an explicit ssl_session_ticket_key did not appear. A server with ssl_session_tickets off did not appear. Those negative cases are why the finding is not “any line that says ssl_session_cache.”

The value used for the cache and the ticket directives is the one written on the server, otherwise the one written on http. nginx -T does not copy inherited directives into every server, and the SSL module also describes cases where the default server supplies a setting. The test servers set the directives directly, or through an include, so that default-server path was not what this run proved. If a server relies on the default server, read that server in the dump before you treat a missing line as absent.

For CVE-2026-97205, the reviewer flags a location where aio is on or threads and ssi is on, checking the location, then the server, then http. The test server produced aio-ssi=aio.invalid /. The review opened no SSI page. Before you treat the finding as exposed, look for include virtual in the files that location serves. An aio value set only inside an if block was not a tested shape.

Verify the report

Run the reviewer against the binary the service will actually start. nginx -T has to be allowed to read the configuration and the certificate files it names. Point --error-log at a writable file when the account running the check cannot open the default error log. The command tests and dumps. It is not a reload, and it is not a substitute for the service’s normal configuration review.

python3 review_nginx_october.py \
  --binary /usr/sbin/nginx \
  --error-log <review-error-log>

On an installed system, omit --config and --prefix so nginx uses its compiled defaults. The test run passed both because the binaries were unpacked rather than installed under /etc/nginx.

A fixed release looks like the 1.30.6 binary, abbreviated here to the lines that decide the result:

version=1.30.6
upstream_fixed=yes
naive_numeric_fixed=yes
CVE-2026-15176=fixed-release
http_v3_module=built
finding=config=not-requested
naive_comparison_mismatch=no

The 1.31.6 binary is the case to keep in the job output:

version=1.31.6
upstream_fixed=no
naive_numeric_fixed=yes
CVE-2026-15176=in-range
CVE-2026-93479=in-range
CVE-2026-95101=in-range
CVE-2026-97629=in-range
CVE-2026-97205=in-range
http_v3_module=built
naive_comparison_mismatch=yes

If naive_comparison_mismatch=yes, do not replace that line with “the version is newer than 1.30.6.” The mismatch is the failure. An in-range advisory is not cleared by a configuration finding, and a finding on a fixed-release binary means the condition is present in a build that contains the October fix. The reviewer exits 2 when the version string is not three numeric components, when nginx -T fails, or when an include cycle is detected. Do not mask that status. A failed dump is not an empty configuration.

Use the two answers that can disagree

Upgrade an unfixed official build to 1.30.6 or later on the stable line you already run, or to 1.31.7 or later on mainline. nginx 1.31.8 reported upstream_fixed=yes. Moving from 1.31.6 to 1.31.8 also picks up changes that are not part of the five security fixes, including the working_directory path change in 1.31.8. Read that interval in the changelog before you treat the move as security-only. Staying on 1.30 and installing 1.30.6 is the smaller step when the service is already on that line.

An older branch stays unfixed in this report. nginx does not list 1.28.3 as a fixed release. Clear that only with a vendor advisory that names the backport, or by leaving the branch. Do not change the comparison to a floor of 1.28.0.

If the release cannot be installed the day you run the report, the findings name the conditions to remove or confirm. Removing a shared session cache, setting ssl_session_tickets off, or setting ssl_session_ticket_key changes ticket handling. Those are the documented controls around automatic rotation, not a fix for the other four advisories. Turning aio off, or not serving SSI from a location that uses it, follows the changelog description of CVE-2026-97205 and still leaves the rest untouched. The fix nginx published is the release.

The report is fit for a job log when three things are true. The binary is the one the service starts. nginx -T succeeded. unresolved-include is absent. A mismatch, or an advisory still in-range, is then a failed review rather than a note to file.

The program that produced these results is below. Run that file, not a shortened paraphrase of the branch rule. The paraphrase is where 1.31.6 gets accepted.

#!/usr/bin/env python3
"""Review an nginx binary and configuration dump against the 14 October 2026 fixes.

The review is read-only. It does not start nginx, send requests, or change a
configuration. A matching condition is a reason to inspect that release, not
proof that a request can reach the vulnerable code.
"""

from __future__ import annotations

import argparse
import fnmatch
import re
import subprocess
import sys
from dataclasses import dataclass, field


Version = tuple[int, int, int]

# Inclusive vulnerable ranges from the nginx security advisories page and the
# 14 October 2026 entries in CHANGES and CHANGES-1.30.
ADVISORIES = (
    ("CVE-2026-15176", (((0, 1, 29), (1, 31, 6)),)),
    ("CVE-2026-93479", (((1, 30, 0), (1, 30, 5)), ((1, 31, 2), (1, 31, 6)))),
    ("CVE-2026-95101", (((1, 25, 0), (1, 31, 6)),)),
    ("CVE-2026-97629", (((1, 23, 2), (1, 31, 6)),)),
    ("CVE-2026-97205", (((0, 8, 11), (1, 31, 6)),)),
)


def parse_version(text: str) -> Version:
    match = re.search(r"(?:nginx/)?(\d+)\.(\d+)\.(\d+)", text)
    if not match:
        raise ValueError("no nginx MAJOR.MINOR.PATCH version found")
    return tuple(int(part) for part in match.groups())  # type: ignore[return-value]


def upstream_fixed(version: Version) -> bool:
    """True only for releases nginx lists as not vulnerable.

    1.30.6 and later on the 1.30 line are fixed. 1.31.7 and later are fixed.
    Comparing the whole version with >= 1.30.6 is wrong: 1.31.6 is numerically
    newer and is still inside every 14 October vulnerable range.
    """

    if version[:2] == (1, 30):
        return version[2] >= 6
    if version[:2] > (1, 30):
        return version >= (1, 31, 7)
    return False


def naive_numeric_fixed(version: Version) -> bool:
    return version >= (1, 30, 6)


def version_in_ranges(version: Version, ranges: tuple[tuple[Version, Version], ...]) -> bool:
    return any(start <= version <= end for start, end in ranges)


def lex(text: str) -> list[str]:
    tokens: list[str] = []
    i = 0
    length = len(text)
    while i < length:
        char = text[i]
        if char.isspace():
            i += 1
            continue
        if char == "#":
            newline = text.find("\n", i)
            if newline < 0:
                break
            i = newline + 1
            continue
        if char in "{};":
            tokens.append(char)
            i += 1
            continue
        if char in "\"'":
            quote = char
            i += 1
            start = i
            while i < length and text[i] != quote:
                i += 2 if text[i] == "\\" and i + 1 < length else 1
            tokens.append(text[start:i])
            i = min(i + 1, length)
            continue
        start = i
        while i < length and not text[i].isspace() and text[i] not in "{};#'\"":
            i += 1
        tokens.append(text[start:i])
    return tokens


@dataclass
class Block:
    name: str
    args: list[str]
    directives: list[tuple[str, list[str]]] = field(default_factory=list)
    children: list["Block"] = field(default_factory=list)


def parse_statements(tokens: list[str], index: int = 0) -> tuple[list[tuple], int]:
    statements: list[tuple] = []
    while index < len(tokens):
        if tokens[index] == "}":
            return statements, index
        if tokens[index] in "{;":
            raise ValueError(f"unexpected {tokens[index]}")
        words = [tokens[index]]
        index += 1
        while index < len(tokens) and tokens[index] not in "{;":
            words.append(tokens[index])
            index += 1
        if index >= len(tokens):
            raise ValueError("unterminated directive")
        if tokens[index] == ";":
            statements.append(("dir", words[0], words[1:]))
            index += 1
            continue
        nested, index = parse_statements(tokens, index + 1)
        if index >= len(tokens) or tokens[index] != "}":
            raise ValueError("unterminated block")
        statements.append(("block", words[0], words[1:], nested))
        index += 1
    return statements, index


def strip_status_lines(text: str) -> str:
    return "\n".join(line for line in text.splitlines() if not line.startswith("nginx:")) + "\n"


def split_dump(text: str) -> list[tuple[str, str]]:
    text = strip_status_lines(text)
    parts = re.split(r"(?m)^# configuration file (.+):\n", text)
    if len(parts) == 1:
        return [("<stdin>", text)]
    sections = []
    preamble = parts[0]
    if preamble.strip() and not preamble.lstrip().startswith("# configuration file"):
        sections.append(("<preamble>", preamble))
    for path, body in zip(parts[1::2], parts[2::2]):
        sections.append((path.strip(), body))
    return sections


def matching_sections(pattern: str, sections: list[tuple[str, str]]) -> list[str]:
    matched = []
    for path, _body in sections:
        if path in {"<preamble>", "<stdin>"}:
            continue
        if path == pattern or fnmatch.fnmatch(path, pattern):
            matched.append(path)
    return matched


def resolve(statements: list[tuple], sections: dict[str, str], stack: tuple[str, ...]) -> list[tuple]:
    resolved: list[tuple] = []
    for statement in statements:
        if statement[0] == "dir" and statement[1] == "include":
            pattern = statement[2][0] if statement[2] else ""
            matches = matching_sections(pattern, list(sections.items()))
            if not matches:
                resolved.append(("dir", "unresolved_include", [pattern]))
                continue
            for path in matches:
                if path in stack:
                    raise ValueError(f"include cycle at {path}")
                nested, end = parse_statements(lex(sections[path]))
                if end != len(lex(sections[path])):
                    raise ValueError(f"could not parse included file {path}")
                resolved.extend(resolve(nested, sections, stack + (path,)))
            continue
        if statement[0] == "block":
            _kind, name, args, nested = statement
            resolved.append(("block", name, args, resolve(nested, sections, stack)))
            continue
        resolved.append(statement)
    return resolved


def statements_to_block(name: str, args: list[str], statements: list[tuple]) -> Block:
    block = Block(name, args)
    for statement in statements:
        if statement[0] == "dir":
            block.directives.append((statement[1], list(statement[2])))
        else:
            block.children.append(statements_to_block(statement[1], list(statement[2]), statement[3]))
    return block


def load_config(text: str) -> Block:
    sections = split_dump(text)
    section_map = {path: body for path, body in sections}
    main_path, main_body = sections[0]
    if main_path == "<preamble>" and len(sections) > 1:
        main_path, main_body = sections[1]
    statements, end = parse_statements(lex(main_body))
    if end != len(lex(main_body)):
        raise ValueError("could not parse the main configuration")
    resolved = resolve(statements, section_map, (main_path,))
    return statements_to_block("main", [], resolved)


def first_directive(block: Block, name: str) -> list[str] | None:
    for directive, args in block.directives:
        if directive == name:
            return args
    return None


def has_directive(block: Block, name: str) -> bool:
    return any(directive == name for directive, _args in block.directives)


def rewrite_flags(block: Block) -> set[str]:
    flags: set[str] = set()
    for directive, args in block.directives:
        if directive == "rewrite" and args:
            flag = args[-1]
            flags.add(flag if flag in {"last", "break", "redirect", "permanent"} else "none")
    for child in block.children:
        if child.name == "if":
            flags.update(rewrite_flags(child))
    return flags


def directive_from(blocks: list[Block], name: str) -> list[str] | None:
    for block in blocks:
        found = first_directive(block, name)
        if found is not None:
            return found
    return None


def review_config(text: str) -> list[str]:
    root = load_config(text)
    findings: list[str] = []
    unresolved = [args[0] for name, args in root.directives if name == "unresolved_include" and args]
    for http_block in [child for child in root.children if child.name == "http"]:
        unresolved.extend(args[0] for name, args in http_block.directives if name == "unresolved_include" and args)
        for server in [child for child in http_block.children if child.name == "server"]:
            server_name = " ".join(first_directive(server, "server_name") or ["<unnamed>"])
            unresolved.extend(args[0] for name, args in server.directives if name == "unresolved_include" and args)
            locations = [child for child in server.children if child.name == "location"]
            server_flags = rewrite_flags(server)
            alias_locations = [loc for loc in locations if has_directive(loc, "alias")]
            other_last = any("last" in rewrite_flags(loc) for loc in locations if loc not in alias_locations)
            if alias_locations and (server_flags or any(rewrite_flags(loc) for loc in locations)):
                if any(rewrite_flags(loc) for loc in alias_locations):
                    relation = "same-location"
                elif server_flags:
                    relation = "server-rewrite"
                elif other_last:
                    relation = "rewrite-last-other-location"
                else:
                    relation = "not-paired"
                findings.append(f"alias-rewrite={server_name} {relation}")

            listens = [args for name, args in server.directives if name == "listen"]
            http3 = first_directive(server, "http3")
            if any("quic" in arg for args in listens for arg in args) or (http3 is not None and http3[:1] != ["off"]):
                findings.append(f"http3={server_name} enabled")

            cache = directive_from([server, http_block], "ssl_session_cache") or []
            tickets = directive_from([server, http_block], "ssl_session_tickets")
            tickets_on = tickets is None or tickets[:1] != ["off"]
            keyed = has_directive(server, "ssl_session_ticket_key") or has_directive(http_block, "ssl_session_ticket_key")
            if any(arg.startswith("shared:") for arg in cache) and tickets_on and not keyed:
                findings.append(f"ticket-rotation={server_name} shared-cache-without-explicit-key")

            for loc in locations:
                aio = directive_from([loc, server, http_block], "aio")
                ssi = directive_from([loc, server, http_block], "ssi")
                aio_on = bool(aio) and (aio[0] in {"on", "threads"} or aio[0].startswith("threads="))
                ssi_on = bool(ssi) and ssi[0] == "on"
                if aio_on and ssi_on:
                    findings.append(f"aio-ssi={server_name} {' '.join(loc.args) or '/'}")

    for pattern in unresolved:
        findings.append(f"unresolved-include={pattern}")
    if not findings:
        findings.append("config=no-october-condition")
    return findings


def run_nginx(binary: str, args: list[str]) -> subprocess.CompletedProcess[str]:
    return subprocess.run([binary, *args], check=False, text=True, capture_output=True)


def nginx_version_text(binary: str) -> str:
    result = run_nginx(binary, ["-v"])
    text = (result.stdout + result.stderr).strip()
    if result.returncode != 0 or "nginx/" not in text:
        raise RuntimeError(text or "nginx -v failed")
    return text


def nginx_built_with_http3(binary: str) -> bool:
    result = run_nginx(binary, ["-V"])
    return "--with-http_v3_module" in (result.stdout + result.stderr)


def nginx_dump(binary: str, config: str | None, prefix: str | None, error_log: str | None) -> str:
    args = ["-T"]
    if prefix:
        args.extend(["-p", prefix])
    if config:
        args.extend(["-c", config])
    if error_log:
        args.extend(["-e", error_log])
    result = run_nginx(binary, args)
    combined = result.stdout + result.stderr
    if result.returncode != 0 or "test is successful" not in combined:
        raise RuntimeError(combined.strip() or "nginx -T failed")
    marker = combined.find("# configuration file ")
    if marker < 0:
        raise RuntimeError("nginx -T produced no configuration dump")
    return strip_status_lines(combined[marker:])


def emit(version_text: str, configure_has_http3: bool | None, findings: list[str]) -> int:
    version = parse_version(version_text)
    print("version=" + ".".join(map(str, version)))
    print("upstream_fixed=" + ("yes" if upstream_fixed(version) else "no"))
    print("naive_numeric_fixed=" + ("yes" if naive_numeric_fixed(version) else "no"))
    for cve, ranges in ADVISORIES:
        if upstream_fixed(version):
            state = "fixed-release"
        elif version_in_ranges(version, ranges):
            state = "in-range"
        else:
            state = "outside-range"
        print(f"{cve}={state}")
    if configure_has_http3 is None:
        print("http_v3_module=not-checked")
    else:
        print("http_v3_module=" + ("built" if configure_has_http3 else "absent"))
    for finding in findings:
        print("finding=" + finding)
    mismatched = naive_numeric_fixed(version) and not upstream_fixed(version)
    print("naive_comparison_mismatch=" + ("yes" if mismatched else "no"))
    return 0


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--binary")
    parser.add_argument("--version-text")
    parser.add_argument("--config")
    parser.add_argument("--prefix")
    parser.add_argument("--error-log")
    parser.add_argument("--dump-file")
    args = parser.parse_args()
    if not args.binary and not args.version_text:
        parser.error("provide --binary or --version-text")

    version_text = args.version_text or nginx_version_text(args.binary)
    configure_has_http3 = nginx_built_with_http3(args.binary) if args.binary else None
    findings: list[str] = []
    if args.dump_file:
        findings = review_config(open(args.dump_file, encoding="utf-8").read())
    elif args.binary and (args.config or args.prefix):
        findings = review_config(nginx_dump(args.binary, args.config, args.prefix, args.error_log))
    else:
        findings = ["config=not-requested"]
    return emit(version_text, configure_has_http3, findings)


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except (OSError, RuntimeError, ValueError) as exc:
        print(f"ERROR={exc}", file=sys.stderr)
        raise SystemExit(2)

Sources