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
|
|
@ -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",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue