Answers two hard questions: "what guards the hashes FIM trusts?" and "how do we make the sensor itself hard to silently disable?" — within the honest limit that a root attacker who shares your privileges can't be fully stopped on-box, only made loud. - pkgdb.py: guards the package DB that `pacman -Qkk` trusts. Anchors a fingerprint of /var/lib/pacman/local (refreshed ONLY by the pacman hook) and cross-checks pacman.log; a DB change with no logged transaction is flagged pkgdb_tamper (CRITICAL, sid 100021) — catches an attacker rewriting a stored checksum to mask a modified binary. Verified end-to-end against a simulated hash-overwrite. - selfprotect.py: Sentinel's own binaries/config/units/hook are always in the FIM watch set (self-integrity); a heartbeat is written each loop; an external `watchdog` command polls a remote dashboard and pushes if the sensor goes silent or unreachable — silence becomes the alarm. - daemon: heartbeat + slow-cadence pkgdb check; DB anchor re-anchored in build_fim_baseline so the pacman hook refreshes it after each transaction - web: /api/status now reports heartbeat_age/stale; dashboard shows it - cli: pkgdb-check, watchdog (--url/--token/--max-age, bypasses min-severity) - config: pkgdb_verify/_interval, heartbeat_max_age - README: threat model (trust-anchor problem) + hardening layers (immutability, signed-package + external anchor); reframes "anti-rootkit" as tamper-evidence - tests: +8 (DB fingerprint, anchor/transaction logic, pacman.log parse, heartbeat + watchdog verdicts). 80/80 pass Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
6.8 KiB
HTML
164 lines
6.8 KiB
HTML
<!DOCTYPE html>
|
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Enodia Sentinel</title>
|
|
<style>
|
|
:root{
|
|
--bg:#0b0e14; --panel:#11151f; --panel2:#161b27; --line:#222a3a;
|
|
--fg:#cdd6e4; --dim:#7c8aa5; --accent:#5ad1c8;
|
|
--crit:#ff5370; --high:#ffb454; --med:#73d0ff;
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--bg);color:var(--fg);
|
|
font:14px/1.5 ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace}
|
|
header{display:flex;align-items:center;gap:14px;padding:14px 20px;
|
|
border-bottom:1px solid var(--line);background:var(--panel)}
|
|
header h1{font-size:16px;margin:0;letter-spacing:.18em;color:var(--accent)}
|
|
header .host{color:var(--dim)}
|
|
.dot{width:10px;height:10px;border-radius:50%;background:#444;display:inline-block}
|
|
.dot.up{background:#46d160;box-shadow:0 0 8px #46d160}
|
|
.dot.down{background:var(--crit)}
|
|
.spacer{flex:1}
|
|
.meta{color:var(--dim);font-size:12px}
|
|
.cards{display:flex;gap:12px;padding:16px 20px;flex-wrap:wrap}
|
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
|
|
padding:12px 16px;min-width:120px}
|
|
.card .n{font-size:26px;font-weight:700}
|
|
.card .l{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.1em}
|
|
.card.crit .n{color:var(--crit)} .card.high .n{color:var(--high)}
|
|
.card.med .n{color:var(--med)}
|
|
main{display:grid;grid-template-columns:minmax(340px,460px) 1fr;gap:0;
|
|
border-top:1px solid var(--line);height:calc(100vh - 180px)}
|
|
.list,.detail{overflow:auto;height:100%}
|
|
.list{border-right:1px solid var(--line)}
|
|
.row{padding:10px 16px;border-bottom:1px solid var(--line);cursor:pointer}
|
|
.row:hover{background:var(--panel2)} .row.sel{background:var(--panel2);
|
|
border-left:3px solid var(--accent);padding-left:13px}
|
|
.row .top{display:flex;justify-content:space-between;gap:8px}
|
|
.sev{font-weight:700;font-size:11px;padding:1px 7px;border-radius:4px}
|
|
.sev.CRITICAL{color:var(--crit);border:1px solid var(--crit)}
|
|
.sev.HIGH{color:var(--high);border:1px solid var(--high)}
|
|
.sev.MEDIUM{color:var(--med);border:1px solid var(--med)}
|
|
.row .sigs{color:var(--fg);margin-top:4px;font-size:13px}
|
|
.row .t{color:var(--dim);font-size:11px}
|
|
.row .sids{color:var(--dim);font-size:11px}
|
|
.detail{padding:18px 22px}
|
|
.detail pre{white-space:pre-wrap;word-break:break-word;margin:0;
|
|
color:#b9c4d8;font-size:12.5px}
|
|
.empty{color:var(--dim);padding:40px;text-align:center}
|
|
.err{color:var(--crit);padding:30px;text-align:center}
|
|
footer{border-top:1px solid var(--line);background:var(--panel);
|
|
padding:8px 20px;color:var(--dim);font-size:11px;display:flex;gap:18px}
|
|
a{color:var(--accent)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>ENODIA SENTINEL</h1>
|
|
<span class="dot" id="dot"></span>
|
|
<span class="host" id="host">—</span>
|
|
<span class="spacer"></span>
|
|
<span class="meta" id="ebpf"></span>
|
|
<span class="meta" id="ver"></span>
|
|
</header>
|
|
|
|
<div class="cards" id="cards"></div>
|
|
|
|
<main>
|
|
<div class="list" id="list"><div class="empty">loading…</div></div>
|
|
<div class="detail" id="detail"><div class="empty">Select an alert to view its forensic snapshot.</div></div>
|
|
</main>
|
|
|
|
<footer>
|
|
<span id="status">connecting…</span>
|
|
<span class="spacer" style="flex:1"></span>
|
|
<span id="refreshed"></span>
|
|
</footer>
|
|
|
|
<script>
|
|
const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
|
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
|
|
let selected = null;
|
|
|
|
async function api(path){
|
|
const r = await fetch(path, {headers:HDR});
|
|
if(r.status===401) throw new Error("unauthorized — bad or missing ?token");
|
|
if(!r.ok) throw new Error("HTTP "+r.status);
|
|
return r.json();
|
|
}
|
|
|
|
function el(tag, cls, txt){const e=document.createElement(tag);
|
|
if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e;}
|
|
|
|
async function refresh(){
|
|
try{
|
|
const st = await api("/api/status");
|
|
document.getElementById("dot").className = "dot "+(st.running?"up":"down");
|
|
document.getElementById("host").textContent = st.host || "";
|
|
document.getElementById("ver").textContent = "v"+st.version;
|
|
document.getElementById("ebpf").textContent = "eBPF: "+(st.ebpf||"?");
|
|
let hb = "";
|
|
if (st.heartbeat_age != null)
|
|
hb = st.heartbeat_stale
|
|
? ` ⚠ heartbeat STALE (${Math.round(st.heartbeat_age)}s) — possible tampering`
|
|
: ` ♥ ${Math.round(st.heartbeat_age)}s ago`;
|
|
const st_el = document.getElementById("status");
|
|
st_el.textContent = (st.running ? "daemon running" : "daemon NOT running") + hb;
|
|
st_el.style.color = (st.running && !st.heartbeat_stale) ? "" : "var(--crit)";
|
|
const c = st.counts||{};
|
|
const cards = document.getElementById("cards"); cards.innerHTML="";
|
|
for(const [lbl,key] of [["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"]]){
|
|
const card=el("div","card "+key);
|
|
card.append(el("div","n",String(c[lbl]||0))); card.append(el("div","l",lbl));
|
|
cards.append(card);
|
|
}
|
|
const tot=el("div","card"); tot.append(el("div","n",String(st.total_alerts||0)));
|
|
tot.append(el("div","l","total alerts")); cards.append(tot);
|
|
|
|
const alerts = await api("/api/alerts");
|
|
const list = document.getElementById("list"); list.innerHTML="";
|
|
if(!alerts.length){ list.append(el("div","empty","No alerts captured.")); }
|
|
for(const a of alerts){
|
|
const row=el("div","row"); row.dataset.name=a.name;
|
|
if(a.name===selected) row.classList.add("sel");
|
|
const top=el("div","top");
|
|
top.append(el("span","sev "+a.severity, a.severity));
|
|
top.append(el("span","t", (a.time||"").replace("T"," ").slice(0,19)));
|
|
row.append(top);
|
|
row.append(el("div","sigs", a.signatures.join(", ")));
|
|
if(a.sids.length) row.append(el("div","sids","sid: "+a.sids.join(", ")));
|
|
row.onclick=()=>openAlert(a.name);
|
|
list.append(row);
|
|
}
|
|
document.getElementById("refreshed").textContent =
|
|
"updated "+new Date().toLocaleTimeString();
|
|
}catch(e){
|
|
document.getElementById("status").textContent = "error: "+e.message;
|
|
if(String(e.message).includes("unauthorized"))
|
|
document.getElementById("detail").innerHTML =
|
|
'<div class="err">Unauthorized. Open this page with ?token=YOUR_TOKEN</div>';
|
|
}
|
|
}
|
|
|
|
async function openAlert(name){
|
|
selected = name;
|
|
document.querySelectorAll(".row").forEach(r=>
|
|
r.classList.toggle("sel", r.dataset.name===name));
|
|
const d = document.getElementById("detail");
|
|
d.innerHTML='<div class="empty">loading…</div>';
|
|
try{
|
|
const a = await api("/api/alerts/"+encodeURIComponent(name));
|
|
d.innerHTML="";
|
|
const pre=el("pre",null,a.text || JSON.stringify(a.json,null,2));
|
|
d.append(pre);
|
|
}catch(e){ d.innerHTML='<div class="err">'+e.message+'</div>'; }
|
|
}
|
|
|
|
refresh();
|
|
setInterval(refresh, 10000);
|
|
</script>
|
|
</body>
|
|
</html>
|