Expand monitoring for credential theft and covert protocols
This commit is contained in:
parent
3e5f8fc3f7
commit
cb334c0c94
17 changed files with 675 additions and 25 deletions
|
|
@ -26,6 +26,7 @@ _DEFAULT_WATCH = (
|
|||
|
||||
_ALL_DETECTORS = (
|
||||
"reverse_shell", "ld_preload", "deleted_exe",
|
||||
"input_snooper", "credential_access", "stealth_network",
|
||||
"new_listener", "new_suid", "persistence", "egress",
|
||||
)
|
||||
|
||||
|
|
@ -60,6 +61,31 @@ class Config:
|
|||
"/tmp", "/dev/shm", "/var/tmp", "/run/user",
|
||||
)
|
||||
watch_persistence: tuple[str, ...] = _DEFAULT_WATCH
|
||||
# Processes expected to hold keyboard/HID event devices open.
|
||||
input_snooper_allow_comms: frozenset[str] = frozenset((
|
||||
"Xorg", "Xwayland", "gnome-shell", "kwin_wayland", "sway",
|
||||
"Hyprland", "systemd-logind", "input-remapper", "keyd", "kanata",
|
||||
))
|
||||
# Processes expected to read credential stores as part of normal auth or
|
||||
# user interaction. Malware harvesting those same files under another comm
|
||||
# still alerts.
|
||||
credential_access_allow_comms: frozenset[str] = frozenset((
|
||||
"sshd", "sudo", "su", "login", "gdm-session-worker", "polkitd",
|
||||
"gnome-keyring-daemon", "kwalletd5", "kwalletd6", "firefox",
|
||||
"chromium", "chrome", "google-chrome", "brave", "brave-browser",
|
||||
))
|
||||
# Extra exact paths or directory prefixes treated as credential material.
|
||||
credential_access_extra_paths: tuple[str, ...] = ()
|
||||
# Expected owners of packet/raw/special protocol sockets. Keep tight: these
|
||||
# families are common in sniffers, tunnels, and covert channels.
|
||||
stealth_network_allow_comms: frozenset[str] = frozenset((
|
||||
"NetworkManager", "systemd-networkd", "wpa_supplicant", "dhcpcd",
|
||||
"tcpdump", "dumpcap", "wireshark", "suricata", "zeek",
|
||||
))
|
||||
# Socket family names to ignore entirely, e.g. ["mptcp"] if your host uses
|
||||
# it legitimately. Valid observed kinds include raw, sctp, dccp, packet,
|
||||
# mptcp, tipc, xdp, and vsock.
|
||||
stealth_network_allow_kinds: frozenset[str] = frozenset()
|
||||
|
||||
# file integrity monitoring (FIM)
|
||||
fim_enabled: bool = True
|
||||
|
|
|
|||
|
|
@ -15,13 +15,16 @@ from ..alert import Alert
|
|||
from ..config import Config
|
||||
from ..system import SystemState
|
||||
from . import (
|
||||
credential_access,
|
||||
deleted_exe,
|
||||
egress,
|
||||
input_snooper,
|
||||
ld_preload,
|
||||
new_listener,
|
||||
new_suid,
|
||||
persistence,
|
||||
reverse_shell,
|
||||
stealth_network,
|
||||
)
|
||||
|
||||
DetectFn = Callable[[SystemState, Config], Iterable[Alert]]
|
||||
|
|
@ -40,6 +43,9 @@ REGISTRY: tuple[Detector, ...] = (
|
|||
Detector("reverse_shell", reverse_shell.detect),
|
||||
Detector("ld_preload", ld_preload.detect),
|
||||
Detector("deleted_exe", deleted_exe.detect),
|
||||
Detector("input_snooper", input_snooper.detect),
|
||||
Detector("credential_access", credential_access.detect),
|
||||
Detector("stealth_network", stealth_network.detect),
|
||||
Detector("egress", egress.detect),
|
||||
Detector("new_listener", new_listener.detect, needs_baseline=True),
|
||||
Detector("persistence", persistence.detect, needs_baseline=True),
|
||||
|
|
|
|||
99
enodia_sentinel/detectors/credential_access.py
Normal file
99
enodia_sentinel/detectors/credential_access.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""credential_access — live process has sensitive credential files open.
|
||||
|
||||
This catches credential harvesters that open shadow databases, private SSH
|
||||
keys, browser credential stores, or NetworkManager secret material during a
|
||||
poll sweep. Legitimate auth agents and browsers are configurable allow-list
|
||||
entries so the rule can stay enabled on desktops.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from ..alert import Alert, Severity
|
||||
from ..config import Config
|
||||
from ..system import SystemState
|
||||
|
||||
SID_CREDENTIAL_ACCESS = 100033
|
||||
|
||||
_EXACT_FILES = frozenset({
|
||||
"/etc/shadow",
|
||||
"/etc/gshadow",
|
||||
"/etc/security/opasswd",
|
||||
})
|
||||
|
||||
_BROWSER_SECRET_NAMES = frozenset({
|
||||
"logins.json",
|
||||
"key4.db",
|
||||
"Login Data",
|
||||
"Cookies",
|
||||
})
|
||||
|
||||
|
||||
def _fd_targets(proc) -> dict[str, str]:
|
||||
targets = getattr(proc, "fd_targets", {})
|
||||
return targets() if callable(targets) else targets
|
||||
|
||||
|
||||
def _clean_target(target: str) -> str:
|
||||
return target.removesuffix(" (deleted)")
|
||||
|
||||
|
||||
def _matches_extra(path: str, entries: tuple[str, ...]) -> bool:
|
||||
for entry in entries:
|
||||
if not entry:
|
||||
continue
|
||||
if entry.endswith("/") and path.startswith(entry):
|
||||
return True
|
||||
if path == entry or path.startswith(entry.rstrip("/") + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sensitive_kind(path: str, cfg: Config) -> str:
|
||||
if not path.startswith("/"):
|
||||
return ""
|
||||
if path in _EXACT_FILES:
|
||||
return "system credential database"
|
||||
if _matches_extra(path, cfg.credential_access_extra_paths):
|
||||
return "configured credential path"
|
||||
if "/.ssh/" in path:
|
||||
name = path.rsplit("/", 1)[-1]
|
||||
if name.startswith("id_") or name.endswith(".pem") or name.endswith(".key"):
|
||||
return "private SSH key"
|
||||
if path.startswith("/etc/NetworkManager/system-connections/"):
|
||||
return "NetworkManager secret profile"
|
||||
name = path.rsplit("/", 1)[-1]
|
||||
if name in _BROWSER_SECRET_NAMES:
|
||||
lowered = path.lower()
|
||||
if any(marker in lowered for marker in (
|
||||
"/.mozilla/", "/firefox/", "/chromium/", "/google-chrome/",
|
||||
"/chrome/", "/brave", "/vivaldi/", "/edge/",
|
||||
)):
|
||||
return "browser credential store"
|
||||
return ""
|
||||
|
||||
|
||||
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
||||
for proc in state.processes:
|
||||
comm = proc.comm or "?"
|
||||
if comm in cfg.credential_access_allow_comms:
|
||||
continue
|
||||
for fd, raw_target in _fd_targets(proc).items():
|
||||
target = _clean_target(raw_target)
|
||||
kind = _sensitive_kind(target, cfg)
|
||||
if not kind:
|
||||
continue
|
||||
yield Alert(
|
||||
severity=Severity.CRITICAL,
|
||||
signature="credential_access",
|
||||
key=f"cred:{proc.pid}:{target}",
|
||||
detail=(
|
||||
f"pid={proc.pid} comm={comm} fd={fd} has {kind} open: "
|
||||
f"{target}"
|
||||
),
|
||||
pids=(proc.pid,),
|
||||
sid=SID_CREDENTIAL_ACCESS,
|
||||
classtype="credential-theft",
|
||||
)
|
||||
break
|
||||
54
enodia_sentinel/detectors/input_snooper.py
Normal file
54
enodia_sentinel/detectors/input_snooper.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""input_snooper — processes reading keyboard/HID event devices.
|
||||
|
||||
Keyloggers commonly open ``/dev/input/event*`` or HID raw devices directly.
|
||||
Normal display servers, compositors, and input remappers can do this too, so
|
||||
known local input stack processes are configurable allow-list entries.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from ..alert import Alert, Severity
|
||||
from ..config import Config
|
||||
from ..system import SystemState
|
||||
|
||||
SID_INPUT_SNOOPER = 100032
|
||||
|
||||
_INPUT_PREFIXES = (
|
||||
"/dev/input/event",
|
||||
"/dev/uinput",
|
||||
"/dev/hidraw",
|
||||
)
|
||||
|
||||
|
||||
def _fd_targets(proc) -> dict[str, str]:
|
||||
targets = getattr(proc, "fd_targets", {})
|
||||
return targets() if callable(targets) else targets
|
||||
|
||||
|
||||
def _is_input_device(target: str) -> bool:
|
||||
return target.startswith(_INPUT_PREFIXES)
|
||||
|
||||
|
||||
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
||||
for proc in state.processes:
|
||||
comm = proc.comm or "?"
|
||||
if comm in cfg.input_snooper_allow_comms:
|
||||
continue
|
||||
for fd, target in _fd_targets(proc).items():
|
||||
if not _is_input_device(target):
|
||||
continue
|
||||
yield Alert(
|
||||
severity=Severity.HIGH,
|
||||
signature="input_snooper",
|
||||
key=f"input:{proc.pid}:{target}",
|
||||
detail=(
|
||||
f"pid={proc.pid} comm={comm} fd={fd} has input device "
|
||||
f"open: {target}"
|
||||
),
|
||||
pids=(proc.pid,),
|
||||
sid=SID_INPUT_SNOOPER,
|
||||
classtype="credential-keylogging",
|
||||
)
|
||||
break
|
||||
66
enodia_sentinel/detectors/stealth_network.py
Normal file
66
enodia_sentinel/detectors/stealth_network.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""stealth_network — unusual socket families used for covert channels.
|
||||
|
||||
TCP and UDP are covered by reverse-shell, egress, and listener checks. This
|
||||
detector watches families attackers use to slide around those paths: raw IP,
|
||||
SCTP, DCCP, packet sockets, XDP, TIPC, vsock, and MPTCP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from ..alert import Alert, Severity
|
||||
from ..config import Config
|
||||
from ..netutil import ip_in_cidrs, is_public_ip, split_host_port
|
||||
from ..system import SystemState
|
||||
|
||||
SID_STEALTH_NETWORK = 100036
|
||||
|
||||
_WATCHED_KINDS = frozenset({
|
||||
"raw", "sctp", "dccp", "packet", "mptcp", "tipc", "xdp", "vsock",
|
||||
})
|
||||
_ALWAYS_HIGH = frozenset({"raw", "packet", "xdp"})
|
||||
_ACTIVE_STATES = frozenset({
|
||||
"ESTAB", "LISTEN", "UNCONN", "CONNECTED", "SYN-SENT", "SYN-RECV",
|
||||
})
|
||||
|
||||
|
||||
def _peer_is_public(peer: str, cfg: Config) -> bool:
|
||||
host, _port = split_host_port(peer)
|
||||
if not is_public_ip(host):
|
||||
return False
|
||||
return not ip_in_cidrs(host, cfg.egress_allow_cidrs)
|
||||
|
||||
|
||||
def _severity(kind: str, state: str, peer: str, cfg: Config) -> Severity:
|
||||
if kind in _ALWAYS_HIGH:
|
||||
return Severity.HIGH
|
||||
if state == "LISTEN" or _peer_is_public(peer, cfg):
|
||||
return Severity.HIGH
|
||||
return Severity.MEDIUM
|
||||
|
||||
|
||||
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
||||
allowed_kinds = set(cfg.stealth_network_allow_kinds)
|
||||
for sock in state.sockets:
|
||||
kind = sock.kind.lower()
|
||||
if kind not in _WATCHED_KINDS:
|
||||
continue
|
||||
if kind in allowed_kinds:
|
||||
continue
|
||||
if sock.comm and sock.comm in cfg.stealth_network_allow_comms:
|
||||
continue
|
||||
if sock.state and sock.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
yield Alert(
|
||||
severity=_severity(kind, sock.state, sock.peer, cfg),
|
||||
signature="stealth_network",
|
||||
key=f"stealthnet:{kind}:{sock.pid}:{sock.local}:{sock.peer}",
|
||||
detail=(
|
||||
f"{kind} socket state={sock.state} local={sock.local} "
|
||||
f"peer={sock.peer} comm={sock.comm or '?'} pid={sock.pid or '?'}"
|
||||
),
|
||||
pids=(sock.pid,) if sock.pid else (),
|
||||
sid=SID_STEALTH_NETWORK,
|
||||
classtype="covert-channel",
|
||||
)
|
||||
|
|
@ -32,6 +32,9 @@ SID_KNOWN_ROOTKIT_MODULE = 100028
|
|||
SID_TAINTED_MODULE = 100029
|
||||
SID_KERNEL_TAINTED = 100030
|
||||
SID_HIDDEN_UDP_PORT = 100031
|
||||
SID_HIDDEN_RAW_SOCKET = 100034
|
||||
SID_RAW_ICMP_SOCKET = 100035
|
||||
SID_HIDDEN_PROTOCOL_SOCKET = 100037
|
||||
|
||||
IFF_PROMISC = 0x100
|
||||
|
||||
|
|
@ -62,6 +65,13 @@ TAINT_FLAGS = {
|
|||
}
|
||||
|
||||
HIGH_RISK_TAINT_BITS = frozenset({1, 3, 7, 12, 13})
|
||||
ICMP_RAW_PROTOCOLS = frozenset({1, 58}) # ICMP, IPv6-ICMP
|
||||
RAW_PROTOCOL_NAMES = {
|
||||
"icmp": 1,
|
||||
"ipv6-icmp": 58,
|
||||
"icmp6": 58,
|
||||
"sctp": 132,
|
||||
}
|
||||
|
||||
|
||||
# --- pure diff cores (unit-tested without a live system) ------------------
|
||||
|
|
@ -86,6 +96,18 @@ def find_hidden_udp_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[in
|
|||
return procnet_ports - ss_ports
|
||||
|
||||
|
||||
def find_hidden_raw_protocols(procnet_protocols: set[int],
|
||||
ss_protocols: set[int]) -> set[int]:
|
||||
"""Raw socket protocols in /proc/net/raw that ss does not report."""
|
||||
return procnet_protocols - ss_protocols
|
||||
|
||||
|
||||
def find_hidden_protocol_kinds(procnet_kinds: set[str],
|
||||
ss_kinds: set[str]) -> set[str]:
|
||||
"""Special socket families visible in /proc/net but absent from ss."""
|
||||
return procnet_kinds - ss_kinds
|
||||
|
||||
|
||||
def _norm_module(name: str) -> str:
|
||||
return name.lower().replace("-", "_")
|
||||
|
||||
|
|
@ -238,6 +260,60 @@ def procnet_udp_ports() -> set[int]:
|
|||
return _procnet_udp_ports("/proc/net/udp") | _procnet_udp_ports("/proc/net/udp6")
|
||||
|
||||
|
||||
def _procnet_raw_protocols(path: str) -> set[int]:
|
||||
protocols: set[int] = set()
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
local = parts[1] # HEX_ADDR:HEX_PROTO
|
||||
proto = int(local.rsplit(":", 1)[1], 16)
|
||||
if proto:
|
||||
protocols.add(proto)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return protocols
|
||||
|
||||
|
||||
def procnet_raw_protocols() -> set[int]:
|
||||
return (_procnet_raw_protocols("/proc/net/raw")
|
||||
| _procnet_raw_protocols("/proc/net/raw6"))
|
||||
|
||||
|
||||
def _procnet_has_rows(path: str) -> bool:
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
if line.strip():
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def procnet_protocol_kinds() -> set[str]:
|
||||
kinds: set[str] = set()
|
||||
checks = {
|
||||
"sctp": (
|
||||
"/proc/net/sctp/eps",
|
||||
"/proc/net/sctp/assocs",
|
||||
"/proc/net/sctp/remaddr",
|
||||
),
|
||||
"dccp": ("/proc/net/dccp", "/proc/net/dccp6"),
|
||||
"packet": ("/proc/net/packet",),
|
||||
"tipc": ("/proc/net/tipc/socket",),
|
||||
"xdp": ("/proc/net/xdp",),
|
||||
}
|
||||
for kind, paths in checks.items():
|
||||
if any(_procnet_has_rows(path) for path in paths):
|
||||
kinds.add(kind)
|
||||
return kinds
|
||||
|
||||
|
||||
def ss_listen_ports(state: SystemState) -> set[int]:
|
||||
ports: set[int] = set()
|
||||
for s in state.listening_sockets():
|
||||
|
|
@ -258,6 +334,31 @@ def ss_udp_ports(state: SystemState) -> set[int]:
|
|||
return ports
|
||||
|
||||
|
||||
def _protocol_from_endpoint(endpoint: str) -> int | None:
|
||||
tail = endpoint.rsplit(":", 1)
|
||||
if len(tail) != 2:
|
||||
return None
|
||||
label = tail[1].strip("[]").lower()
|
||||
if label.isdigit():
|
||||
return int(label)
|
||||
return RAW_PROTOCOL_NAMES.get(label)
|
||||
|
||||
|
||||
def ss_raw_protocols(state: SystemState) -> set[int]:
|
||||
protocols: set[int] = set()
|
||||
for s in state.sockets:
|
||||
if s.kind != "raw":
|
||||
continue
|
||||
proto = _protocol_from_endpoint(s.local)
|
||||
if proto is not None:
|
||||
protocols.add(proto)
|
||||
return protocols
|
||||
|
||||
|
||||
def ss_protocol_kinds(state: SystemState) -> set[str]:
|
||||
return {s.kind for s in state.sockets if s.kind}
|
||||
|
||||
|
||||
def promiscuous_interfaces() -> list[str]:
|
||||
out: list[str] = []
|
||||
base = "/sys/class/net"
|
||||
|
|
@ -361,6 +462,37 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
|
|||
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_udp)))),
|
||||
sid=SID_HIDDEN_UDP_PORT, classtype="rootkit-hidden-port")
|
||||
|
||||
raw_protocols = procnet_raw_protocols()
|
||||
hidden_raw = find_hidden_raw_protocols(raw_protocols, ss_raw_protocols(state))
|
||||
if hidden_raw:
|
||||
yield Alert(
|
||||
severity=Severity.HIGH, signature="rootkit_hidden_raw_socket",
|
||||
key=f"rk:hidraw:{min(hidden_raw)}",
|
||||
detail=("raw socket protocol(s) in /proc/net/raw not reported by ss "
|
||||
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_raw)))),
|
||||
sid=SID_HIDDEN_RAW_SOCKET, classtype="rootkit-hidden-socket")
|
||||
|
||||
raw_icmp = raw_protocols & ICMP_RAW_PROTOCOLS
|
||||
if raw_icmp:
|
||||
yield Alert(
|
||||
severity=Severity.HIGH, signature="raw_icmp_socket",
|
||||
key=f"rk:rawicmp:{min(raw_icmp)}",
|
||||
detail=("persistent raw ICMP socket protocol(s) visible in /proc/net/raw: "
|
||||
+ ", ".join(map(str, sorted(raw_icmp)))
|
||||
+ " — validate authorized ping/monitoring tools or ICMP knockers"),
|
||||
sid=SID_RAW_ICMP_SOCKET, classtype="raw-socket-backdoor")
|
||||
|
||||
hidden_protocols = find_hidden_protocol_kinds(
|
||||
procnet_protocol_kinds(), ss_protocol_kinds(state))
|
||||
if hidden_protocols:
|
||||
yield Alert(
|
||||
severity=Severity.HIGH, signature="rootkit_hidden_protocol_socket",
|
||||
key=f"rk:hidproto:{sorted(hidden_protocols)[0]}",
|
||||
detail=("special protocol socket family present in /proc/net but "
|
||||
"absent from ss output (tool may be hooked): "
|
||||
+ ", ".join(sorted(hidden_protocols))),
|
||||
sid=SID_HIDDEN_PROTOCOL_SOCKET, classtype="rootkit-hidden-socket")
|
||||
|
||||
for iface in promiscuous_interfaces():
|
||||
yield Alert(
|
||||
severity=Severity.MEDIUM, signature="promiscuous_interface",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ from pathlib import Path
|
|||
|
||||
_INO_RE = re.compile(r"\bino:(\d+)")
|
||||
_USER_RE = re.compile(r'users:\(\("([^"]+)",pid=(\d+),fd=\d+')
|
||||
_SS_NETIDS = frozenset({
|
||||
"tcp", "udp", "raw", "sctp", "dccp", "mptcp",
|
||||
"p_raw", "p_dgr", "packet", "tipc", "xdp", "vsock",
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -65,6 +69,23 @@ class Process:
|
|||
out[k] = v
|
||||
return out
|
||||
|
||||
@cached_property
|
||||
def fd_targets(self) -> dict[str, str]:
|
||||
"""Open file descriptor targets, keyed by fd number as a string."""
|
||||
out: dict[str, str] = {}
|
||||
fd_dir = f"/proc/{self.pid}/fd"
|
||||
try:
|
||||
names = sorted(os.listdir(fd_dir),
|
||||
key=lambda x: int(x) if x.isdigit() else 0)
|
||||
except OSError:
|
||||
return out
|
||||
for name in names:
|
||||
try:
|
||||
out[name] = os.readlink(os.path.join(fd_dir, name))
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
@cached_property
|
||||
def status(self) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
|
|
@ -105,6 +126,7 @@ class Socket:
|
|||
inode: int | None
|
||||
comm: str
|
||||
pid: int | None
|
||||
kind: str = ""
|
||||
|
||||
|
||||
class SystemState:
|
||||
|
|
@ -166,8 +188,19 @@ class SystemState:
|
|||
if self._injected_socks is not None:
|
||||
return self._injected_socks
|
||||
out: list[Socket] = []
|
||||
for args in (["-tanep"], ["-uanep"]):
|
||||
out.extend(self._parse_ss(self._run_ss(args)))
|
||||
for args, kind in (
|
||||
(["-tanep"], "tcp"),
|
||||
(["-uanep"], "udp"),
|
||||
(["-wanep"], "raw"),
|
||||
(["-Sanep"], "sctp"),
|
||||
(["-danep"], "dccp"),
|
||||
(["-0anep"], "packet"),
|
||||
(["-Manep"], "mptcp"),
|
||||
(["--tipc", "-anep"], "tipc"),
|
||||
(["--xdp", "-anep"], "xdp"),
|
||||
(["--vsock", "-anep"], "vsock"),
|
||||
):
|
||||
out.extend(self._parse_ss(self._run_ss(args), kind=kind))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -182,13 +215,16 @@ class SystemState:
|
|||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_ss(text: str) -> list[Socket]:
|
||||
def _parse_ss(text: str, *, kind: str = "") -> list[Socket]:
|
||||
socks: list[Socket] = []
|
||||
for line in text.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
state, _rq, _sq, local, peer = parts[:5]
|
||||
if parts[0].lower() in _SS_NETIDS and len(parts) >= 6:
|
||||
state, local, peer = parts[1], parts[4], parts[5]
|
||||
else:
|
||||
state, local, peer = parts[0], parts[3], parts[4]
|
||||
rest = line
|
||||
ino_m = _INO_RE.search(rest)
|
||||
usr_m = _USER_RE.search(rest)
|
||||
|
|
@ -199,6 +235,7 @@ class SystemState:
|
|||
inode=int(ino_m.group(1)) if ino_m else None,
|
||||
comm=usr_m.group(1) if usr_m else "",
|
||||
pid=int(usr_m.group(2)) if usr_m else None,
|
||||
kind=kind,
|
||||
))
|
||||
return socks
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue