Expand rootkit monitoring and Claude docs

This commit is contained in:
Luna 2026-06-12 04:57:11 -07:00
parent a7129e5666
commit 3e5f8fc3f7
16 changed files with 524 additions and 32 deletions

View file

@ -74,7 +74,7 @@ def main(argv: list[str] | None = None) -> int:
pv.add_argument("--sample", type=int, default=0,
help="packages to verify (0 = config default; rotates)")
sub.add_parser("rootcheck",
help="anti-rootkit cross-view: hidden procs/modules/ports")
help="anti-rootkit cross-view and kernel/module taint checks")
st = sub.add_parser("status", help="print daemon health/alert summary")
st.add_argument("--json", action="store_true", help="emit status as JSON")
inc = sub.add_parser("incident",
@ -197,7 +197,8 @@ def _cmd_rootcheck(cfg: Config) -> int:
from . import rootcheck
alerts = list(rootcheck.run(cfg))
if not alerts:
print("Rootcheck: no hidden processes, modules, ports, or sniffers found.")
print("Rootcheck: no hidden processes/modules/ports, sniffers, "
"known rootkit modules, or kernel taint found.")
return 0
for a in sorted(alerts, key=lambda x: -x.severity):
print(f"[{a.severity}] {a.signature:<22} {a.detail}")

View file

@ -82,6 +82,9 @@ class Config:
rootcheck_enabled: bool = True
rootcheck_interval: int = 300 # seconds between cross-view sweeps
rootcheck_pid_cap: int = 65536 # upper PID to brute-force via kill(0)
# Legitimate out-of-tree/proprietary/unsigned modules accepted by operator
# policy (e.g. vendor GPU or virtualization drivers).
rootcheck_module_allow: tuple[str, ...] = ()
# incident grouping (collapse related alerts by process lineage, then time)
incident_tracking: bool = True

View file

@ -28,9 +28,41 @@ SID_HIDDEN_PROC = 100022
SID_HIDDEN_MODULE = 100023
SID_HIDDEN_PORT = 100024
SID_PROMISC = 100025
SID_KNOWN_ROOTKIT_MODULE = 100028
SID_TAINTED_MODULE = 100029
SID_KERNEL_TAINTED = 100030
SID_HIDDEN_UDP_PORT = 100031
IFF_PROMISC = 0x100
KNOWN_ROOTKIT_MODULES = frozenset({
"adore", "adore-ng", "azazel", "diamorphine", "ipsecs_kbeast",
"kbeast", "knark", "reptile", "suterusu", "syslogk",
})
TAINT_FLAGS = {
0: "proprietary-module",
1: "forced-module-load",
2: "unsafe-smp",
3: "forced-module-unload",
4: "machine-check",
5: "bad-page",
6: "user-tainted",
7: "kernel-died-recently",
8: "acpi-override",
9: "kernel-warning",
10: "staging-driver",
11: "firmware-workaround",
12: "out-of-tree-module",
13: "unsigned-module",
14: "soft-lockup",
15: "live-patched",
16: "auxiliary-taint",
17: "randstruct-plugin",
}
HIGH_RISK_TAINT_BITS = frozenset({1, 3, 7, 12, 13})
# --- pure diff cores (unit-tested without a live system) ------------------
@ -49,6 +81,30 @@ def find_hidden_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[int]:
return procnet_ports - ss_ports
def find_hidden_udp_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[int]:
"""UDP ports in /proc/net/udp that ss does not report."""
return procnet_ports - ss_ports
def _norm_module(name: str) -> str:
return name.lower().replace("-", "_")
def find_known_rootkit_modules(modules: set[str]) -> set[str]:
known = {_norm_module(m) for m in KNOWN_ROOTKIT_MODULES}
return {m for m in modules if _norm_module(m) in known}
def decode_kernel_taint(value: int) -> list[str]:
return [label for bit, label in sorted(TAINT_FLAGS.items())
if value & (1 << bit)]
def taint_severity(value: int) -> Severity:
risky = any(value & (1 << bit) for bit in HIGH_RISK_TAINT_BITS)
return Severity.HIGH if risky else Severity.MEDIUM
# --- system views ---------------------------------------------------------
def proc_pids() -> set[int]:
@ -110,6 +166,36 @@ def sys_live_modules() -> set[str]:
return out
def module_taints() -> dict[str, str]:
"""Read per-module taint markers from /sys/module/*/taint.
Values are kernel-provided letters such as P/O/E for proprietary,
out-of-tree, or unsigned modules. Built-ins often do not expose this file.
"""
out: dict[str, str] = {}
base = "/sys/module"
try:
for m in os.listdir(base):
try:
with open(os.path.join(base, m, "taint")) as fh:
taint = fh.read().strip()
except OSError:
continue
if taint and taint != "0":
out[m] = taint
except OSError:
pass
return out
def kernel_taint() -> int:
try:
with open("/proc/sys/kernel/tainted") as fh:
return int(fh.read().strip())
except (OSError, ValueError):
return 0
def _procnet_listen_ports(path: str) -> set[int]:
ports: set[int] = set()
try:
@ -130,6 +216,28 @@ def procnet_listen_ports() -> set[int]:
return _procnet_listen_ports("/proc/net/tcp") | _procnet_listen_ports("/proc/net/tcp6")
def _procnet_udp_ports(path: str) -> set[int]:
ports: set[int] = set()
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
parts = line.split()
if len(parts) < 2:
continue
local = parts[1] # HEX_ADDR:HEX_PORT
port = int(local.rsplit(":", 1)[1], 16)
if port:
ports.add(port)
except (OSError, ValueError):
pass
return ports
def procnet_udp_ports() -> set[int]:
return _procnet_udp_ports("/proc/net/udp") | _procnet_udp_ports("/proc/net/udp6")
def ss_listen_ports(state: SystemState) -> set[int]:
ports: set[int] = set()
for s in state.listening_sockets():
@ -139,6 +247,17 @@ def ss_listen_ports(state: SystemState) -> set[int]:
return ports
def ss_udp_ports(state: SystemState) -> set[int]:
ports: set[int] = set()
for s in state.sockets:
if s.state not in {"UNCONN", "ESTAB"}:
continue
tail = s.local.rsplit(":", 1)
if len(tail) == 2 and tail[1].isdigit():
ports.add(int(tail[1]))
return ports
def promiscuous_interfaces() -> list[str]:
out: list[str] = []
base = "/sys/class/net"
@ -184,7 +303,9 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
pids=tuple(sorted(confirmed)[:20]),
sid=SID_HIDDEN_PROC, classtype="rootkit-hidden-process")
hidden_mods = find_hidden_modules(proc_modules(), sys_live_modules())
proc_mods = proc_modules()
sys_mods = sys_live_modules()
hidden_mods = find_hidden_modules(proc_mods, sys_mods)
for m in sorted(hidden_mods):
yield Alert(
severity=Severity.CRITICAL, signature="rootkit_hidden_module",
@ -192,6 +313,36 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
detail=f"kernel module live in /sys/module but hidden from /proc/modules: {m}",
sid=SID_HIDDEN_MODULE, classtype="rootkit-hidden-module")
all_modules = proc_mods | sys_mods
known = find_known_rootkit_modules(all_modules)
for m in sorted(known):
yield Alert(
severity=Severity.CRITICAL, signature="rootkit_known_module",
key=f"rk:knownmod:{m}",
detail=f"known LKM rootkit module name loaded or visible: {m}",
sid=SID_KNOWN_ROOTKIT_MODULE, classtype="rootkit-known-module")
allowed = {_norm_module(m) for m in getattr(cfg, "rootcheck_module_allow", ())}
for m, taint in sorted(module_taints().items()):
if _norm_module(m) in allowed or m in known:
continue
yield Alert(
severity=Severity.HIGH, signature="rootkit_tainted_module",
key=f"rk:taintmod:{m}",
detail=(f"kernel module has taint marker '{taint}' "
f"(out-of-tree/proprietary/unsigned module): {m}"),
sid=SID_TAINTED_MODULE, classtype="rootkit-tainted-module")
kt = kernel_taint()
if kt:
flags = decode_kernel_taint(kt)
yield Alert(
severity=taint_severity(kt), signature="kernel_tainted",
key=f"rk:ktaint:{kt}",
detail=(f"kernel taint value {kt} ({', '.join(flags) or 'unknown'}) — "
"may indicate unsigned/out-of-tree modules, forced loads, or kernel faults"),
sid=SID_KERNEL_TAINTED, classtype="kernel-integrity")
hidden_ports = find_hidden_ports(procnet_listen_ports(), ss_listen_ports(state))
if hidden_ports:
yield Alert(
@ -201,6 +352,15 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_ports)))),
sid=SID_HIDDEN_PORT, classtype="rootkit-hidden-port")
hidden_udp = find_hidden_udp_ports(procnet_udp_ports(), ss_udp_ports(state))
if hidden_udp:
yield Alert(
severity=Severity.HIGH, signature="rootkit_hidden_udp_port",
key=f"rk:hidudp:{min(hidden_udp)}",
detail=("UDP port(s) in /proc/net/udp not reported by ss "
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_udp)))),
sid=SID_HIDDEN_UDP_PORT, classtype="rootkit-hidden-port")
for iface in promiscuous_interfaces():
yield Alert(
severity=Severity.MEDIUM, signature="promiscuous_interface",

View file

@ -40,11 +40,12 @@
.section-title{display:flex;align-items:center;justify-content:space-between;
padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em;
text-transform:uppercase}
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px}
.metrics{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:16px}
.metric{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px}
.metric b{display:block;font-size:24px;line-height:1;color:#fff}
.metric span{display:block;color:var(--muted);font-size:11px;margin-top:8px;text-transform:uppercase}
.metric.crit b{color:var(--red)} .metric.high b{color:var(--amber)} .metric.med b{color:var(--blue)}
.metric.posture b{color:var(--accent)}
.list{padding:0 8px 14px}
.item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px;
cursor:pointer;border-radius:7px;margin-bottom:4px}
@ -118,10 +119,12 @@
<div class="tabs">
<button class="active" data-tab="incident">Incident</button>
<button data-tab="alerts">Alerts</button>
<button data-tab="posture">Posture</button>
<button data-tab="events">Events</button>
</div>
<section id="incidentTab"></section>
<section id="alertsTab" hidden></section>
<section id="postureTab" hidden></section>
<section id="eventsTab" hidden></section>
</main>
@ -134,7 +137,7 @@
<script>
const TOKEN = new URLSearchParams(location.search).get("token") || "";
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
const state = {incidents:[], alerts:[], selected:null, incident:null, plan:null, tab:"incident"};
const state = {incidents:[], alerts:[], posture:null, selected:null, incident:null, plan:null, tab:"incident"};
async function api(path){
const r = await fetch(path,{headers:HDR});
@ -150,10 +153,10 @@ function fmt(t){return (t||"?").replace("T"," ").slice(0,19)}
async function refresh(){
try{
const [status, incidents, alerts, events] = await Promise.all([
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/events")
const [status, incidents, alerts, posture, events] = await Promise.all([
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/events")
]);
state.incidents = incidents; state.alerts = alerts; state.events = events.events || [];
state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.events = events.events || [];
renderStatus(status); renderLists();
if(!state.selected && incidents.length) await openIncident(incidents[0].id);
else if(state.selected) await openIncident(state.selected, false);
@ -169,9 +172,11 @@ function renderStatus(st){
document.getElementById("ebpf").textContent = "eBPF "+(st.ebpf || "unknown");
const counts = st.counts || {};
const metrics = document.getElementById("metrics"); metrics.innerHTML = "";
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""]].forEach(([k,c])=>{
const m=el("div","metric "+c); m.append(el("b",null,String(k==="TOTAL" ? st.total_alerts||0 : counts[k]||0)));
m.append(el("span",null,k==="TOTAL" ? "alerts" : k)); metrics.append(m);
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""],["POSTURE","posture"]].forEach(([k,c])=>{
const value = k==="TOTAL" ? st.total_alerts||0 : k==="POSTURE" ? state.posture?.count||0 : counts[k]||0;
const label = k==="TOTAL" ? "alerts" : k==="POSTURE" ? "findings" : k;
const m=el("div","metric "+c); m.append(el("b",null,String(value)));
m.append(el("span",null,label)); metrics.append(m);
});
}
@ -212,7 +217,8 @@ async function openIncident(id, mark=true){
async function openAlert(name){
state.tab = "alerts"; setTabButtons();
const box = document.getElementById("alertsTab");
box.hidden = false; document.getElementById("incidentTab").hidden = true; document.getElementById("eventsTab").hidden = true;
box.hidden = false; document.getElementById("incidentTab").hidden = true;
document.getElementById("postureTab").hidden = true; document.getElementById("eventsTab").hidden = true;
box.innerHTML='<div class="empty">loading snapshot</div>';
try{
const a = await api("/api/alerts/"+encodeURIComponent(name));
@ -261,8 +267,10 @@ function renderTab(){
setTabButtons();
document.getElementById("incidentTab").hidden = state.tab !== "incident";
document.getElementById("alertsTab").hidden = state.tab !== "alerts";
document.getElementById("postureTab").hidden = state.tab !== "posture";
document.getElementById("eventsTab").hidden = state.tab !== "events";
if(state.tab === "incident") renderIncident();
if(state.tab === "posture") renderPosture();
if(state.tab === "events") renderEvents();
}
function setTabButtons(){
@ -273,8 +281,37 @@ function renderEvents(){
box.innerHTML = '<div class="card"><h3>Event tail</h3><pre></pre></div>';
box.querySelector("pre").textContent = (state.events||[]).join("\n") || "No events.";
}
function renderPosture(){
const box = document.getElementById("postureTab");
const report = state.posture || {findings:[], counts:{}};
box.innerHTML = "";
const summary = el("div","card");
summary.append(el("h2",null,"Host posture"));
const counts = report.counts || {};
summary.append(tags([
"critical "+(counts.CRITICAL||0),
"high "+(counts.HIGH||0),
"medium "+(counts.MEDIUM||0)
]));
if(!report.findings || !report.findings.length){
summary.append(el("div","fine","No SSH, sudo, PATH, sensitive-file, or package-signature posture findings."));
box.append(summary);
return;
}
summary.append(el("div","fine",String(report.count)+" advisory finding(s). These are read-only hygiene checks, not live containment actions."));
box.append(summary);
report.findings.forEach(f=>{
const row = el("div","card");
const top = el("div","top"); top.append(sev(f.severity)); top.append(el("span","id",f.signature));
row.append(top);
row.append(tags(["sid "+(f.sid||0), f.classtype||"host-posture"]));
row.append(el("code","cmd",f.detail || ""));
box.append(row);
});
}
function showError(msg){
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("postureTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>';
}
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; renderTab();});

View file

@ -252,6 +252,23 @@ def response_plan(cfg: Config, incident_id: str) -> dict | None:
return respond.build_plan(bundle, cfg)
def posture_report(cfg: Config, runner=None) -> dict:
"""Return advisory host posture findings for the management console."""
if runner is None:
from . import posture
runner = posture.run
findings = sorted(runner(cfg), key=lambda a: (-a.severity, a.signature))
counts: dict[str, int] = {}
for f in findings:
sev = str(f.severity)
counts[sev] = counts.get(sev, 0) + 1
return {
"count": len(findings),
"counts": counts,
"findings": [f.to_dict() for f in findings],
}
# --- HTTP layer ------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
@ -299,6 +316,8 @@ class _Handler(BaseHTTPRequestHandler):
200 if alert else 404)
elif path == "/api/events":
self._json({"events": tail_events(cfg)})
elif path == "/api/posture":
self._json(posture_report(cfg))
elif path == "/api/incidents":
self._json(list_incidents(cfg))
elif path.startswith("/api/incidents/"):