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

@@ -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
# ---------------------------------------------------------------------------