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 - Incident tracking groups snapshots by process lineage and time window and
exposes `incident list/show/export`. exposes `incident list/show/export`.
- The dashboard is HTTPS-only, token-protected off loopback, and read-only. It - The dashboard is HTTPS-only, token-protected off loopback, and read-only. It
shows status, incidents, timelines, alerts, posture findings, event-rule shows status, incidents, timelines, alerts, posture findings,
metadata, event tail, dry-run response plans, and a local Settings menu with integrity/watchdog state, event-rule metadata, event tail, dry-run response
persistent themes. Web tests cover the theme registry and core text/status plans, and a local Settings menu with persistent themes. Web tests cover the
contrast checks. theme registry, integrity console wiring, and core text/status contrast
checks.
- `respond plan <incident-id>` builds read-only containment/recovery plans, - `respond plan <incident-id>` builds read-only containment/recovery plans,
persists CLI-generated plans under `response-plans/`, and appends persists CLI-generated plans under `response-plans/`, and appends
`response-audit.log`. Dashboard/API plan previews do not write artifacts. `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 saved on first run and printed in the startup line. Open
`https://<tailscale-ip>:8787/?token=…`. `https://<tailscale-ip>:8787/?token=…`.
- **Read-only management**: incidents, timelines, alert inventory, posture - **Read-only management**: incidents, timelines, alert inventory, posture
findings, event rule atlas, event tail, and dry-run response plans. No findings, integrity/watchdog state, event rule atlas, event tail, and dry-run
commands are executed from the browser. JSON response plans. No commands are executed from the browser. JSON
API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`, API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
`/api/posture`, `/api/rules`, `/api/alerts`, `/api/alerts/<id>`, `/api/posture`, `/api/integrity`, `/api/rules`, `/api/alerts`,
`/api/events`. `/api/alerts/<id>`, `/api/events`.
- **Operator settings**: the self-contained dashboard includes a Settings menu - **Operator settings**: the self-contained dashboard includes a Settings menu
with persistent local themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula, with persistent local themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula,
Solarized Dark, Solarized Light, and Twilight. Static dashboard tests guard 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 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. that certificate or replace it with a private/local CA certificate.
The console exposes incidents, alert snapshots, posture findings, event-rule The console exposes incidents, alert snapshots, posture findings,
metadata, event tail, and dry-run response plans. It remains read-only; response integrity/watchdog state, event-rule metadata, event tail, and dry-run response
commands are displayed for review but not executed from the browser. Dashboard plans. It remains read-only; response commands are displayed for review but not
settings are local to the browser and include persistent themes: Console, Paper, executed from the browser. Dashboard settings are local to the browser and
Contrast, LGBTQ, Trans, Dracula, Solarized Dark, Solarized Light, and Twilight. include persistent themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula,
The theme registry and core text/status contrast pairs are covered by the web Solarized Dark, Solarized Light, and Twilight. The theme registry and core
test suite. text/status contrast pairs are covered by the web test suite.
Expected use: `enodia-sentinel-web.service`. 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 only ones you have consciously accepted (e.g. password auth on a host that
needs it). needs it).
The HTTPS dashboard shows the same posture findings under the Posture tab for The HTTPS dashboard shows the same posture findings under the Posture tab and
quick remote review. summarizes FIM/package anchors plus heartbeat freshness under the Integrity tab
for quick remote review.
## Alert Workflow ## Alert Workflow

View file

@ -95,7 +95,7 @@ Purpose: define a stable single-host product.
- ✅ Add documentation versioning and manpage-style command reference. - ✅ Add documentation versioning and manpage-style command reference.
- ✅ Harden packaging for Arch first, then document Debian/RPM install paths. - ✅ Harden packaging for Arch first, then document Debian/RPM install paths.
- ✅ Add signed release artifacts and checksums. - ✅ 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. incidents, posture, integrity state, response plans, and watchdog status.
- ✅ Add a local dashboard Settings menu with persistent themes for dark - ✅ Add a local dashboard Settings menu with persistent themes for dark
console, light evidence review, high-contrast operation, LGBTQ, Trans, 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_age` | number or null | Seconds since heartbeat, if present. |
| `heartbeat_stale` | boolean | Whether heartbeat exceeds configured max age. | | `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` ## `enodia.response.plan.v1`
Dry-run response plan from `respond plan <incident-id>`. 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. | | 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`. | | 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. | | Push | ntfy, Pushover, and generic webhook notifications. |
| Red-team harness | Safe drills for testing signatures and demos. | | 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_VIEW_V1 = "enodia.incident.view.v1"
INCIDENT_BUNDLE_V1 = "enodia.incident.bundle.v1" INCIDENT_BUNDLE_V1 = "enodia.incident.bundle.v1"
STATUS_V1 = "enodia.status.v1" STATUS_V1 = "enodia.status.v1"
INTEGRITY_V1 = "enodia.integrity.v1"
RESPONSE_PLAN_V1 = "enodia.response.plan.v1" RESPONSE_PLAN_V1 = "enodia.response.plan.v1"
RESPONSE_AUDIT_V1 = "enodia.response.audit.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; .section-title{display:flex;align-items:center;justify-content:space-between;
padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em; padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em;
text-transform:uppercase} 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{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 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 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.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} .list{padding:0 8px 14px}
.item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px; .item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px;
cursor:pointer;border-radius:7px;margin-bottom:4px} 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{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 dt{color:var(--muted);font-size:11px}
.cond dd{margin:0;color:var(--soft);font-size:11px;word-break:break-word} .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{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} .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)} .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 class="active" data-tab="incident">Incident</button>
<button data-tab="alerts">Alerts</button> <button data-tab="alerts">Alerts</button>
<button data-tab="posture">Posture</button> <button data-tab="posture">Posture</button>
<button data-tab="integrity">Integrity</button>
<button data-tab="rules">Rules</button> <button data-tab="rules">Rules</button>
<button data-tab="events">Events</button> <button data-tab="events">Events</button>
</div> </div>
<section id="incidentTab"></section> <section id="incidentTab"></section>
<section id="alertsTab" hidden></section> <section id="alertsTab" hidden></section>
<section id="postureTab" hidden></section> <section id="postureTab" hidden></section>
<section id="integrityTab" hidden></section>
<section id="rulesTab" hidden></section> <section id="rulesTab" hidden></section>
<section id="eventsTab" hidden></section> <section id="eventsTab" hidden></section>
</main> </main>
@ -316,7 +333,7 @@
<script> <script>
const TOKEN = new URLSearchParams(location.search).get("token") || ""; const TOKEN = new URLSearchParams(location.search).get("token") || "";
const HDR = TOKEN ? {Authorization:"Bearer "+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 = [ const THEME_OPTIONS = [
["console","Console"],["paper","Paper"],["contrast","Contrast"], ["console","Console"],["paper","Paper"],["contrast","Contrast"],
["pride","LGBTQ"],["trans","Trans"],["dracula","Dracula"], ["pride","LGBTQ"],["trans","Trans"],["dracula","Dracula"],
@ -329,7 +346,7 @@ function tabFromHash(){
const name = location.hash.replace(/^#/,""); const name = location.hash.replace(/^#/,"");
return TABS.has(name) ? name : "incident"; 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){ async function api(path){
const r = await fetch(path,{headers:HDR}); const r = await fetch(path,{headers:HDR});
@ -381,10 +398,10 @@ function setSettings(open){
async function refresh(){ async function refresh(){
try{ try{
const [status, incidents, alerts, posture, rules, events] = await Promise.all([ 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/rules"), api("/api/events") 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(); renderStatus(status); renderLists();
if(!state.selected && incidents.length) await openIncident(incidents[0].id); if(!state.selected && incidents.length) await openIncident(incidents[0].id);
else if(state.selected) await openIncident(state.selected, false); 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; document.getElementById("ebpf").textContent = "eBPF exec "+exec+" / syscall "+syscall;
const counts = st.counts || {}; const counts = st.counts || {};
const metrics = document.getElementById("metrics"); metrics.innerHTML = ""; const metrics = document.getElementById("metrics"); metrics.innerHTML = "";
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""],["POSTURE","posture"]].forEach(([k,c])=>{ [["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 : counts[k]||0; 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; 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))); const m=el("div","metric "+c); m.append(el("b",null,String(value)));
m.append(el("span",null,label)); metrics.append(m); 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(){ function renderLists(){
document.getElementById("incidentCount").textContent = state.incidents.length; document.getElementById("incidentCount").textContent = state.incidents.length;
@ -449,6 +472,7 @@ async function openAlert(name){
const box = document.getElementById("alertsTab"); const box = document.getElementById("alertsTab");
box.hidden = false; document.getElementById("incidentTab").hidden = true; box.hidden = false; document.getElementById("incidentTab").hidden = true;
document.getElementById("postureTab").hidden = true; document.getElementById("rulesTab").hidden = true; document.getElementById("postureTab").hidden = true; document.getElementById("rulesTab").hidden = true;
document.getElementById("integrityTab").hidden = true;
document.getElementById("eventsTab").hidden = true; document.getElementById("eventsTab").hidden = true;
box.innerHTML='<div class="empty">loading snapshot</div>'; box.innerHTML='<div class="empty">loading snapshot</div>';
try{ try{
@ -499,10 +523,12 @@ function renderTab(){
document.getElementById("incidentTab").hidden = state.tab !== "incident"; document.getElementById("incidentTab").hidden = state.tab !== "incident";
document.getElementById("alertsTab").hidden = state.tab !== "alerts"; document.getElementById("alertsTab").hidden = state.tab !== "alerts";
document.getElementById("postureTab").hidden = state.tab !== "posture"; document.getElementById("postureTab").hidden = state.tab !== "posture";
document.getElementById("integrityTab").hidden = state.tab !== "integrity";
document.getElementById("rulesTab").hidden = state.tab !== "rules"; document.getElementById("rulesTab").hidden = state.tab !== "rules";
document.getElementById("eventsTab").hidden = state.tab !== "events"; document.getElementById("eventsTab").hidden = state.tab !== "events";
if(state.tab === "incident") renderIncident(); if(state.tab === "incident") renderIncident();
if(state.tab === "posture") renderPosture(); if(state.tab === "posture") renderPosture();
if(state.tab === "integrity") renderIntegrity();
if(state.tab === "rules") renderRules(); if(state.tab === "rules") renderRules();
if(state.tab === "events") renderEvents(); if(state.tab === "events") renderEvents();
} }
@ -542,6 +568,93 @@ function renderPosture(){
box.append(row); 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(){ function renderRules(){
const box = document.getElementById("rulesTab"); const box = document.getElementById("rulesTab");
const catalog = state.rules || {rules:[], by_event:{}, by_severity:{}, by_origin:{}}; const catalog = state.rules || {rules:[], by_event:{}, by_severity:{}, by_origin:{}};
@ -589,6 +702,7 @@ function renderRules(){
function showError(msg){ function showError(msg){
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>'; document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("postureTab").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("rulesTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("plan").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 ------------------------------------------------------------ # --- HTTP layer ------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
@ -368,6 +482,8 @@ class _Handler(BaseHTTPRequestHandler):
self._json(posture_report(cfg), send_body=send_body) self._json(posture_report(cfg), send_body=send_body)
elif path == "/api/rules": elif path == "/api/rules":
self._json(rules_catalog(cfg), send_body=send_body) 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": elif path == "/api/incidents":
self._json(list_incidents(cfg), send_body=send_body) self._json(list_incidents(cfg), send_body=send_body)
elif path.startswith("/api/incidents/"): elif path.startswith("/api/incidents/"):

View file

@ -178,6 +178,24 @@ class TestSchemaContracts(unittest.TestCase):
"heartbeat_stale": bool, "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): def test_response_plan_and_audit_contracts(self):
iid = self._record_incident_with_snapshot() iid = self._record_incident_with_snapshot()
bundle = respond.load_bundle(self.cfg, iid) bundle = respond.load_bundle(self.cfg, iid)

View file

@ -172,6 +172,28 @@ class TestDataLayer(unittest.TestCase):
self.assertEqual(rule["event"], "exec") self.assertEqual(rule["event"], "exec")
self.assertIn("conditions", rule) 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): def test_dashboard_has_persistent_theme_controls(self):
html = (web._STATIC / "dashboard.html").read_text() html = (web._STATIC / "dashboard.html").read_text()
self.assertIn('id="settingsMenu"', html) self.assertIn('id="settingsMenu"', html)
@ -234,6 +256,14 @@ class TestDataLayer(unittest.TestCase):
f"{theme} {foreground} on {background} contrast is {ratio:.2f}", 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): class TestNetworkHelpers(unittest.TestCase):
def test_is_loopback(self): def test_is_loopback(self):
@ -330,6 +360,14 @@ class TestAuth(unittest.TestCase):
self.assertIn("rules", data) self.assertIn("rules", data)
self.assertGreater(data["count"], 0) 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__": if __name__ == "__main__":
unittest.main() unittest.main()