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:
Luna 2026-06-18 17:39:53 -07:00
parent e0e5cd62d9
commit 11d91a40b4
12 changed files with 337 additions and 29 deletions

View file

@ -50,10 +50,11 @@ 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-rule
metadata, event tail, dry-run response plans, and a local Settings menu with
persistent themes. Web tests cover the theme registry and core text/status
contrast checks.
shows status, incidents, timelines, alerts, posture findings,
integrity/watchdog state, event-rule metadata, event tail, dry-run response
plans, and a local Settings menu with persistent themes. Web tests cover the
theme registry, integrity console wiring, and core text/status contrast
checks.
- `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

@ -296,11 +296,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 rule atlas, event tail, and dry-run response plans. No
commands are executed from the browser. JSON
findings, integrity/watchdog state, 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/rules`, `/api/alerts`, `/api/alerts/<id>`,
`/api/events`.
`/api/posture`, `/api/integrity`, `/api/rules`, `/api/alerts`,
`/api/alerts/<id>`, `/api/events`.
- **Operator settings**: the self-contained dashboard includes a Settings menu
with persistent local themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula,
Solarized Dark, Solarized Light, and Twilight. Static dashboard tests guard

View file

@ -333,13 +333,13 @@ 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-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. Dashboard
settings are local to the browser and include persistent themes: Console, Paper,
Contrast, LGBTQ, Trans, Dracula, Solarized Dark, Solarized Light, and Twilight.
The theme registry and core text/status contrast pairs are covered by the web
test suite.
The console exposes incidents, alert snapshots, posture findings,
integrity/watchdog state, 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. Dashboard settings are local to the browser and
include persistent themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula,
Solarized Dark, Solarized Light, and Twilight. The theme registry and core
text/status contrast pairs are covered by the web test suite.
Expected use: `enodia-sentinel-web.service`.

View file

@ -73,8 +73,9 @@ Expected healthy output:
only ones you have consciously accepted (e.g. password auth on a host that
needs it).
The HTTPS dashboard shows the same posture findings under the Posture tab for
quick remote review.
The HTTPS dashboard shows the same posture findings under the Posture tab and
summarizes FIM/package anchors plus heartbeat freshness under the Integrity tab
for quick remote review.
## Alert Workflow

View file

@ -95,7 +95,7 @@ Purpose: define a stable single-host product.
- ✅ Add documentation versioning and manpage-style command reference.
- ✅ Harden packaging for Arch first, then document Debian/RPM install paths.
- ✅ Add signed release artifacts and checksums.
- Improve dashboard from alert browser to local console:
- Improve dashboard from alert browser to local console:
incidents, posture, integrity state, response plans, and watchdog status.
- ✅ Add a local dashboard Settings menu with persistent themes for dark
console, light evidence review, high-contrast operation, LGBTQ, Trans,

View file

@ -90,6 +90,25 @@ Required fields:
| `heartbeat_age` | number or null | Seconds since heartbeat, if present. |
| `heartbeat_stale` | boolean | Whether heartbeat exceeds configured max age. |
## `enodia.integrity.v1`
Integrity/watchdog object returned by `/api/integrity`. This is a read-only
summary of existing state; the dashboard endpoint does not run live FIM or
package verification work.
Required fields:
| Field | Type | Meaning |
|---|---|---|
| `schema` | string | `enodia.integrity.v1`. |
| `generated_at` | number | Unix timestamp when the report was built. |
| `status` | string | Overall state: `ok`, `review`, or `critical`. |
| `checks` | object | Compact per-check status map. |
| `watchdog` | object | Daemon running state, heartbeat age, stale flag, max age, and verdict message. |
| `anchors` | object | FIM baseline, package DB anchor, pacman keyring, and SigLevel summary. |
| `sentinel_footprint` | object | Configured Sentinel self-integrity paths and present/missing counts. |
| `read_only` | boolean | Always true for this dashboard API. |
## `enodia.response.plan.v1`
Dry-run response plan from `respond plan <incident-id>`.

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-rule metadata, event tail, and response-plan previews. |
| Dashboard | HTTPS-only read-only web view for status, incidents, timelines, alert browsing, posture findings, integrity/watchdog state, 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

@ -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"

View file

@ -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>';
}

View file

@ -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/"):

View file

@ -178,6 +178,24 @@ class TestSchemaContracts(unittest.TestCase):
"heartbeat_stale": bool,
})
def test_integrity_v1_contract(self):
report = web.integrity_report(self.cfg, status={
"running": True,
"heartbeat_age": 1.0,
"heartbeat_stale": False,
})
self.assertEqual(report["schema"], schemas.INTEGRITY_V1)
_assert_types(self, report, {
"schema": str,
"generated_at": float,
"status": str,
"checks": dict,
"watchdog": dict,
"anchors": dict,
"sentinel_footprint": dict,
"read_only": bool,
})
def test_response_plan_and_audit_contracts(self):
iid = self._record_incident_with_snapshot()
bundle = respond.load_bundle(self.cfg, iid)

View file

@ -172,6 +172,28 @@ class TestDataLayer(unittest.TestCase):
self.assertEqual(rule["event"], "exec")
self.assertIn("conditions", rule)
def test_integrity_report_shape(self):
(self.tmp / "fim-baseline.json").write_text(json.dumps({
"/etc/passwd": {"sha256": "a"},
"/etc/sudoers": {"sha256": "b"},
}))
(self.tmp / "pkgdb-anchor.json").write_text(json.dumps({
"fingerprint": "0123456789abcdef0123456789abcdef",
"time": 999.0,
}))
status = {
"running": True,
"heartbeat_age": 10.0,
"heartbeat_stale": False,
}
report = web.integrity_report(self.cfg, status=status, now=1000.0)
self.assertEqual(report["schema"], "enodia.integrity.v1")
self.assertEqual(report["checks"]["watchdog"], "ok")
self.assertEqual(report["anchors"]["fim_baseline"]["count"], 2)
self.assertEqual(report["anchors"]["pkgdb"]["fingerprint_prefix"], "0123456789abcdef")
self.assertTrue(report["read_only"])
self.assertIn("sentinel_footprint", report)
def test_dashboard_has_persistent_theme_controls(self):
html = (web._STATIC / "dashboard.html").read_text()
self.assertIn('id="settingsMenu"', html)
@ -234,6 +256,14 @@ class TestDataLayer(unittest.TestCase):
f"{theme} {foreground} on {background} contrast is {ratio:.2f}",
)
def test_dashboard_has_integrity_console(self):
html = (web._STATIC / "dashboard.html").read_text()
self.assertIn('data-tab="integrity"', html)
self.assertIn('id="integrityTab"', html)
self.assertIn('/api/integrity', html)
self.assertIn('function renderIntegrity()', html)
self.assertIn('Integrity state', html)
class TestNetworkHelpers(unittest.TestCase):
def test_is_loopback(self):
@ -330,6 +360,14 @@ class TestAuth(unittest.TestCase):
self.assertIn("rules", data)
self.assertGreater(data["count"], 0)
def test_integrity_endpoint(self):
resp = self._get("/api/integrity", token="secret-token")
data = json.loads(resp.read())
self.assertEqual(data["schema"], "enodia.integrity.v1")
self.assertIn("watchdog", data)
self.assertIn("anchors", data)
self.assertTrue(data["read_only"])
if __name__ == "__main__":
unittest.main()