Skip to content

Commit 6894a4c

Browse files
DavidJBiancoclaude
andcommitted
Phase 6.3: Complete P2 moderate realism fixes (remaining 5 items)
Completes all 16 P2 items from REALISM_ASSESSMENT-2.md: - #22: DB connections require scenario-detected servers (no hardcoded 10.0.100.x fallback) - #27: SSH source IPs from scenario system IPs (not 10.0.x.x) - #28: Syslog emitter sorts buffer chronologically before flush - #31: eCAR/Zeek source port correlation (shared src_port param) - #24: Storyline events interleaved into baseline hour loop via pre-parsed time index + _execute_single_storyline_event() 765 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 29f2477 commit 6894a4c

5 files changed

Lines changed: 124 additions & 70 deletions

File tree

TODO.md

Lines changed: 34 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -767,58 +767,40 @@
767767

768768
### 6.3 P2: Moderate (Polish & Realism)
769769

770-
- [ ] **Add jitter to storyline timestamps** (Assessment #17)
771-
- Attack timestamps are exact multiples of 900s/3600s with .000 microseconds
772-
- Background events have realistic jitter; the contrast is obvious
773-
- Files: `engine.py` (`_execute_storyline_event`)
774-
- [ ] **Realistic LogonIDs** (Assessment #18)
775-
- TargetLogonId values 0x3e7, 0x3e8, 0x3e9... incrementing by 1
776-
- Real LSASS generates high-entropy 64-bit values like 0x1A2B3C4D
777-
- 0x3e7 is SYSTEM's well-known LogonID but assigned to regular users
778-
- Files: `state_manager.py` (logon ID generation)
779-
- [ ] **Populate real base64 in encoded commands** (Assessment #19)
780-
- Literal `<base64_encoded_command>` placeholder never replaced with actual base64
781-
- Files: `engine.py` (storyline execution)
782-
- [ ] **Use realistic public IPs for exfiltration** (Assessment #20)
783-
- RFC 5737 documentation IPs (203.0.113.x, 198.51.100.x) are not internet-routable
784-
- Files: scenario YAML, `engine.py`
785-
- [ ] **Add Kerberos auth package + LogonGuids** (Assessment #21)
786-
- LogonGuid always null; Kerberos-enabled domains populate this for cross-machine correlation
787-
- AuthenticationPackageName never "Kerberos" — should dominate in AD
788-
- Files: `activity.py` (`generate_logon`)
789-
- [ ] **Fix internal DNS IPs and DB ports** (Assessment #22)
790-
- db-primary.corp.local resolves to 10.0.100.x but docs say 10.10.100.x; MySQL 3306 instead of SQL 1433
791-
- Files: `activity.py` (REVERSE_DNS, EXTERNAL_IPS), `engine.py`
792-
- [ ] **Fix Zeek `ts` type consistency** (Assessment #23)
793-
- String in zeek_conn.json, bare number in zeek_dns.json; should be consistent
794-
- Files: `emitters/zeek.py`, `emitters/zeek_dns.py`
795-
- [ ] **Interleave scenario events chronologically** (Assessment #24)
796-
- Attack events appended as separate block at end of ecar.json/zeek_conn.json
797-
- Files: `emitters/base.py` or `engine.py` (output ordering)
798-
- [ ] **Add FQDN to Computer names** (Assessment #25)
799-
- Short names like EXEC-WS-04 instead of EXEC-WS-04.corp.meridiancapital.com
800-
- Files: `activity.py` (Computer field in Windows events)
801-
- [ ] **Add RID gaps and computer account SIDs** (Assessment #26)
802-
- RIDs 1001-1105 with zero gaps; no computer account RIDs or deleted-object gaps
803-
- Files: `engine.py` (SID registry)
804-
- [ ] **Use SSH source IPs from documented subnets** (Assessment #27, N4)
805-
- SSH from 10.0.x.x instead of documented 10.10.x.x topology
806-
- Files: `engine.py` (syslog generation), `activity.py`
807-
- [ ] **Sort syslog chronologically** (Assessment #28)
808-
- Within each hour, timestamps jump randomly; real syslog is append-only/ordered
809-
- Files: `engine.py` (`_generate_system_traffic` syslog section), `emitters/syslog.py`
810-
- [ ] **Limit systemd-timesyncd message** (Assessment #29, N5)
811-
- "Synchronized for the first time" repeats dozens of times; should appear once per boot
812-
- Files: `engine.py` (syslog templates)
813-
- [ ] **Diversify Zeek history strings** (Assessment #30)
814-
- Only ~9 patterns; real Zeek has dozens including retransmission markers (T/t), varied FIN ordering
815-
- Files: `activity.py` (TCP_CONN_STATE_DISTRIBUTION)
816-
- [ ] **Correlate eCAR/Zeek source ports** (Assessment #31)
817-
- eCAR FLOW and Zeek conn at same timestamp show different source ports
818-
- Files: `activity.py` (`generate_connection`, `_emit_ecar_flow_event`)
819-
- [ ] **Add SERVFAIL responses** (Assessment #32)
820-
- Zero SERVFAIL in 39K DNS records; real environments have ~0.1-0.5%
821-
- Files: `activity.py` (`_emit_dns_lookup`)
770+
- [x] **Add jitter to storyline timestamps** (Assessment #17)
771+
- ±30s random jitter + random microseconds; causal ordering enforced
772+
- [x] **Realistic LogonIDs** (Assessment #18)
773+
- Random 32-bit hex values (0x10000-0xFFFFFFFF), well-known values excluded
774+
- [x] **Populate real base64 in encoded commands** (Assessment #19)
775+
- 10 realistic decoded PowerShell commands, proper UTF-16LE base64 encoding
776+
- [x] **Use realistic public IPs for exfiltration** (Assessment #20)
777+
- Real cloud/hosting IPs (DigitalOcean, Linode, Vultr ranges) replace RFC 5737
778+
- [x] **Add Kerberos auth package + LogonGuids** (Assessment #21)
779+
- Type 3: 70% Kerberos/20% NTLM/10% Negotiate; non-null GUID when Kerberos
780+
- [x] **Fix internal DNS IPs and DB ports** (Assessment #22)
781+
- DB connections require scenario-detected servers; no fallback to hardcoded 10.0.100.x
782+
- [x] **Fix Zeek `ts` type consistency** (Assessment #23)
783+
- Both emitters output float (bare number in JSON), not string
784+
- [x] **Interleave scenario events chronologically** (Assessment #24)
785+
- Storyline events injected into baseline hour loop; remaining executed after
786+
- Pre-parsed event times by hour; `_execute_single_storyline_event()` for interleaving
787+
- [x] **Add FQDN to Computer names** (Assessment #25)
788+
- Optional `Environment.domain` field; auto-inferred from user emails
789+
- All Windows event Computer fields: `hostname.domain`; dynamic NetBIOS domain
790+
- [x] **Add RID gaps and computer account SIDs** (Assessment #26)
791+
- Random 1-5 gaps between user RIDs; well-known RIDs (500-502); computer SIDs
792+
- [x] **Use SSH source IPs from documented subnets** (Assessment #27)
793+
- SSH sources from scenario system IPs, not hardcoded 10.0.x.x
794+
- [x] **Sort syslog chronologically** (Assessment #28)
795+
- SyslogEmitter sorts buffer by timestamp prefix before flush
796+
- [x] **Limit systemd-timesyncd message** (Assessment #29)
797+
- "for the first time" only once per system; subsequent messages varied
798+
- [x] **Diversify Zeek history strings** (Assessment #30)
799+
- 21 TCP + 8 UDP patterns (was 7+4); retransmission markers, varied FIN ordering
800+
- [x] **Correlate eCAR/Zeek source ports** (Assessment #31)
801+
- src_port passed from generate_connection() to _emit_ecar_flow_event()
802+
- [x] **Add SERVFAIL responses** (Assessment #32)
803+
- 0.2% SERVFAIL; multi-answer DNS (2-5 IPs) for external A records
822804

823805
### 6.4 P3: Minor (Nice-to-Have Improvements)
824806

src/evidenceforge/generation/activity.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,7 +1139,7 @@ def generate_connection(
11391139

11401140
# Phase 5.2: Emit eCAR FLOW/CONNECT for eCAR-equipped hosts
11411141
# Use src_ip to find the hostname for this connection
1142-
self._emit_ecar_flow_event(src_ip, dst_ip, dst_port, time, src_ip)
1142+
self._emit_ecar_flow_event(src_ip, dst_ip, dst_port, time, src_ip, src_port=src_port)
11431143

11441144
return uid
11451145

@@ -1686,8 +1686,8 @@ def execute_baseline_activity(
16861686
service = db['service']
16871687
dst_port = db['port']
16881688
else:
1689-
service = 'mysql'
1690-
dst_port = 3306
1689+
# No DB servers detected from scenario; skip DB connection
1690+
return
16911691
else:
16921692
service = None
16931693
dst_port = 443
@@ -2204,18 +2204,21 @@ def _emit_ecar_registry_event(
22042204
def _emit_ecar_flow_event(
22052205
self, src_ip: str, dst_ip: str, dst_port: int,
22062206
time: datetime, hostname: str, pid: int = -1,
2207+
src_port: int = 0,
22072208
) -> None:
22082209
"""Emit eCAR FLOW/CONNECT event."""
22092210
if 'ecar' not in self.emitters:
22102211
return
2212+
if src_port == 0:
2213+
src_port = _get_rng().randint(49152, 65535)
22112214
self.emitters['ecar'].emit_event({
22122215
'timestamp': time,
22132216
'hostname': hostname,
22142217
'object': 'FLOW',
22152218
'action': 'CONNECT',
22162219
'pid': pid,
22172220
'src_ip': src_ip,
2218-
'src_port': _get_rng().randint(49152, 65535),
2221+
'src_port': src_port,
22192222
'dst_ip': dst_ip,
22202223
'dst_port': dst_port,
22212224
'protocol': 'tcp',

src/evidenceforge/generation/emitters/syslog.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ def emit_event(self, event_data: dict[str, Any]) -> None:
1818
rendered = self._render_event(event_data)
1919
self._buffer_event(rendered)
2020

21+
def flush(self) -> None:
22+
"""Flush with chronological sorting (syslog is append-only/ordered)."""
23+
with self._file_lock:
24+
self.buffer.sort() # ISO timestamp prefix → lexicographic sort works
25+
self._flush_unlocked()
26+
2127
def _render_event(self, event_data: dict[str, Any]) -> str:
2228
"""Render syslog event to text format.
2329

src/evidenceforge/generation/engine.py

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,15 +121,21 @@ def generate(self) -> None:
121121
self._generate_baseline()
122122
self._report_progress("phase_end", {"phase": "baseline"})
123123

124-
# Phase 3: Execute storyline events (if present)
124+
# Phase 6.3: Execute remaining storyline events not covered by baseline hours
125125
if self.scenario.storyline:
126-
logger.info(f"Executing {len(self.scenario.storyline)} storyline events")
127-
self._report_progress("phase_start", {
128-
"phase": "storyline",
129-
"description": f"Executing {len(self.scenario.storyline)} storyline events"
130-
})
131-
self._execute_storyline()
132-
self._report_progress("phase_end", {"phase": "storyline"})
126+
remaining = [i for i in range(len(self.scenario.storyline))
127+
if i not in self._storyline_executed]
128+
if remaining:
129+
logger.info(f"Executing {len(remaining)} remaining storyline events (outside baseline window)")
130+
self._report_progress("phase_start", {
131+
"phase": "storyline",
132+
"description": f"Executing {len(remaining)} remaining storyline events"
133+
})
134+
for idx in remaining:
135+
self._execute_single_storyline_event(idx)
136+
self._storyline_executed.add(idx)
137+
self._barrier_flush_all_emitters()
138+
self._report_progress("phase_end", {"phase": "storyline"})
133139
finally:
134140
# Phase 4: Finalize and close emitters (always, even on error)
135141
self._report_progress("phase_start", {"phase": "finalize", "description": "Finalizing generation"})
@@ -255,6 +261,20 @@ def _initialize(self) -> None:
255261
self._system_pids: dict[str, dict[str, int]] = {} # hostname -> {role: pid}
256262
self._seed_system_process_trees()
257263

264+
# Phase 6.3: Pre-parse storyline event times for interleaved generation
265+
self._storyline_by_hour: dict[int, list] = {} # hour_epoch -> list of (time, event_idx)
266+
if self.scenario.storyline:
267+
for idx, event in enumerate(self.scenario.storyline):
268+
event_time = self._parse_storyline_time(event.time)
269+
hour_key = int(event_time.replace(minute=0, second=0, microsecond=0).timestamp())
270+
self._storyline_by_hour.setdefault(hour_key, []).append((event_time, idx))
271+
# Sort each hour's events by time
272+
for key in self._storyline_by_hour:
273+
self._storyline_by_hour[key].sort()
274+
logger.info(f"Pre-parsed {len(self.scenario.storyline)} storyline events across {len(self._storyline_by_hour)} hours")
275+
276+
self._storyline_executed: set[int] = set()
277+
258278
logger.info("Initialization complete")
259279

260280
def _generate_baseline(self) -> None:
@@ -326,6 +346,13 @@ def _generate_baseline(self) -> None:
326346
# Phase 5.4: Generate system traffic (DNS, NTP, scheduled tasks)
327347
self._generate_system_traffic(current_hour)
328348

349+
# Phase 6.3: Interleave storyline events into this hour
350+
hour_key = int(current_hour.timestamp())
351+
for event_time, event_idx in self._storyline_by_hour.get(hour_key, []):
352+
if event_idx not in self._storyline_executed:
353+
self._execute_single_storyline_event(event_idx)
354+
self._storyline_executed.add(event_idx)
355+
329356
# Phase 5.2: Terminate stale processes
330357
self._terminate_stale_processes(current_hour)
331358

@@ -854,6 +881,39 @@ def _execute_storyline(self) -> None:
854881
# Barrier flush after each storyline event (ensures event written before proceeding)
855882
self._barrier_flush_all_emitters()
856883

884+
def _execute_single_storyline_event(self, event_idx: int) -> None:
885+
"""Execute a single storyline event by index (used for interleaved generation)."""
886+
storyline_event = self.scenario.storyline[event_idx]
887+
event_num = event_idx + 1
888+
889+
# Parse event time with jitter
890+
event_time = self._parse_storyline_time(storyline_event.time)
891+
jitter_rng = random.Random(hash(f"jitter_{event_num}_{self.scenario.name}"))
892+
jitter = timedelta(
893+
seconds=jitter_rng.uniform(-30, 30),
894+
microseconds=jitter_rng.randint(0, 999999),
895+
)
896+
event_time = event_time + jitter
897+
898+
actor = self._find_actor(storyline_event.actor)
899+
system = self._find_system(storyline_event.system)
900+
if not actor or not system:
901+
return
902+
903+
logger.info(f"Executing interleaved storyline event: {storyline_event.actor} on {storyline_event.system} at {event_time}")
904+
905+
event_types = self._match_activity_to_events(storyline_event.activity)
906+
self.state_manager.set_current_time(event_time)
907+
908+
for event_type in event_types:
909+
malicious_event = self._execute_storyline_event(
910+
actor=actor, system=system, time=event_time,
911+
event_type=event_type, activity=storyline_event.activity,
912+
details=storyline_event.details,
913+
)
914+
if malicious_event:
915+
self.malicious_events.append(malicious_event)
916+
857917
def _parse_storyline_time(self, time_str: str) -> datetime:
858918
"""Parse storyline event time to absolute datetime.
859919
@@ -1773,8 +1833,9 @@ def _generate_system_traffic(self, current_hour: datetime) -> None:
17731833
'message': action,
17741834
})
17751835
elif source_roll < 0.80:
1776-
# sshd — disconnect/keepalive messages
1777-
ip = f'10.0.{rng.randint(0,10)}.{rng.randint(1,254)}'
1836+
# sshd — disconnect/keepalive messages (use scenario system IPs)
1837+
other_ips = [s.ip for s in self.scenario.environment.systems if s.ip != system.ip]
1838+
ip = rng.choice(other_ips) if other_ips else system.ip
17781839
port = rng.randint(49152, 65535)
17791840
msgs = [
17801841
f'Received disconnect from {ip} port {port}:11: disconnected by user',

tests/unit/test_activity.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,16 +437,18 @@ def test_execute_baseline_activity_connection_git(self, activity_gen, test_user,
437437
assert event_data['id.resp_h'] in EXTERNAL_IPS['connection_git']
438438

439439
def test_execute_baseline_activity_connection_db(self, activity_gen, test_user, test_system, state_manager, mock_emitters):
440-
"""execute_baseline_activity should handle database connection."""
440+
"""execute_baseline_activity should handle database connection with detected servers."""
441441
timestamp = datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
442442
state_manager.set_current_time(timestamp)
443443

444+
# Set up scenario-detected DB servers (required for DB connections)
445+
activity_gen._db_servers = [{'ip': '10.10.100.20', 'port': 1433, 'service': 'mssql'}]
444446
activity_gen.execute_baseline_activity(test_user, test_system, timestamp, 'connection_db')
445447

446448
event_data = mock_emitters['zeek_conn'].emit_event.call_args[0][0]
447-
assert event_data['service'] == 'mysql'
448-
assert event_data['id.resp_p'] == 3306
449-
assert event_data['id.resp_h'] in EXTERNAL_IPS['connection_db']
449+
assert event_data['service'] == 'mssql'
450+
assert event_data['id.resp_p'] == 1433
451+
assert event_data['id.resp_h'] == '10.10.100.20'
450452

451453
def test_execute_baseline_activity_connection_excludes_src_ip(self, activity_gen, test_user, state_manager, mock_emitters):
452454
"""execute_baseline_activity should not connect system to itself."""

0 commit comments

Comments
 (0)