Skip to content

Commit 08e73d6

Browse files
DavidJBiancoclaude
andcommitted
Fix Zeek format issues: timestamp precision and network visibility
Addresses two critical issues identified in generated Zeek conn.log output: 1. Timestamp precision: Format timestamps to exactly 6 decimal places (microseconds) to match real Zeek logs. Previously, trailing zeros were lost during JSON serialization, causing inconsistent precision. 2. Network connection validation: Add comprehensive validation to prevent generating connections that network sensors cannot observe: - Same source/destination IP (traffic never traverses network) - Localhost addresses (127.0.0.0/8) - Link-local addresses (169.254.0.0/16) - Multicast/reserved addresses (224.0.0.0/4) Changes: - src/log_generator/generation/emitters/zeek.py: Format timestamp as string with f"{ts.timestamp():.6f}" before JSON serialization - src/log_generator/generation/activity.py: Add _is_invalid_network_connection() helper, validate in generate_connection(), filter destinations in execute_baseline_activity(), update DB server IPs to separate subnet - src/log_generator/generation/engine.py: Add destination validation in storyline execution - tests/unit/test_zeek_format_accuracy.py: Add test_timestamp_precision() to prevent regression (issue occurred multiple times) - TODO.md: Add Network Visibility Architecture to Phase 2.5 (MVP feature, full network topology modeling for realistic sensor placement) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 93b689d commit 08e73d6

5 files changed

Lines changed: 189 additions & 11 deletions

File tree

TODO.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,27 @@
176176
- [ ] Timezone handling in utilities
177177
- [ ] Test: Timezone conversions (UTC internal → system timezone output)
178178

179-
### 2.5 Persona-Based Activity Generation
179+
### 2.5 Network Visibility Architecture
180+
181+
- [ ] Model network topology and sensor placement in scenario schema
182+
- [ ] Define network segments (CIDR ranges) in environment
183+
- [ ] Specify sensor placement (which segments are monitored)
184+
- [ ] Define sensor capabilities (direction: inbound/outbound/bidirectional)
185+
- [ ] Implement traffic visibility calculation
186+
- [ ] Determine if connection would traverse monitored network points
187+
- [ ] Validate connections based on network topology
188+
- [ ] Skip connections that wouldn't be visible to configured sensors
189+
- [ ] Update `generation/activity.py` connection logic
190+
- [ ] Check if connection would be observable by network sensors
191+
- [ ] Consider source/destination network segments
192+
- [ ] Apply sensor placement rules
193+
- [ ] Test: Intra-segment traffic not visible unless sensor on segment
194+
- [ ] Test: Cross-segment traffic visible if sensor monitors either segment
195+
- [ ] Test: External traffic visible if sensor monitors perimeter
196+
197+
**Note:** Phase 1 implemented basic IP validation (no localhost, no same src/dst, no link-local/multicast). This phase adds full network topology modeling for realistic sensor placement.
198+
199+
### 2.6 Persona-Based Activity Generation
180200

181201
- [ ] `generation/persona.py` - Persona activity pattern execution
182202
- [ ] Load persona definitions (from scenario)
@@ -186,7 +206,7 @@
186206
- [ ] Test: Persona activity patterns match definitions
187207
- [ ] Test: Temporal distributions look realistic
188208

189-
### 2.6 LLM Integration (Bedrock Client)
209+
### 2.7 LLM Integration (Bedrock Client)
190210

191211
- [ ] `llm/client.py` - BedrockClient implementation
192212
- [ ] Chat and complete methods
@@ -200,14 +220,14 @@
200220
- [ ] Test: Retry logic with mocked failures
201221
- [ ] Test: Non-retryable errors fail immediately
202222

203-
### 2.7 Medium Dataset Support
223+
### 2.8 Medium Dataset Support
204224

205225
- [ ] Optimize StateManager for 100K+ events
206226
- [ ] Memory profiling to ensure <2GB usage
207227
- [ ] Test: 8-hour, 100-user scenario (target: ~100K events, <10 min generation time)
208228
- [ ] Test: Memory usage stays under 2GB
209229

210-
### 2.8 Phase 2 Testing & Scenarios
230+
### 2.9 Phase 2 Testing & Scenarios
211231

212232
- [ ] Integration test: 8-hour scenario with all 5 formats
213233
- [ ] Create test fixture: `fixtures/scenarios/medium-dataset.yaml` (100 users, 8 hours)

src/log_generator/generation/activity.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,57 @@
7575
'104.26.7.33', # gitlab.com
7676
],
7777
'connection_db': [
78-
'10.0.10.50', # Internal DB server
79-
'192.168.100.25', # Internal DB server
78+
# For internal DB connections, use dedicated DB server IPs in separate subnet
79+
# This prevents matching workstation IPs (10.0.10.x) which would create
80+
# same source/destination connections that network sensors can't observe
81+
'10.0.100.10', # Internal DB server (separate subnet)
82+
'10.0.100.11', # Internal DB replica
8083
],
8184
}
8285

8386

87+
def _is_invalid_network_connection(src_ip: str, dst_ip: str) -> tuple[bool, str]:
88+
"""Validate that a network connection would be observable by network sensors.
89+
90+
Network-based data sources like Zeek can only observe traffic that actually
91+
traverses the network. This function checks for connections that would never
92+
be visible to network sensors.
93+
94+
Args:
95+
src_ip: Source IP address
96+
dst_ip: Destination IP address
97+
98+
Returns:
99+
Tuple of (is_invalid, reason). If is_invalid=True, connection should not be generated.
100+
"""
101+
# Check if source and destination are the same
102+
if src_ip == dst_ip:
103+
return True, f"Source and destination are identical ({src_ip})"
104+
105+
# Check for localhost addresses (127.0.0.0/8)
106+
# Network sensors cannot observe localhost traffic
107+
if src_ip.startswith('127.') or dst_ip.startswith('127.'):
108+
return True, f"Connection involves localhost address (src={src_ip}, dst={dst_ip})"
109+
110+
# Check for link-local addresses (169.254.0.0/16)
111+
# These are auto-configured and typically not routed
112+
if src_ip.startswith('169.254.') or dst_ip.startswith('169.254.'):
113+
return True, f"Connection involves link-local address (src={src_ip}, dst={dst_ip})"
114+
115+
# Check for multicast addresses (224.0.0.0/4)
116+
# These require special handling and shouldn't appear in typical conn logs
117+
try:
118+
src_first_octet = int(src_ip.split('.')[0])
119+
dst_first_octet = int(dst_ip.split('.')[0])
120+
if src_first_octet >= 224 or dst_first_octet >= 224:
121+
return True, f"Connection involves multicast/reserved address (src={src_ip}, dst={dst_ip})"
122+
except (ValueError, IndexError):
123+
# Invalid IP format - let it pass, will be caught by other validation
124+
pass
125+
126+
return False, ""
127+
128+
84129
class ActivityGenerator:
85130
"""Generates specific activity events using StateManager and emitters.
86131
@@ -306,6 +351,15 @@ def generate_connection(
306351
Returns:
307352
Zeek UID (18-character string)
308353
"""
354+
# Validate connection would be observable by network sensors
355+
is_invalid, reason = _is_invalid_network_connection(src_ip, dst_ip)
356+
if is_invalid:
357+
logger.warning(
358+
f"Skipping invalid network connection: {src_ip} -> {dst_ip}. "
359+
f"Reason: {reason}. Network sensors would not observe this traffic."
360+
)
361+
return "" # Return empty UID to indicate skipped connection
362+
309363
src_port = random.randint(49152, 65535) # Ephemeral port
310364

311365
# Create connection in StateManager
@@ -411,8 +465,21 @@ def execute_baseline_activity(
411465

412466
# Connection activities
413467
elif activity_type in EXTERNAL_IPS:
414-
# Choose random destination IP
415-
dst_ip = random.choice(EXTERNAL_IPS[activity_type])
468+
# Choose random destination IP (exclude source system's IP)
469+
available_destinations = [
470+
ip for ip in EXTERNAL_IPS[activity_type]
471+
if ip != system.ip
472+
]
473+
474+
if not available_destinations:
475+
# No valid destinations (all IPs match source)
476+
logger.debug(
477+
f"Skipping {activity_type} for {system.hostname}: "
478+
f"no valid destination IPs (all match source {system.ip})"
479+
)
480+
return
481+
482+
dst_ip = random.choice(available_destinations)
416483

417484
# Set service and port based on activity type
418485
if activity_type == 'connection_web':

src/log_generator/generation/emitters/zeek.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,13 @@ def _render_event(self, event_data: dict[str, Any]) -> str:
5959
ts = event_data["ts"]
6060
if isinstance(ts, datetime):
6161
# Convert to epoch with microseconds
62-
# NOTE: Ensure datetime objects include microseconds for proper precision
63-
event_data["ts"] = ts.timestamp()
62+
# Format to exactly 6 decimal places to match Zeek format
63+
# This prevents loss of trailing zeros during JSON serialization
64+
event_data["ts"] = f"{ts.timestamp():.6f}"
6465
elif isinstance(ts, str):
6566
# If string, parse it
6667
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
67-
event_data["ts"] = dt.timestamp()
68+
event_data["ts"] = f"{dt.timestamp():.6f}"
6869

6970
# Handle dotted field names (id.orig_h, etc.)
7071
# Zeek template expects 'data' dict for dotted fields

src/log_generator/generation/engine.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,14 @@ def _execute_storyline_event(
524524
dst_port = details.get('dst_port', 443)
525525
service = details.get('service', 'https')
526526

527+
# Validate destination is different from source
528+
if dst_ip == system.ip:
529+
logger.warning(
530+
f"Skipping storyline connection: dst_ip {dst_ip} matches system IP {system.ip}. "
531+
f"Adjusting to external IP."
532+
)
533+
dst_ip = '198.51.100.10' # Force to external IP
534+
527535
uid = self.activity_generator.generate_connection(
528536
src_ip=system.ip,
529537
dst_ip=dst_ip,

tests/unit/test_zeek_format_accuracy.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,85 @@ def test_field_types(self):
127127
assert expected_type in (str, int, float, bool), (
128128
f"Field '{field}' has invalid type: {expected_type}"
129129
)
130+
131+
def test_timestamp_precision(self):
132+
"""Verify Zeek timestamps have exactly 6 decimal places (microseconds).
133+
134+
This test ensures timestamps maintain microsecond precision and don't
135+
lose trailing zeros when serialized to JSON. Real Zeek logs always use
136+
exactly 6 decimal places for the epoch timestamp.
137+
"""
138+
from datetime import datetime
139+
from log_generator.generation.emitters.zeek import ZeekEmitter
140+
from log_generator.formats import load_format
141+
from pathlib import Path
142+
import tempfile
143+
import json
144+
145+
# Create emitter with temporary output file
146+
format_def = load_format('zeek_conn')
147+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
148+
output_file = Path(f.name)
149+
150+
try:
151+
emitter = ZeekEmitter(format_def, output_file)
152+
153+
# Create test event with specific microseconds
154+
test_time = datetime(2024, 1, 15, 10, 30, 45, 123456) # 123456 microseconds
155+
156+
event_data = {
157+
'ts': test_time,
158+
'uid': 'C1234567890ABCDE',
159+
'id.orig_h': '10.0.10.5',
160+
'id.orig_p': 50000,
161+
'id.resp_h': '93.184.216.34',
162+
'id.resp_p': 443,
163+
'proto': 'tcp',
164+
'service': 'https',
165+
'duration': 1.5,
166+
'orig_bytes': 1000,
167+
'resp_bytes': 5000,
168+
'conn_state': 'SF',
169+
'local_orig': True,
170+
'local_resp': False,
171+
'missed_bytes': 0,
172+
'history': 'ShADadfF',
173+
'orig_pkts': 10,
174+
'orig_ip_bytes': 1400,
175+
'resp_pkts': 12,
176+
'resp_ip_bytes': 5480,
177+
'ip_proto': 6,
178+
}
179+
180+
emitter.emit_event(event_data)
181+
emitter.close()
182+
183+
# Read the generated JSON
184+
with open(output_file) as f:
185+
line = f.readline()
186+
generated = json.loads(line)
187+
188+
# Verify timestamp has exactly 6 decimal places
189+
ts_str = str(generated['ts'])
190+
191+
# Split at decimal point
192+
assert '.' in ts_str, f"Timestamp missing decimal point: {ts_str}"
193+
integer_part, decimal_part = ts_str.split('.')
194+
195+
# Check exactly 6 decimal places
196+
assert len(decimal_part) == 6, (
197+
f"Timestamp must have exactly 6 decimal places (microseconds), "
198+
f"got {len(decimal_part)}: {ts_str}"
199+
)
200+
201+
# Verify it matches expected value
202+
expected_ts = test_time.timestamp()
203+
actual_ts = float(generated['ts'])
204+
assert abs(actual_ts - expected_ts) < 0.000001, (
205+
f"Timestamp value mismatch: expected {expected_ts}, got {actual_ts}"
206+
)
207+
208+
finally:
209+
# Clean up
210+
if output_file.exists():
211+
output_file.unlink()

0 commit comments

Comments
 (0)