Skip to content

Commit 3073114

Browse files
authored
Phase 7: Canonical event model (#1)
All event emission routed through EventDispatcher. 12 generate_* methods use SecurityEvent dispatch; remaining emissions use dispatch_raw. A/B eval: 82.3→83.7, expert panel: 36→30 tells, 0 regressions. 761 tests passing.
1 parent 758d4e2 commit 3073114

35 files changed

Lines changed: 3466 additions & 1271 deletions

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,6 @@ checkpoints/
5454
# Claude Code sessions
5555
.claude/
5656

57-
# Scenarios we generate
58-
.scenarios/
57+
# Ignore any scenarios we might produce
58+
scenarios/
5959

AGENTS.md

Lines changed: 105 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ evidence-forge/
141141
│ │ ├── format_def.py # Pydantic models for format definitions
142142
│ │ └── state.py # Runtime state models (dataclasses)
143143
│ │
144+
│ ├── events/ # Canonical event model (intermediate representation)
145+
│ │ ├── __init__.py # Re-exports SecurityEvent, RawLogEntry, all contexts
146+
│ │ ├── base.py # SecurityEvent, RawLogEntry dataclasses
147+
│ │ ├── contexts.py # Composable context dataclasses (HostContext, AuthContext, etc.)
148+
│ │ └── dispatcher.py # EventDispatcher (routes events to StateManager + emitters)
149+
│ │
144150
│ ├── validation/
145151
│ │ ├── __init__.py
146152
│ │ └── schema.py # Pydantic-based schema validation
@@ -149,7 +155,7 @@ evidence-forge/
149155
│ │ ├── __init__.py
150156
│ │ ├── engine.py # Main generation orchestrator (includes persona logic)
151157
│ │ ├── state_manager.py # State tracking (sessions, processes, connections)
152-
│ │ ├── activity.py # Activity script execution (includes persona behavior)
158+
│ │ ├── activity.py # Activity generation (builds SecurityEvents, dispatches via EventDispatcher)
153159
│ │ ├── network_visibility.py # Network visibility/perspective logic
154160
│ │ └── emitters/
155161
│ │ ├── __init__.py
@@ -559,6 +565,47 @@ def redact_secrets(obj: dict[str, Any]) -> dict[str, Any]:
559565

560566
## Key Architecture Patterns
561567

568+
### Canonical Event Model
569+
570+
The generation engine uses a **canonical event model** -- an intermediate representation layer between activity generation and log rendering. Instead of ActivityGenerator calling each emitter separately with manually-coordinated fields, it builds a single `SecurityEvent` object that carries all shared metadata. An `EventDispatcher` routes the event to StateManager and to matching emitters.
571+
572+
**Core principle: consistency by construction, not by coordination.** Two emitters cannot disagree about a port number because there is only one port number -- on the event object.
573+
574+
**Two-phase build + dispatch pattern:**
575+
576+
```python
577+
def generate_logon(self, user, system, time, logon_type=2, source_ip=None):
578+
# Phase 1: Allocate IDs from StateManager
579+
logon_id = self.state_manager.create_session(...)
580+
581+
# Phase 2: Build complete SecurityEvent
582+
event = SecurityEvent(
583+
timestamp=time,
584+
event_type="logon",
585+
host=self._build_host_context(system),
586+
auth=AuthContext(username=user.username, logon_id=logon_id, ...),
587+
)
588+
589+
# Phase 3: Dispatch (routes to matching emitters)
590+
self.dispatcher.dispatch(event)
591+
return logon_id
592+
```
593+
594+
**Key types** (all in `src/evidenceforge/events/`):
595+
- `SecurityEvent` -- Canonical event carrying composable context objects
596+
- `RawLogEntry` -- Escape hatch for single-format entries that bypass the event model
597+
- Context dataclasses: `HostContext`, `AuthContext`, `ProcessContext`, `NetworkContext`, `DnsContext`, `FileContext`, `RegistryContext`, `IdsContext`
598+
- `EventDispatcher` -- Routes events to `StateManager.apply()` + matching emitters, with network visibility filtering via `NetworkVisibilityEngine.get_log_formats_for_connection()`
599+
600+
**Event model rules:**
601+
- ActivityGenerator builds `SecurityEvent` objects; it never calls emitter methods directly
602+
- IDs (logon_id, pid, zeek_uid) are allocated by StateManager *before* building the SecurityEvent (two-phase build)
603+
- `StateManager.apply()` records state from a fully-constructed event; it does not allocate IDs
604+
- Each emitter declares `_supported_types` and implements `can_handle(event)` for self-selection
605+
- `RawLogEntry` is the escape hatch for simple, single-format log entries -- use sparingly
606+
- Events are transient -- they are GC'd after dispatch; StateManager owns durable state
607+
- Full design details in `docs/event-model-prd.md`
608+
562609
### LLM Client Abstraction (Future)
563610

564611
The LLM client abstraction is planned for future built-in LLM integration. Currently, scenario creation is handled by Claude Code Skills (external to the codebase). The patterns below are kept as reference for when the `llm/` module is implemented.
@@ -792,7 +839,8 @@ class StateManager:
792839
**State rules:**
793840
- StateManager is the ONLY place to track sessions, processes, connections
794841
- Emitters READ state (to get LogonIDs, PIDs for events)
795-
- Orchestrator WRITES state (creates sessions/processes as scenario executes)
842+
- ActivityGenerator WRITES state (allocates IDs via `create_session()`, `create_process()`, `open_connection()` before building SecurityEvents)
843+
- `apply(event)` records state from a fully-constructed `SecurityEvent` — handles teardown (logoff, process termination) and updates (connection bytes). Does NOT allocate IDs.
796844
- No automatic cleanup (realistic incompleteness is acceptable per PRD)
797845
- Thread-safe for reads, single-threaded for writes
798846

@@ -802,87 +850,51 @@ All log format emitters inherit from `LogEmitter` ABC:
802850

803851
```python
804852
from abc import ABC, abstractmethod
805-
from pathlib import Path
853+
from evidenceforge.events import SecurityEvent
806854
807855
class LogEmitter(ABC):
808856
"""Base class for all log format emitters."""
809857
810-
def __init__(self, output_path: Path, state_manager: StateManager):
811-
self.output_path = output_path
812-
self.state_manager = state_manager
813-
self._buffer: list[str] = []
814-
self._buffer_size = 10_000 # Flush every 10K events
858+
_supported_types: set[str] = set() # Overridden by each subclass
859+
860+
@abstractmethod
861+
def can_handle(self, event: SecurityEvent) -> bool:
862+
"""Return True if this emitter can render this event type."""
863+
...
815864
816865
@abstractmethod
817-
def emit_event(self, event: Event) -> None:
818-
"""Emit a single event to the log.
866+
def emit(self, event: SecurityEvent) -> None:
867+
"""Render a SecurityEvent to this emitter's format.
819868
820-
Args:
821-
event: Event to emit (type depends on emitter)
869+
Implementations build a field dict from SecurityEvent contexts,
870+
then pass it to the existing Jinja2 template for final string rendering.
822871
"""
823-
pass
872+
...
873+
874+
def emit_raw(self, event_data: dict[str, Any]) -> None:
875+
"""Emit from raw dict -- used by RawLogEntry escape hatch."""
876+
...
824877
825878
@abstractmethod
826879
def flush(self) -> None:
827880
"""Flush buffered events to disk."""
828-
pass
829-
830-
def _write_buffered(self, line: str) -> None:
831-
"""Add line to buffer, flush if needed."""
832-
self._buffer.append(line)
833-
if len(self._buffer) >= self._buffer_size:
834-
self.flush()
881+
...
835882
```
836883

837-
**Example emitter (Zeek conn.log):**
838-
```python
839-
from datetime import datetime
840-
841-
class ZeekConnEmitter(LogEmitter):
842-
"""Zeek connection log emitter."""
843-
844-
def emit_event(self, event: ConnectionEvent) -> None:
845-
"""Emit a Zeek conn.log line."""
846-
# Get connection state from StateManager
847-
conn = self.state_manager.get_connection(event.conn_id)
848-
849-
# Format as TSV
850-
line = "\t".join([
851-
str(event.timestamp.timestamp()), # ts
852-
conn.conn_id, # uid
853-
conn.src_ip, # id.orig_h
854-
str(conn.src_port), # id.orig_p
855-
conn.dst_ip, # id.resp_h
856-
str(conn.dst_port), # id.resp_p
857-
conn.protocol, # proto
858-
"-", # service (can be "-" if unknown)
859-
str(conn.duration), # duration
860-
str(conn.bytes_sent), # orig_bytes
861-
str(conn.bytes_received), # resp_bytes
862-
conn.state, # conn_state
863-
# ... additional fields
864-
])
865-
866-
self._write_buffered(line)
867-
868-
def flush(self) -> None:
869-
"""Write buffer to disk."""
870-
if not self._buffer:
871-
return
872-
873-
with self.output_path.open("a") as f:
874-
f.write("\n".join(self._buffer) + "\n")
875-
876-
logger.debug("Flushed %d events to %s", len(self._buffer), self.output_path)
877-
self._buffer.clear()
878-
```
884+
Each emitter's `emit()` method follows this pattern:
885+
1. Build a field dict from SecurityEvent contexts (explicit `_render_{event_type}()` method)
886+
2. Pass the dict to the existing Jinja2 template for final string formatting
887+
3. Buffer the rendered string (or raw dict for WindowsEventEmitter's deferred rendering)
879888

880889
**Emitter rules:**
881890
- Read state from StateManager, never mutate it
882891
- Buffer writes (10K events), use atomic flush
883-
- Use Jinja2 templates from format definitions for rendering
884-
- Handle timezone conversion (UTC system/format timezone)
892+
- Use Jinja2 templates from format definitions for final string rendering
893+
- Handle timezone conversion (UTC -> system/format timezone)
885894
- Each emitter runs in separate thread, writes to separate file
895+
- Emitters declare `_supported_types` and implement `can_handle()` for dispatcher self-selection
896+
- Emitters receive `SecurityEvent` objects via `emit()`, not raw dicts (except via `emit_raw()` escape hatch)
897+
- OS-specific emitters (Windows, Syslog) check `event.host.os_category` in `can_handle()`
886898

887899
### Format Definitions
888900

@@ -1347,6 +1359,15 @@ def test_state_manager_creates_unique_pids(user_count: int):
13471359
pids.add(pid)
13481360
```
13491361

1362+
### Event Model Tests
1363+
1364+
Tests for the canonical event model follow these patterns:
1365+
- **Event construction:** Verify `SecurityEvent` with various context combinations creates valid objects
1366+
- **Dispatcher routing:** Verify `EventDispatcher` routes events to correct emitters based on `can_handle()` and network visibility
1367+
- **Render parity:** For each migrated activity type, verify rendered output is structurally equivalent to pre-migration output
1368+
- **Slots enforcement:** Verify `slots=True` prevents adding undeclared attributes on context dataclasses
1369+
- **Two-phase build:** Verify IDs allocated by StateManager appear on the SecurityEvent and in rendered output
1370+
13501371
## Skills
13511372

13521373
Claude Code Skills handle the interactive, creative aspects of scenario creation -- work that was originally planned as a built-in conversational CLI.
@@ -1492,6 +1513,26 @@ eforge install-skills --global
14921513
self.flush() # Final flush on close
14931514
```
14941515

1516+
9. **Bypass the event model by calling emitters directly from ActivityGenerator**
1517+
```python
1518+
# WRONG -- manual coordination across emitters
1519+
def generate_logon(self, user, system, time):
1520+
logon_id = self.state_manager.create_session(...)
1521+
self.emitters['windows'].emit_event({"EventID": 4624, "TargetLogonId": logon_id, ...})
1522+
self.emitters['syslog'].emit_event({"message": f"session opened for user {user}", ...})
1523+
self.emitters['ecar'].emit_event({"action": "start", "logon_id": logon_id, ...})
1524+
1525+
# CORRECT -- build SecurityEvent, dispatch handles routing
1526+
def generate_logon(self, user, system, time):
1527+
logon_id = self.state_manager.create_session(...)
1528+
event = SecurityEvent(
1529+
timestamp=time, event_type="logon",
1530+
host=self._build_host_context(system),
1531+
auth=AuthContext(username=user.username, logon_id=logon_id, ...),
1532+
)
1533+
self.dispatcher.dispatch(event)
1534+
```
1535+
14951536
### DO
14961537

14971538
1. **Validate early, fail fast**

0 commit comments

Comments
 (0)