From 54bd8ff1f4f1f6db7770428274c930cf4e524966 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 10 Jul 2026 04:43:35 -0700 Subject: [PATCH] feat(schema): add enodia.event.v1 EVE-style event envelope --- docs/SCHEMAS.md | 17 +++++++++++++ enodia_sentinel/event.py | 46 +++++++++++++++++++++++++++++++++++ enodia_sentinel/schemas.py | 1 + tests/test_event_envelope.py | 47 ++++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 enodia_sentinel/event.py create mode 100644 tests/test_event_envelope.py diff --git a/docs/SCHEMAS.md b/docs/SCHEMAS.md index 5c2d31a..8bcdf6d 100644 --- a/docs/SCHEMAS.md +++ b/docs/SCHEMAS.md @@ -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. | +| `` | 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 diff --git a/enodia_sentinel/event.py b/enodia_sentinel/event.py new file mode 100644 index 0000000..cd4d454 --- /dev/null +++ b/enodia_sentinel/event.py @@ -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, + } diff --git a/enodia_sentinel/schemas.py b/enodia_sentinel/schemas.py index da82cb7..eadde08 100644 --- a/enodia_sentinel/schemas.py +++ b/enodia_sentinel/schemas.py @@ -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" diff --git a/tests/test_event_envelope.py b/tests/test_event_envelope.py new file mode 100644 index 0000000..debeec8 --- /dev/null +++ b/tests/test_event_envelope.py @@ -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()