# 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