From ad2b6d1fee3c00d549098263a5b50ff08d6e4608 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Fri, 30 Oct 2026 11:21:27 -0500 Subject: [PATCH 1/2] fix(infra): mitigate CVE-2026-98432 on Latitude hosts --- infra/justfile | 1 + infra/nixos/hosts/finite-lat-2/default.nix | 1 + infra/nixos/hosts/finite-lat-3/default.nix | 1 + infra/nixos/hosts/finite-lat-4/default.nix | 1 + infra/nixos/hosts/finite-lat-5/default.nix | 1 + infra/nixos/hosts/finite-lat-7/default.nix | 1 + infra/nixos/modules/tcp-ip-options-guard.nix | 42 +++++++++ infra/runbooks/README.md | 1 + infra/runbooks/tcp-ip-options-guard.md | 93 +++++++++++++++++++ infra/scripts/mitigate-cve-2026-98432 | 27 ++++++ scripts/ci/select-harnesses | 2 + scripts/finite_status.py | 8 ++ scripts/finite_status_tcp_security.py | 61 ++++++++++++ .../tests/test_finite_status_tcp_security.py | 35 +++++++ 14 files changed, 275 insertions(+) create mode 100644 infra/nixos/modules/tcp-ip-options-guard.nix create mode 100644 infra/runbooks/tcp-ip-options-guard.md create mode 100755 infra/scripts/mitigate-cve-2026-98432 create mode 100644 scripts/finite_status_tcp_security.py create mode 100644 scripts/tests/test_finite_status_tcp_security.py diff --git a/infra/justfile b/infra/justfile index 7b41fd828..98caa1555 100644 --- a/infra/justfile +++ b/infra/justfile @@ -15,6 +15,7 @@ finite-status-contract: cd .. && python3 -m unittest discover -s scripts/tests -p 'test_finite_status_brain_scoped.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_finite_status_brain_protocol.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_finite_status_brain_bindings.py' + cd .. && python3 -m unittest discover -s scripts/tests -p 'test_finite_status_tcp_security.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_kata_guest_probe.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_kata_network_probe.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_runtime_route_status.py' diff --git a/infra/nixos/hosts/finite-lat-2/default.nix b/infra/nixos/hosts/finite-lat-2/default.nix index 6bae5a144..ed0d84566 100644 --- a/infra/nixos/hosts/finite-lat-2/default.nix +++ b/infra/nixos/hosts/finite-lat-2/default.nix @@ -87,6 +87,7 @@ in ../../modules/monitoring.nix ../../modules/finite-litestream.nix ../../modules/ssh-flood-limits.nix + ../../modules/tcp-ip-options-guard.nix ]; networking.hostName = "finite-lat-2"; diff --git a/infra/nixos/hosts/finite-lat-3/default.nix b/infra/nixos/hosts/finite-lat-3/default.nix index 956ea7e0e..97a0fea83 100644 --- a/infra/nixos/hosts/finite-lat-3/default.nix +++ b/infra/nixos/hosts/finite-lat-3/default.nix @@ -31,6 +31,7 @@ in ../../modules/kata-runner-host.nix ../../modules/metrics.nix ../../modules/ssh-flood-limits.nix + ../../modules/tcp-ip-options-guard.nix ]; networking.hostName = "finite-lat-3"; diff --git a/infra/nixos/hosts/finite-lat-4/default.nix b/infra/nixos/hosts/finite-lat-4/default.nix index 0af11b96a..73960377b 100644 --- a/infra/nixos/hosts/finite-lat-4/default.nix +++ b/infra/nixos/hosts/finite-lat-4/default.nix @@ -40,6 +40,7 @@ in ../../modules/kata-runner-host.nix ../../modules/metrics.nix ../../modules/ssh-flood-limits.nix + ../../modules/tcp-ip-options-guard.nix ]; networking.hostName = "finite-lat-4"; diff --git a/infra/nixos/hosts/finite-lat-5/default.nix b/infra/nixos/hosts/finite-lat-5/default.nix index a80df68ed..dcc98749f 100644 --- a/infra/nixos/hosts/finite-lat-5/default.nix +++ b/infra/nixos/hosts/finite-lat-5/default.nix @@ -36,6 +36,7 @@ in ../../modules/kata-runner-host.nix ../../modules/metrics.nix ../../modules/ssh-flood-limits.nix + ../../modules/tcp-ip-options-guard.nix ]; networking.hostName = "finite-lat-5"; diff --git a/infra/nixos/hosts/finite-lat-7/default.nix b/infra/nixos/hosts/finite-lat-7/default.nix index 8c88f7eb0..9cc0267d1 100644 --- a/infra/nixos/hosts/finite-lat-7/default.nix +++ b/infra/nixos/hosts/finite-lat-7/default.nix @@ -37,6 +37,7 @@ in ../../modules/kata-runner-host.nix ../../modules/metrics.nix ../../modules/ssh-flood-limits.nix + ../../modules/tcp-ip-options-guard.nix ]; networking.hostName = "finite-lat-7"; diff --git a/infra/nixos/modules/tcp-ip-options-guard.nix b/infra/nixos/modules/tcp-ip-options-guard.nix new file mode 100644 index 000000000..c22604dc1 --- /dev/null +++ b/infra/nixos/modules/tcp-ip-options-guard.nix @@ -0,0 +1,42 @@ +# CVE-2026-98432: protect host listeners and routed Kata traffic while the +# fleet moves to fixed host and guest kernels. No service restart is needed. +{ pkgs, ... }: +let + # First fixed release in the fleet's qualified 6.18 branch. Override only + # the kernel source, keeping the OS, applications, and Kata pins unchanged. + kernelVersion = "6.18.56"; + kernel = pkgs.linux_6_18.override { + argsOverride = { + version = kernelVersion; + modDirVersion = kernelVersion; + src = pkgs.fetchurl { + url = "https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${kernelVersion}.tar.xz"; + sha256 = "4633bbda2ab95eaf2a7af4041236451d6759c72e6946265531af4b3aacefbf89"; + }; + }; + }; +in +{ + boot.kernelPackages = pkgs.linuxPackagesFor kernel; + boot.kernel.sysctl."net.mptcp.enabled" = 0; + + systemd.services.finite-cve-2026-98432 = { + description = "Block IPv4 TCP IP options (CVE-2026-98432)"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-pre.target" ]; + before = [ "network-pre.target" ]; + after = [ "systemd-modules-load.service" ]; + unitConfig.DefaultDependencies = false; + path = [ + pkgs.iptables + pkgs.procps + ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + # Deliberately no ExecStop: stopping/replacing this unit must not reopen + # vulnerable listeners during a NixOS switch or rollback. + script = builtins.readFile ../../scripts/mitigate-cve-2026-98432; + }; +} diff --git a/infra/runbooks/README.md b/infra/runbooks/README.md index 4388d9969..af0efe876 100644 --- a/infra/runbooks/README.md +++ b/infra/runbooks/README.md @@ -28,6 +28,7 @@ fresh read-only evidence establish observed state. | Missing conversations | [Chat diagnosis](chats-appear-missing.md) | | Billing support | [Stripe](stripe-billing.md) | | Host access | [Break-glass](break-glass.md) | +| IPv4 TCP kernel vulnerability mitigation | [TCP IP-options guard](tcp-ip-options-guard.md) | ## Standing boundaries diff --git a/infra/runbooks/tcp-ip-options-guard.md b/infra/runbooks/tcp-ip-options-guard.md new file mode 100644 index 000000000..a46fe152b --- /dev/null +++ b/infra/runbooks/tcp-ip-options-guard.md @@ -0,0 +1,93 @@ +# IPv4 TCP options guard + +The Latitude hosts import `nixos/modules/tcp-ip-options-guard.nix` for +[CVE-2026-98432](https://git.kernel.org/pub/scm/linux/security/vulns.git/plain/cve/published/2026/CVE-2026-98432.mbox). +It pins the fixed 6.18 kernel source without advancing the OS, application, +or Kata inputs. The early boot service runs +`infra/scripts/mitigate-cve-2026-98432`: drop IPv4 TCP packets with IP options +in raw PREROUTING, then disable MPTCP. Ordinary TCP, IPv6, and UDP (including +WireGuard) do not match. IP options are distinct from TCP options such as MSS. + +The filter applies before conntrack, host listeners, and routed guest traffic. +It does not establish protection for guest-local or directly bridged traffic +that bypasses the host IPv4 stack. Keep it until the affected host and reachable +guest kernels are fixed. Disabling MPTCP alone does not retire existing +listeners, so do not remove the filter just because the sysctl is zero. + +## Observe and verify + +Run the current complete `finite-status` bundle as root on each host: + +```sh +scripts/finite-status --json +scripts/finite-status --tcp-security +iptables -t raw -L PREROUTING -nvx --line-numbers +``` + +The TCP probe requires the exact barrier to be the first raw PREROUTING rule, +MPTCP disabled, the guard service active, and its attachment to the boot target. +Green means the mitigation is present, not that the kernel is patched. Compare +`uname -r` after reboot against the reviewed fixed kernel artifact. No reboot +or product-service restart is needed to install the filter itself. + +## Emergency service and declarative handoff + +The emergency installer uses a writable operator unit because NixOS owns +`/etc/systemd/system` as a read-only store symlink. Its files are: + +- `/etc/finite/cve-2026-98432.sh` (the repository mitigation script) +- `/etc/finite/cve-2026-98432.service` (the operator unit source) +- `/etc/systemd/system.control/finite-cve-2026-98432.service` (symlink) +- `/etc/systemd/system.control/multi-user.target.wants/finite-cve-2026-98432.service` + (boot dependency symlink) + +The unit is a oneshot ordered before `network-pre.target`, after module loading, +with `DefaultDependencies=no`, `RemainAfterExit=yes`, and no `ExecStop`. +It invokes the script with `/run/current-system/sw/bin/bash` and +`PATH=/run/current-system/sw/bin:/run/wrappers/bin`. The operator unit remains +authoritative across reboot and NixOS activation until explicitly removed. + +Deploy the reviewed CI closure through the host's normal runbook. Verify that +the new closure contains the guard unit before removing the operator override: + +```sh +test -e /run/current-system/etc/systemd/system/finite-cve-2026-98432.service +rm /etc/systemd/system.control/multi-user.target.wants/finite-cve-2026-98432.service +rm /etc/systemd/system.control/finite-cve-2026-98432.service +systemctl daemon-reload +systemctl restart finite-cve-2026-98432.service +scripts/finite-status --tcp-security +``` + +Restart only the guard: it has no stop action and its apply operation is +idempotent, so the existing filter stays installed throughout the handoff. +Verify the probe's `unit_fragment` resolves to the declarative unit, then remove +the two obsolete `/etc/finite/cve-2026-98432.*` files. Follow the normal +Runner drain and reboot procedures to replace the running kernel; switching +the closure alone does not replace it. Do not combine this with Runtime +replacement or user-data repair. + +## Rollback boundary + +The live mitigation changes only packet filtering, the MPTCP sysctl, and its +operator boot service. It does not change chat stores, identity, Runtime +assignments, volumes, or recovery material. No data restore is needed to undo +it. Pre-change firewall, sysctl, service, and canonical status evidence is kept +in `/run/finite-cve-2026-98432/` until reboot; copy required evidence off-host +before rebooting. Never restore the entire old firewall over concurrent changes. + +Removing this mitigation reopens the vulnerability on an unpatched kernel. +If separately authorized because it causes an incident, remove only the guard's +unit and exact rule, and restore the recorded pre-change MPTCP value: + +```sh +systemctl stop finite-cve-2026-98432.service +# Remove its boot symlink/unit using the operator or declarative owner above. +iptables -w 10 -t raw -D PREROUTING -p tcp -m u32 --u32 '0>>24&0xF=6:15' \ + -m comment --comment finite-cve-2026-98432 -j DROP +sysctl -w net.mptcp.enabled="$(cat /run/finite-cve-2026-98432/mptcp-before)" +``` + +Run both status modes and product probes before and after any rollback. A +binary or boot rollback to the previous vulnerable kernel must retain the +guard and operator boot unit. diff --git a/infra/scripts/mitigate-cve-2026-98432 b/infra/scripts/mitigate-cve-2026-98432 new file mode 100755 index 000000000..5d689d1f9 --- /dev/null +++ b/infra/scripts/mitigate-cve-2026-98432 @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Temporary defense for the IPv4 request-socket options UAF. Keep this rule +# until every host and reachable guest kernel carries the upstream fix. +set -euo pipefail + +rule=( + -p tcp -m u32 --u32 '0>>24&0xF=6:15' + -m comment --comment finite-cve-2026-98432 -j DROP +) + +case "${1:---apply}" in + --apply) + # Raw PREROUTING covers local delivery and routed container traffic before + # conntrack and before any established-connection or port allow rules. + if ! iptables -w 10 -t raw -C PREROUTING "${rule[@]}" 2>/dev/null; then + iptables -w 10 -t raw -I PREROUTING 1 "${rule[@]}" + fi + # Existing MPTCP listeners can survive this sysctl; the packet filter is + # the immediate protection and must be installed first. + sysctl -w net.mptcp.enabled=0 + ;; + --check) + test "$(sysctl -n net.mptcp.enabled)" = 0 + iptables -w 10 -t raw -C PREROUTING "${rule[@]}" + ;; + *) echo "usage: $0 [--apply|--check]" >&2; exit 64 ;; +esac diff --git a/scripts/ci/select-harnesses b/scripts/ci/select-harnesses index bf797ecff..7c589e0d7 100755 --- a/scripts/ci/select-harnesses +++ b/scripts/ci/select-harnesses @@ -102,12 +102,14 @@ FINITE_STATUS_PATHS = { "scripts/finite_status_hosted_identity.py", "scripts/finite_status_brain_scoped.py", "scripts/finite_status_brain_protocol.py", + "scripts/finite_status_tcp_security.py", "scripts/check_finite_status_contract.py", "scripts/tests/test_finite_status.py", "scripts/tests/test_finite_status_brain_roster.py", "scripts/tests/test_finite_status_hosted_identity.py", "scripts/tests/test_finite_status_brain_scoped.py", "scripts/tests/test_finite_status_brain_protocol.py", + "scripts/tests/test_finite_status_tcp_security.py", "scripts/tests/fixtures/finite_status_aug1.json", } diff --git a/scripts/finite_status.py b/scripts/finite_status.py index d7a4c609f..9d90c504f 100644 --- a/scripts/finite_status.py +++ b/scripts/finite_status.py @@ -243,6 +243,7 @@ "finite_status_brain_bindings.py", "finite_status_brain_protocol.py", "finite_tinfoil_status.py", + "finite_status_tcp_security.py", "kata_guest_probe.py", "kata_network_probe.py", ) @@ -4896,6 +4897,8 @@ def parse_args(arguments: list[str]) -> argparse.Namespace: help="read one Project's exact Core recovery inputs without credential values; emits JSON") mode.add_argument("--kata-recovery-host", action="store_true", help="read host prerequisites for an isolated Kata recovery rehearsal; emits JSON") + mode.add_argument("--tcp-security", action="store_true", + help="read CVE-2026-98432 packet-filter, MPTCP, and boot-persistence evidence; emits JSON") mode.add_argument("--runtime-route", metavar="SOURCE_MACHINE_ID", help="compare one owned Kata Runtime's direct and published identity routes") mode.add_argument("--runtime-lifecycle", nargs=4, @@ -5003,6 +5006,10 @@ def main(arguments: list[str] | None = None) -> None: elif options.runtime_lifecycle: report = collect_runtime_lifecycle(*options.runtime_lifecycle, guest_agent_probe=options.guest_agent_probe) + elif options.tcp_security: + from finite_status_tcp_security import collect + + report = collect() elif options.tinfoil: from finite_tinfoil_status import collect @@ -5049,6 +5056,7 @@ def main(arguments: list[str] | None = None) -> None: } if ( options.json + or options.tcp_security or options.tinfoil or options.finite_private_usage or options.brain_identity diff --git a/scripts/finite_status_tcp_security.py b/scripts/finite_status_tcp_security.py new file mode 100644 index 000000000..a91f49eee --- /dev/null +++ b/scripts/finite_status_tcp_security.py @@ -0,0 +1,61 @@ +"""Read-only CVE-2026-98432 mitigation evidence for the Latitude fleet.""" + +from __future__ import annotations + +import platform +import shlex +import subprocess +from datetime import datetime, timezone + + +RULE = [ + "-A", "PREROUTING", "-p", "tcp", "-m", "u32", "--u32", + "0x0>>0x18&0xf=0x6:0xf", "-m", "comment", "--comment", + "finite-cve-2026-98432", "-j", "DROP", +] + + +def verdict(mptcp: str, rules: str, unit: dict[str, str]) -> dict: + """Require the early packet barrier, not just a disabled MPTCP sysctl.""" + ordered = [shlex.split(line) for line in rules.splitlines() if line.startswith("-A ")] + barrier = bool(ordered and ordered[0] == RULE) + boot_attached = "multi-user.target" in unit.get("WantedBy", "").split() + active = unit.get("ActiveState") == "active" + mitigated = barrier and mptcp.strip() == "0" and active and boot_attached + return { + "status": "green" if mitigated else "red", + "state": "mitigated" if mitigated else "mitigation-incomplete", + "mptcp_disabled": mptcp.strip() == "0", + "ipv4_tcp_options_drop_first": barrier, + "unit_active": active, + "unit_attached_to_boot": boot_attached, + "unit_fragment": unit.get("FragmentPath"), + "scope": "host IPv4 PREROUTING, including routed guest traffic; not a guest-kernel patch audit", + "kernel_fix": "6.18.56 is the first fixed upstream 6.18 release; mitigation is not a kernel patch", + } + + +def collect() -> dict: + def run(*args: str) -> str: + result = subprocess.run(args, capture_output=True, text=True, timeout=15, check=True) + return result.stdout + + try: + mptcp = run("sysctl", "-n", "net.mptcp.enabled") + rules = run("iptables", "-w", "10", "-t", "raw", "-S", "PREROUTING") + properties = run( + "systemctl", "show", "finite-cve-2026-98432.service", + "-p", "ActiveState", "-p", "WantedBy", "-p", "FragmentPath", + ) + unit = dict(line.split("=", 1) for line in properties.splitlines() if "=" in line) + section = verdict(mptcp, rules, unit) + except (OSError, subprocess.SubprocessError, ValueError) as error: + section = {"status": "unknown", "state": "unavailable", "error": str(error)} + section["running_kernel"] = platform.release() + return { + "schema_version": "finite.status.v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "overall_status": section["status"], + "exit_code": {"green": 0, "red": 1, "unknown": 2}[section["status"]], + "sections": {"tcp_security": section}, + } diff --git a/scripts/tests/test_finite_status_tcp_security.py b/scripts/tests/test_finite_status_tcp_security.py new file mode 100644 index 000000000..a500033cb --- /dev/null +++ b/scripts/tests/test_finite_status_tcp_security.py @@ -0,0 +1,35 @@ +"""Avoid reporting green for incomplete emergency mitigations.""" + +import importlib.util +import shlex +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "tcp_security", Path(__file__).resolve().parents[1] / "finite_status_tcp_security.py" +) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class TcpSecurityTests(unittest.TestCase): + def setUp(self): + self.rule = shlex.join(module.RULE) + self.unit = {"ActiveState": "active", "WantedBy": "multi-user.target"} + + def test_complete_mitigation(self): + self.assertEqual(module.verdict("0\n", self.rule, self.unit)["state"], "mitigated") + + def test_sysctl_alone_cannot_protect_existing_listeners(self): + self.assertEqual(module.verdict("0", "", self.unit)["status"], "red") + + def test_earlier_accept_rule_cannot_bypass_barrier(self): + rules = "-A PREROUTING -j ACCEPT\n" + self.rule + self.assertEqual(module.verdict("0", rules, self.unit)["status"], "red") + + def test_unrelated_drop_is_not_evidence(self): + rules = self.rule.replace("6:0xf", "7:0xf") + self.assertEqual(module.verdict("0", rules, self.unit)["status"], "red") + + def test_runtime_only_unit_is_not_persistent(self): + self.assertEqual(module.verdict("0", self.rule, {"ActiveState": "active"})["status"], "red") From 59d7b147bdd93a050515b0c7d68c317bd7288736 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Fri, 30 Oct 2026 11:42:02 -0500 Subject: [PATCH 2/2] fix(infra): enforce guard ordering and persistent boot evidence --- infra/justfile | 1 + infra/runbooks/tcp-ip-options-guard.md | 16 ++- infra/scripts/mitigate-cve-2026-98432 | 14 ++- scripts/ci/select-harnesses | 2 + scripts/finite_status_tcp_security.py | 69 ++++++++++- .../tests/test_finite_status_tcp_security.py | 115 +++++++++++++++-- scripts/tests/test_tcp_ip_options_guard.py | 117 ++++++++++++++++++ 7 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 scripts/tests/test_tcp_ip_options_guard.py diff --git a/infra/justfile b/infra/justfile index 98caa1555..6c85dc511 100644 --- a/infra/justfile +++ b/infra/justfile @@ -16,6 +16,7 @@ finite-status-contract: cd .. && python3 -m unittest discover -s scripts/tests -p 'test_finite_status_brain_protocol.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_finite_status_brain_bindings.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_finite_status_tcp_security.py' + cd .. && python3 -m unittest discover -s scripts/tests -p 'test_tcp_ip_options_guard.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_kata_guest_probe.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_kata_network_probe.py' cd .. && python3 -m unittest discover -s scripts/tests -p 'test_runtime_route_status.py' diff --git a/infra/runbooks/tcp-ip-options-guard.md b/infra/runbooks/tcp-ip-options-guard.md index a46fe152b..61aed74bb 100644 --- a/infra/runbooks/tcp-ip-options-guard.md +++ b/infra/runbooks/tcp-ip-options-guard.md @@ -25,11 +25,18 @@ iptables -t raw -L PREROUTING -nvx --line-numbers ``` The TCP probe requires the exact barrier to be the first raw PREROUTING rule, -MPTCP disabled, the guard service active, and its attachment to the boot target. +MPTCP disabled, the guard service active, and a persistent unit plus matching +boot dependency on disk. A loaded `WantedBy` property alone is insufficient: +runtime-enabled units and dependencies under `/run` disappear on reboot. Green means the mitigation is present, not that the kernel is patched. Compare `uname -r` after reboot against the reviewed fixed kernel artifact. No reboot or product-service restart is needed to install the filter itself. +Reapplying the guard repairs ordering by inserting a new first rule whenever +the existing barrier is absent or below another rule. It keeps older copies +instead of deleting by rule number, preserving protection and unrelated rules +throughout the repair. Repeated application with the barrier first adds nothing. + ## Emergency service and declarative handoff The emergency installer uses a writable operator unit because NixOS owns @@ -83,8 +90,11 @@ unit and exact rule, and restore the recorded pre-change MPTCP value: ```sh systemctl stop finite-cve-2026-98432.service # Remove its boot symlink/unit using the operator or declarative owner above. -iptables -w 10 -t raw -D PREROUTING -p tcp -m u32 --u32 '0>>24&0xF=6:15' \ - -m comment --comment finite-cve-2026-98432 -j DROP +while iptables -w 10 -t raw -C PREROUTING -p tcp -m u32 --u32 '0>>24&0xF=6:15' \ + -m comment --comment finite-cve-2026-98432 -j DROP; do + iptables -w 10 -t raw -D PREROUTING -p tcp -m u32 --u32 '0>>24&0xF=6:15' \ + -m comment --comment finite-cve-2026-98432 -j DROP +done sysctl -w net.mptcp.enabled="$(cat /run/finite-cve-2026-98432/mptcp-before)" ``` diff --git a/infra/scripts/mitigate-cve-2026-98432 b/infra/scripts/mitigate-cve-2026-98432 index 5d689d1f9..dc29ce8f7 100755 --- a/infra/scripts/mitigate-cve-2026-98432 +++ b/infra/scripts/mitigate-cve-2026-98432 @@ -8,20 +8,30 @@ rule=( -m comment --comment finite-cve-2026-98432 -j DROP ) +# Match the normalized output of the fleet's pinned iptables. Presence later +# in the chain is insufficient: an earlier ACCEPT can bypass the barrier. +first_rule='-A PREROUTING -p tcp -m u32 --u32 "0x0>>0x18&0xf=0x6:0xf" -m comment --comment finite-cve-2026-98432 -j DROP' +barrier_first() { + test "$(iptables -w 10 -t raw -S PREROUTING 1)" = "$first_rule" +} + case "${1:---apply}" in --apply) # Raw PREROUTING covers local delivery and routed container traffic before # conntrack and before any established-connection or port allow rules. - if ! iptables -w 10 -t raw -C PREROUTING "${rule[@]}" 2>/dev/null; then + if ! barrier_first 2>/dev/null; then + # Insert before removing anything. Retain any older copy farther down: + # deleting by rule number could race with unrelated firewall changes. iptables -w 10 -t raw -I PREROUTING 1 "${rule[@]}" fi + barrier_first # Existing MPTCP listeners can survive this sysctl; the packet filter is # the immediate protection and must be installed first. sysctl -w net.mptcp.enabled=0 ;; --check) test "$(sysctl -n net.mptcp.enabled)" = 0 - iptables -w 10 -t raw -C PREROUTING "${rule[@]}" + barrier_first ;; *) echo "usage: $0 [--apply|--check]" >&2; exit 64 ;; esac diff --git a/scripts/ci/select-harnesses b/scripts/ci/select-harnesses index 7c589e0d7..e0e34638c 100755 --- a/scripts/ci/select-harnesses +++ b/scripts/ci/select-harnesses @@ -103,6 +103,7 @@ FINITE_STATUS_PATHS = { "scripts/finite_status_brain_scoped.py", "scripts/finite_status_brain_protocol.py", "scripts/finite_status_tcp_security.py", + "infra/scripts/mitigate-cve-2026-98432", "scripts/check_finite_status_contract.py", "scripts/tests/test_finite_status.py", "scripts/tests/test_finite_status_brain_roster.py", @@ -110,6 +111,7 @@ FINITE_STATUS_PATHS = { "scripts/tests/test_finite_status_brain_scoped.py", "scripts/tests/test_finite_status_brain_protocol.py", "scripts/tests/test_finite_status_tcp_security.py", + "scripts/tests/test_tcp_ip_options_guard.py", "scripts/tests/fixtures/finite_status_aug1.json", } diff --git a/scripts/finite_status_tcp_security.py b/scripts/finite_status_tcp_security.py index a91f49eee..84334ac9f 100644 --- a/scripts/finite_status_tcp_security.py +++ b/scripts/finite_status_tcp_security.py @@ -6,6 +6,7 @@ import shlex import subprocess from datetime import datetime, timezone +from pathlib import Path RULE = [ @@ -13,13 +14,72 @@ "0x0>>0x18&0xf=0x6:0xf", "-m", "comment", "--comment", "finite-cve-2026-98432", "-j", "DROP", ] +UNIT = "finite-cve-2026-98432.service" +PERSISTENT_UNIT_DIRS = ("/etc/systemd/system.control", "/etc/systemd/system") -def verdict(mptcp: str, rules: str, unit: dict[str, str]) -> dict: +def persistent_boot(unit: dict[str, str], root: Path) -> dict: + """Verify the loaded unit and a matching boot link survive loss of /run.""" + evidence = {"unit_persistent": False, "persistent_boot_link": None} + + def persistent_path(logical: str) -> Path: + path = Path(logical) + if not path.is_absolute() or ".." in path.parts: + raise ValueError("invalid absolute unit path") + # Check both the original location and symlink destinations. An /etc + # alias to a /run unit (or wants directory) is still runtime-only. + prefixes = ("/etc/", "/nix/store/") + if not logical.startswith(prefixes): + raise ValueError("unit path is not persistent") + mapped = root / path.relative_to("/") + for candidate in (mapped.parent, mapped): + physical = candidate.resolve(strict=True) + resolved = "/" + physical.relative_to(root).as_posix() + if not resolved.startswith(prefixes): + raise ValueError("unit path resolves outside persistent storage") + if not mapped.is_file(): + raise ValueError("unit path is not a file") + return mapped.resolve(strict=True) + + try: + active = persistent_path(unit.get("FragmentPath", "")) + except (OSError, RuntimeError, ValueError): + return evidence + for directory in PERSISTENT_UNIT_DIRS: + try: + logical = f"{directory}/{UNIT}" + candidate = root / logical.lstrip("/") + if candidate.exists() or candidate.is_symlink(): + evidence["unit_persistent"] = persistent_path(logical) == active + break + except (OSError, RuntimeError, ValueError): + # A higher-priority mask, broken link, or runtime alias must not + # be ignored in favor of a lower-priority persistent definition. + return evidence + if not evidence["unit_persistent"]: + return evidence + for directory in PERSISTENT_UNIT_DIRS: + link = f"{directory}/multi-user.target.wants/{UNIT}" + try: + candidate = root / link.lstrip("/") + if candidate.exists() or candidate.is_symlink(): + if candidate.is_symlink() and persistent_path(link) == active: + evidence["persistent_boot_link"] = link + break + except (OSError, RuntimeError, ValueError): + break + return evidence + + +def verdict(mptcp: str, rules: str, unit: dict[str, str], *, root: Path = Path("/")) -> dict: """Require the early packet barrier, not just a disabled MPTCP sysctl.""" ordered = [shlex.split(line) for line in rules.splitlines() if line.startswith("-A ")] barrier = bool(ordered and ordered[0] == RULE) - boot_attached = "multi-user.target" in unit.get("WantedBy", "").split() + persistence = persistent_boot(unit, root) + boot_attached = ( + "multi-user.target" in unit.get("WantedBy", "").split() + and persistence["persistent_boot_link"] is not None + ) active = unit.get("ActiveState") == "active" mitigated = barrier and mptcp.strip() == "0" and active and boot_attached return { @@ -30,12 +90,13 @@ def verdict(mptcp: str, rules: str, unit: dict[str, str]) -> dict: "unit_active": active, "unit_attached_to_boot": boot_attached, "unit_fragment": unit.get("FragmentPath"), + **persistence, "scope": "host IPv4 PREROUTING, including routed guest traffic; not a guest-kernel patch audit", "kernel_fix": "6.18.56 is the first fixed upstream 6.18 release; mitigation is not a kernel patch", } -def collect() -> dict: +def collect(*, root: Path = Path("/")) -> dict: def run(*args: str) -> str: result = subprocess.run(args, capture_output=True, text=True, timeout=15, check=True) return result.stdout @@ -48,7 +109,7 @@ def run(*args: str) -> str: "-p", "ActiveState", "-p", "WantedBy", "-p", "FragmentPath", ) unit = dict(line.split("=", 1) for line in properties.splitlines() if "=" in line) - section = verdict(mptcp, rules, unit) + section = verdict(mptcp, rules, unit, root=root) except (OSError, subprocess.SubprocessError, ValueError) as error: section = {"status": "unknown", "state": "unavailable", "error": str(error)} section["running_kernel"] = platform.release() diff --git a/scripts/tests/test_finite_status_tcp_security.py b/scripts/tests/test_finite_status_tcp_security.py index a500033cb..0bb4c1057 100644 --- a/scripts/tests/test_finite_status_tcp_security.py +++ b/scripts/tests/test_finite_status_tcp_security.py @@ -2,8 +2,11 @@ import importlib.util import shlex +import subprocess +import tempfile import unittest from pathlib import Path +from unittest import mock spec = importlib.util.spec_from_file_location( "tcp_security", Path(__file__).resolve().parents[1] / "finite_status_tcp_security.py" @@ -14,22 +17,120 @@ class TcpSecurityTests(unittest.TestCase): def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() self.rule = shlex.join(module.RULE) - self.unit = {"ActiveState": "active", "WantedBy": "multi-user.target"} + self.unit = { + "ActiveState": "active", "WantedBy": "multi-user.target", + "FragmentPath": f"/etc/systemd/system.control/{module.UNIT}", + } + source = self.root / "etc/finite/guard.service" + source.parent.mkdir(parents=True) + source.write_text("[Service]\nType=oneshot\n") + self.attach("etc/systemd/system.control", source) + + def attach(self, directory, source): + directory = self.root / directory + wants = directory / "multi-user.target.wants" + wants.mkdir(parents=True) + (directory / module.UNIT).symlink_to(source) + (wants / module.UNIT).symlink_to(source) + + def verdict(self, mptcp="0", rules=None, unit=None): + return module.verdict( + mptcp, self.rule if rules is None else rules, + self.unit if unit is None else unit, root=self.root, + ) def test_complete_mitigation(self): - self.assertEqual(module.verdict("0\n", self.rule, self.unit)["state"], "mitigated") + self.assertEqual(self.verdict("0\n")["state"], "mitigated") def test_sysctl_alone_cannot_protect_existing_listeners(self): - self.assertEqual(module.verdict("0", "", self.unit)["status"], "red") + self.assertEqual(self.verdict(rules="")["status"], "red") def test_earlier_accept_rule_cannot_bypass_barrier(self): rules = "-A PREROUTING -j ACCEPT\n" + self.rule - self.assertEqual(module.verdict("0", rules, self.unit)["status"], "red") + self.assertEqual(self.verdict(rules=rules)["status"], "red") def test_unrelated_drop_is_not_evidence(self): rules = self.rule.replace("6:0xf", "7:0xf") - self.assertEqual(module.verdict("0", rules, self.unit)["status"], "red") + self.assertEqual(self.verdict(rules=rules)["status"], "red") + + def test_missing_loaded_boot_dependency_is_not_green(self): + self.assertEqual(self.verdict(unit={**self.unit, "WantedBy": ""})["status"], "red") + + def test_active_enabled_runtime_unit_is_not_persistent(self): + source = self.root / "run/systemd/system/guard.service" + source.parent.mkdir(parents=True) + source.write_text("[Service]\nType=oneshot\n") + self.attach("run/systemd/system/units", source) + self.unit["FragmentPath"] = "/run/systemd/system/guard.service" + self.assertEqual(self.verdict()["status"], "red") + + def test_persistent_unit_with_only_runtime_wants_link_is_red(self): + link = self.root / f"etc/systemd/system.control/multi-user.target.wants/{module.UNIT}" + link.unlink() + self.attach("run/systemd/system", self.root / "etc/finite/guard.service") + self.assertEqual(self.verdict()["status"], "red") + + def test_etc_alias_to_runtime_unit_is_red(self): + source = self.root / "run/guard.service" + source.parent.mkdir() + source.write_text("[Service]\nType=oneshot\n") + for relative in (module.UNIT, f"multi-user.target.wants/{module.UNIT}"): + path = self.root / "etc/systemd/system.control" / relative + path.unlink() + path.symlink_to(source) + self.assertEqual(self.verdict()["status"], "red") + + def test_runtime_backed_wants_directory_is_red(self): + wants = self.root / "etc/systemd/system.control/multi-user.target.wants" + runtime = self.root / "run/wants" + runtime.parent.mkdir() + wants.rename(runtime) + wants.symlink_to(runtime) + self.assertEqual(self.verdict()["status"], "red") + + def nixos_handoff(self): + for relative in (module.UNIT, f"multi-user.target.wants/{module.UNIT}"): + (self.root / "etc/systemd/system.control" / relative).unlink() + source = self.root / f"nix/store/unit-guard/{module.UNIT}" + source.parent.mkdir(parents=True) + source.write_text("[Service]\nType=oneshot\n") + directory = self.root / "nix/store/system/etc/systemd/system" + directory.mkdir(parents=True) + (self.root / "etc/systemd/system").symlink_to(directory) + self.attach("etc/systemd/system", source) + self.unit["FragmentPath"] = f"/nix/store/unit-guard/{module.UNIT}" + + def test_declarative_nixos_unit_and_wants_symlinks_are_persistent(self): + self.nixos_handoff() + self.assertEqual(self.verdict()["status"], "green") + + def test_higher_priority_override_cannot_be_ignored(self): + self.nixos_handoff() + override = self.root / "etc/finite/override.service" + override.write_text("[Service]\nType=oneshot\n") + (self.root / f"etc/systemd/system.control/{module.UNIT}").symlink_to(override) + self.assertEqual(self.verdict()["status"], "red") + + def test_boot_link_must_resolve_to_the_loaded_unit(self): + link = self.root / f"etc/systemd/system.control/multi-user.target.wants/{module.UNIT}" + other = self.root / "etc/finite/other.service" + other.write_text("[Service]\nType=oneshot\n") + link.unlink() + link.symlink_to(other) + self.assertEqual(self.verdict()["status"], "red") - def test_runtime_only_unit_is_not_persistent(self): - self.assertEqual(module.verdict("0", self.rule, {"ActiveState": "active"})["status"], "red") + def test_collector_rejects_enabled_runtime_with_wantedby_present(self): + properties = ( + "ActiveState=active\nWantedBy=multi-user.target\n" + f"FragmentPath=/run/systemd/system/{module.UNIT}\n" + ) + outputs = ["0\n", self.rule, properties] + with mock.patch.object(module.subprocess, "run", side_effect=[ + subprocess.CompletedProcess([], 0, stdout=text) for text in outputs + ]): + result = module.collect(root=self.root) + self.assertEqual(result["overall_status"], "red") diff --git a/scripts/tests/test_tcp_ip_options_guard.py b/scripts/tests/test_tcp_ip_options_guard.py new file mode 100644 index 000000000..04be5a5a6 --- /dev/null +++ b/scripts/tests/test_tcp_ip_options_guard.py @@ -0,0 +1,117 @@ +"""Exercise the actual guard script against stateful firewall/sysctl fixtures.""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[2] / "infra/scripts/mitigate-cve-2026-98432" +GUARD = '-A PREROUTING -p tcp -m u32 --u32 "0x0>>0x18&0xf=0x6:0xf" -m comment --comment finite-cve-2026-98432 -j DROP' +ACCEPT = "-A PREROUTING -s 192.0.2.1/32 -j ACCEPT" +OTHER = "-A PREROUTING -p udp -j ACCEPT" +SHIM = r''' +import json, os, sys +from pathlib import Path +p = Path(os.environ['GUARD_STATE']) +s = json.loads(p.read_text()) +a = sys.argv[1:] +command = Path(sys.argv[0]).name +s['events'].append([command, *a]) +code = 0 +output = '' +if command == 'iptables': + assert a[:4] == ['-w', '10', '-t', 'raw'], a + if a[4:] == ['-S', 'PREROUTING', '1']: + if s['rules']: + output = s['rules'][0] + else: + code = 1 + else: + assert a[4:7] == ['-I', 'PREROUTING', '1'], a + assert a[7:] == ['-p','tcp','-m','u32','--u32','0>>24&0xF=6:15', + '-m','comment','--comment','finite-cve-2026-98432','-j','DROP'], a + if s.get('fail_insert'): + code = 1 + else: + s['rules'].insert(0, os.environ['GUARD_CANONICAL']) +elif a == ['-n', 'net.mptcp.enabled']: + output = s['mptcp'] +else: + assert command == 'sysctl' and a == ['-w', 'net.mptcp.enabled=0'], a + if s.get('fail_sysctl'): + code = 1 + else: + s['mptcp'] = '0' +p.write_text(json.dumps(s)) +print(output) +sys.exit(code) +''' + + +class GuardScriptTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.state_path = self.root / "state.json" + shim = self.root / "shim" + shim.write_text(f"#!{sys.executable}\n" + SHIM) + shim.chmod(0o755) + for command in ("iptables", "sysctl"): + (self.root / command).symlink_to(shim) + self.environment = { + **os.environ, "PATH": f"{self.root}:{os.environ['PATH']}", + "GUARD_STATE": str(self.state_path), "GUARD_CANONICAL": GUARD, + } + + def state(self, rules=(), **extra): + self.state_path.write_text(json.dumps({ + "rules": list(rules), "mptcp": "1", "events": [], **extra, + })) + + def run_script(self, argument="--apply", success=True): + result = subprocess.run( + ["bash", str(SCRIPT), argument], env=self.environment, + capture_output=True, text=True, + ) + self.assertEqual(result.returncode == 0, success, result.stderr) + return json.loads(self.state_path.read_text()) + + def test_clean_and_repeated_apply_preserve_unrelated_rules(self): + self.state([OTHER]) + self.run_script() + state = self.run_script() + self.assertEqual(state['rules'], [GUARD, OTHER]) + self.assertEqual(sum('-I' in event for event in state['events']), 1) + self.run_script('--check') + + def test_guard_behind_accept_is_rejected_then_repaired_without_a_gap(self): + self.state([ACCEPT, GUARD, OTHER], mptcp='0') + self.run_script('--check', success=False) + state = self.run_script() + # The previous guard stays in place while the new first rule is added. + # No delete/flush or rule-number deletion can affect concurrent rules. + self.assertEqual(state['rules'], [GUARD, ACCEPT, GUARD, OTHER]) + self.run_script('--check') + self.assertEqual(self.run_script()['rules'], state['rules']) + + def test_failed_insert_does_not_disable_mptcp_or_remove_existing_rules(self): + self.state([ACCEPT, GUARD], fail_insert=True) + state = self.run_script(success=False) + self.assertEqual(state['rules'], [ACCEPT, GUARD]) + self.assertEqual(state['mptcp'], '1') + + def test_failed_sysctl_keeps_packet_barrier_and_check_fails(self): + self.state([OTHER], fail_sysctl=True) + state = self.run_script(success=False) + self.assertEqual(state['rules'], [GUARD, OTHER]) + self.run_script('--check', success=False) + + def test_invalid_argument_changes_nothing(self): + self.state([OTHER]) + state = self.run_script('--invalid', success=False) + self.assertEqual(state['events'], [])