Expand TUI guidance and host detection coverage
This commit is contained in:
parent
3b037646d2
commit
8194d13734
20 changed files with 414 additions and 42 deletions
12
README.md
12
README.md
|
|
@ -72,7 +72,7 @@ EDRs are built on:
|
|||
| `input_snooper` | A non-allowlisted process holding `/dev/input`, `/dev/uinput`, or HID raw devices open | Keyloggers have to read keystroke/event devices somewhere; expected compositors/remappers are tunable |
|
||||
| `credential_access` | A non-allowlisted process with shadow files, private SSH keys, browser stores, or secret profiles open | Credential harvesters have to open the material they steal; legitimate auth/keyring/browser readers are tunable |
|
||||
| `stealth_network` | Raw, SCTP, DCCP, packet, MPTCP, TIPC, XDP, or vsock activity | Covert channels often avoid ordinary TCP/UDP paths; expected network managers/sniffers are tunable |
|
||||
| `memory_obfuscation` | Executable anonymous/memfd/deleted mappings, RWX pages, or mapped process-hiding libraries | Encrypted/packed payloads still need executable memory after decrypting; hide libraries must be mapped to hook tools |
|
||||
| `memory_obfuscation` | Executable anonymous/memfd/deleted mappings, RWX pages, mapped process-hiding libraries, or executable `.so` mappings from writable runtime paths | Encrypted/packed payloads still need executable memory after decrypting; hide/injection libraries must be mapped to hook tools |
|
||||
| `new_listener` | A listening port absent from the startup baseline | Bind shells/backdoors have to listen somewhere |
|
||||
| `new_suid` | A new SUID/SGID binary (critical in a writable dir) | A SUID `/tmp` binary is a textbook privesc trick |
|
||||
| `persistence` | Changes to cron, systemd units, `authorized_keys`, rc files | Persistence has to write somewhere that survives reboot |
|
||||
|
|
@ -229,11 +229,15 @@ enodia-sentinel-tui
|
|||
The TUI mirrors the read-only management console for terminal operators:
|
||||
status, recent alerts, incidents, posture findings, integrity/watchdog state,
|
||||
incident timelines, event-rule metadata, event tail, and dry-run response-plan
|
||||
previews.
|
||||
previews. It includes plain-language view descriptions and a beginner workflow:
|
||||
start at `1` Status, then use `3` Incidents, `4` Timeline, `9` Response Plans,
|
||||
and `6` Integrity. The TUI displays evidence and suggested commands but does not
|
||||
change the host.
|
||||
|
||||
Keys: `1` status, `2` alerts, `3` incidents, `4` timelines, `5` posture,
|
||||
`6` integrity, `7` rules, `8` events, `9` response plans, `r` refresh, `/` filter, `:`
|
||||
command mode, `Tab` complete command names, `q` quit.
|
||||
`6` integrity, `7` rules, `8` events, `9` response plans, `n`/`b` next/back in
|
||||
the beginner workflow, `r` refresh, `/` filter, `:` command mode, `Tab` complete
|
||||
command names, `q` quit.
|
||||
|
||||
Shell completion is generated without extra dependencies:
|
||||
|
||||
|
|
|
|||
|
|
@ -403,10 +403,16 @@ daemon status, recent alert snapshots, incident index and timelines, posture
|
|||
findings, integrity/watchdog state, event-rule metadata, event tail, and
|
||||
read-only response-plan previews.
|
||||
|
||||
Beginner-oriented prompts in each view explain what the data means and suggest a
|
||||
safe flow: check Status first, review grouped Incidents, read the Timeline,
|
||||
inspect Response Plans, then confirm Integrity. The TUI is read-only; it never
|
||||
executes response commands.
|
||||
|
||||
Keys:
|
||||
|
||||
- `1` / `2` / `3` / `4`: status, alerts, incidents, timelines.
|
||||
- `5` / `6` / `7` / `8` / `9`: posture, integrity, rules, events, response plans.
|
||||
- `n` / `b`: next/back through the beginner workflow.
|
||||
- `j` / `k` or arrows: scroll.
|
||||
- `r`: refresh.
|
||||
- `/`: filter the current view.
|
||||
|
|
@ -416,7 +422,8 @@ Keys:
|
|||
- `q`: quit.
|
||||
|
||||
Command mode supports `status`, `alerts`, `incidents`, `timeline`, `posture`,
|
||||
`integrity`, `rules`, `events`, `response`, `help`, `refresh`,
|
||||
`integrity`, `rules`, `events`, `response`, `help`, `home`, `guide`, `next`,
|
||||
`back`, `refresh`,
|
||||
`filter <text>`, `clear`, and `quit`.
|
||||
|
||||
### `web`
|
||||
|
|
|
|||
|
|
@ -80,8 +80,10 @@ for quick remote review.
|
|||
For SSH/tmux operators, `enodia-sentinel tui` provides a terminal dashboard over
|
||||
the same local read-only management state: status, recent alerts, incidents,
|
||||
incident timelines, posture findings, integrity/watchdog state, event-rule
|
||||
metadata, event tail, and dry-run response-plan previews. Use `:` for command
|
||||
mode and `Tab` to complete TUI commands.
|
||||
metadata, event tail, and dry-run response-plan previews. Newer operators can
|
||||
follow the built-in workflow hints: Status -> Incidents -> Timeline -> Response
|
||||
Plans -> Integrity. Press `n`/`b` to step through that workflow, or use `:` for
|
||||
command mode and `Tab` to complete TUI commands.
|
||||
|
||||
On desktop hosts the optional tray applet (`enodia-sentinel-tray`, `[tray]`
|
||||
extra) wraps the same workflow: it drives the same `systemctl` units operators
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ work that is bigger than single alerts.
|
|||
raw ICMP/special-protocol rootcheck detection.
|
||||
- ✅ Add process-hiding and memory-obfuscation coverage: `/proc` vs `ps`
|
||||
cross-view checks plus memory-map scans for hide libraries, RWX memory, and
|
||||
executable anonymous/memfd/deleted mappings.
|
||||
executable anonymous/memfd/deleted mappings, plus writable-path executable
|
||||
library mappings that suggest userland process injection.
|
||||
- ✅ Add event-driven memory telemetry for short-lived `mprotect`/`mmap` RWX,
|
||||
`memfd_create`, `ptrace`, seccomp, cross-process memory access, and memory
|
||||
locking syscalls.
|
||||
|
|
@ -131,8 +132,11 @@ Purpose: reduce blind spots and connect related signals.
|
|||
- ✅ Add the first typed host-event rule family beside exec/syscall rules:
|
||||
`rules test` accepts `tcp_connect` event JSON and ships SID `100067` for
|
||||
interpreter egress to unusual public ports, with a replayable fixture.
|
||||
- ✅ Add a second typed host-event rule family for `listen` events: SID `100068`
|
||||
flags interpreters opening listeners on unusual local ports, with local-port
|
||||
match conditions and a replayable fixture.
|
||||
- Continue generalizing the rule engine across more typed host events:
|
||||
`bind`, `listen`, `accept`, `file_write`, `chmod/chown`, `setuid/setgid`,
|
||||
`bind`, `accept`, `file_write`, `chmod/chown`, `setuid/setgid`,
|
||||
`capset`, and module-load events.
|
||||
- Keep rules declarative and Snort-like:
|
||||
`sid`, `msg`, `severity`, `classtype`, `event`, and match fields such as
|
||||
|
|
|
|||
|
|
@ -223,3 +223,22 @@ Expected false positives:
|
|||
- Developer tooling using interpreters for custom APIs on high ports.
|
||||
|
||||
Drill or fixture: Use `rules test` with a `tcp_connect` event whose `comm` is an interpreter and whose public `peer_port` is not a common service port.
|
||||
|
||||
## SID 100068: Interpreter opened a listener on an unusual local port
|
||||
|
||||
- Event: `listen`
|
||||
- Signature: `host_rule.suspicious-listener`
|
||||
- Classtype: `suspicious-listener`
|
||||
- Severity: `HIGH`
|
||||
- Origin: `builtin`
|
||||
|
||||
Match fields:
|
||||
- `events`: `listen`
|
||||
- `comm`: `ash`, `bash`, `curl`, `dash`, `fetch`, `ksh`, `lua`, `nc`, `ncat`, `netcat`, `node`, `nodejs`, `perl`, `php`, `python`, `python2`, `python3`, `ruby`, `sh`, `socat`, `wget`, `zsh`
|
||||
- `local_port_exclude`: `22`, `53`, `80`, `123`, `443`, `853`
|
||||
|
||||
Expected false positives:
|
||||
- Developer HTTP servers, netcat listeners, or local test harnesses intentionally opened from an interpreter.
|
||||
- Administrative troubleshooting that temporarily listens on a high port.
|
||||
|
||||
Drill or fixture: Use `rules test` with a `listen` event whose `comm` is an interpreter and whose `local_port` is not a common service port.
|
||||
|
|
|
|||
|
|
@ -243,14 +243,18 @@ modules you intentionally trust.
|
|||
|
||||
---
|
||||
|
||||
## Runbook 5 — Memory obfuscation or process-hiding library
|
||||
## Runbook 5 — Memory obfuscation, process hiding, or injection library
|
||||
|
||||
**Triggers:** `memory_obfuscation` (100039), `process_hiding_library` (100048)
|
||||
**Triggers:** `memory_obfuscation` (100039), `process_hiding_library` (100048),
|
||||
`process_injection_library` (100049)
|
||||
|
||||
These alerts come from `/proc/<pid>/maps`. They do not read process memory; they
|
||||
flag map shapes associated with encrypted/packed payloads or userland hiding:
|
||||
RWX pages, executable anonymous memory, executable `memfd`/deleted mappings, or
|
||||
libraries with process-hiding names such as `libhide`.
|
||||
libraries with process-hiding names such as `libhide`. They also flag executable
|
||||
shared-library mappings from writable/runtime paths such as `/tmp`, `/dev/shm`,
|
||||
`/var/tmp`, and `/run/user`, which are common places for injected userland hooks
|
||||
or staged `.so` payloads.
|
||||
|
||||
**Confirm**
|
||||
|
||||
|
|
@ -259,6 +263,9 @@ libraries with process-hiding names such as `libhide`.
|
|||
- For `process_hiding_library`, check whether the mapped library is package-owned
|
||||
and whether it is loaded into tools like `ps`, `pgrep`, `ss`, shells, or admin
|
||||
utilities.
|
||||
- For `process_injection_library`, preserve the mapped `.so`, check its owner,
|
||||
hash, parent process, and whether it was deliberately loaded by a known
|
||||
profiler, debugger, or local instrumentation tool.
|
||||
- For `memory_obfuscation`, distinguish expected JIT runtimes from unknown
|
||||
implants. Tune known runtimes with `memory_obfuscation_allow_comms`; use
|
||||
`memory_obfuscation_allow_paths` only for well-understood local runtimes or
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""memory_obfuscation — memory-map indicators of hiding or encrypted payloads.
|
||||
"""memory_obfuscation — memory-map indicators of hiding or injected payloads.
|
||||
|
||||
Encrypted/packed implants still need executable memory after decrypting code.
|
||||
This detector does not read process memory; it only inspects ``/proc/<pid>/maps``
|
||||
for high-signal shapes: executable anonymous mappings, executable memfd/deleted
|
||||
mappings, RWX pages, and mapped libraries associated with process hiding.
|
||||
mappings, RWX pages, and mapped libraries associated with process hiding or
|
||||
process injection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -16,10 +17,13 @@ from ..system import MemoryMap, SystemState
|
|||
|
||||
SID_MEMORY_OBFUSCATION = 100039
|
||||
SID_PROCESS_HIDING_LIBRARY = 100048
|
||||
SID_PROCESS_INJECTION_LIBRARY = 100049
|
||||
|
||||
_HIDE_LIBRARY_HINTS = frozenset({
|
||||
"libhide", "libprocesshide", "process_hide", "proc_hide", "rootkit_hide",
|
||||
})
|
||||
_WRITABLE_MAP_PREFIXES = ("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/")
|
||||
_LIB_EXTENSIONS = (".so", ".so.", ".dylib")
|
||||
|
||||
|
||||
def _maps(proc) -> list[MemoryMap]:
|
||||
|
|
@ -40,6 +44,14 @@ def _hide_library(path: str) -> bool:
|
|||
return any(hint in name for hint in _HIDE_LIBRARY_HINTS)
|
||||
|
||||
|
||||
def _writable_library(path: str) -> bool:
|
||||
lower = path.lower()
|
||||
if not lower.startswith(_WRITABLE_MAP_PREFIXES):
|
||||
return False
|
||||
name = lower.rsplit("/", 1)[-1]
|
||||
return any(ext in name for ext in _LIB_EXTENSIONS)
|
||||
|
||||
|
||||
def _allowed_path(path: str, cfg: Config) -> bool:
|
||||
for prefix in cfg.memory_obfuscation_allow_paths:
|
||||
if prefix and path.startswith(prefix):
|
||||
|
|
@ -57,6 +69,10 @@ def _indicator(mm: MemoryMap, cfg: Config) -> tuple[str, Severity, str]:
|
|||
)
|
||||
executable = "x" in mm.perms
|
||||
writable = "w" in mm.perms
|
||||
if executable and path and _writable_library(path):
|
||||
return "process_injection_library", Severity.HIGH, (
|
||||
f"executable library mapped from writable/runtime path: {path}"
|
||||
)
|
||||
if executable and ("memfd:" in path or "(deleted)" in path):
|
||||
return "memory_obfuscation", Severity.CRITICAL, (
|
||||
f"executable transient mapping perms={mm.perms} path={path or '<anonymous>'}"
|
||||
|
|
@ -83,8 +99,10 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
|||
if signature == "memory_obfuscation" and comm in cfg.memory_obfuscation_allow_comms:
|
||||
continue
|
||||
seen.add(signature)
|
||||
sid = (SID_PROCESS_HIDING_LIBRARY if signature == "process_hiding_library"
|
||||
else SID_MEMORY_OBFUSCATION)
|
||||
sid = {
|
||||
"process_hiding_library": SID_PROCESS_HIDING_LIBRARY,
|
||||
"process_injection_library": SID_PROCESS_INJECTION_LIBRARY,
|
||||
}.get(signature, SID_MEMORY_OBFUSCATION)
|
||||
yield Alert(
|
||||
severity=severity,
|
||||
signature=signature,
|
||||
|
|
@ -95,6 +113,8 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
|||
),
|
||||
pids=(proc.pid,),
|
||||
sid=sid,
|
||||
classtype=("process-hiding" if signature == "process_hiding_library"
|
||||
else "memory-obfuscation"),
|
||||
classtype={
|
||||
"process_hiding_library": "process-hiding",
|
||||
"process_injection_library": "process-injection",
|
||||
}.get(signature, "memory-obfuscation"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ class HostRule:
|
|||
peer_public: bool | None = None
|
||||
peer_ports: frozenset[int] = frozenset()
|
||||
peer_port_exclude: frozenset[int] = frozenset()
|
||||
local_ports: frozenset[int] = frozenset()
|
||||
local_port_exclude: frozenset[int] = frozenset()
|
||||
argv_regex: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
|
|
@ -37,7 +39,8 @@ class HostRule:
|
|||
raise ValueError(f"rule sid={self.sid} has no event types")
|
||||
if not any((
|
||||
self.comm, self.parent_comm, self.peer_public is not None,
|
||||
self.peer_ports, self.peer_port_exclude, self.argv_regex,
|
||||
self.peer_ports, self.peer_port_exclude, self.local_ports,
|
||||
self.local_port_exclude, self.argv_regex,
|
||||
)):
|
||||
raise ValueError(f"rule sid={self.sid} has no match conditions")
|
||||
if self.argv_regex is not None:
|
||||
|
|
@ -59,6 +62,10 @@ class HostRule:
|
|||
return False
|
||||
if self.peer_port_exclude and ev.peer_port in self.peer_port_exclude:
|
||||
return False
|
||||
if self.local_ports and ev.local_port not in self.local_ports:
|
||||
return False
|
||||
if self.local_port_exclude and ev.local_port in self.local_port_exclude:
|
||||
return False
|
||||
if self._argv_re is not None and not self._argv_re.search(ev.argv_str):
|
||||
return False
|
||||
return True
|
||||
|
|
@ -71,6 +78,7 @@ class HostRule:
|
|||
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}"
|
||||
),
|
||||
pids=(ev.pid,),
|
||||
sid=self.sid,
|
||||
|
|
@ -89,6 +97,15 @@ DEFAULT_HOST_RULES: tuple[HostRule, ...] = (
|
|||
peer_public=True,
|
||||
peer_port_exclude=_COMMON_PUBLIC_PORTS,
|
||||
),
|
||||
HostRule(
|
||||
sid=100068,
|
||||
msg="Interpreter opened a listener on an unusual local port",
|
||||
severity=Severity.HIGH,
|
||||
classtype="suspicious-listener",
|
||||
events=frozenset({"listen"}),
|
||||
comm=_INTERPRETERS,
|
||||
local_port_exclude=_COMMON_PUBLIC_PORTS,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,10 @@ def _host_rule_record(rule: HostRule, origin: str) -> dict[str, Any]:
|
|||
conditions["peer_ports"] = sorted(rule.peer_ports)
|
||||
if rule.peer_port_exclude:
|
||||
conditions["peer_port_exclude"] = sorted(rule.peer_port_exclude)
|
||||
if rule.local_ports:
|
||||
conditions["local_ports"] = sorted(rule.local_ports)
|
||||
if rule.local_port_exclude:
|
||||
conditions["local_port_exclude"] = sorted(rule.local_port_exclude)
|
||||
if rule.argv_regex:
|
||||
conditions["argv_regex"] = rule.argv_regex
|
||||
return {
|
||||
|
|
@ -263,6 +267,13 @@ _RULE_DOCS: dict[int, dict[str, Any]] = {
|
|||
],
|
||||
"drill": "Use `rules test` with a `tcp_connect` event whose `comm` is an interpreter and whose public `peer_port` is not a common service port.",
|
||||
},
|
||||
100068: {
|
||||
"false_positives": [
|
||||
"Developer HTTP servers, netcat listeners, or local test harnesses intentionally opened from an interpreter.",
|
||||
"Administrative troubleshooting that temporarily listens on a high port.",
|
||||
],
|
||||
"drill": "Use `rules test` with a `listen` event whose `comm` is an interpreter and whose `local_port` is not a common service port.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -344,8 +355,10 @@ def _host_event(event: dict[str, Any]) -> HostEvent:
|
|||
argv=tuple(str(a) for a in event.get("argv", ())),
|
||||
peer_ip=str(event.get("peer_ip", event.get("remote_ip", ""))),
|
||||
peer_port=_to_int(event.get("peer_port", event.get("remote_port", 0))),
|
||||
local_ip=str(event.get("local_ip", "")),
|
||||
local_port=_to_int(event.get("local_port", 0)),
|
||||
local_ip=str(event.get("local_ip", event.get("bind_ip",
|
||||
event.get("listen_ip", "")))),
|
||||
local_port=_to_int(event.get("local_port", event.get("bind_port",
|
||||
event.get("listen_port", 0)))),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ BUILTIN_SIDS: tuple[SidInfo, ...] = (
|
|||
SidInfo(memory_obfuscation.SID_PROCESS_HIDING_LIBRARY,
|
||||
"process_hiding_library", "detector",
|
||||
f"{_DETECTORS}.memory_obfuscation"),
|
||||
SidInfo(memory_obfuscation.SID_PROCESS_INJECTION_LIBRARY,
|
||||
"process_injection_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"),
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ def triage_alert(alert: dict, processes: list[dict], cfg: Config,
|
|||
return Verdict(LIKELY_FP, f"preload library is package-owned: {lib}")
|
||||
return Verdict(REVIEW, "library injection — verify the .so by hand")
|
||||
|
||||
if sig == "process_injection_library":
|
||||
return Verdict(REVIEW, "executable library mapped from writable path")
|
||||
|
||||
if sig == "deleted_exe":
|
||||
if "memfd:" in detail:
|
||||
return Verdict(REVIEW, "memfd-backed (fileless) execution")
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ VIEWS = (
|
|||
"response",
|
||||
"help",
|
||||
)
|
||||
WORKFLOW_VIEWS = ("status", "incidents", "timeline", "response", "integrity")
|
||||
TUI_COMMANDS = {
|
||||
"status": "show daemon health and heartbeat",
|
||||
"alerts": "show recent captured alert snapshots",
|
||||
|
|
@ -35,11 +36,57 @@ TUI_COMMANDS = {
|
|||
"events": "tail local events.log",
|
||||
"response": "preview read-only response plans for incidents",
|
||||
"help": "show keys and commands",
|
||||
"home": "return to the Status starting view",
|
||||
"guide": "show beginner workflow help",
|
||||
"next": "move to the next beginner workflow view",
|
||||
"back": "move to the previous beginner workflow view",
|
||||
"refresh": "reload data from disk",
|
||||
"filter": "filter current view: filter <text>",
|
||||
"clear": "clear filter and message",
|
||||
"quit": "exit the TUI",
|
||||
}
|
||||
VIEW_INFO = {
|
||||
"status": (
|
||||
"Status",
|
||||
"Start here. Shows whether the Sentinel sensor is running and fresh.",
|
||||
),
|
||||
"alerts": (
|
||||
"Alerts",
|
||||
"Individual findings from detectors. Use Incidents when several alerts may be related.",
|
||||
),
|
||||
"incidents": (
|
||||
"Incidents",
|
||||
"Grouped alerts that tell one story. Start investigations here.",
|
||||
),
|
||||
"timeline": (
|
||||
"Timeline",
|
||||
"Time-ordered incident activity. Use this to understand what happened first.",
|
||||
),
|
||||
"posture": (
|
||||
"Posture",
|
||||
"Hardening advice. These are risky settings, not proof of compromise by themselves.",
|
||||
),
|
||||
"integrity": (
|
||||
"Integrity",
|
||||
"Trust checks for heartbeat, FIM, package DB, signatures, and accepted drift.",
|
||||
),
|
||||
"rules": (
|
||||
"Rules",
|
||||
"Detection rule catalog. Mostly useful for tuning or understanding why an alert fired.",
|
||||
),
|
||||
"events": (
|
||||
"Events",
|
||||
"Technical event log. Useful for troubleshooting sensor startup and probes.",
|
||||
),
|
||||
"response": (
|
||||
"Response Plans",
|
||||
"Read-only containment and recovery suggestions. The TUI never executes commands.",
|
||||
),
|
||||
"help": (
|
||||
"Help",
|
||||
"Keys, commands, and a safe first investigation workflow.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -126,7 +173,23 @@ def run_tui_command(state: TuiState, command: str) -> TuiState:
|
|||
if name in VIEWS:
|
||||
state.view = name
|
||||
state.scroll = 0
|
||||
state.message = f"view: {name}"
|
||||
state.message = f"view: {_view_label(name)}"
|
||||
elif name == "home":
|
||||
state.view = "status"
|
||||
state.scroll = 0
|
||||
state.message = "view: Status"
|
||||
elif name == "guide":
|
||||
state.view = "help"
|
||||
state.scroll = 0
|
||||
state.message = "beginner guide"
|
||||
elif name == "next":
|
||||
state.view = _workflow_neighbor(state.view, 1)
|
||||
state.scroll = 0
|
||||
state.message = f"next: {_view_label(state.view)}"
|
||||
elif name == "back":
|
||||
state.view = _workflow_neighbor(state.view, -1)
|
||||
state.scroll = 0
|
||||
state.message = f"back: {_view_label(state.view)}"
|
||||
elif name == "refresh":
|
||||
state.message = "refreshed"
|
||||
elif name == "filter":
|
||||
|
|
@ -169,6 +232,8 @@ def render_lines(state: TuiState, width: int = 100) -> list[str]:
|
|||
lines = _render_response(model.get("response") or [], width)
|
||||
else:
|
||||
lines = _render_help(width)
|
||||
if view != "help":
|
||||
lines = _with_intro(view, lines, width)
|
||||
if state.filter_text:
|
||||
needle = state.filter_text.lower()
|
||||
lines = [line for line in lines if needle in line.lower()]
|
||||
|
|
@ -233,6 +298,12 @@ def _loop(stdscr, cfg: Config) -> None:
|
|||
state.view, state.scroll = "response", 0
|
||||
elif key in (ord("h"), ord("?")):
|
||||
state.view, state.scroll = "help", 0
|
||||
elif key in (ord("n"), ord("N")):
|
||||
state.view, state.scroll = _workflow_neighbor(state.view, 1), 0
|
||||
state.message = f"next: {_view_label(state.view)}"
|
||||
elif key in (ord("b"), ord("B")):
|
||||
state.view, state.scroll = _workflow_neighbor(state.view, -1), 0
|
||||
state.message = f"back: {_view_label(state.view)}"
|
||||
elif key == ord(":"):
|
||||
state.command_mode = True
|
||||
state.command = ""
|
||||
|
|
@ -277,7 +348,10 @@ def _draw(stdscr, state: TuiState) -> None:
|
|||
width = max(width, 20)
|
||||
stdscr.erase()
|
||||
stdscr.addnstr(0, 0, _header_text(state, width), width - 1, curses.A_REVERSE)
|
||||
meta = f"view={state.view} filter={state.filter_text or '-'} {state.message}"
|
||||
meta = (
|
||||
f"view={_view_label(state.view)} "
|
||||
f"filter={state.filter_text or '-'} {state.message}"
|
||||
)
|
||||
stdscr.addnstr(1, 0, meta[:width - 1], width - 1)
|
||||
lines = render_lines(state, width)
|
||||
body_h = max(0, height - 4)
|
||||
|
|
@ -285,7 +359,11 @@ def _draw(stdscr, state: TuiState) -> None:
|
|||
state.scroll = min(state.scroll, max_scroll)
|
||||
for row, line in enumerate(lines[state.scroll:state.scroll + body_h], start=2):
|
||||
stdscr.addnstr(row, 0, line[:width - 1], width - 1)
|
||||
prompt = ":" + state.command if state.command_mode else "Press ':' for commands; Tab completes command names."
|
||||
prompt = (
|
||||
":" + state.command
|
||||
if state.command_mode
|
||||
else "Read-only. h help, n next, b back, 1-9 views, / filter, q quit."
|
||||
)
|
||||
stdscr.addnstr(height - 1, 0, prompt[:width - 1], width - 1, curses.A_REVERSE)
|
||||
if state.command_mode:
|
||||
stdscr.move(height - 1, min(width - 1, len(prompt)))
|
||||
|
|
@ -306,13 +384,45 @@ def _header_text(state: TuiState, width: int) -> str:
|
|||
elif width >= len(title) + len(compact_tabs) + 2:
|
||||
text = title + compact_tabs
|
||||
else:
|
||||
text = f"{title}view={state.view} 1-9 switch h help r refresh q quit"
|
||||
text = (
|
||||
f"{title}{_view_label(state.view)} "
|
||||
"1-9 switch h help r refresh q quit"
|
||||
)
|
||||
return text[:max(0, width - 1)]
|
||||
|
||||
|
||||
def _view_label(view: str) -> str:
|
||||
return VIEW_INFO.get(view, (view.title(), ""))[0]
|
||||
|
||||
|
||||
def _workflow_neighbor(view: str, direction: int) -> str:
|
||||
try:
|
||||
index = WORKFLOW_VIEWS.index(view)
|
||||
except ValueError:
|
||||
index = 0
|
||||
index = max(0, min(len(WORKFLOW_VIEWS) - 1, index + direction))
|
||||
return WORKFLOW_VIEWS[index]
|
||||
|
||||
|
||||
def _with_intro(view: str, lines: list[str], width: int) -> list[str]:
|
||||
label, hint = VIEW_INFO.get(view, (view.title(), ""))
|
||||
intro = [f"{label} - {_clip(hint, max(20, width - len(label) - 3))}"]
|
||||
if view in {"alerts", "incidents", "timeline", "response"}:
|
||||
intro.append("Suggested flow: 3 Incidents -> 4 Timeline -> 9 Response Plans.")
|
||||
elif view == "status":
|
||||
intro.append("If the daemon is stopped or heartbeat is stale, start/restart the service before trusting old data.")
|
||||
elif view == "integrity":
|
||||
intro.append("Overall 'review' means check details; 'critical' means the watchdog or heartbeat needs attention.")
|
||||
intro.append("")
|
||||
return intro + lines
|
||||
|
||||
|
||||
def _render_status(status: dict, width: int) -> list[str]:
|
||||
if not status:
|
||||
return ["No status available."]
|
||||
return [
|
||||
"No status available.",
|
||||
"Next step: start the daemon or check that this TUI uses the same config/log_dir.",
|
||||
]
|
||||
age = status.get("heartbeat_age")
|
||||
heartbeat = "none" if age is None else f"{int(age)}s ago"
|
||||
if status.get("heartbeat_stale"):
|
||||
|
|
@ -330,15 +440,25 @@ def _render_status(status: dict, width: int) -> list[str]:
|
|||
f"Exec probe: {status.get('ebpf_exec', 'unknown')}",
|
||||
f"Syscall: {status.get('ebpf_syscall', 'unknown')}",
|
||||
"",
|
||||
"Use 2-9 to inspect alerts, incidents, timelines, posture, integrity, rules, events, and response plans.",
|
||||
"Use ':' for commands.",
|
||||
"Plain language:",
|
||||
" Daemon running + fresh heartbeat = the local sensor is alive.",
|
||||
" Alerts are raw findings; incidents group related findings together.",
|
||||
" eBPF unknown/disabled means Sentinel falls back to polling detectors.",
|
||||
"",
|
||||
"Use 3 for incidents, 4 for timelines, 9 for response plans, or h for help.",
|
||||
]
|
||||
|
||||
|
||||
def _render_alerts(alerts: list[dict], width: int) -> list[str]:
|
||||
if not alerts:
|
||||
return ["No alert snapshots found."]
|
||||
lines = [f"{'TIME':<24} {'SEV':<9} {'SID':<7} SIGNATURE / DETAIL"]
|
||||
return [
|
||||
"No alert snapshots found.",
|
||||
"This is good if the daemon is running and the heartbeat is fresh.",
|
||||
]
|
||||
lines = [
|
||||
"Severity guide: CRITICAL = urgent, HIGH = review soon, MEDIUM = investigate when practical.",
|
||||
f"{'TIME':<24} {'SEV':<9} {'SID':<7} SIGNATURE / DETAIL",
|
||||
]
|
||||
for snap in alerts:
|
||||
for alert in snap.get("alerts", []):
|
||||
detail = alert.get("detail", "")
|
||||
|
|
@ -353,8 +473,14 @@ def _render_alerts(alerts: list[dict], width: int) -> list[str]:
|
|||
|
||||
def _render_incidents(incidents: list[dict], width: int) -> list[str]:
|
||||
if not incidents:
|
||||
return ["No incidents recorded."]
|
||||
lines = [f"{'INCIDENT':<28} {'LAST SEEN':<24} {'SEV':<9} {'#':>3} SIGNATURES"]
|
||||
return [
|
||||
"No incidents recorded.",
|
||||
"Incidents appear after alerts are grouped by process lineage or time window.",
|
||||
]
|
||||
lines = [
|
||||
"Pick an incident id, then use 4 Timeline to see the order of events.",
|
||||
f"{'INCIDENT':<28} {'LAST SEEN':<24} {'SEV':<9} {'#':>3} SIGNATURES",
|
||||
]
|
||||
for inc in incidents:
|
||||
sigs = ", ".join(inc.get("signatures", []))
|
||||
lines.append(
|
||||
|
|
@ -366,7 +492,10 @@ def _render_incidents(incidents: list[dict], width: int) -> list[str]:
|
|||
|
||||
def _render_timelines(views: list[dict], width: int) -> list[str]:
|
||||
if not views:
|
||||
return ["No incident timelines."]
|
||||
return [
|
||||
"No incident timelines.",
|
||||
"Timelines need at least one recorded incident snapshot.",
|
||||
]
|
||||
lines = []
|
||||
for view in views:
|
||||
if view.get("error"):
|
||||
|
|
@ -411,8 +540,9 @@ def _render_posture(report: dict, width: int) -> list[str]:
|
|||
"",
|
||||
]
|
||||
if not findings:
|
||||
lines.append("No posture findings.")
|
||||
lines.append("No posture findings. SSH, sudo, PATH, file perms, and package signature policy look sound.")
|
||||
return lines
|
||||
lines.append("These are hardening tasks. Fixing them reduces risk even if no attack is active.")
|
||||
lines.append(f"{'SEV':<9} {'SID':<7} {'SIGNATURE':<22} DETAIL")
|
||||
for finding in findings:
|
||||
detail = finding.get("detail", "")
|
||||
|
|
@ -480,7 +610,7 @@ def _render_rules(catalog, width: int) -> list[str]:
|
|||
return [f"Rules unavailable: {catalog['error']}"]
|
||||
rules = catalog.get("rules", []) if isinstance(catalog, dict) else catalog
|
||||
if not rules:
|
||||
return ["No rules loaded."]
|
||||
return ["No rules loaded.", "This usually means no built-in rule metadata was available."]
|
||||
lines = []
|
||||
if isinstance(catalog, dict):
|
||||
lines.append(
|
||||
|
|
@ -502,15 +632,26 @@ def _render_rules(catalog, width: int) -> list[str]:
|
|||
|
||||
def _render_events(events: list[str], width: int) -> list[str]:
|
||||
if not events:
|
||||
return ["No events.log entries found."]
|
||||
return [_clip(line, width) for line in events]
|
||||
return [
|
||||
"No events.log entries found.",
|
||||
"This log is mostly for troubleshooting daemon startup and probe status.",
|
||||
]
|
||||
return [
|
||||
"Newest daemon/probe messages are shown below. These are technical logs.",
|
||||
"",
|
||||
*[_clip(line, width) for line in events],
|
||||
]
|
||||
|
||||
|
||||
def _render_response(plans: list[dict], width: int) -> list[str]:
|
||||
if not plans:
|
||||
return ["No incidents with response plans."]
|
||||
return [
|
||||
"No incidents with response plans.",
|
||||
"Response plans appear after an incident exists. They are suggestions only.",
|
||||
]
|
||||
lines = [
|
||||
"Read-only response previews. Commands are not executed from the TUI.",
|
||||
"Review evidence before stopping processes, blocking IPs, or moving files.",
|
||||
"",
|
||||
]
|
||||
for plan in plans:
|
||||
|
|
@ -540,8 +681,21 @@ def _render_response(plans: list[dict], width: int) -> list[str]:
|
|||
|
||||
def _render_help(width: int) -> list[str]:
|
||||
lines = [
|
||||
"New User Workflow:",
|
||||
" 1 Status confirm the sensor is running and heartbeat is fresh",
|
||||
" 3 Incidents find grouped activity instead of chasing raw alerts",
|
||||
" 4 Timeline read what happened in time order",
|
||||
" 9 Response review suggested commands; the TUI does not execute them",
|
||||
" 6 Integrity check watchdog, file integrity, and package trust",
|
||||
" Press n/b to walk this workflow forward/back.",
|
||||
"",
|
||||
"Safety:",
|
||||
" This TUI is read-only. It displays commands and evidence, but does not change the host.",
|
||||
" If you are unsure, preserve evidence first and avoid deleting or moving files.",
|
||||
"",
|
||||
"Keys:",
|
||||
" 1-9 switch views: status, alerts, incidents, timeline, posture, integrity, rules, events, response",
|
||||
" n / b next/back in the beginner workflow",
|
||||
" j/k or arrows scroll",
|
||||
" PgUp/PgDn page scroll",
|
||||
" r refresh",
|
||||
|
|
|
|||
12
tests/fixtures/sids/100068-interpreter-unusual-listener.json
vendored
Normal file
12
tests/fixtures/sids/100068-interpreter-unusual-listener.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"event": "listen",
|
||||
"pid": 4242,
|
||||
"ppid": 1,
|
||||
"uid": 1000,
|
||||
"comm": "python3",
|
||||
"parent_comm": "bash",
|
||||
"path": "/usr/bin/python3",
|
||||
"argv": ["-m", "http.server", "4444"],
|
||||
"local_ip": "0.0.0.0",
|
||||
"local_port": 4444
|
||||
}
|
||||
|
|
@ -303,6 +303,16 @@ DRILLS: dict[int, Callable[[], list[Alert]]] = {
|
|||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100049: lambda: list(memory_obfuscation.detect(
|
||||
SystemState(processes=[
|
||||
FakeProc(pid=355, comm="sshd",
|
||||
memory_maps=[
|
||||
MemoryMap(0xb000, 0xc000, "r-xp",
|
||||
"/tmp/libinject.so"),
|
||||
]),
|
||||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100050: lambda: list(posture.systemd_findings([
|
||||
posture.UnitFact("foo.service", "/etc/systemd/system/foo.service",
|
||||
0o100664, True, ("/usr/bin/foo",)),
|
||||
|
|
|
|||
|
|
@ -197,6 +197,30 @@ class TestMemoryObfuscation(unittest.TestCase):
|
|||
self.assertEqual(alerts[0].signature, "process_hiding_library")
|
||||
self.assertEqual(alerts[0].severity, Severity.CRITICAL)
|
||||
|
||||
def test_process_injection_library_alerts(self):
|
||||
proc = FakeProc(
|
||||
pid=355, comm="sshd",
|
||||
memory_maps=[MemoryMap(0xb000, 0xc000, "r-xp", "/tmp/libinject.so")],
|
||||
)
|
||||
alerts = list(memory_obfuscation.detect(SystemState(processes=[proc]), cfg()))
|
||||
self.assertEqual(len(alerts), 1)
|
||||
self.assertEqual(alerts[0].signature, "process_injection_library")
|
||||
self.assertEqual(alerts[0].sid,
|
||||
memory_obfuscation.SID_PROCESS_INJECTION_LIBRARY)
|
||||
self.assertEqual(alerts[0].classtype, "process-injection")
|
||||
self.assertEqual(alerts[0].severity, Severity.HIGH)
|
||||
|
||||
def test_process_injection_library_allow_path(self):
|
||||
c = cfg()
|
||||
c.memory_obfuscation_allow_paths = ("/tmp/known-profiler/",)
|
||||
proc = FakeProc(
|
||||
pid=356, comm="python3",
|
||||
memory_maps=[
|
||||
MemoryMap(0xd000, 0xe000, "r-xp", "/tmp/known-profiler/libhook.so"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(list(memory_obfuscation.detect(SystemState(processes=[proc]), c)), [])
|
||||
|
||||
def test_jit_allowlisted_comm_ignored(self):
|
||||
proc = FakeProc(
|
||||
pid=353, comm="node",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,18 @@ class TestRuleEventCompatibility(unittest.TestCase):
|
|||
}
|
||||
self.assertIn(100067, self._sids(event))
|
||||
|
||||
def test_host_event_accepts_listen_shape(self):
|
||||
event = {
|
||||
"type": "listen",
|
||||
"pid": "4242",
|
||||
"ppid": "1",
|
||||
"uid": "1000",
|
||||
"comm": "python3",
|
||||
"bind_ip": "0.0.0.0",
|
||||
"bind_port": "4444",
|
||||
}
|
||||
self.assertIn(100068, self._sids(event))
|
||||
|
||||
def test_unknown_event_shape_still_errors(self):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "event JSON must be an exec, syscall, or host event"
|
||||
|
|
|
|||
|
|
@ -19,12 +19,16 @@ class TestRuleOps(unittest.TestCase):
|
|||
self.assertIn(100001, sids)
|
||||
self.assertIn(100060, sids)
|
||||
self.assertIn(100067, sids)
|
||||
self.assertIn(100068, sids)
|
||||
exec_rule = next(r for r in rules if r["sid"] == 100002)
|
||||
self.assertEqual(exec_rule["event"], "exec")
|
||||
self.assertIn("argv_regex", exec_rule["conditions"])
|
||||
host_rule = next(r for r in rules if r["sid"] == 100067)
|
||||
self.assertEqual(host_rule["event"], "tcp_connect")
|
||||
self.assertIn("peer_port_exclude", host_rule["conditions"])
|
||||
listener_rule = next(r for r in rules if r["sid"] == 100068)
|
||||
self.assertEqual(listener_rule["event"], "listen")
|
||||
self.assertIn("local_port_exclude", listener_rule["conditions"])
|
||||
|
||||
def test_configured_exec_rule_is_listed(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
|
|
@ -97,6 +101,33 @@ exec_comm = ["id"]
|
|||
}
|
||||
self.assertEqual(ruleops.test_event(cfg, event), [])
|
||||
|
||||
def test_matches_host_listen_event_json(self):
|
||||
cfg = Config()
|
||||
event = {
|
||||
"event": "listen",
|
||||
"pid": 42,
|
||||
"ppid": 1,
|
||||
"uid": 1000,
|
||||
"comm": "python3",
|
||||
"local_ip": "0.0.0.0",
|
||||
"local_port": 4444,
|
||||
}
|
||||
alerts = ruleops.test_event(cfg, event)
|
||||
self.assertIn(100068, {a.sid for a in alerts})
|
||||
|
||||
def test_host_listen_common_port_no_match(self):
|
||||
cfg = Config()
|
||||
event = {
|
||||
"event": "listen",
|
||||
"pid": 42,
|
||||
"ppid": 1,
|
||||
"uid": 1000,
|
||||
"comm": "python3",
|
||||
"local_ip": "0.0.0.0",
|
||||
"local_port": 443,
|
||||
}
|
||||
self.assertEqual(ruleops.test_event(cfg, event), [])
|
||||
|
||||
def test_render_markdown_documents_rules(self):
|
||||
text = ruleops.render_markdown(Config())
|
||||
self.assertIn("# Enodia Sentinel Event Rule Reference", text)
|
||||
|
|
@ -168,6 +199,7 @@ class TestRulesCli(unittest.TestCase):
|
|||
self.assertIn("Enodia Sentinel Event Rule Reference", out)
|
||||
self.assertIn("SID 100060", out)
|
||||
self.assertIn("SID 100067", out)
|
||||
self.assertIn("SID 100068", out)
|
||||
self.assertIn("Expected false positives", out)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class TestRegistry(unittest.TestCase):
|
|||
self.assertEqual(len(nums), len(set(nums)))
|
||||
|
||||
def test_count_and_helpers(self):
|
||||
self.assertEqual(len(sids.BUILTIN_SIDS), 53)
|
||||
self.assertEqual(len(sids.BUILTIN_SIDS), 55)
|
||||
self.assertEqual(
|
||||
sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ class TestOtherSignatures(unittest.TestCase):
|
|||
self.assertIn("memory_obfuscation_allow_comms", v.suggest)
|
||||
self.assertIn('"jit-runtime"', v.suggest)
|
||||
|
||||
def test_process_injection_library_is_review(self):
|
||||
a = alert("process_injection_library",
|
||||
"pid=45 comm=sshd executable library mapped from writable/runtime path: /tmp/libinject.so",
|
||||
sid=100049)
|
||||
v = triage_alert(a, [], Config(), is_owned=NONE)
|
||||
self.assertEqual(v.label, REVIEW)
|
||||
self.assertIn("writable path", v.reason)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -52,6 +52,19 @@ class TestTuiCore(unittest.TestCase):
|
|||
run_tui_command(state, "quit")
|
||||
self.assertTrue(state.should_quit)
|
||||
|
||||
def test_beginner_workflow_commands(self):
|
||||
state = TuiState()
|
||||
run_tui_command(state, "next")
|
||||
self.assertEqual(state.view, "incidents")
|
||||
run_tui_command(state, "next")
|
||||
self.assertEqual(state.view, "timeline")
|
||||
run_tui_command(state, "back")
|
||||
self.assertEqual(state.view, "incidents")
|
||||
run_tui_command(state, "home")
|
||||
self.assertEqual(state.view, "status")
|
||||
run_tui_command(state, "guide")
|
||||
self.assertEqual(state.view, "help")
|
||||
|
||||
def test_collect_model_contains_dashboard_parity_sections(self):
|
||||
model = collect_model(self.cfg)
|
||||
for key in (
|
||||
|
|
@ -64,6 +77,8 @@ class TestTuiCore(unittest.TestCase):
|
|||
lines = render_lines(state, width=80)
|
||||
self.assertTrue(any("Daemon:" in line for line in lines))
|
||||
self.assertTrue(any("Heartbeat:" in line for line in lines))
|
||||
self.assertTrue(any("Plain language:" in line for line in lines))
|
||||
self.assertTrue(any("fresh heartbeat" in line for line in lines))
|
||||
|
||||
def test_render_alerts_and_filter(self):
|
||||
(self.tmp / "alert-1.json").write_text(json.dumps({
|
||||
|
|
@ -161,10 +176,13 @@ class TestTuiCore(unittest.TestCase):
|
|||
self.assertTrue(any("Overall:" in line for line in integrity))
|
||||
self.assertTrue(any("ttl expired" in line for line in integrity))
|
||||
events = render_lines(TuiState(view="events", model=model), width=100)
|
||||
self.assertEqual(events, ["2026-07-09T01:02:03 event happened"])
|
||||
self.assertTrue(any("technical logs" in line for line in events))
|
||||
self.assertTrue(any("2026-07-09T01:02:03 event happened" in line
|
||||
for line in events))
|
||||
response = render_lines(TuiState(view="response", model=model), width=100)
|
||||
self.assertTrue(any("inc-1" in line for line in response))
|
||||
self.assertTrue(any("tar -czf /tmp/evidence.tgz" in line for line in response))
|
||||
self.assertTrue(any("not executed" in line for line in response))
|
||||
|
||||
def test_cli_completion_outputs_scripts(self):
|
||||
for shell in ("bash", "zsh"):
|
||||
|
|
@ -182,6 +200,9 @@ class TestTuiCore(unittest.TestCase):
|
|||
help_lines = render_lines(TuiState(view="help"), width=100)
|
||||
for command in TUI_COMMANDS:
|
||||
self.assertTrue(any(command in line for line in help_lines), command)
|
||||
self.assertTrue(any("New User Workflow" in line for line in help_lines))
|
||||
self.assertTrue(any("read-only" in line for line in help_lines))
|
||||
self.assertTrue(any("n/b" in line for line in help_lines))
|
||||
|
||||
def test_header_scales_to_terminal_width(self):
|
||||
state = TuiState(view="integrity")
|
||||
|
|
@ -189,7 +210,7 @@ class TestTuiCore(unittest.TestCase):
|
|||
with self.subTest(width=width):
|
||||
header = _header_text(state, width)
|
||||
self.assertLessEqual(len(header), width - 1)
|
||||
self.assertIn("view=integrity", _header_text(state, 60))
|
||||
self.assertIn("Integrity", _header_text(state, 60))
|
||||
self.assertIn("6 Int", _header_text(state, 100))
|
||||
self.assertIn("6 Integrity", _header_text(state, 180))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue