feat(os_fingerprint): Phase 1 — extend OS sysctls with 6 new fingerprint knobs

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.
This commit is contained in:
2026-04-10 16:06:36 -04:00
parent d8457c57f3
commit 5e83c9e48d
2 changed files with 227 additions and 4 deletions

View File

@@ -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 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. 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) Primary discriminator leveraged by nmap: net.ipv4.ip_default_ttl (TTL)
Linux → 64 Linux → 64
Windows → 128 Windows → 128
BSD (FreeBSD/macOS)→ 64 (different TCP options, but same TTL as Linux) BSD (FreeBSD/macOS)→ 64 (different TCP options, but same TTL as Linux)
Embedded / network → 255 Embedded / network → 255
Secondary tuning (TCP behaviour): Secondary discriminators (nmap OPS / WIN / ECN / T2T6 probe groups):
net.ipv4.tcp_syn_retries SYN retransmits before giving up 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 (T2T6 timing); Windows shorter
Note: net.core.rmem_default is a global (non-namespaced) sysctl and cannot be 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 from __future__ import annotations
@@ -24,27 +34,59 @@ OS_SYSCTLS: dict[str, dict[str, str]] = {
"linux": { "linux": {
"net.ipv4.ip_default_ttl": "64", "net.ipv4.ip_default_ttl": "64",
"net.ipv4.tcp_syn_retries": "6", "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": { "windows": {
"net.ipv4.ip_default_ttl": "128", "net.ipv4.ip_default_ttl": "128",
"net.ipv4.tcp_syn_retries": "2", "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": { "bsd": {
"net.ipv4.ip_default_ttl": "64", "net.ipv4.ip_default_ttl": "64",
"net.ipv4.tcp_syn_retries": "6", "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": { "embedded": {
"net.ipv4.ip_default_ttl": "255", "net.ipv4.ip_default_ttl": "255",
"net.ipv4.tcp_syn_retries": "3", "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": { "cisco": {
"net.ipv4.ip_default_ttl": "255", "net.ipv4.ip_default_ttl": "255",
"net.ipv4.tcp_syn_retries": "2", "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" _DEFAULT_OS = "linux"
_REQUIRED_SYSCTLS: frozenset[str] = frozenset(OS_SYSCTLS["linux"].keys())
def get_os_sysctls(nmap_os: str) -> dict[str, str]: def get_os_sysctls(nmap_os: str) -> dict[str, str]:
"""Return the sysctl dict for *nmap_os*. Falls back to Linux on unknown slugs.""" """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]: def all_os_families() -> list[str]:
"""Return all registered nmap OS family slugs.""" """Return all registered nmap OS family slugs."""
return list(OS_SYSCTLS.keys()) return list(OS_SYSCTLS.keys())

View File

@@ -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(): 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" 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(): def test_unknown_os_falls_back_to_linux():
result = get_os_sysctls("nonexistent-os") result = get_os_sysctls("nonexistent-os")
assert result == get_os_sysctls("linux") assert result == get_os_sysctls("linux")
@@ -61,6 +189,25 @@ def test_all_os_families_non_empty():
assert "embedded" in families 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 # 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" 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 # CLI helper: nmap_os flows from archetype into DeckyConfig
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------