From a6814ad92e0183ad01f4df4ce430a3ae1283f4fd Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Wed, 7 Oct 2026 07:20:27 +0200 Subject: [PATCH] feat: report what listens, and which Windows service it is One script: Get-NetTCPConnection -State Listen, Get-NetUDPEndpoint, the processes holding them and Win32_Service's PIDs. A process running one service names it; a shared svchost runs several, and which of them holds the socket Windows does not say, so it names none. http.sys sockets (WinRM, IIS, SMB) belong to the System process. The fixture is synthetic: no Windows host was at hand. tools/harvest.py records the real output. For netOrk#673. --- CHANGELOG.md | 2 + README.md | 1 + napalm_windows/windows.py | 77 ++++++++++++++++++++++++- pyproject.toml | 2 +- tests/fixtures/synthetic/listening.json | 31 ++++++++++ tests/test_windows.py | 73 +++++++++++++++++++++++ tools/harvest.py | 1 + 7 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/synthetic/listening.json diff --git a/CHANGELOG.md b/CHANGELOG.md index af604b4..cb7e31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - PowerShell Remoting transport over WinRM (pypsrp), one runspace pool per connection. - `get_facts`, `get_interfaces`, `get_interfaces_ip`, `get_arp_table`, `get_route_to`. - `get_services` and `manage_service` (start, stop, restart, enable, disable). +- `get_listening_sockets`: every listening TCP and bound UDP socket with its process and, + where the process runs one Windows service, that service (netOrk #673). Fixture synthetic. - Discovery fingerprints: WinRM ports 5985/5986, `Microsoft-HTTPAPI` / `Microsoft-IIS` server headers, Windows SNMP `sysDescr`, OpenSSH-for-Windows banner. - `tools/harvest.py` to record real script output as test fixtures. diff --git a/README.md b/README.md index a21cc6f..80358ac 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ handshake once. | `get_arp_table` | `Get-NetNeighbor -AddressFamily IPv4` (broadcast, multicast, unreachable dropped) | | `get_route_to` | `Get-NetRoute` (multicast, broadcast, loopback dropped) | | `get_services` / `manage_service` | `Win32_Service`, `Start-/Stop-/Restart-Service`, `Set-Service -StartupType` | +| `get_listening_sockets` | `Get-NetTCPConnection -State Listen`, `Get-NetUDPEndpoint`, `Get-Process`, `Win32_Service` (a shared svchost names no service) | `running_kernel` carries the full build including the update revision (`10.0.20348.2340`): Windows ships fixes as UBR bumps, which is what CVE diff --git a/napalm_windows/windows.py b/napalm_windows/windows.py index 6ac4ec9..4f85b02 100644 --- a/napalm_windows/windows.py +++ b/napalm_windows/windows.py @@ -24,7 +24,7 @@ from typing import Any from napalm.base.exceptions import ConnectionClosedException from napalm_device_types import FingerprintRule, OSDriver, PortSpec -from napalm_device_types.models import ServiceDict +from napalm_device_types.models import ListeningSocketDict, ListeningSocketsDict, ServiceDict from napalm_windows.transport import PowerShellError, PsrpTransport @@ -134,6 +134,42 @@ ConvertTo-Json -Compress -Depth 3 -InputObject @( ) """ +# Every listening TCP and bound UDP socket, the processes holding them and the +# services those processes run. Sockets owned by http.sys (WinRM, IIS) belong to +# the System process; a shared svchost runs several services in one process. +_PS_LISTENING = r""" +$ErrorActionPreference = 'Stop' +$tcp = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue) +$udp = @(Get-NetUDPEndpoint -ErrorAction SilentlyContinue) +$ids = @(@($tcp) + @($udp) | ForEach-Object { [int]$_.OwningProcess } | Sort-Object -Unique) +ConvertTo-Json -Compress -Depth 3 -InputObject ([pscustomobject]@{ + tcp = @($tcp | ForEach-Object { + [pscustomobject]@{ + address = [string]$_.LocalAddress + port = [int]$_.LocalPort + pid = [int]$_.OwningProcess + } + }) + udp = @($udp | ForEach-Object { + [pscustomobject]@{ + address = [string]$_.LocalAddress + port = [int]$_.LocalPort + pid = [int]$_.OwningProcess + } + }) + processes = @(if ($ids.Count) { + Get-Process -Id $ids -ErrorAction SilentlyContinue | ForEach-Object { + [pscustomobject]@{ pid = [int]$_.Id; name = $_.ProcessName } + } + }) + services = @( + Get-CimInstance -ClassName Win32_Service -Filter 'ProcessId <> 0' | ForEach-Object { + [pscustomobject]@{ pid = [int]$_.ProcessId; name = $_.Name } + } + ) +}) +""" + _SERVICE_COMMANDS = { "start": "Start-Service -Name {name}", "stop": "Stop-Service -Name {name}", @@ -198,6 +234,27 @@ def _is_real_neighbour_mac(mac: str) -> bool: return not int(mac.split(":", 1)[0], 16) & 1 +def _listening_socket( + proto: str, entry: dict[str, Any], names: dict[int, str], owners: dict[int, list[str]] +) -> ListeningSocketDict: + """One socket of ``_PS_LISTENING``. A process running one service names it; a + shared svchost runs several, and which of them holds the socket Windows does + not say. PID 0 is no process.""" + pid = int(entry.get("pid") or 0) + address, _, zone = str(entry.get("address") or "").partition("%") + services = owners.get(pid, []) + return { + "proto": proto, + "address": address, + "port": int(entry.get("port") or 0), + "interface": zone or None, + "process": names.get(pid) if pid else None, + "pid": pid or None, + "unit": services[0] if len(services) == 1 else None, + "container_id": None, + } + + def _is_noise_route(destination: str) -> bool: """Multicast, limited broadcast and loopback — present on every host.""" try: @@ -444,6 +501,24 @@ class WindowsDriver(OSDriver): ) return services + def get_listening_sockets(self) -> ListeningSocketsDict: + """Every listening TCP and bound UDP socket, with the process and the + Windows service behind it (``Get-NetTCPConnection``, ``Get-NetUDPEndpoint``, + ``Win32_Service``). WinRM needs an administrator, who sees every + process, so the reading is ``attributed``.""" + raw = self._run_ps(_PS_LISTENING) or {} + names = {int(p["pid"]): str(p["name"]) for p in _as_list(raw.get("processes"))} + owners: dict[int, list[str]] = {} + for service in _as_list(raw.get("services")): + owners.setdefault(int(service["pid"]), []).append(str(service["name"])) + sockets = [ + _listening_socket(proto, entry, names, owners) + for proto in ("tcp", "udp") + for entry in _as_list(raw.get(proto)) + ] + sockets.sort(key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or "")) + return {"attributed": True, "sockets": sockets} + def manage_service(self, name: str, action: str) -> dict[str, Any]: template = _SERVICE_COMMANDS.get(action) if template is None: diff --git a/pyproject.toml b/pyproject.toml index 35c212c..892b7a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ ] dependencies = [ "napalm>=4.0", - "napalm-device-types>=0.5.0", + "napalm-device-types>=2.4.0", "pypsrp>=0.8", ] diff --git a/tests/fixtures/synthetic/listening.json b/tests/fixtures/synthetic/listening.json new file mode 100644 index 0000000..9e72998 --- /dev/null +++ b/tests/fixtures/synthetic/listening.json @@ -0,0 +1,31 @@ +{ + "tcp": [ + {"address": "0.0.0.0", "port": 135, "pid": 912}, + {"address": "::", "port": 135, "pid": 912}, + {"address": "0.0.0.0", "port": 445, "pid": 4}, + {"address": "0.0.0.0", "port": 5985, "pid": 4}, + {"address": "0.0.0.0", "port": 3389, "pid": 1180}, + {"address": "0.0.0.0", "port": 1433, "pid": 4321}, + {"address": "127.0.0.1", "port": 1434, "pid": 4321}, + {"address": "fe80::1c2a:3b4c:5d6e:7f80%12", "port": 1433, "pid": 4321} + ], + "udp": [ + {"address": "0.0.0.0", "port": 123, "pid": 1404}, + {"address": "::", "port": 123, "pid": 1404}, + {"address": "0.0.0.0", "port": 5353, "pid": 0} + ], + "processes": [ + {"pid": 4, "name": "System"}, + {"pid": 912, "name": "svchost"}, + {"pid": 1180, "name": "svchost"}, + {"pid": 1404, "name": "svchost"}, + {"pid": 4321, "name": "sqlservr"} + ], + "services": [ + {"pid": 912, "name": "RpcEptMapper"}, + {"pid": 912, "name": "RpcSs"}, + {"pid": 1180, "name": "TermService"}, + {"pid": 1404, "name": "W32Time"}, + {"pid": 4321, "name": "MSSQL$SQLEXPRESS"} + ] +} diff --git a/tests/test_windows.py b/tests/test_windows.py index d653991..516e2e5 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -362,6 +362,79 @@ class TestGetServices: ] +class TestGetListeningSockets: + """netOrk #673: what listens, and which Windows service it is.""" + + def _sockets(self, answer: str | None = None) -> dict: + answer = answer or _fixture("listening.json") + reading = _driver(_PS_LISTENING=answer).get_listening_sockets() + return {(s["proto"], s["port"], s["address"]): s for s in reading["sockets"]} + + def test_read_as_an_administrator(self): + reading = _driver(_PS_LISTENING=_fixture("listening.json")).get_listening_sockets() + + assert reading["attributed"] is True + assert len(reading["sockets"]) == 11 + + def test_a_socket_comes_with_its_process_and_service(self): + assert self._sockets()[("tcp", 1433, "0.0.0.0")] == { + "proto": "tcp", + "address": "0.0.0.0", + "port": 1433, + "interface": None, + "process": "sqlservr", + "pid": 4321, + "unit": "MSSQL$SQLEXPRESS", + "container_id": None, + } + + def test_a_svchost_of_its_own_names_its_service(self): + assert self._sockets()[("tcp", 3389, "0.0.0.0")]["unit"] == "TermService" + + def test_a_shared_svchost_names_none(self): + """RpcSs and RpcEptMapper share a process: the socket is one of theirs, + and which one Windows does not say.""" + rpc = self._sockets()[("tcp", 135, "0.0.0.0")] + + assert (rpc["process"], rpc["unit"]) == ("svchost", None) + + def test_http_sys_listens_as_the_system_process(self): + winrm = self._sockets()[("tcp", 5985, "0.0.0.0")] + + assert (winrm["process"], winrm["pid"], winrm["unit"]) == ("System", 4, None) + + def test_the_scope_of_a_link_local_address_is_its_interface(self): + assert self._sockets()[("tcp", 1433, "fe80::1c2a:3b4c:5d6e:7f80")]["interface"] == "12" + + def test_a_socket_without_a_process_is_kept(self): + mdns = self._sockets()[("udp", 5353, "0.0.0.0")] + + assert (mdns["process"], mdns["pid"], mdns["unit"]) == (None, None, None) + + def test_one_socket_arrives_as_a_bare_object(self): + """ConvertTo-Json unwraps a one-element array.""" + answer = json.dumps( + { + "tcp": {"address": "0.0.0.0", "port": 3389, "pid": 1180}, + "udp": [], + "processes": {"pid": 1180, "name": "svchost"}, + "services": {"pid": 1180, "name": "TermService"}, + } + ) + + assert self._sockets(answer)[("tcp", 3389, "0.0.0.0")]["unit"] == "TermService" + + def test_sorted_by_protocol_port_and_address(self): + reading = _driver(_PS_LISTENING=_fixture("listening.json")).get_listening_sockets() + keys = [(s["proto"], s["port"], s["address"]) for s in reading["sockets"]] + + assert keys == sorted(keys) + + def test_the_script_reads_and_changes_nothing(self): + for verb in ("Set-", "Stop-", "Start-", "Remove-", "New-"): + assert verb not in mod._PS_LISTENING + + class TestManageService: @pytest.mark.parametrize( ("action", "command"), diff --git a/tools/harvest.py b/tools/harvest.py index 7dba331..582edd9 100644 --- a/tools/harvest.py +++ b/tools/harvest.py @@ -24,6 +24,7 @@ SCRIPTS = { "arp": mod._PS_ARP, "routes": mod._PS_ROUTES, "services": mod._PS_SERVICES, + "listening": mod._PS_LISTENING, } OUT = pathlib.Path(__file__).parent / "harvest-out"