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.
This commit is contained in:
Christian Manivong committed 2026-10-07 07:20:27 +02:00
1 parent 1ce42ef099
commit a6814ad92e
7 files changed
+185 -2

No files matched your search

+2
View File
@@ -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.
+1
View File
@@ -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
+76 -1
View File
@@ -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:
+1 -1
View File
@@ -39,7 +39,7 @@ classifiers = [
]
dependencies = [
"napalm>=4.0",
"napalm-device-types>=0.5.0",
"napalm-device-types>=2.4.0",
"pypsrp>=0.8",
]
+31
View File
@@ -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"}
]
}
+73
View File
@@ -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"),
+1
View File
@@ -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"