feat: report what listens, and which Windows service it is #1
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user