Add dashboard rule atlas

This commit is contained in:
Luna 2026-06-15 16:37:51 -07:00
parent 5db88d285e
commit e43d7689a0
7 changed files with 130 additions and 17 deletions

View file

@ -50,8 +50,8 @@ Implemented investigation/response state:
- Incident tracking groups snapshots by process lineage and time window and
exposes `incident list/show/export`.
- The dashboard is HTTPS-only, token-protected off loopback, and read-only. It
shows status, incidents, timelines, alerts, posture findings, event tail, and
dry-run response plans.
shows status, incidents, timelines, alerts, posture findings, event-rule
metadata, event tail, and dry-run response plans.
- `respond plan <incident-id>` builds read-only containment/recovery plans,
persists CLI-generated plans under `response-plans/`, and appends
`response-audit.log`. Dashboard/API plan previews do not write artifacts.

View file

@ -287,10 +287,11 @@ sudo systemctl enable --now enodia-sentinel-web
saved on first run and printed in the startup line. Open
`https://<tailscale-ip>:8787/?token=…`.
- **Read-only management**: incidents, timelines, alert inventory, posture
findings, event tail, and dry-run response plans. No commands are executed
from the browser. JSON
findings, event rule atlas, event tail, and dry-run response plans. No
commands are executed from the browser. JSON
API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
`/api/posture`, `/api/alerts`, `/api/alerts/<id>`, `/api/events`.
`/api/posture`, `/api/rules`, `/api/alerts`, `/api/alerts/<id>`,
`/api/events`.
CLI-generated response plans are saved for handoff/review under
`<log_dir>/response-plans/`, with a JSONL trail in

View file

@ -322,9 +322,9 @@ HTTPS is mandatory. If `web_tls_cert` / `web_tls_key` are not configured,
Sentinel creates a self-signed pair under `log_dir`; add a browser exception for
that certificate or replace it with a private/local CA certificate.
The console exposes incidents, alert snapshots, posture findings, event tail,
and dry-run response plans. It remains read-only; response commands are displayed
for review but not executed from the browser.
The console exposes incidents, alert snapshots, posture findings, event-rule
metadata, event tail, and dry-run response plans. It remains read-only; response
commands are displayed for review but not executed from the browser.
Expected use: `enodia-sentinel-web.service`.

View file

@ -102,7 +102,7 @@ When a fresh alert survives cooldown, Sentinel writes:
|---|---|
| CLI | Run daemon, one-shot checks, baseline management, FIM checks, package DB checks, rootcheck, posture audit, incident review/export, triage, status/watchdog, and response planning. |
| Logs | Durable local evidence under `/var/log/enodia-sentinel`. |
| Dashboard | HTTPS-only read-only web view for status, incidents, timelines, alert browsing, posture findings, event tail, and response-plan previews. |
| Dashboard | HTTPS-only read-only web view for status, incidents, timelines, alert browsing, posture findings, event-rule metadata, event tail, and response-plan previews. |
| Push | ntfy, Pushover, and generic webhook notifications. |
| Red-team harness | Safe drills for testing signatures and demos. |

View file

@ -10,7 +10,8 @@
--bg:#0a0c0f; --ink:#d8dee7; --muted:#8590a3; --soft:#aeb7c7;
--panel:#11151b; --panel2:#151b23; --rail:#0d1117; --line:#26313f;
--accent:#22c7a9; --amber:#f2b84b; --red:#f06476; --blue:#61a8ff;
--ok:#56d364; --shadow:0 18px 48px rgba(0,0,0,.28);
--violet:#b68cff; --copper:#d8925b; --ok:#56d364;
--shadow:0 18px 48px rgba(0,0,0,.28);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
@ -74,6 +75,21 @@
.event b{display:block;color:#fff}
.event code,.cmd{display:block;margin-top:6px;padding:8px;background:#080a0d;
border:1px solid var(--line);border-radius:6px;color:#cbd5e1;white-space:pre-wrap;word-break:break-word}
.rule-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:12px}
.rule-counts{display:flex;gap:7px;flex-wrap:wrap}
.rule-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}
.rule-card{position:relative;overflow:hidden;border:1px solid var(--line);
border-radius:8px;background:linear-gradient(135deg,rgba(17,21,27,.98),rgba(10,12,15,.96));
padding:12px 12px 12px 16px}
.rule-card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent)}
.rule-card.syscall:before{background:var(--violet)}
.rule-card.exec:before{background:var(--copper)}
.rule-meta{display:flex;align-items:center;gap:8px;justify-content:space-between;margin-bottom:8px}
.rule-sid{font-size:12px;color:#fff}
.rule-msg{color:var(--ink);margin:8px 0 10px}
.cond{display:grid;grid-template-columns:98px 1fr;gap:6px;border-top:1px solid rgba(255,255,255,.06);padding-top:7px}
.cond dt{color:var(--muted);font-size:11px}
.cond dd{margin:0;color:var(--soft);font-size:11px;word-break:break-word}
.action{border:1px solid var(--line);border-radius:8px;background:#0d1117;padding:11px;margin-bottom:10px}
.action .head{display:flex;gap:8px;align-items:center;justify-content:space-between}
.risk{font-size:10px;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--muted)}
@ -120,11 +136,13 @@
<button class="active" data-tab="incident">Incident</button>
<button data-tab="alerts">Alerts</button>
<button data-tab="posture">Posture</button>
<button data-tab="rules">Rules</button>
<button data-tab="events">Events</button>
</div>
<section id="incidentTab"></section>
<section id="alertsTab" hidden></section>
<section id="postureTab" hidden></section>
<section id="rulesTab" hidden></section>
<section id="eventsTab" hidden></section>
</main>
@ -137,7 +155,12 @@
<script>
const TOKEN = new URLSearchParams(location.search).get("token") || "";
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
const state = {incidents:[], alerts:[], posture:null, selected:null, incident:null, plan:null, tab:"incident"};
const TABS = new Set(["incident","alerts","posture","rules","events"]);
function tabFromHash(){
const name = location.hash.replace(/^#/,"");
return TABS.has(name) ? name : "incident";
}
const state = {incidents:[], alerts:[], posture:null, rules:null, selected:null, incident:null, plan:null, tab:tabFromHash()};
async function api(path){
const r = await fetch(path,{headers:HDR});
@ -153,10 +176,10 @@ function fmt(t){return (t||"?").replace("T"," ").slice(0,19)}
async function refresh(){
try{
const [status, incidents, alerts, posture, events] = await Promise.all([
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/events")
const [status, incidents, alerts, posture, rules, events] = await Promise.all([
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/rules"), api("/api/events")
]);
state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.events = events.events || [];
state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.rules = rules; 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);
@ -217,10 +240,11 @@ async function openIncident(id, mark=true){
}
async function openAlert(name){
state.tab = "alerts"; setTabButtons();
state.tab = "alerts"; history.replaceState(null,"","#alerts"); setTabButtons();
const box = document.getElementById("alertsTab");
box.hidden = false; document.getElementById("incidentTab").hidden = true;
document.getElementById("postureTab").hidden = true; document.getElementById("eventsTab").hidden = true;
document.getElementById("postureTab").hidden = true; document.getElementById("rulesTab").hidden = true;
document.getElementById("eventsTab").hidden = true;
box.innerHTML='<div class="empty">loading snapshot</div>';
try{
const a = await api("/api/alerts/"+encodeURIComponent(name));
@ -270,9 +294,11 @@ function renderTab(){
document.getElementById("incidentTab").hidden = state.tab !== "incident";
document.getElementById("alertsTab").hidden = state.tab !== "alerts";
document.getElementById("postureTab").hidden = state.tab !== "posture";
document.getElementById("rulesTab").hidden = state.tab !== "rules";
document.getElementById("eventsTab").hidden = state.tab !== "events";
if(state.tab === "incident") renderIncident();
if(state.tab === "posture") renderPosture();
if(state.tab === "rules") renderRules();
if(state.tab === "events") renderEvents();
}
function setTabButtons(){
@ -311,12 +337,58 @@ function renderPosture(){
box.append(row);
});
}
function renderRules(){
const box = document.getElementById("rulesTab");
const catalog = state.rules || {rules:[], by_event:{}, by_severity:{}, by_origin:{}};
box.innerHTML = "";
const summary = el("div","card");
const head = el("div","rule-head");
const left = el("div");
left.append(el("h2",null,"Rule atlas"));
left.append(el("div","fine","Active exec and syscall rules. This console displays rule metadata; it does not change detection behavior."));
head.append(left);
const counts = el("div","rule-counts");
const eventCounts = catalog.by_event || {};
const severityCounts = catalog.by_severity || {};
const originCounts = catalog.by_origin || {};
[
"rules "+(catalog.count||0),
"exec "+(eventCounts.exec||0),
"syscall "+(eventCounts.syscall||0),
"critical "+(severityCounts.CRITICAL||0),
"configured "+(originCounts.configured||0)
].forEach(x=>counts.append(el("span","tag",x)));
head.append(counts);
summary.append(head);
box.append(summary);
const grid = el("div","rule-grid");
(catalog.rules||[]).forEach(r=>{
const card = el("article","rule-card "+r.event);
const meta = el("div","rule-meta");
meta.append(el("span","rule-sid","SID "+r.sid+" / "+r.event));
meta.append(sev(r.severity));
card.append(meta);
card.append(tags([r.origin, r.classtype, r.signature]));
card.append(el("div","rule-msg",r.msg));
const dl = el("dl","cond");
Object.entries(r.conditions||{}).forEach(([k,v])=>{
dl.append(el("dt",null,k));
dl.append(el("dd",null,Array.isArray(v) ? v.join(", ") : String(v)));
});
card.append(dl);
grid.append(card);
});
if(!(catalog.rules||[]).length) grid.append(el("div","empty","No rules loaded."));
box.append(grid);
}
function showError(msg){
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("postureTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("rulesTab").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();});
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; location.hash=state.tab; renderTab();});
window.addEventListener("hashchange",()=>{state.tab=tabFromHash(); renderTab();});
document.getElementById("refreshBtn").onclick=refresh;
refresh();
setInterval(refresh, 10000);

View file

@ -275,6 +275,27 @@ def posture_report(cfg: Config, runner=None) -> dict:
}
def rules_catalog(cfg: Config) -> dict:
"""Return active event-rule metadata for the management console."""
from . import ruleops
rules = ruleops.list_rules(cfg)
by_event: dict[str, int] = {}
by_severity: dict[str, int] = {}
by_origin: dict[str, int] = {}
for r in rules:
by_event[r["event"]] = by_event.get(r["event"], 0) + 1
by_severity[r["severity"]] = by_severity.get(r["severity"], 0) + 1
by_origin[r["origin"]] = by_origin.get(r["origin"], 0) + 1
return {
"count": len(rules),
"by_event": by_event,
"by_severity": by_severity,
"by_origin": by_origin,
"rules": rules,
}
# --- HTTP layer ------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
@ -324,6 +345,8 @@ class _Handler(BaseHTTPRequestHandler):
self._json({"events": tail_events(cfg)})
elif path == "/api/posture":
self._json(posture_report(cfg))
elif path == "/api/rules":
self._json(rules_catalog(cfg))
elif path == "/api/incidents":
self._json(list_incidents(cfg))
elif path.startswith("/api/incidents/"):

View file

@ -105,6 +105,17 @@ class TestDataLayer(unittest.TestCase):
self.assertEqual(report["counts"], {"HIGH": 1, "MEDIUM": 1})
self.assertEqual(report["findings"][0]["signature"], "ssh_root_login")
def test_rules_catalog_shape(self):
catalog = web.rules_catalog(self.cfg)
self.assertGreater(catalog["count"], 0)
self.assertIn("exec", catalog["by_event"])
self.assertIn("syscall", catalog["by_event"])
self.assertIn("CRITICAL", catalog["by_severity"])
self.assertIn("builtin", catalog["by_origin"])
rule = next(r for r in catalog["rules"] if r["sid"] == 100002)
self.assertEqual(rule["event"], "exec")
self.assertIn("conditions", rule)
class TestNetworkHelpers(unittest.TestCase):
def test_is_loopback(self):
@ -182,6 +193,12 @@ class TestAuth(unittest.TestCase):
self.assertIn("findings", data)
self.assertIn("count", data)
def test_rules_endpoint(self):
resp = self._get("/api/rules", token="secret-token")
data = json.loads(resp.read())
self.assertIn("rules", data)
self.assertGreater(data["count"], 0)
if __name__ == "__main__":
unittest.main()