Add dashboard rule atlas
This commit is contained in:
parent
5db88d285e
commit
e43d7689a0
7 changed files with 130 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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/"):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue