99 lines
3.1 KiB
Python
99 lines
3.1 KiB
Python
# 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
|