Add dashboard integrity/watchdog console
Surface integrity and watchdog state in the read-only dashboard via a new /api/integrity endpoint and integrity_report(): watchdog/heartbeat verdict, FIM baseline and package-DB anchor freshness, pacman keyring and SigLevel posture, and Sentinel's own self-integrity footprint. The endpoint summarizes existing anchors and heartbeats only — it runs no live FIM or package verification from the request path, keeping the dashboard read-only. Add the enodia.integrity.v1 schema (schemas.py + docs/SCHEMAS.md) with a contract test, a new "Integrity" dashboard tab and summary metric, and web tests for the report shape, schema contract, endpoint, and console wiring. Docs updated; completes the v1.0 "dashboard to local console" roadmap item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e0e5cd62d9
commit
11d91a40b4
12 changed files with 337 additions and 29 deletions
|
|
@ -12,5 +12,6 @@ INCIDENT_V1 = "enodia.incident.v1"
|
|||
INCIDENT_VIEW_V1 = "enodia.incident.view.v1"
|
||||
INCIDENT_BUNDLE_V1 = "enodia.incident.bundle.v1"
|
||||
STATUS_V1 = "enodia.status.v1"
|
||||
INTEGRITY_V1 = "enodia.integrity.v1"
|
||||
RESPONSE_PLAN_V1 = "enodia.response.plan.v1"
|
||||
RESPONSE_AUDIT_V1 = "enodia.response.audit.v1"
|
||||
|
|
|
|||
|
|
@ -186,12 +186,13 @@
|
|||
.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(5,1fr);gap:10px;margin-bottom:16px}
|
||||
.metrics{display:grid;grid-template-columns:repeat(6,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:var(--white)}
|
||||
.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)}
|
||||
.metric.posture b{color:var(--accent)} .metric.integrity b{color:var(--violet);
|
||||
font-size:16px;text-transform:uppercase}
|
||||
.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}
|
||||
|
|
@ -235,6 +236,20 @@
|
|||
.cond{display:grid;grid-template-columns:98px 1fr;gap:6px;border-top:1px solid var(--line);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}
|
||||
.integrity-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
|
||||
.state-card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
|
||||
padding:13px;box-shadow:var(--shadow)}
|
||||
.state-card.ok{border-color:var(--ok)} .state-card.review{border-color:var(--amber)}
|
||||
.state-card.critical{border-color:var(--red)}
|
||||
.state-card h3{margin-bottom:8px}
|
||||
.state-line{display:grid;grid-template-columns:120px minmax(0,1fr);gap:7px;
|
||||
border-top:1px solid var(--line);padding-top:7px;margin-top:7px}
|
||||
.state-line span:first-child{color:var(--muted);font-size:11px;text-transform:uppercase}
|
||||
.state-line span:last-child{word-break:break-word;color:var(--soft)}
|
||||
.path-list{display:grid;gap:6px;margin-top:8px;max-height:230px;overflow:auto}
|
||||
.path-row{display:grid;grid-template-columns:58px 1fr;gap:8px;font-size:11px}
|
||||
.path-row b{color:var(--accent);font-weight:700}
|
||||
.path-row.missing b{color:var(--muted)}
|
||||
.action{border:1px solid var(--line);border-radius:8px;background:var(--panel);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)}
|
||||
|
|
@ -297,12 +312,14 @@
|
|||
<button class="active" data-tab="incident">Incident</button>
|
||||
<button data-tab="alerts">Alerts</button>
|
||||
<button data-tab="posture">Posture</button>
|
||||
<button data-tab="integrity">Integrity</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="integrityTab" hidden></section>
|
||||
<section id="rulesTab" hidden></section>
|
||||
<section id="eventsTab" hidden></section>
|
||||
</main>
|
||||
|
|
@ -316,7 +333,7 @@
|
|||
<script>
|
||||
const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
||||
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
|
||||
const TABS = new Set(["incident","alerts","posture","rules","events"]);
|
||||
const TABS = new Set(["incident","alerts","posture","integrity","rules","events"]);
|
||||
const THEME_OPTIONS = [
|
||||
["console","Console"],["paper","Paper"],["contrast","Contrast"],
|
||||
["pride","LGBTQ"],["trans","Trans"],["dracula","Dracula"],
|
||||
|
|
@ -329,7 +346,7 @@ 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()};
|
||||
const state = {incidents:[], alerts:[], posture:null, integrity:null, rules:null, selected:null, incident:null, plan:null, tab:tabFromHash()};
|
||||
|
||||
async function api(path){
|
||||
const r = await fetch(path,{headers:HDR});
|
||||
|
|
@ -381,10 +398,10 @@ function setSettings(open){
|
|||
|
||||
async function refresh(){
|
||||
try{
|
||||
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")
|
||||
const [status, incidents, alerts, posture, integrity, rules, events] = await Promise.all([
|
||||
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/integrity"), api("/api/rules"), api("/api/events")
|
||||
]);
|
||||
state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.rules = rules; state.events = events.events || [];
|
||||
state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.integrity = integrity; 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);
|
||||
|
|
@ -402,13 +419,19 @@ function renderStatus(st){
|
|||
document.getElementById("ebpf").textContent = "eBPF exec "+exec+" / syscall "+syscall;
|
||||
const counts = st.counts || {};
|
||||
const metrics = document.getElementById("metrics"); metrics.innerHTML = "";
|
||||
[["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;
|
||||
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""],["POSTURE","posture"],["INTEGRITY","integrity"]].forEach(([k,c])=>{
|
||||
const value = k==="TOTAL" ? st.total_alerts||0 : k==="POSTURE" ? state.posture?.count||0 : k==="INTEGRITY" ? integrityMetricStatus(state.integrity?.status) : counts[k]||0;
|
||||
const label = k==="TOTAL" ? "alerts" : k==="POSTURE" ? "findings" : k==="INTEGRITY" ? "state" : k;
|
||||
const m=el("div","metric "+c); m.append(el("b",null,String(value)));
|
||||
m.append(el("span",null,label)); metrics.append(m);
|
||||
});
|
||||
}
|
||||
function integrityMetricStatus(status){
|
||||
if(status === "ok") return "OK";
|
||||
if(status === "critical") return "CRIT";
|
||||
if(status === "review") return "REVIEW";
|
||||
return "UNK";
|
||||
}
|
||||
|
||||
function renderLists(){
|
||||
document.getElementById("incidentCount").textContent = state.incidents.length;
|
||||
|
|
@ -449,6 +472,7 @@ async function openAlert(name){
|
|||
const box = document.getElementById("alertsTab");
|
||||
box.hidden = false; document.getElementById("incidentTab").hidden = true;
|
||||
document.getElementById("postureTab").hidden = true; document.getElementById("rulesTab").hidden = true;
|
||||
document.getElementById("integrityTab").hidden = true;
|
||||
document.getElementById("eventsTab").hidden = true;
|
||||
box.innerHTML='<div class="empty">loading snapshot</div>';
|
||||
try{
|
||||
|
|
@ -499,10 +523,12 @@ 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("integrityTab").hidden = state.tab !== "integrity";
|
||||
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 === "integrity") renderIntegrity();
|
||||
if(state.tab === "rules") renderRules();
|
||||
if(state.tab === "events") renderEvents();
|
||||
}
|
||||
|
|
@ -542,6 +568,93 @@ function renderPosture(){
|
|||
box.append(row);
|
||||
});
|
||||
}
|
||||
function humanAge(seconds){
|
||||
if(seconds === null || seconds === undefined) return "-";
|
||||
const n = Math.round(seconds);
|
||||
if(n < 90) return n+"s";
|
||||
if(n < 7200) return Math.round(n/60)+"m";
|
||||
return Math.round(n/3600)+"h";
|
||||
}
|
||||
function addStateLine(card, key, value){
|
||||
const row = el("div","state-line");
|
||||
row.append(el("span",null,key));
|
||||
row.append(el("span",null,value === null || value === undefined || value === "" ? "-" : String(value)));
|
||||
card.append(row);
|
||||
}
|
||||
function renderIntegrity(){
|
||||
const box = document.getElementById("integrityTab");
|
||||
const report = state.integrity || {status:"unknown", checks:{}, watchdog:{}, anchors:{}, sentinel_footprint:{paths:[]}};
|
||||
box.innerHTML = "";
|
||||
const summary = el("div","card");
|
||||
summary.append(el("h2",null,"Integrity state"));
|
||||
const checks = report.checks || {};
|
||||
summary.append(tags([
|
||||
"overall "+(report.status||"unknown"),
|
||||
"watchdog "+(checks.watchdog||"unknown"),
|
||||
"fim "+(checks.fim_baseline||"unknown"),
|
||||
"pkgdb "+(checks.pkgdb_anchor||"unknown"),
|
||||
"siglevel "+(checks.pacman_siglevel||"unknown")
|
||||
]));
|
||||
summary.append(el("div","fine","Generated "+new Date((report.generated_at||0)*1000).toLocaleString()));
|
||||
box.append(summary);
|
||||
|
||||
const grid = el("div","integrity-grid");
|
||||
const wd = report.watchdog || {};
|
||||
const watchdog = el("article","state-card "+(wd.ok ? "ok" : "critical"));
|
||||
watchdog.append(el("h3",null,"Watchdog"));
|
||||
watchdog.append(sev(wd.ok ? "OK" : "CRITICAL"));
|
||||
addStateLine(watchdog,"message",wd.message);
|
||||
addStateLine(watchdog,"daemon",wd.daemon_running ? "running" : "not running");
|
||||
addStateLine(watchdog,"heartbeat",humanAge(wd.heartbeat_age));
|
||||
addStateLine(watchdog,"max age",humanAge(wd.max_age));
|
||||
addStateLine(watchdog,"path",wd.heartbeat_path);
|
||||
grid.append(watchdog);
|
||||
|
||||
const anchors = report.anchors || {};
|
||||
const fim = anchors.fim_baseline || {};
|
||||
const fimCard = el("article","state-card "+(fim.status === "ok" ? "ok" : "review"));
|
||||
fimCard.append(el("h3",null,"FIM baseline"));
|
||||
addStateLine(fimCard,"status",fim.status);
|
||||
addStateLine(fimCard,"entries",fim.count);
|
||||
addStateLine(fimCard,"age",humanAge(fim.age));
|
||||
addStateLine(fimCard,"path",fim.path);
|
||||
grid.append(fimCard);
|
||||
|
||||
const pkg = anchors.pkgdb || {};
|
||||
const pkgCard = el("article","state-card "+(pkg.status === "ok" ? "ok" : "review"));
|
||||
pkgCard.append(el("h3",null,"Package DB anchor"));
|
||||
addStateLine(pkgCard,"status",pkg.status);
|
||||
addStateLine(pkgCard,"age",humanAge(pkg.age));
|
||||
addStateLine(pkgCard,"fingerprint",pkg.fingerprint_prefix);
|
||||
addStateLine(pkgCard,"path",pkg.path);
|
||||
grid.append(pkgCard);
|
||||
|
||||
const pacman = anchors.pacman || {};
|
||||
const pacCard = el("article","state-card "+(pacman.siglevel_status === "ok" && pacman.keyring_present ? "ok" : "review"));
|
||||
pacCard.append(el("h3",null,"Pacman trust"));
|
||||
addStateLine(pacCard,"siglevel",pacman.siglevel_status || "unknown");
|
||||
addStateLine(pacCard,"keyring",pacman.keyring_present ? "present" : "missing");
|
||||
if(pacman.siglevel_alert) addStateLine(pacCard,"finding",pacman.siglevel_alert.detail);
|
||||
grid.append(pacCard);
|
||||
|
||||
const footprint = report.sentinel_footprint || {paths:[]};
|
||||
const footCard = el("article","state-card ok");
|
||||
footCard.append(el("h3",null,"Sentinel footprint"));
|
||||
addStateLine(footCard,"configured",footprint.configured);
|
||||
addStateLine(footCard,"present",footprint.present);
|
||||
addStateLine(footCard,"not present",footprint.missing);
|
||||
const paths = el("div","path-list");
|
||||
(footprint.paths||[]).forEach(p=>{
|
||||
const row = el("div","path-row "+(p.exists ? "" : "missing"));
|
||||
row.append(el("b",null,p.exists ? "present" : "absent"));
|
||||
row.append(el("span",null,p.path));
|
||||
paths.append(row);
|
||||
});
|
||||
footCard.append(paths);
|
||||
grid.append(footCard);
|
||||
|
||||
box.append(grid);
|
||||
}
|
||||
function renderRules(){
|
||||
const box = document.getElementById("rulesTab");
|
||||
const catalog = state.rules || {rules:[], by_event:{}, by_severity:{}, by_origin:{}};
|
||||
|
|
@ -589,6 +702,7 @@ function renderRules(){
|
|||
function showError(msg){
|
||||
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
|
||||
document.getElementById("postureTab").innerHTML = '<div class="err">'+msg+'</div>';
|
||||
document.getElementById("integrityTab").innerHTML = '<div class="err">'+msg+'</div>';
|
||||
document.getElementById("rulesTab").innerHTML = '<div class="err">'+msg+'</div>';
|
||||
document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,6 +303,120 @@ def rules_catalog(cfg: Config) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _json_file_state(path: Path, now: float) -> dict:
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": False,
|
||||
"status": "missing",
|
||||
"mtime": None,
|
||||
"age": None,
|
||||
"count": None,
|
||||
}
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": True,
|
||||
"status": "unreadable",
|
||||
"mtime": st.st_mtime,
|
||||
"age": max(0.0, now - st.st_mtime),
|
||||
"count": None,
|
||||
}
|
||||
count = len(data) if isinstance(data, dict) else None
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": True,
|
||||
"status": "ok",
|
||||
"mtime": st.st_mtime,
|
||||
"age": max(0.0, now - st.st_mtime),
|
||||
"count": count,
|
||||
}
|
||||
|
||||
|
||||
def integrity_report(cfg: Config, status: dict | None = None,
|
||||
now: float | None = None) -> dict:
|
||||
"""Return dashboard-readable integrity and watchdog state.
|
||||
|
||||
This is intentionally a summary of existing anchors and heartbeats. It does
|
||||
not run live FIM or package verification work from the web request path.
|
||||
"""
|
||||
from . import pkgdb
|
||||
from .selfprotect import SELF_PATHS, heartbeat_path, watchdog_verdict
|
||||
|
||||
ts = time.time() if now is None else now
|
||||
status = status or daemon_status(cfg)
|
||||
watchdog_ok, watchdog_message = watchdog_verdict(status, cfg.heartbeat_max_age)
|
||||
anchor = pkgdb.load_anchor(cfg)
|
||||
anchor_path = pkgdb._anchor_path(cfg)
|
||||
anchor_time = anchor.get("time") if isinstance(anchor, dict) else None
|
||||
sig_alert = pkgdb.siglevel_alert()
|
||||
watched = []
|
||||
for raw in SELF_PATHS:
|
||||
path = Path(raw)
|
||||
watched.append({"path": raw, "exists": path.exists()})
|
||||
present = sum(1 for item in watched if item["exists"])
|
||||
fim_state = _json_file_state(cfg.fim_baseline, ts)
|
||||
pkg_state = {
|
||||
"path": str(anchor_path),
|
||||
"exists": bool(anchor),
|
||||
"status": "ok" if anchor else "missing",
|
||||
"time": anchor_time,
|
||||
"age": max(0.0, ts - anchor_time) if isinstance(anchor_time, (int, float)) else None,
|
||||
"fingerprint_prefix": (
|
||||
str(anchor.get("fingerprint", ""))[:16]
|
||||
if isinstance(anchor, dict) else ""
|
||||
),
|
||||
}
|
||||
keyring_present = pkgdb.keyring_present()
|
||||
checks = {
|
||||
"watchdog": "ok" if watchdog_ok else "review",
|
||||
"fim_baseline": fim_state["status"],
|
||||
"pkgdb_anchor": pkg_state["status"],
|
||||
"pacman_siglevel": "review" if sig_alert else "ok",
|
||||
"pacman_keyring": "ok" if keyring_present else "missing",
|
||||
}
|
||||
overall = "ok"
|
||||
if any(v in ("review", "missing", "unreadable") for v in checks.values()):
|
||||
overall = "review"
|
||||
if not watchdog_ok:
|
||||
overall = "critical"
|
||||
return {
|
||||
"schema": schemas.INTEGRITY_V1,
|
||||
"generated_at": ts,
|
||||
"status": overall,
|
||||
"checks": checks,
|
||||
"watchdog": {
|
||||
"ok": watchdog_ok,
|
||||
"message": watchdog_message,
|
||||
"heartbeat_path": str(heartbeat_path(cfg)),
|
||||
"heartbeat_age": status.get("heartbeat_age"),
|
||||
"heartbeat_stale": status.get("heartbeat_stale", False),
|
||||
"max_age": cfg.heartbeat_max_age,
|
||||
"daemon_running": status.get("running", False),
|
||||
},
|
||||
"anchors": {
|
||||
"fim_baseline": fim_state,
|
||||
"pkgdb": pkg_state,
|
||||
"pacman": {
|
||||
"keyring_present": keyring_present,
|
||||
"siglevel_status": "review" if sig_alert else "ok",
|
||||
"siglevel_alert": sig_alert.to_dict() if sig_alert else None,
|
||||
},
|
||||
},
|
||||
"sentinel_footprint": {
|
||||
"configured": len(watched),
|
||||
"present": present,
|
||||
"missing": len(watched) - present,
|
||||
"paths": watched,
|
||||
},
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
|
||||
# --- HTTP layer ------------------------------------------------------------
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
|
|
@ -368,6 +482,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
self._json(posture_report(cfg), send_body=send_body)
|
||||
elif path == "/api/rules":
|
||||
self._json(rules_catalog(cfg), send_body=send_body)
|
||||
elif path == "/api/integrity":
|
||||
self._json(integrity_report(cfg), send_body=send_body)
|
||||
elif path == "/api/incidents":
|
||||
self._json(list_incidents(cfg), send_body=send_body)
|
||||
elif path.startswith("/api/incidents/"):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue