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
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>`.
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/"):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue