Add stable v1 schema IDs and docs

This commit is contained in:
Luna 2026-06-16 04:05:12 -07:00
parent bfef23fc4a
commit 7a176ea238
13 changed files with 409 additions and 8 deletions

View file

@ -12,6 +12,7 @@ import signal
import sys
from . import __version__, detectors
from . import schemas
from .config import Config
from .daemon import Sentinel
from .system import SystemState, scan_suid_binaries
@ -376,6 +377,7 @@ def _cmd_incident(cfg: Config, action: str, iid: str | None, as_json: bool) -> i
if action == "export":
bundle = {
"schema": schemas.INCIDENT_BUNDLE_V1,
"incident": inc,
"snapshots": [r for name in inc.get("snapshots", [])
if (r := _load_snapshot_report(cfg, name)) is not None],
@ -386,7 +388,11 @@ def _cmd_incident(cfg: Config, action: str, iid: str | None, as_json: bool) -> i
# action == "show"
timeline = _incident_timeline(cfg, inc)
if as_json:
print(json.dumps({"incident": inc, "timeline": timeline}, indent=2))
print(json.dumps({
"schema": schemas.INCIDENT_VIEW_V1,
"incident": inc,
"timeline": timeline,
}, indent=2))
return 0
print(f"Incident {inc['id']} [{inc.get('severity', '?')}] on {inc.get('host', '?')}")
print(f" first seen: {inc.get('first_seen', '?')}")

View file

@ -28,6 +28,7 @@ import time
from collections.abc import Callable, Iterable
from datetime import datetime
from . import schemas
from .alert import Alert, Severity
from .config import Config
@ -112,7 +113,12 @@ def index_path(cfg: Config):
def load_index(cfg: Config) -> dict:
try:
data = json.loads(index_path(cfg).read_text())
return data if isinstance(data, dict) else {}
if not isinstance(data, dict):
return {}
for inc in data.values():
if isinstance(inc, dict):
inc.setdefault("schema", schemas.INCIDENT_V1)
return data
except (OSError, ValueError):
return {}
@ -161,6 +167,7 @@ def record(cfg: Config, snapshot_name: str, alerts: list[Alert],
if iid is None:
iid = _new_id(when)
index[iid] = {
"schema": schemas.INCIDENT_V1,
"id": iid, "host": host,
"first_ts": when, "last_ts": when,
"first_seen": iso, "last_seen": iso,
@ -169,6 +176,7 @@ def record(cfg: Config, snapshot_name: str, alerts: list[Alert],
"lineage": [], "snapshots": [], "alert_count": 0,
}
inc = index[iid]
inc.setdefault("schema", schemas.INCIDENT_V1)
inc["last_ts"] = when
inc["last_seen"] = iso
inc["severity"] = max_severity(inc.get("severity", severity), severity)

View file

@ -15,6 +15,7 @@ from datetime import datetime
from pathlib import Path
from typing import Any
from . import schemas
from .config import Config
from .netutil import is_public_ip
@ -189,7 +190,7 @@ def build_plan(bundle: dict, cfg: Config) -> dict:
numbered.append(d)
return {
"schema": "enodia.response.plan.v1",
"schema": schemas.RESPONSE_PLAN_V1,
"plan_id": f"plan-{incident_id}",
"incident_id": incident_id,
"created_at": datetime.now().astimezone().isoformat(),
@ -242,6 +243,7 @@ def persist_plan(cfg: Config, plan: dict, actor: str = "cli") -> dict[str, str]:
audit_path = response_audit_path(cfg)
record = {
"schema": schemas.RESPONSE_AUDIT_V1,
"time": datetime.now().astimezone().isoformat(),
"event": "response_plan_generated",
"actor": actor,

View file

@ -0,0 +1,16 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Stable JSON schema identifiers for operator-facing contracts.
These are lightweight schema IDs, not a full JSON Schema implementation. Tests
pin the minimum v1 fields and types so downstream automation can rely on them
while newer releases remain free to add fields.
"""
ALERT_V1 = "enodia.alert.v1"
ALERT_SNAPSHOT_V1 = "enodia.alert.snapshot.v1"
INCIDENT_V1 = "enodia.incident.v1"
INCIDENT_VIEW_V1 = "enodia.incident.view.v1"
INCIDENT_BUNDLE_V1 = "enodia.incident.bundle.v1"
STATUS_V1 = "enodia.status.v1"
RESPONSE_PLAN_V1 = "enodia.response.plan.v1"
RESPONSE_AUDIT_V1 = "enodia.response.audit.v1"

View file

@ -15,6 +15,7 @@ import time
from datetime import datetime
from pathlib import Path
from . import schemas
from .alert import Alert, Severity
from .config import Config
from .system import Process, SystemState
@ -173,6 +174,7 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
cfg, base.with_suffix(".log").name, alerts, lineage, now.timestamp(), host)
report = {
"schema": schemas.ALERT_SNAPSHOT_V1,
"time": now.isoformat(),
"host": host,
"severity": str(severity),

View file

@ -24,6 +24,7 @@ from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
from . import __version__
from . import schemas
from .config import Config
_STATIC = Path(__file__).parent / "static"
@ -199,6 +200,7 @@ def daemon_status(cfg: Config) -> dict:
from .selfprotect import heartbeat_age
age = heartbeat_age(cfg)
return {
"schema": schemas.STATUS_V1,
"version": __version__,
"running": pid_alive,
"total_alerts": len(alerts),
@ -246,7 +248,12 @@ def get_incident(cfg: Config, incident_id: str) -> dict | None:
if isinstance(p.get("pid"), int)],
})
timeline.sort(key=lambda r: r.get("time", ""))
return {"incident": inc, "timeline": timeline, "snapshots": snapshots}
return {
"schema": schemas.INCIDENT_VIEW_V1,
"incident": inc,
"timeline": timeline,
"snapshots": snapshots,
}
def response_plan(cfg: Config, incident_id: str) -> dict | None: