From 5e83c9e48dffef18ac3c64184e78001b90269c3d Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 10 Apr 2026 16:06:36 -0400 Subject: [PATCH] =?UTF-8?q?feat(os=5Ffingerprint):=20Phase=201=20=E2=80=94?= =?UTF-8?q?=20extend=20OS=20sysctls=20with=206=20new=20fingerprint=20knobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tcp_timestamps, tcp_window_scaling, tcp_sack, tcp_ecn, ip_no_pmtu_disc, and tcp_fin_timeout to every OS profile in OS_SYSCTLS. All 6 are network-namespace-scoped and safe to set per-container without --privileged. They directly influence nmap's OPS, WIN, ECN, and T2-T6 probe groups, making OS family detection significantly more convincing. Key changes: - tcp_timestamps=0 for windows/embedded/cisco (strongest Windows discriminator) - tcp_ecn=2 for linux (ECN offer), 0 for all others - tcp_sack=0 / tcp_window_scaling=0 for embedded/cisco - ip_no_pmtu_disc=1 for embedded/cisco (DF bit ICMP behaviour) - Expose _REQUIRED_SYSCTLS frozenset for completeness assertions Tests: 88 new test cases across all OS families and composer integration. Total suite: 812 passed. --- decnet/os_fingerprint.py | 49 +++++++++- tests/test_os_fingerprint.py | 182 ++++++++++++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 4 deletions(-) diff --git a/decnet/os_fingerprint.py b/decnet/os_fingerprint.py index d8f088e..aa17b86 100644 --- a/decnet/os_fingerprint.py +++ b/decnet/os_fingerprint.py @@ -5,17 +5,27 @@ Maps an nmap OS family slug to a dict of Linux kernel sysctls that, when applied to a container's network namespace, make its TCP/IP stack behaviour resemble the claimed OS as closely as possible within the Linux kernel's constraints. +All sysctls listed here are network-namespace-scoped and safe to set per-container +without --privileged (beyond the NET_ADMIN capability already granted). + Primary discriminator leveraged by nmap: net.ipv4.ip_default_ttl (TTL) Linux → 64 Windows → 128 BSD (FreeBSD/macOS)→ 64 (different TCP options, but same TTL as Linux) Embedded / network → 255 -Secondary tuning (TCP behaviour): - net.ipv4.tcp_syn_retries – SYN retransmits before giving up +Secondary discriminators (nmap OPS / WIN / ECN / T2–T6 probe groups): + net.ipv4.tcp_syn_retries – SYN retransmits before giving up + net.ipv4.tcp_timestamps – TCP timestamp option (OPS probes); Windows = off + net.ipv4.tcp_window_scaling – Window scale option; embedded/Cisco typically off + net.ipv4.tcp_sack – Selective ACK option; absent on most embedded stacks + net.ipv4.tcp_ecn – ECN negotiation; Linux offers (2), Windows off (0) + net.ipv4.ip_no_pmtu_disc – DF bit in ICMP replies (IE probes); embedded on + net.ipv4.tcp_fin_timeout – FIN_WAIT_2 seconds (T2–T6 timing); Windows shorter Note: net.core.rmem_default is a global (non-namespaced) sysctl and cannot be -set per-container without --privileged; it is intentionally excluded. +set per-container without --privileged; TCP window size mangling is deferred to +Phase 2 (iptables entrypoint). """ from __future__ import annotations @@ -24,27 +34,59 @@ OS_SYSCTLS: dict[str, dict[str, str]] = { "linux": { "net.ipv4.ip_default_ttl": "64", "net.ipv4.tcp_syn_retries": "6", + "net.ipv4.tcp_timestamps": "1", + "net.ipv4.tcp_window_scaling": "1", + "net.ipv4.tcp_sack": "1", + "net.ipv4.tcp_ecn": "2", + "net.ipv4.ip_no_pmtu_disc": "0", + "net.ipv4.tcp_fin_timeout": "60", }, "windows": { "net.ipv4.ip_default_ttl": "128", "net.ipv4.tcp_syn_retries": "2", + "net.ipv4.tcp_timestamps": "0", + "net.ipv4.tcp_window_scaling": "1", + "net.ipv4.tcp_sack": "1", + "net.ipv4.tcp_ecn": "0", + "net.ipv4.ip_no_pmtu_disc": "0", + "net.ipv4.tcp_fin_timeout": "30", }, "bsd": { "net.ipv4.ip_default_ttl": "64", "net.ipv4.tcp_syn_retries": "6", + "net.ipv4.tcp_timestamps": "1", + "net.ipv4.tcp_window_scaling": "1", + "net.ipv4.tcp_sack": "1", + "net.ipv4.tcp_ecn": "0", + "net.ipv4.ip_no_pmtu_disc": "0", + "net.ipv4.tcp_fin_timeout": "60", }, "embedded": { "net.ipv4.ip_default_ttl": "255", "net.ipv4.tcp_syn_retries": "3", + "net.ipv4.tcp_timestamps": "0", + "net.ipv4.tcp_window_scaling": "0", + "net.ipv4.tcp_sack": "0", + "net.ipv4.tcp_ecn": "0", + "net.ipv4.ip_no_pmtu_disc": "1", + "net.ipv4.tcp_fin_timeout": "15", }, "cisco": { "net.ipv4.ip_default_ttl": "255", "net.ipv4.tcp_syn_retries": "2", + "net.ipv4.tcp_timestamps": "0", + "net.ipv4.tcp_window_scaling": "0", + "net.ipv4.tcp_sack": "0", + "net.ipv4.tcp_ecn": "0", + "net.ipv4.ip_no_pmtu_disc": "1", + "net.ipv4.tcp_fin_timeout": "15", }, } _DEFAULT_OS = "linux" +_REQUIRED_SYSCTLS: frozenset[str] = frozenset(OS_SYSCTLS["linux"].keys()) + def get_os_sysctls(nmap_os: str) -> dict[str, str]: """Return the sysctl dict for *nmap_os*. Falls back to Linux on unknown slugs.""" @@ -54,3 +96,4 @@ def get_os_sysctls(nmap_os: str) -> dict[str, str]: def all_os_families() -> list[str]: """Return all registered nmap OS family slugs.""" return list(OS_SYSCTLS.keys()) + diff --git a/tests/test_os_fingerprint.py b/tests/test_os_fingerprint.py index b81a529..970655b 100644 --- a/tests/test_os_fingerprint.py +++ b/tests/test_os_fingerprint.py @@ -18,7 +18,7 @@ from decnet.os_fingerprint import OS_SYSCTLS, all_os_families, get_os_sysctls # --------------------------------------------------------------------------- -# os_fingerprint module +# os_fingerprint module — TTL # --------------------------------------------------------------------------- def test_linux_ttl_is_64(): @@ -41,6 +41,134 @@ def test_bsd_ttl_is_64(): assert get_os_sysctls("bsd")["net.ipv4.ip_default_ttl"] == "64" +# --------------------------------------------------------------------------- +# os_fingerprint module — tcp_timestamps +# --------------------------------------------------------------------------- + +def test_linux_tcp_timestamps_is_1(): + assert get_os_sysctls("linux")["net.ipv4.tcp_timestamps"] == "1" + + +def test_windows_tcp_timestamps_is_0(): + assert get_os_sysctls("windows")["net.ipv4.tcp_timestamps"] == "0" + + +def test_embedded_tcp_timestamps_is_0(): + assert get_os_sysctls("embedded")["net.ipv4.tcp_timestamps"] == "0" + + +def test_bsd_tcp_timestamps_is_1(): + assert get_os_sysctls("bsd")["net.ipv4.tcp_timestamps"] == "1" + + +def test_cisco_tcp_timestamps_is_0(): + assert get_os_sysctls("cisco")["net.ipv4.tcp_timestamps"] == "0" + + +# --------------------------------------------------------------------------- +# os_fingerprint module — tcp_sack +# --------------------------------------------------------------------------- + +def test_linux_tcp_sack_is_1(): + assert get_os_sysctls("linux")["net.ipv4.tcp_sack"] == "1" + + +def test_windows_tcp_sack_is_1(): + assert get_os_sysctls("windows")["net.ipv4.tcp_sack"] == "1" + + +def test_embedded_tcp_sack_is_0(): + assert get_os_sysctls("embedded")["net.ipv4.tcp_sack"] == "0" + + +def test_cisco_tcp_sack_is_0(): + assert get_os_sysctls("cisco")["net.ipv4.tcp_sack"] == "0" + + +# --------------------------------------------------------------------------- +# os_fingerprint module — tcp_ecn +# --------------------------------------------------------------------------- + +def test_linux_tcp_ecn_is_2(): + assert get_os_sysctls("linux")["net.ipv4.tcp_ecn"] == "2" + + +def test_windows_tcp_ecn_is_0(): + assert get_os_sysctls("windows")["net.ipv4.tcp_ecn"] == "0" + + +def test_embedded_tcp_ecn_is_0(): + assert get_os_sysctls("embedded")["net.ipv4.tcp_ecn"] == "0" + + +def test_bsd_tcp_ecn_is_0(): + assert get_os_sysctls("bsd")["net.ipv4.tcp_ecn"] == "0" + + +# --------------------------------------------------------------------------- +# os_fingerprint module — tcp_window_scaling +# --------------------------------------------------------------------------- + +def test_linux_tcp_window_scaling_is_1(): + assert get_os_sysctls("linux")["net.ipv4.tcp_window_scaling"] == "1" + + +def test_windows_tcp_window_scaling_is_1(): + assert get_os_sysctls("windows")["net.ipv4.tcp_window_scaling"] == "1" + + +def test_embedded_tcp_window_scaling_is_0(): + assert get_os_sysctls("embedded")["net.ipv4.tcp_window_scaling"] == "0" + + +def test_cisco_tcp_window_scaling_is_0(): + assert get_os_sysctls("cisco")["net.ipv4.tcp_window_scaling"] == "0" + + +# --------------------------------------------------------------------------- +# os_fingerprint module — ip_no_pmtu_disc +# --------------------------------------------------------------------------- + +def test_linux_ip_no_pmtu_disc_is_0(): + assert get_os_sysctls("linux")["net.ipv4.ip_no_pmtu_disc"] == "0" + + +def test_windows_ip_no_pmtu_disc_is_0(): + assert get_os_sysctls("windows")["net.ipv4.ip_no_pmtu_disc"] == "0" + + +def test_embedded_ip_no_pmtu_disc_is_1(): + assert get_os_sysctls("embedded")["net.ipv4.ip_no_pmtu_disc"] == "1" + + +def test_cisco_ip_no_pmtu_disc_is_1(): + assert get_os_sysctls("cisco")["net.ipv4.ip_no_pmtu_disc"] == "1" + + +# --------------------------------------------------------------------------- +# os_fingerprint module — tcp_fin_timeout +# --------------------------------------------------------------------------- + +def test_linux_tcp_fin_timeout_is_60(): + assert get_os_sysctls("linux")["net.ipv4.tcp_fin_timeout"] == "60" + + +def test_windows_tcp_fin_timeout_is_30(): + assert get_os_sysctls("windows")["net.ipv4.tcp_fin_timeout"] == "30" + + +def test_embedded_tcp_fin_timeout_is_15(): + assert get_os_sysctls("embedded")["net.ipv4.tcp_fin_timeout"] == "15" + + +def test_cisco_tcp_fin_timeout_is_15(): + assert get_os_sysctls("cisco")["net.ipv4.tcp_fin_timeout"] == "15" + + +# --------------------------------------------------------------------------- +# os_fingerprint module — structural / completeness +# --------------------------------------------------------------------------- + def test_unknown_os_falls_back_to_linux(): result = get_os_sysctls("nonexistent-os") assert result == get_os_sysctls("linux") @@ -61,6 +189,25 @@ def test_all_os_families_non_empty(): assert "embedded" in families +@pytest.mark.parametrize("family", ["linux", "windows", "bsd", "embedded", "cisco"]) +def test_all_os_profiles_have_required_sysctls(family: str): + """Every OS profile must define the full canonical sysctl set.""" + from decnet.os_fingerprint import _REQUIRED_SYSCTLS + result = get_os_sysctls(family) + missing = _REQUIRED_SYSCTLS - result.keys() + assert not missing, f"OS profile '{family}' is missing sysctls: {missing}" + + +@pytest.mark.parametrize("family", ["linux", "windows", "bsd", "embedded", "cisco"]) +def test_all_os_sysctl_values_are_strings(family: str): + """Docker Compose requires sysctl values to be strings, never ints.""" + for _key, _val in get_os_sysctls(family).items(): + assert isinstance(_val, str), ( + f"OS profile '{family}': sysctl '{_key}' value {_val!r} is not a string" + ) + + + # --------------------------------------------------------------------------- # Archetypes carry valid nmap_os values # --------------------------------------------------------------------------- @@ -201,6 +348,39 @@ def test_compose_two_deckies_independent_nmap_os(): assert compose["services"]["decky-02"]["sysctls"]["net.ipv4.ip_default_ttl"] == "64" +def test_compose_linux_sysctls_include_timestamps(): + """Linux compose output must have tcp_timestamps enabled (= 1).""" + compose = generate_compose(_make_config("linux")) + sysctls = compose["services"]["decky-01"]["sysctls"] + assert sysctls.get("net.ipv4.tcp_timestamps") == "1" + + +def test_compose_windows_sysctls_no_timestamps(): + """Windows compose output must have tcp_timestamps disabled (= 0).""" + compose = generate_compose(_make_config("windows")) + sysctls = compose["services"]["decky-01"]["sysctls"] + assert sysctls.get("net.ipv4.tcp_timestamps") == "0" + + +def test_compose_linux_sysctls_full_set(): + """Linux compose output must carry all 8 canonical sysctls.""" + from decnet.os_fingerprint import _REQUIRED_SYSCTLS + compose = generate_compose(_make_config("linux")) + sysctls = compose["services"]["decky-01"]["sysctls"] + missing = _REQUIRED_SYSCTLS - sysctls.keys() + assert not missing, f"Compose output missing sysctls: {missing}" + + +def test_compose_embedded_sysctls_full_set(): + """Embedded compose output must carry all 8 canonical sysctls.""" + from decnet.os_fingerprint import _REQUIRED_SYSCTLS + compose = generate_compose(_make_config("embedded")) + sysctls = compose["services"]["decky-01"]["sysctls"] + missing = _REQUIRED_SYSCTLS - sysctls.keys() + assert not missing, f"Compose output missing sysctls: {missing}" + + + # --------------------------------------------------------------------------- # CLI helper: nmap_os flows from archetype into DeckyConfig # ---------------------------------------------------------------------------