Add enrichment and SID coverage gates
This commit is contained in:
parent
93ac47dd82
commit
66472134d8
32 changed files with 1623 additions and 31 deletions
|
|
@ -683,9 +683,11 @@ def _cmd_triage(cfg: Config) -> int:
|
|||
if v.suggest:
|
||||
suggestions.add(v.suggest)
|
||||
if suggestions:
|
||||
print("\nTo suppress the false positives, add to your config:")
|
||||
print("\nSuggested TOML after review (Sentinel does not edit config):")
|
||||
for s in sorted(suggestions):
|
||||
print(f" # {s}")
|
||||
for line in s.splitlines():
|
||||
print(f" {line}")
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
210
enodia_sentinel/enrich.py
Normal file
210
enodia_sentinel/enrich.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Post-alert enrichment for captured snapshots.
|
||||
|
||||
This module stays cheap: it annotates already-captured process, file, network,
|
||||
and integrity context without running heavyweight package verification or
|
||||
rootcheck sweeps during snapshot capture.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from .config import Config
|
||||
from .fim import hash_file
|
||||
from .netutil import is_public_ip
|
||||
from .provenance import package_owner
|
||||
from .system import SystemState
|
||||
|
||||
_IP_RE = re.compile(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])")
|
||||
|
||||
|
||||
def build(
|
||||
report: dict,
|
||||
state: SystemState,
|
||||
cfg: Config,
|
||||
*,
|
||||
lineage: set[int] | None = None,
|
||||
recent_files: list[str] | None = None,
|
||||
owner: Callable[[str], str | None] = package_owner,
|
||||
) -> dict:
|
||||
alerts = report.get("alerts", [])
|
||||
return {
|
||||
"processes": [_process_context(state, proc, cfg, owner)
|
||||
for proc in report.get("processes", [])],
|
||||
"remotes": [_remote_context(ip) for ip in sorted(_remote_ips(alerts))],
|
||||
"files": [_file_context(path, owner) for path in sorted(_paths(alerts))],
|
||||
"recent_writes": sorted(set(recent_files or [])),
|
||||
"lineage": _lineage_context(state, sorted(lineage or set())),
|
||||
"integrity": _integrity_context(cfg),
|
||||
}
|
||||
|
||||
|
||||
def format_text(enrichment: dict) -> str:
|
||||
lines: list[str] = []
|
||||
for proc in enrichment.get("processes", []):
|
||||
lines.append(
|
||||
"process pid={pid} comm={comm} package={pkg} sha256={sha}".format(
|
||||
pid=proc.get("pid"),
|
||||
comm=proc.get("comm") or "?",
|
||||
pkg=proc.get("package_owner") or "unowned/unknown",
|
||||
sha=(proc.get("exe_sha256") or "-")[:16],
|
||||
)
|
||||
)
|
||||
chain = " <- ".join(
|
||||
f"{p.get('pid')}:{p.get('comm') or '?'}"
|
||||
for p in proc.get("parent_chain", [])
|
||||
)
|
||||
if chain:
|
||||
lines.append(f" parent chain: {chain}")
|
||||
for remote in enrichment.get("remotes", []):
|
||||
lines.append(f"remote {remote['ip']}: {remote['classification']}")
|
||||
for f in enrichment.get("files", []):
|
||||
mode = f"0{f['mode']:o}" if f.get("mode") is not None else "-"
|
||||
lines.append(
|
||||
f"file {f['path']}: exists={f['exists']} mode={mode} "
|
||||
f"owner={f.get('package_owner') or 'unowned/unknown'}"
|
||||
)
|
||||
integrity = enrichment.get("integrity", {})
|
||||
if integrity:
|
||||
lines.append(
|
||||
"integrity: fim_baseline={fim} pkgdb_anchor={pkg} "
|
||||
"rootcheck={root}".format(
|
||||
fim=integrity.get("fim_baseline", {}).get("status", "unknown"),
|
||||
pkg=integrity.get("pkgdb_anchor", {}).get("status", "unknown"),
|
||||
root="enabled" if integrity.get("rootcheck", {}).get("enabled") else "disabled",
|
||||
)
|
||||
)
|
||||
return "\n".join(lines) if lines else " (no enrichment available)"
|
||||
|
||||
|
||||
def _process_context(
|
||||
state: SystemState,
|
||||
proc: dict,
|
||||
cfg: Config,
|
||||
owner: Callable[[str], str | None],
|
||||
) -> dict:
|
||||
exe = _clean_path(proc.get("exe", ""))
|
||||
return {
|
||||
"pid": proc.get("pid"),
|
||||
"comm": proc.get("comm", ""),
|
||||
"exe": exe,
|
||||
"package_owner": owner(exe) if exe else None,
|
||||
"exe_sha256": hash_file(exe) if exe and os.path.isfile(exe) else None,
|
||||
"parent_chain": _parent_chain(state, proc.get("ppid", 0),
|
||||
cfg.incident_lineage_depth),
|
||||
}
|
||||
|
||||
|
||||
def _parent_chain(state: SystemState, ppid: int, depth: int) -> list[dict]:
|
||||
chain: list[dict] = []
|
||||
seen: set[int] = set()
|
||||
cur = ppid
|
||||
for _ in range(max(depth, 0)):
|
||||
if not cur or cur in seen:
|
||||
break
|
||||
seen.add(cur)
|
||||
proc = state.process(cur)
|
||||
if proc is None:
|
||||
chain.append({"pid": cur, "comm": "", "ppid": 0})
|
||||
break
|
||||
chain.append({"pid": cur, "comm": proc.comm, "ppid": proc.ppid})
|
||||
cur = proc.ppid
|
||||
return chain
|
||||
|
||||
|
||||
def _lineage_context(state: SystemState, lineage: list[int]) -> list[dict]:
|
||||
out = []
|
||||
for pid in lineage:
|
||||
proc = state.process(pid)
|
||||
out.append({
|
||||
"pid": pid,
|
||||
"comm": proc.comm if proc else "",
|
||||
"present": proc is not None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _remote_ips(alerts: list[dict]) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for alert in alerts:
|
||||
for ip in _IP_RE.findall(alert.get("detail", "")):
|
||||
out.add(ip)
|
||||
return out
|
||||
|
||||
|
||||
def _remote_context(ip: str) -> dict:
|
||||
try:
|
||||
parsed = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return {"ip": ip, "classification": "invalid"}
|
||||
if is_public_ip(ip):
|
||||
classification = "public"
|
||||
elif parsed.is_loopback:
|
||||
classification = "loopback"
|
||||
elif parsed.is_private:
|
||||
classification = "private"
|
||||
elif parsed.is_link_local:
|
||||
classification = "link-local"
|
||||
else:
|
||||
classification = "reserved"
|
||||
return {"ip": ip, "classification": classification}
|
||||
|
||||
|
||||
def _paths(alerts: list[dict]) -> set[str]:
|
||||
paths: set[str] = set()
|
||||
for alert in alerts:
|
||||
for text in (alert.get("detail", ""), alert.get("key", "")):
|
||||
for token in re.findall(r"(?<![\w.])/[^\s,\])]+", text):
|
||||
paths.add(_clean_path(token.rstrip(":")))
|
||||
return {p for p in paths if p}
|
||||
|
||||
|
||||
def _file_context(path: str, owner: Callable[[str], str | None]) -> dict:
|
||||
p = Path(path)
|
||||
try:
|
||||
st = p.lstat()
|
||||
except OSError:
|
||||
return {"path": path, "exists": False, "mode": None, "uid": None,
|
||||
"gid": None, "package_owner": None}
|
||||
return {
|
||||
"path": path,
|
||||
"exists": True,
|
||||
"mode": stat.S_IMODE(st.st_mode),
|
||||
"uid": st.st_uid,
|
||||
"gid": st.st_gid,
|
||||
"package_owner": owner(path),
|
||||
}
|
||||
|
||||
|
||||
def _integrity_context(cfg: Config) -> dict:
|
||||
return {
|
||||
"fim_baseline": _path_status(cfg.fim_baseline),
|
||||
"pkgdb_anchor": _path_status(cfg.log_dir / "pkgdb-anchor.json"),
|
||||
"package_verify": {
|
||||
"enabled": bool(cfg.pkgdb_pkgverify),
|
||||
"sample": cfg.pkgdb_pkgverify_sample,
|
||||
},
|
||||
"rootcheck": {
|
||||
"enabled": bool(cfg.rootcheck_enabled),
|
||||
"interval": cfg.rootcheck_interval,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _path_status(path: Path) -> dict:
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return {"path": str(path), "status": "missing", "age": None}
|
||||
return {"path": str(path), "status": "present",
|
||||
"age": max(0, int(time.time() - st.st_mtime))}
|
||||
|
||||
|
||||
def _clean_path(path: str) -> str:
|
||||
return path.replace(" (deleted)", "")
|
||||
|
|
@ -172,6 +172,15 @@ def build_plan(bundle: dict, cfg: Config) -> dict:
|
|||
reason="Confirm package-owned and FIM-monitored files match expected state.",
|
||||
risk="low",
|
||||
))
|
||||
if {"fim_pkg_modified", "pkg_signature_mismatch", "pkgdb_tamper"} & sigs:
|
||||
add(ResponseAction(
|
||||
id="",
|
||||
category="verification",
|
||||
title="Verify package-owned files against signed cache packages",
|
||||
command=("enodia-sentinel", "pkgdb-verify"),
|
||||
reason="Check on-disk package files against signed package metadata instead of the mutable local DB.",
|
||||
risk="low",
|
||||
))
|
||||
if any(s.startswith("rootkit_") or s in {"new_listener", "promiscuous_interface"}
|
||||
for s in sigs):
|
||||
add(ResponseAction(
|
||||
|
|
@ -182,6 +191,27 @@ def build_plan(bundle: dict, cfg: Config) -> dict:
|
|||
reason="Confirm cross-view hidden process/module/port findings are gone.",
|
||||
risk="low",
|
||||
))
|
||||
if {"persistence", "new_suid", "fim_added", "fim_modified", "fim_removed"} & sigs:
|
||||
add(ResponseAction(
|
||||
id="",
|
||||
category="verification",
|
||||
title="Confirm no persistence diff remains",
|
||||
command=("enodia-sentinel", "check", "--json"),
|
||||
reason="Re-run the detector set after containment to confirm persistence-related alerts are gone.",
|
||||
risk="low",
|
||||
))
|
||||
|
||||
watchdog_url = _watchdog_url(cfg)
|
||||
add(ResponseAction(
|
||||
id="",
|
||||
category="verification",
|
||||
title="Confirm heartbeat and dashboard visibility",
|
||||
command=("enodia-sentinel", "watchdog", "--url", watchdog_url,
|
||||
"--token", "<token>", "--insecure-tls"),
|
||||
reason="Run from another host or tailnet peer to prove the sensor heartbeat and HTTPS dashboard are reachable.",
|
||||
risk="low",
|
||||
targets={"url": watchdog_url},
|
||||
))
|
||||
|
||||
numbered = []
|
||||
for i, action in enumerate(actions, 1):
|
||||
|
|
@ -336,3 +366,12 @@ def _is_quarantine_candidate(path: str, sig: str) -> bool:
|
|||
if path == "/etc/ld.so.preload":
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _watchdog_url(cfg: Config) -> str:
|
||||
if cfg.dashboard_url:
|
||||
return cfg.dashboard_url.rstrip("/")
|
||||
host = cfg.web_bind or "<dashboard-host>"
|
||||
if ":" in host and not host.startswith("[") and host != "<dashboard-host>":
|
||||
host = f"[{host}]"
|
||||
return f"https://{host}:{cfg.web_port}"
|
||||
|
|
|
|||
125
enodia_sentinel/sids.py
Normal file
125
enodia_sentinel/sids.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Canonical registry of every built-in SID.
|
||||
|
||||
Aggregates the constants where they are defined; nothing is renumbered or
|
||||
moved. The core poll detectors carry some SIDs inline in the Alert they build,
|
||||
so those rows are tabulated here and cross-checked by tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import fim, pkgdb, posture, rootcheck
|
||||
from .detectors import (
|
||||
credential_access,
|
||||
input_snooper,
|
||||
memory_obfuscation,
|
||||
stealth_network,
|
||||
)
|
||||
from .events.rules import DEFAULT_EXEC_RULES
|
||||
from .events.syscall_rules import DEFAULT_SYSCALL_RULES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SidInfo:
|
||||
sid: int
|
||||
name: str
|
||||
engine: str
|
||||
module: str
|
||||
|
||||
|
||||
def _rule_rows(rules, engine: str, module: str) -> tuple[SidInfo, ...]:
|
||||
return tuple(
|
||||
SidInfo(r.sid, f"{engine}.{r.classtype}", engine, module)
|
||||
for r in rules
|
||||
)
|
||||
|
||||
|
||||
_DETECTORS = "enodia_sentinel.detectors"
|
||||
|
||||
BUILTIN_SIDS: tuple[SidInfo, ...] = (
|
||||
*_rule_rows(DEFAULT_EXEC_RULES, "exec_rule",
|
||||
"enodia_sentinel.events.rules"),
|
||||
*_rule_rows(DEFAULT_SYSCALL_RULES, "syscall_rule",
|
||||
"enodia_sentinel.events.syscall_rules"),
|
||||
SidInfo(100010, "reverse_shell", "detector", f"{_DETECTORS}.reverse_shell"),
|
||||
SidInfo(100011, "ld_preload", "detector", f"{_DETECTORS}.ld_preload"),
|
||||
SidInfo(100012, "deleted_exe", "detector", f"{_DETECTORS}.deleted_exe"),
|
||||
SidInfo(100013, "new_listener", "detector", f"{_DETECTORS}.new_listener"),
|
||||
SidInfo(100014, "new_suid", "detector", f"{_DETECTORS}.new_suid"),
|
||||
SidInfo(100015, "persistence", "detector", f"{_DETECTORS}.persistence"),
|
||||
SidInfo(100016, "egress", "detector", f"{_DETECTORS}.egress"),
|
||||
SidInfo(input_snooper.SID_INPUT_SNOOPER, "input_snooper", "detector",
|
||||
f"{_DETECTORS}.input_snooper"),
|
||||
SidInfo(credential_access.SID_CREDENTIAL_ACCESS, "credential_access",
|
||||
"detector", f"{_DETECTORS}.credential_access"),
|
||||
SidInfo(stealth_network.SID_STEALTH_NETWORK, "stealth_network",
|
||||
"detector", f"{_DETECTORS}.stealth_network"),
|
||||
SidInfo(memory_obfuscation.SID_MEMORY_OBFUSCATION, "memory_obfuscation",
|
||||
"detector", f"{_DETECTORS}.memory_obfuscation"),
|
||||
SidInfo(memory_obfuscation.SID_PROCESS_HIDING_LIBRARY,
|
||||
"process_hiding_library", "detector",
|
||||
f"{_DETECTORS}.memory_obfuscation"),
|
||||
SidInfo(fim.SID_MODIFIED, "fim_modified", "fim", "enodia_sentinel.fim"),
|
||||
SidInfo(fim.SID_ADDED, "fim_added", "fim", "enodia_sentinel.fim"),
|
||||
SidInfo(fim.SID_REMOVED, "fim_removed", "fim", "enodia_sentinel.fim"),
|
||||
SidInfo(fim.SID_PKG, "pkg_verify_mismatch", "fim", "enodia_sentinel.fim"),
|
||||
SidInfo(pkgdb.SID_PKGDB, "pkgdb_tamper", "pkgdb", "enodia_sentinel.pkgdb"),
|
||||
SidInfo(pkgdb.SID_SIGLEVEL, "pacman_siglevel_disabled", "pkgdb",
|
||||
"enodia_sentinel.pkgdb"),
|
||||
SidInfo(pkgdb.SID_PKGVERIFY, "pkg_signature_mismatch", "pkgdb",
|
||||
"enodia_sentinel.pkgdb"),
|
||||
SidInfo(rootcheck.SID_HIDDEN_PROC, "rootkit_hidden_process", "rootcheck",
|
||||
"enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_HIDDEN_MODULE, "rootkit_hidden_module", "rootcheck",
|
||||
"enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_HIDDEN_PORT, "rootkit_hidden_port", "rootcheck",
|
||||
"enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_PROMISC, "promiscuous_interface", "rootcheck",
|
||||
"enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_KNOWN_ROOTKIT_MODULE, "rootkit_known_module",
|
||||
"rootcheck", "enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_TAINTED_MODULE, "rootkit_tainted_module",
|
||||
"rootcheck", "enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_KERNEL_TAINTED, "kernel_tainted", "rootcheck",
|
||||
"enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_HIDDEN_UDP_PORT, "rootkit_hidden_udp_port",
|
||||
"rootcheck", "enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_HIDDEN_RAW_SOCKET, "rootkit_hidden_raw_socket",
|
||||
"rootcheck", "enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_RAW_ICMP_SOCKET, "raw_icmp_socket", "rootcheck",
|
||||
"enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_HIDDEN_PROTOCOL_SOCKET,
|
||||
"rootkit_hidden_protocol_socket", "rootcheck",
|
||||
"enodia_sentinel.rootcheck"),
|
||||
SidInfo(rootcheck.SID_PS_HIDDEN_PROCESS, "rootkit_ps_hidden_process",
|
||||
"rootcheck", "enodia_sentinel.rootcheck"),
|
||||
SidInfo(posture.SID_SSH_ROOT_LOGIN, "ssh_root_login", "posture",
|
||||
"enodia_sentinel.posture"),
|
||||
SidInfo(posture.SID_SSH_PASSWORD_AUTH, "ssh_password_auth", "posture",
|
||||
"enodia_sentinel.posture"),
|
||||
SidInfo(posture.SID_SSH_EMPTY_PASSWORD, "ssh_empty_password", "posture",
|
||||
"enodia_sentinel.posture"),
|
||||
SidInfo(posture.SID_SUDO_NOPASSWD, "sudo_nopasswd", "posture",
|
||||
"enodia_sentinel.posture"),
|
||||
SidInfo(posture.SID_SUDO_NOAUTH, "sudo_no_authenticate", "posture",
|
||||
"enodia_sentinel.posture"),
|
||||
SidInfo(posture.SID_SUDOERS_WRITABLE, "sudoers_writable", "posture",
|
||||
"enodia_sentinel.posture"),
|
||||
SidInfo(posture.SID_PATH_WRITABLE, "path_writable", "posture",
|
||||
"enodia_sentinel.posture"),
|
||||
SidInfo(posture.SID_SENSITIVE_FILE_PERM, "sensitive_file_perm", "posture",
|
||||
"enodia_sentinel.posture"),
|
||||
SidInfo(posture.SID_SYSTEMD_UNIT_WRITABLE, "systemd_unit_writable",
|
||||
"posture", "enodia_sentinel.posture"),
|
||||
SidInfo(posture.SID_SYSTEMD_EXEC_UNSAFE, "systemd_exec_unsafe", "posture",
|
||||
"enodia_sentinel.posture"),
|
||||
)
|
||||
|
||||
|
||||
def all_sids() -> frozenset[int]:
|
||||
return frozenset(s.sid for s in BUILTIN_SIDS)
|
||||
|
||||
|
||||
def engine_sids(engine: str) -> frozenset[int]:
|
||||
return frozenset(s.sid for s in BUILTIN_SIDS if s.engine == engine)
|
||||
|
|
@ -173,6 +173,15 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
|
|||
incident_id = incident.record(
|
||||
cfg, base.with_suffix(".log").name, alerts, lineage, now.timestamp(), host)
|
||||
|
||||
cutoff = int(time.time()) - 3600
|
||||
recent_files = []
|
||||
for root in cfg.watch_persistence:
|
||||
try:
|
||||
if os.path.isfile(root) and os.lstat(root).st_mtime > cutoff:
|
||||
recent_files.append(root)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
report = {
|
||||
"schema": schemas.ALERT_SNAPSHOT_V1,
|
||||
"time": now.isoformat(),
|
||||
|
|
@ -183,18 +192,14 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
|
|||
"processes": [_pid_detail(state, p) for p in pids],
|
||||
}
|
||||
|
||||
cutoff = int(time.time()) - 3600
|
||||
recent_files = []
|
||||
for root in cfg.watch_persistence:
|
||||
try:
|
||||
if os.path.isfile(root) and os.lstat(root).st_mtime > cutoff:
|
||||
recent_files.append(root)
|
||||
except OSError:
|
||||
pass
|
||||
from . import enrich
|
||||
report["enrichment"] = enrich.build(
|
||||
report, state, cfg, lineage=lineage, recent_files=recent_files)
|
||||
|
||||
ps_out = _run(["ps", "-eo", "pid,ppid,user,etimes,pcpu,pmem,stat,comm",
|
||||
"--sort=-pcpu"])
|
||||
extras = {
|
||||
"Post-alert enrichment": enrich.format_text(report["enrichment"]),
|
||||
"Process tree (top by CPU)": "\n".join(ps_out.splitlines()[:40]),
|
||||
"Sockets (ss -tanp)": "\n".join(_run(["ss", "-tanp"]).splitlines()[:80]),
|
||||
"/etc/ld.so.preload": (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ never auto-deletion. ``is_owned`` is injectable for testing.
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ _ALWAYS_REVIEW = {"reverse_shell", "egress"}
|
|||
class Verdict:
|
||||
label: str # LIKELY_FP or REVIEW
|
||||
reason: str
|
||||
suggest: str = "" # optional config line to suppress future copies
|
||||
suggest: str = "" # optional TOML snippet to suppress future copies
|
||||
|
||||
|
||||
def _exe_for(alert: dict, processes: list[dict]) -> str:
|
||||
|
|
@ -39,6 +40,32 @@ def _exe_for(alert: dict, processes: list[dict]) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _comm_from_detail(detail: str) -> str:
|
||||
m = re.search(r"\bcomm=([^\s]+)", detail)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def _alert_context(alert: dict) -> str:
|
||||
sid = alert.get("sid") or "unknown"
|
||||
classtype = alert.get("classtype") or "unknown"
|
||||
return f"sid={sid} classtype={classtype}"
|
||||
|
||||
|
||||
def _toml_string_array(name: str, values) -> str:
|
||||
items = ", ".join(json.dumps(v) for v in sorted(set(values)) if v)
|
||||
return f"{name} = [{items}]"
|
||||
|
||||
|
||||
def _allow_comm_suggestion(alert: dict, cfg: Config, key: str, comm: str,
|
||||
why: str) -> str:
|
||||
current = getattr(cfg, key)
|
||||
return "\n".join((
|
||||
f"# Triage suggestion for {_alert_context(alert)}.",
|
||||
f"# Add only after verifying this {comm!r} activity is expected: {why}.",
|
||||
_toml_string_array(key, set(current) | {comm}),
|
||||
))
|
||||
|
||||
|
||||
def triage_alert(alert: dict, processes: list[dict], cfg: Config,
|
||||
is_owned: Callable[[str], bool] = is_package_owned) -> Verdict:
|
||||
sig = alert.get("signature", "")
|
||||
|
|
@ -73,11 +100,15 @@ def triage_alert(alert: dict, processes: list[dict], cfg: Config,
|
|||
if comm in cfg.listener_allow_comms:
|
||||
return Verdict(LIKELY_FP, f"comm '{comm}' is allow-listed")
|
||||
if exe and is_owned(exe):
|
||||
owner = comm
|
||||
return Verdict(LIKELY_FP,
|
||||
f"listener binary is package-owned ({comm})",
|
||||
suggest=f'listener_allow_comms += "{comm}"')
|
||||
return Verdict(REVIEW, f"unrecognized listener {addr} ({comm})")
|
||||
suggest=_allow_comm_suggestion(
|
||||
alert, cfg, "listener_allow_comms", comm,
|
||||
"package-owned listener"))
|
||||
return Verdict(REVIEW, f"unrecognized listener {addr} ({comm})",
|
||||
suggest=_allow_comm_suggestion(
|
||||
alert, cfg, "listener_allow_comms", comm,
|
||||
"known service listener"))
|
||||
|
||||
if sig == "new_suid":
|
||||
path = detail.rsplit(": ", 1)[-1]
|
||||
|
|
@ -85,6 +116,42 @@ def triage_alert(alert: dict, processes: list[dict], cfg: Config,
|
|||
return Verdict(LIKELY_FP, "SUID binary is package-owned")
|
||||
return Verdict(REVIEW, f"unowned SUID binary: {path}")
|
||||
|
||||
if sig == "input_snooper":
|
||||
comm = _comm_from_detail(detail)
|
||||
if comm:
|
||||
return Verdict(REVIEW, "input device access — verify this is local input stack",
|
||||
suggest=_allow_comm_suggestion(
|
||||
alert, cfg, "input_snooper_allow_comms", comm,
|
||||
"expected input-device reader"))
|
||||
return Verdict(REVIEW, "input device access — verify this is local input stack")
|
||||
|
||||
if sig == "credential_access":
|
||||
comm = _comm_from_detail(detail)
|
||||
if comm:
|
||||
return Verdict(REVIEW, "credential file access — verify the process role",
|
||||
suggest=_allow_comm_suggestion(
|
||||
alert, cfg, "credential_access_allow_comms", comm,
|
||||
"expected credential-store reader"))
|
||||
return Verdict(REVIEW, "credential file access — verify the process role")
|
||||
|
||||
if sig == "stealth_network":
|
||||
comm = _comm_from_detail(detail)
|
||||
if comm and comm != "?":
|
||||
return Verdict(REVIEW, "unusual socket family — verify the protocol use",
|
||||
suggest=_allow_comm_suggestion(
|
||||
alert, cfg, "stealth_network_allow_comms", comm,
|
||||
"expected raw/special-protocol socket owner"))
|
||||
return Verdict(REVIEW, "unusual socket family — verify the protocol use")
|
||||
|
||||
if sig == "memory_obfuscation":
|
||||
comm = _comm_from_detail(detail)
|
||||
if comm:
|
||||
return Verdict(REVIEW, "executable or RWX memory mapping — verify runtime/JIT use",
|
||||
suggest=_allow_comm_suggestion(
|
||||
alert, cfg, "memory_obfuscation_allow_comms", comm,
|
||||
"expected executable memory runtime"))
|
||||
return Verdict(REVIEW, "executable or RWX memory mapping — verify runtime/JIT use")
|
||||
|
||||
if sig == "persistence":
|
||||
return Verdict(REVIEW, "a persistence-relevant file changed — confirm intent")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue