feat: report what listens, and which Windows service it is #1

Merged
christianmanivong merged 1 commits from feat/listening-sockets into main 2026-10-07 05:21:33 +00:00
7 changed files with 185 additions and 2 deletions
Showing only changes of commit a6814ad92e - Show all commits

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
- PowerShell Remoting transport over WinRM (pypsrp), one runspace pool per connection. - 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_facts`, `get_interfaces`, `get_interfaces_ip`, `get_arp_table`, `get_route_to`.
- `get_services` and `manage_service` (start, stop, restart, enable, disable). - `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` - Discovery fingerprints: WinRM ports 5985/5986, `Microsoft-HTTPAPI` / `Microsoft-IIS`
server headers, Windows SNMP `sysDescr`, OpenSSH-for-Windows banner. server headers, Windows SNMP `sysDescr`, OpenSSH-for-Windows banner.
- `tools/harvest.py` to record real script output as test fixtures. - `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_arp_table` | `Get-NetNeighbor -AddressFamily IPv4` (broadcast, multicast, unreachable dropped) |
| `get_route_to` | `Get-NetRoute` (multicast, broadcast, loopback dropped) | | `get_route_to` | `Get-NetRoute` (multicast, broadcast, loopback dropped) |
| `get_services` / `manage_service` | `Win32_Service`, `Start-/Stop-/Restart-Service`, `Set-Service -StartupType` | | `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 `running_kernel` carries the full build including the update revision
(`10.0.20348.2340`): Windows ships fixes as UBR bumps, which is what CVE (`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.base.exceptions import ConnectionClosedException
from napalm_device_types import FingerprintRule, OSDriver, PortSpec 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 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 = { _SERVICE_COMMANDS = {
"start": "Start-Service -Name {name}", "start": "Start-Service -Name {name}",
"stop": "Stop-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 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: def _is_noise_route(destination: str) -> bool:
"""Multicast, limited broadcast and loopback — present on every host.""" """Multicast, limited broadcast and loopback — present on every host."""
try: try:
@@ -444,6 +501,24 @@ class WindowsDriver(OSDriver):
) )
return services 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]: def manage_service(self, name: str, action: str) -> dict[str, Any]:
template = _SERVICE_COMMANDS.get(action) template = _SERVICE_COMMANDS.get(action)
if template is None: if template is None:
+1 -1
View File
@@ -39,7 +39,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"napalm>=4.0", "napalm>=4.0",
"napalm-device-types>=0.5.0", "napalm-device-types>=2.4.0",
"pypsrp>=0.8", "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: class TestManageService:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("action", "command"), ("action", "command"),
+1
View File
@@ -24,6 +24,7 @@ SCRIPTS = {
"arp": mod._PS_ARP, "arp": mod._PS_ARP,
"routes": mod._PS_ROUTES, "routes": mod._PS_ROUTES,
"services": mod._PS_SERVICES, "services": mod._PS_SERVICES,
"listening": mod._PS_LISTENING,
} }
OUT = pathlib.Path(__file__).parent / "harvest-out" OUT = pathlib.Path(__file__).parent / "harvest-out"