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:
@@ -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 / T2–T6 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 (T2–T6 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())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user