feat(schema): add enodia.event.v1 EVE-style event envelope

This commit is contained in:
Luna 2026-07-10 04:43:35 -07:00
parent 8d929b50bd
commit 54bd8ff1f4
4 changed files with 111 additions and 0 deletions

View file

@ -12,6 +12,23 @@ not be removed, renamed, or change type.
Schema IDs live in `enodia_sentinel/schemas.py`, and compatibility tests in
`tests/test_schema_contracts.py` pin the required v1 fields.
## `enodia.event.v1`
Uniform envelope that wraps every emitted record with a typed discriminator.
This is the Python↔Go interoperability contract: any implementation must emit
the same envelope for the same input. Modeled on Suricata's EVE JSON — the
type-specific payload lives under a key named by `event_type`.
Required fields:
| Field | Type | Meaning |
|---|---|---|
| `schema` | string | `enodia.event.v1`. |
| `event_type` | string | Discriminator: `alert`, `incident`, or `status` (additive set). |
| `timestamp` | string | ISO-8601 emission time. |
| `host` | string | Hostname at emission time. |
| `<event_type>` | object | Payload, under a key equal to `event_type` (e.g. `alert` holds an `enodia.alert.v1` object). |
## `enodia.alert.v1`
Single detection object, used inside alert snapshots and by commands such as

46
enodia_sentinel/event.py Normal file
View file

@ -0,0 +1,46 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""EVE-style event envelope: one typed wrapper for every emitted record.
Every envelope carries a stable ``schema`` id, an ``event_type`` discriminator,
a ``timestamp`` and ``host``, and the type-specific payload under a key named by
the event type (mirroring Suricata's EVE JSON, where an ``alert`` record holds
its detail under ``"alert"``). This is the reference contract a future Go agent
must emit byte-for-byte; the Python side is the oracle.
"""
from __future__ import annotations
import os
from datetime import datetime
from .schemas import EVENT_V1
# Closed set of v1 discriminators. New types are added additively in later
# phases (e.g. "dns", "tls", "http", "flow", "anomaly", "capture"); removing or
# renaming a value is a breaking change.
EVENT_TYPES = frozenset({"alert", "incident", "status"})
def build_event(
event_type: str,
payload: dict,
*,
host: str | None = None,
timestamp: str | None = None,
) -> dict:
"""Wrap ``payload`` in an ``enodia.event.v1`` envelope.
Raises ``ValueError`` if ``event_type`` is not a known v1 discriminator.
"""
if event_type not in EVENT_TYPES:
raise ValueError(f"unknown event_type: {event_type!r}")
if host is None:
host = os.uname().nodename
if timestamp is None:
timestamp = datetime.now().astimezone().isoformat()
return {
"schema": EVENT_V1,
"event_type": event_type,
"timestamp": timestamp,
"host": host,
event_type: payload,
}

View file

@ -18,3 +18,4 @@ FIRST_SEEN_V1 = "enodia.first_seen.v1"
RESPONSE_PLAN_V1 = "enodia.response.plan.v1"
RESPONSE_AUDIT_V1 = "enodia.response.audit.v1"
RECONCILE_V1 = "enodia.reconcile.v1"
EVENT_V1 = "enodia.event.v1"

View file

@ -0,0 +1,47 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Compatibility tests for the enodia.event.v1 envelope."""
import os
import unittest
from datetime import datetime
from enodia_sentinel import event, schemas
class TestEventEnvelope(unittest.TestCase):
def test_schema_id_is_stable(self):
self.assertEqual(schemas.EVENT_V1, "enodia.event.v1")
def test_build_event_shape_and_types(self):
env = event.build_event("alert", {"sid": 100010})
self.assertEqual(env["schema"], schemas.EVENT_V1)
self.assertEqual(env["event_type"], "alert")
self.assertIsInstance(env["timestamp"], str)
self.assertIsInstance(env["host"], str)
# EVE-style: payload lives under a key named by event_type.
self.assertEqual(env["alert"], {"sid": 100010})
def test_timestamp_defaults_to_iso8601(self):
env = event.build_event("status", {"running": True})
# Parses as ISO-8601 without raising.
datetime.fromisoformat(env["timestamp"])
def test_host_defaults_to_nodename(self):
env = event.build_event("status", {"running": True})
self.assertEqual(env["host"], os.uname().nodename)
def test_overrides_are_respected(self):
env = event.build_event(
"incident", {"id": "x"}, host="host-a", timestamp="2026-07-10T00:00:00-07:00")
self.assertEqual(env["host"], "host-a")
self.assertEqual(env["timestamp"], "2026-07-10T00:00:00-07:00")
def test_unknown_event_type_rejected(self):
with self.assertRaises(ValueError):
event.build_event("nope", {})
def test_event_types_is_closed_set(self):
self.assertEqual(event.EVENT_TYPES, frozenset({"alert", "incident", "status"}))
if __name__ == "__main__":
unittest.main()