Add host correlation and assurance coverage
This commit is contained in:
parent
8194d13734
commit
b40ac4252c
36 changed files with 944 additions and 23 deletions
91
enodia_sentinel/assurance.py
Normal file
91
enodia_sentinel/assurance.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Append-only hash-chain records for local forensic artifacts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import schemas
|
||||
|
||||
CHAIN_NAME = "hash-chain.jsonl"
|
||||
|
||||
|
||||
def chain_path(cfg) -> Path:
|
||||
return cfg.log_dir / CHAIN_NAME
|
||||
|
||||
|
||||
def _sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def file_digest(path: Path) -> str:
|
||||
return _sha256_bytes(path.read_bytes())
|
||||
|
||||
|
||||
def last_chain_hash(cfg) -> str:
|
||||
try:
|
||||
lines = chain_path(cfg).read_text().splitlines()
|
||||
except OSError:
|
||||
return ""
|
||||
for line in reversed(lines):
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
value = record.get("chain_hash")
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def append_artifact(cfg, kind: str, path: Path) -> dict:
|
||||
"""Append a tamper-evident hash-chain record for ``path``."""
|
||||
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
prev = last_chain_hash(cfg)
|
||||
try:
|
||||
digest = file_digest(path)
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
digest = ""
|
||||
size = 0
|
||||
record = {
|
||||
"schema": schemas.HASH_CHAIN_V1,
|
||||
"time": time.time(),
|
||||
"kind": kind,
|
||||
"path": str(path),
|
||||
"size": size,
|
||||
"sha256": digest,
|
||||
"prev_hash": prev,
|
||||
}
|
||||
payload = json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
|
||||
record["chain_hash"] = _sha256_bytes(payload + prev.encode())
|
||||
with chain_path(cfg).open("a") as fh:
|
||||
fh.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
try:
|
||||
chain_path(cfg).chmod(0o640)
|
||||
except OSError:
|
||||
pass
|
||||
return record
|
||||
|
||||
|
||||
def summary(cfg) -> dict:
|
||||
path = chain_path(cfg)
|
||||
try:
|
||||
lines = path.read_text().splitlines()
|
||||
except OSError:
|
||||
return {"path": str(path), "exists": False, "count": 0, "last_hash": ""}
|
||||
last = ""
|
||||
for line in reversed(lines):
|
||||
try:
|
||||
last = json.loads(line).get("chain_hash", "")
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": True,
|
||||
"count": len(lines),
|
||||
"last_hash": last,
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ _DEFAULT_WATCH = (
|
|||
_ALL_DETECTORS = (
|
||||
"reverse_shell", "ld_preload", "deleted_exe",
|
||||
"input_snooper", "credential_access", "stealth_network",
|
||||
"memory_obfuscation",
|
||||
"memory_obfuscation", "first_seen",
|
||||
"new_listener", "new_suid", "persistence", "egress",
|
||||
)
|
||||
|
||||
|
|
@ -98,6 +98,10 @@ class Config:
|
|||
# memory-map shapes (for local JIT runtimes or known instrumentation).
|
||||
memory_obfuscation_allow_paths: tuple[str, ...] = ()
|
||||
|
||||
# First-seen tracking stores an operator-visible baseline under log_dir and
|
||||
# alerts on new public destinations or listener ports after the first pass.
|
||||
first_seen_enabled: bool = True
|
||||
|
||||
# file integrity monitoring (FIM)
|
||||
fim_enabled: bool = True
|
||||
fim_paths: tuple[str, ...] = () # populated from fim.DEFAULT_FIM_PATHS
|
||||
|
|
|
|||
66
enodia_sentinel/correlation.py
Normal file
66
enodia_sentinel/correlation.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Read-only incident correlation rules.
|
||||
|
||||
Correlation does not hide the raw alerts or mutate the host. It adds a small
|
||||
``correlations`` list to an incident when the incident's existing evidence
|
||||
matches a higher-confidence story.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .alert import Severity
|
||||
|
||||
SID_MULTI_STAGE_INTRUSION = 100080
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CorrelationRule:
|
||||
sid: int
|
||||
signature: str
|
||||
classtype: str
|
||||
severity: Severity
|
||||
summary: str
|
||||
required_any: tuple[set[str], ...]
|
||||
max_window: int
|
||||
|
||||
|
||||
DEFAULT_RULES: tuple[CorrelationRule, ...] = (
|
||||
CorrelationRule(
|
||||
sid=SID_MULTI_STAGE_INTRUSION,
|
||||
signature="correlation.multi_stage_intrusion",
|
||||
classtype="multi-stage-intrusion",
|
||||
severity=Severity.CRITICAL,
|
||||
summary=(
|
||||
"Web/database service spawned a shell and the same incident showed "
|
||||
"suspicious egress or an unusual listener within the correlation window"
|
||||
),
|
||||
required_any=(
|
||||
{"exec_rule.web-rce"},
|
||||
{"host_rule.suspicious-egress", "host_rule.suspicious-listener"},
|
||||
),
|
||||
max_window=600,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def correlate(incident: dict, rules: tuple[CorrelationRule, ...] = DEFAULT_RULES) -> list[dict]:
|
||||
"""Return correlation records matched by an incident index entry."""
|
||||
signatures = set(incident.get("signatures") or [])
|
||||
duration = float(incident.get("last_ts", 0.0)) - float(incident.get("first_ts", 0.0))
|
||||
out: list[dict] = []
|
||||
for rule in rules:
|
||||
if duration > rule.max_window:
|
||||
continue
|
||||
if not all(signatures & choices for choices in rule.required_any):
|
||||
continue
|
||||
out.append({
|
||||
"sid": rule.sid,
|
||||
"signature": rule.signature,
|
||||
"classtype": rule.classtype,
|
||||
"severity": str(rule.severity),
|
||||
"summary": rule.summary,
|
||||
"window_seconds": rule.max_window,
|
||||
"matched_signatures": sorted(signatures),
|
||||
})
|
||||
return out
|
||||
|
|
@ -18,6 +18,7 @@ from . import (
|
|||
credential_access,
|
||||
deleted_exe,
|
||||
egress,
|
||||
first_seen,
|
||||
input_snooper,
|
||||
ld_preload,
|
||||
memory_obfuscation,
|
||||
|
|
@ -49,6 +50,7 @@ REGISTRY: tuple[Detector, ...] = (
|
|||
Detector("stealth_network", stealth_network.detect),
|
||||
Detector("memory_obfuscation", memory_obfuscation.detect),
|
||||
Detector("egress", egress.detect),
|
||||
Detector("first_seen", first_seen.detect, needs_baseline=True),
|
||||
Detector("new_listener", new_listener.detect, needs_baseline=True),
|
||||
Detector("persistence", persistence.detect, needs_baseline=True),
|
||||
Detector("new_suid", new_suid.detect, needs_baseline=True, expensive=True),
|
||||
|
|
@ -68,4 +70,6 @@ def run_all(
|
|||
continue
|
||||
if wanted is not None and det.name not in wanted:
|
||||
continue
|
||||
if det.needs_baseline and state.listener_baseline is None:
|
||||
continue
|
||||
yield from det.detect(state, cfg)
|
||||
|
|
|
|||
108
enodia_sentinel/detectors/first_seen.py
Normal file
108
enodia_sentinel/detectors/first_seen.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""first_seen — rarity tracking for network behavior.
|
||||
|
||||
The first pass creates a local baseline without alerting. Later passes alert
|
||||
when a process name reaches a new public destination or opens a new listener
|
||||
port. This complements signature detections without flooding on daemon startup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
from .. import schemas
|
||||
from ..alert import Alert, Severity
|
||||
from ..config import Config
|
||||
from ..netutil import is_public_ip, split_host_port
|
||||
from ..system import SystemState
|
||||
|
||||
SID_FIRST_PUBLIC_DESTINATION = 100074
|
||||
SID_FIRST_LISTENER_PORT = 100075
|
||||
STORE_NAME = "first-seen.json"
|
||||
|
||||
|
||||
def store_path(cfg: Config):
|
||||
return cfg.log_dir / STORE_NAME
|
||||
|
||||
|
||||
def _load(cfg: Config) -> dict:
|
||||
try:
|
||||
data = json.loads(store_path(cfg).read_text())
|
||||
if isinstance(data, dict):
|
||||
data.setdefault("schema", schemas.FIRST_SEEN_V1)
|
||||
data.setdefault("public_destinations", {})
|
||||
data.setdefault("listener_ports", {})
|
||||
data.setdefault("initialized", False)
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return {
|
||||
"schema": schemas.FIRST_SEEN_V1,
|
||||
"initialized": False,
|
||||
"public_destinations": {},
|
||||
"listener_ports": {},
|
||||
}
|
||||
|
||||
|
||||
def _save(cfg: Config, data: dict) -> None:
|
||||
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp = store_path(cfg).with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, sort_keys=True))
|
||||
os.replace(tmp, store_path(cfg))
|
||||
try:
|
||||
store_path(cfg).chmod(0o640)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
||||
if not cfg.first_seen_enabled:
|
||||
return
|
||||
data = _load(cfg)
|
||||
initialized = bool(data.get("initialized"))
|
||||
public = data.setdefault("public_destinations", {})
|
||||
listeners = data.setdefault("listener_ports", {})
|
||||
alerts: list[Alert] = []
|
||||
|
||||
for sock in state.sockets:
|
||||
comm = sock.comm or "?"
|
||||
if sock.state == "ESTAB":
|
||||
host, port = split_host_port(sock.peer)
|
||||
if is_public_ip(host) and port:
|
||||
seen = set(public.get(comm, []))
|
||||
target = f"{host}:{port}"
|
||||
if target not in seen:
|
||||
seen.add(target)
|
||||
public[comm] = sorted(seen)
|
||||
if initialized:
|
||||
alerts.append(Alert(
|
||||
Severity.MEDIUM,
|
||||
"first_public_destination",
|
||||
f"firstdest:{comm}:{target}",
|
||||
f"comm={comm} first public destination {target}",
|
||||
(sock.pid,) if sock.pid else (),
|
||||
sid=SID_FIRST_PUBLIC_DESTINATION,
|
||||
classtype="network-rarity",
|
||||
))
|
||||
if sock.state in {"LISTEN", "UNCONN"} and sock.kind in {"tcp", "udp", ""}:
|
||||
_host, port = split_host_port(sock.local)
|
||||
if port and port.isdigit() and int(port):
|
||||
seen_ports = set(listeners.get(comm, []))
|
||||
if port not in seen_ports:
|
||||
seen_ports.add(port)
|
||||
listeners[comm] = sorted(seen_ports, key=lambda p: int(p))
|
||||
if initialized:
|
||||
alerts.append(Alert(
|
||||
Severity.MEDIUM,
|
||||
"first_listener_port",
|
||||
f"firstlisten:{comm}:{port}",
|
||||
f"comm={comm} first listening port {port}",
|
||||
(sock.pid,) if sock.pid else (),
|
||||
sid=SID_FIRST_LISTENER_PORT,
|
||||
classtype="network-rarity",
|
||||
))
|
||||
|
||||
data["initialized"] = True
|
||||
_save(cfg, data)
|
||||
yield from alerts
|
||||
|
|
@ -19,6 +19,9 @@ class HostEvent:
|
|||
peer_port: int = 0
|
||||
local_ip: str = ""
|
||||
local_port: int = 0
|
||||
target_uid: int = -1
|
||||
target_gid: int = -1
|
||||
mode: int = 0
|
||||
|
||||
@property
|
||||
def argv_str(self) -> str:
|
||||
|
|
@ -38,4 +41,7 @@ class HostEvent:
|
|||
"peer_port": self.peer_port,
|
||||
"local_ip": self.local_ip,
|
||||
"local_port": self.local_port,
|
||||
"target_uid": self.target_uid,
|
||||
"target_gid": self.target_gid,
|
||||
"mode": self.mode,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ _INTERPRETERS = frozenset(
|
|||
"node nodejs nc ncat netcat socat curl wget fetch".split()
|
||||
)
|
||||
_COMMON_PUBLIC_PORTS = frozenset({22, 53, 80, 123, 443, 853})
|
||||
_PERSISTENCE_PREFIXES = (
|
||||
"/etc/cron.d/", "/etc/crontab", "/etc/systemd/system/",
|
||||
"/etc/ld.so.preload", "/root/.ssh/authorized_keys",
|
||||
"/etc/sudoers", "/etc/sudoers.d/",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -32,6 +37,9 @@ class HostRule:
|
|||
peer_port_exclude: frozenset[int] = frozenset()
|
||||
local_ports: frozenset[int] = frozenset()
|
||||
local_port_exclude: frozenset[int] = frozenset()
|
||||
path_prefixes: tuple[str, ...] = ()
|
||||
target_uids: frozenset[int] = frozenset()
|
||||
target_gids: frozenset[int] = frozenset()
|
||||
argv_regex: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
|
|
@ -40,7 +48,8 @@ class HostRule:
|
|||
if not any((
|
||||
self.comm, self.parent_comm, self.peer_public is not None,
|
||||
self.peer_ports, self.peer_port_exclude, self.local_ports,
|
||||
self.local_port_exclude, self.argv_regex,
|
||||
self.local_port_exclude, self.path_prefixes, self.target_uids,
|
||||
self.target_gids, self.argv_regex,
|
||||
)):
|
||||
raise ValueError(f"rule sid={self.sid} has no match conditions")
|
||||
if self.argv_regex is not None:
|
||||
|
|
@ -66,6 +75,15 @@ class HostRule:
|
|||
return False
|
||||
if self.local_port_exclude and ev.local_port in self.local_port_exclude:
|
||||
return False
|
||||
if self.path_prefixes and not any(
|
||||
ev.path == prefix or ev.path.startswith(prefix)
|
||||
for prefix in self.path_prefixes
|
||||
):
|
||||
return False
|
||||
if self.target_uids and ev.target_uid not in self.target_uids:
|
||||
return False
|
||||
if self.target_gids and ev.target_gid not in self.target_gids:
|
||||
return False
|
||||
if self._argv_re is not None and not self._argv_re.search(ev.argv_str):
|
||||
return False
|
||||
return True
|
||||
|
|
@ -74,11 +92,17 @@ class HostRule:
|
|||
return Alert(
|
||||
severity=self.severity,
|
||||
signature=f"host_rule.{self.classtype}",
|
||||
key=f"host:{self.sid}:{ev.event}:{ev.pid}:{ev.peer_ip}:{ev.peer_port}",
|
||||
key=(
|
||||
f"host:{self.sid}:{ev.event}:{ev.pid}:"
|
||||
f"{ev.peer_ip}:{ev.peer_port}:{ev.local_ip}:{ev.local_port}:"
|
||||
f"{ev.path}:{ev.target_uid}:{ev.target_gid}:{ev.mode}"
|
||||
),
|
||||
detail=(
|
||||
f"sid={self.sid} {self.msg} - pid={ev.pid} ppid={ev.ppid} "
|
||||
f"comm={ev.comm} event={ev.event} peer={ev.peer_ip}:{ev.peer_port} "
|
||||
f"local={ev.local_ip}:{ev.local_port}"
|
||||
f"local={ev.local_ip}:{ev.local_port} path={ev.path} "
|
||||
f"target_uid={ev.target_uid} target_gid={ev.target_gid} "
|
||||
f"mode={oct(ev.mode) if ev.mode else '-'}"
|
||||
),
|
||||
pids=(ev.pid,),
|
||||
sid=self.sid,
|
||||
|
|
@ -106,6 +130,41 @@ DEFAULT_HOST_RULES: tuple[HostRule, ...] = (
|
|||
comm=_INTERPRETERS,
|
||||
local_port_exclude=_COMMON_PUBLIC_PORTS,
|
||||
),
|
||||
HostRule(
|
||||
sid=100069,
|
||||
msg="Interpreter wrote to a persistence path",
|
||||
severity=Severity.HIGH,
|
||||
classtype="persistence-write",
|
||||
events=frozenset({"file_write"}),
|
||||
comm=_INTERPRETERS,
|
||||
path_prefixes=_PERSISTENCE_PREFIXES,
|
||||
),
|
||||
HostRule(
|
||||
sid=100070,
|
||||
msg="Permissions or ownership changed on a persistence path",
|
||||
severity=Severity.HIGH,
|
||||
classtype="persistence-permission-change",
|
||||
events=frozenset({"chmod", "chown"}),
|
||||
path_prefixes=_PERSISTENCE_PREFIXES,
|
||||
),
|
||||
HostRule(
|
||||
sid=100071,
|
||||
msg="Interpreter requested a root UID transition",
|
||||
severity=Severity.HIGH,
|
||||
classtype="privilege-transition",
|
||||
events=frozenset({"setuid"}),
|
||||
comm=_INTERPRETERS,
|
||||
target_uids=frozenset({0}),
|
||||
),
|
||||
HostRule(
|
||||
sid=100072,
|
||||
msg="Interpreter requested a root GID transition",
|
||||
severity=Severity.HIGH,
|
||||
classtype="privilege-transition",
|
||||
events=frozenset({"setgid"}),
|
||||
comm=_INTERPRETERS,
|
||||
target_gids=frozenset({0}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ def load_index(cfg: Config) -> dict:
|
|||
for inc in data.values():
|
||||
if isinstance(inc, dict):
|
||||
inc.setdefault("schema", schemas.INCIDENT_V1)
|
||||
inc.setdefault("correlations", [])
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
|
@ -174,6 +175,7 @@ def record(cfg: Config, snapshot_name: str, alerts: list[Alert],
|
|||
"severity": severity,
|
||||
"signatures": [], "sids": [], "pids": [],
|
||||
"lineage": [], "snapshots": [], "alert_count": 0,
|
||||
"correlations": [],
|
||||
}
|
||||
inc = index[iid]
|
||||
inc.setdefault("schema", schemas.INCIDENT_V1)
|
||||
|
|
@ -186,6 +188,16 @@ def record(cfg: Config, snapshot_name: str, alerts: list[Alert],
|
|||
inc["lineage"] = sorted(set(inc["lineage"]) | lineage)
|
||||
inc["snapshots"].append(snapshot_name)
|
||||
inc["alert_count"] += len(alerts)
|
||||
from . import correlation
|
||||
inc["correlations"] = correlation.correlate(inc)
|
||||
for corr in inc["correlations"]:
|
||||
if corr["sid"] not in inc["sids"]:
|
||||
inc["sids"].append(corr["sid"])
|
||||
inc["severity"] = max_severity(
|
||||
inc.get("severity", severity),
|
||||
max((c.get("severity", severity) for c in inc["correlations"]),
|
||||
default=severity),
|
||||
)
|
||||
save_index(cfg, index)
|
||||
return iid
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -168,6 +168,12 @@ def _host_rule_record(rule: HostRule, origin: str) -> dict[str, Any]:
|
|||
conditions["local_ports"] = sorted(rule.local_ports)
|
||||
if rule.local_port_exclude:
|
||||
conditions["local_port_exclude"] = sorted(rule.local_port_exclude)
|
||||
if rule.path_prefixes:
|
||||
conditions["path_prefixes"] = list(rule.path_prefixes)
|
||||
if rule.target_uids:
|
||||
conditions["target_uids"] = sorted(rule.target_uids)
|
||||
if rule.target_gids:
|
||||
conditions["target_gids"] = sorted(rule.target_gids)
|
||||
if rule.argv_regex:
|
||||
conditions["argv_regex"] = rule.argv_regex
|
||||
return {
|
||||
|
|
@ -359,6 +365,9 @@ def _host_event(event: dict[str, Any]) -> HostEvent:
|
|||
event.get("listen_ip", "")))),
|
||||
local_port=_to_int(event.get("local_port", event.get("bind_port",
|
||||
event.get("listen_port", 0)))),
|
||||
target_uid=_to_int(event.get("target_uid", event.get("uid_target", -1))),
|
||||
target_gid=_to_int(event.get("target_gid", event.get("gid_target", -1))),
|
||||
mode=_to_int(event.get("mode", 0)),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ INCIDENT_VIEW_V1 = "enodia.incident.view.v1"
|
|||
INCIDENT_BUNDLE_V1 = "enodia.incident.bundle.v1"
|
||||
STATUS_V1 = "enodia.status.v1"
|
||||
INTEGRITY_V1 = "enodia.integrity.v1"
|
||||
HASH_CHAIN_V1 = "enodia.hash_chain.v1"
|
||||
FIRST_SEEN_V1 = "enodia.first_seen.v1"
|
||||
RESPONSE_PLAN_V1 = "enodia.response.plan.v1"
|
||||
RESPONSE_AUDIT_V1 = "enodia.response.audit.v1"
|
||||
RECONCILE_V1 = "enodia.reconcile.v1"
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ from __future__ import annotations
|
|||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import fim, pkgdb, posture, rootcheck
|
||||
from . import correlation, fim, pkgdb, posture, rootcheck
|
||||
from .detectors import (
|
||||
credential_access,
|
||||
first_seen,
|
||||
input_snooper,
|
||||
memory_obfuscation,
|
||||
stealth_network,
|
||||
|
|
@ -66,6 +67,15 @@ BUILTIN_SIDS: tuple[SidInfo, ...] = (
|
|||
SidInfo(memory_obfuscation.SID_PROCESS_INJECTION_LIBRARY,
|
||||
"process_injection_library", "detector",
|
||||
f"{_DETECTORS}.memory_obfuscation"),
|
||||
SidInfo(first_seen.SID_FIRST_PUBLIC_DESTINATION,
|
||||
"first_public_destination", "detector",
|
||||
f"{_DETECTORS}.first_seen"),
|
||||
SidInfo(first_seen.SID_FIRST_LISTENER_PORT,
|
||||
"first_listener_port", "detector",
|
||||
f"{_DETECTORS}.first_seen"),
|
||||
SidInfo(correlation.SID_MULTI_STAGE_INTRUSION,
|
||||
"multi_stage_intrusion", "correlation",
|
||||
"enodia_sentinel.correlation"),
|
||||
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"),
|
||||
|
|
|
|||
|
|
@ -237,6 +237,11 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
|
|||
fh.write(f"{now.isoformat()} [{severity}] captured "
|
||||
f"{base.with_suffix('.log')} — signatures: {sigs}\n")
|
||||
|
||||
from . import assurance
|
||||
assurance.append_artifact(cfg, "snapshot-log", base.with_suffix(".log"))
|
||||
assurance.append_artifact(cfg, "snapshot-json", base.with_suffix(".json"))
|
||||
assurance.append_artifact(cfg, "events-log", cfg.events_log)
|
||||
|
||||
_notify(cfg, f"{severity}: {sigs}")
|
||||
|
||||
# Phone push (ntfy / Pushover / webhook), gated by notify_min_severity.
|
||||
|
|
|
|||
|
|
@ -510,6 +510,15 @@ def _render_timelines(views: list[dict], width: int) -> list[str]:
|
|||
sigs = ", ".join(inc.get("signatures") or [])
|
||||
if sigs:
|
||||
lines.append(f" signatures: {_clip(sigs, max(20, width - 15))}")
|
||||
correlations = inc.get("correlations") or []
|
||||
for corr in correlations:
|
||||
lines.append(
|
||||
f" correlation sid={corr.get('sid', '?')} "
|
||||
f"{corr.get('severity', '?')} {corr.get('signature', '?')}"
|
||||
)
|
||||
lines.append(
|
||||
f" {_clip(corr.get('summary', ''), max(20, width - 4))}"
|
||||
)
|
||||
timeline = view.get("timeline") or []
|
||||
if not timeline:
|
||||
lines.append(" No timeline rows.")
|
||||
|
|
@ -566,6 +575,7 @@ def _render_integrity(report: dict, width: int) -> list[str]:
|
|||
pacman = anchors.get("pacman") or {}
|
||||
footprint = report.get("sentinel_footprint") or {}
|
||||
reconciliation = report.get("reconciliation") or {}
|
||||
hash_chain = report.get("hash_chain") or {}
|
||||
lines = [
|
||||
f"Overall: {report.get('status', 'unknown')}",
|
||||
f"Generated: {report.get('generated_at', '?')}",
|
||||
|
|
@ -588,6 +598,9 @@ def _render_integrity(report: dict, width: int) -> list[str]:
|
|||
f" Package DB: {pkgdb.get('status', 'unknown')} prefix={pkgdb.get('fingerprint_prefix', '')}",
|
||||
f" Pacman keyring:{' present' if pacman.get('keyring_present') else ' missing'}",
|
||||
f" SigLevel: {pacman.get('siglevel_status', 'unknown')}",
|
||||
f" Hash chain: {'present' if hash_chain.get('exists') else 'missing'} "
|
||||
f"count={hash_chain.get('count', 0)}",
|
||||
f" {_clip(hash_chain.get('path', '?'), max(20, width - 17))}",
|
||||
"",
|
||||
"Sentinel footprint:",
|
||||
f" Present: {footprint.get('present', 0)}/{footprint.get('configured', 0)}",
|
||||
|
|
|
|||
|
|
@ -345,6 +345,7 @@ def integrity_report(cfg: Config, status: dict | None = None,
|
|||
not run live FIM or package verification work from the web request path.
|
||||
"""
|
||||
from . import pkgdb
|
||||
from . import assurance
|
||||
from .selfprotect import SELF_PATHS, heartbeat_path, watchdog_verdict
|
||||
|
||||
ts = time.time() if now is None else now
|
||||
|
|
@ -373,10 +374,12 @@ def integrity_report(cfg: Config, status: dict | None = None,
|
|||
}
|
||||
keyring_present = pkgdb.keyring_present()
|
||||
reconciliation = _reconciliation_summary(cfg)
|
||||
hash_chain = assurance.summary(cfg)
|
||||
checks = {
|
||||
"watchdog": "ok" if watchdog_ok else "review",
|
||||
"fim_baseline": fim_state["status"],
|
||||
"pkgdb_anchor": pkg_state["status"],
|
||||
"hash_chain": "ok" if hash_chain["exists"] else "missing",
|
||||
"pacman_siglevel": "review" if sig_alert else "ok",
|
||||
"pacman_keyring": "ok" if keyring_present else "missing",
|
||||
"reconciliation": "review" if reconciliation["stale"] else "ok",
|
||||
|
|
@ -416,6 +419,7 @@ def integrity_report(cfg: Config, status: dict | None = None,
|
|||
"paths": watched,
|
||||
},
|
||||
"reconciliation": reconciliation,
|
||||
"hash_chain": hash_chain,
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue