feat(schema): add enodia.event.v1 EVE-style event envelope
This commit is contained in:
parent
8d929b50bd
commit
54bd8ff1f4
4 changed files with 111 additions and 0 deletions
|
|
@ -12,6 +12,23 @@ not be removed, renamed, or change type.
|
||||||
Schema IDs live in `enodia_sentinel/schemas.py`, and compatibility tests in
|
Schema IDs live in `enodia_sentinel/schemas.py`, and compatibility tests in
|
||||||
`tests/test_schema_contracts.py` pin the required v1 fields.
|
`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`
|
## `enodia.alert.v1`
|
||||||
|
|
||||||
Single detection object, used inside alert snapshots and by commands such as
|
Single detection object, used inside alert snapshots and by commands such as
|
||||||
|
|
|
||||||
46
enodia_sentinel/event.py
Normal file
46
enodia_sentinel/event.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -18,3 +18,4 @@ FIRST_SEEN_V1 = "enodia.first_seen.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"
|
||||||
RECONCILE_V1 = "enodia.reconcile.v1"
|
RECONCILE_V1 = "enodia.reconcile.v1"
|
||||||
|
EVENT_V1 = "enodia.event.v1"
|
||||||
|
|
|
||||||
47
tests/test_event_envelope.py
Normal file
47
tests/test_event_envelope.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue