Skip to content

Commit 0aa3528

Browse files
DavidJBiancoclaude
andcommitted
Add system.roles, segment.exposure, proxy_access format (Phases 1-3)
Model additions: - System.roles: list field for system functions (forward_proxy, web_server, dns_server, mail_server, etc.) - NetworkSegment.exposure: internal|external|both for client IP mix - Migrated web_server detection from service-name matching to roles Web access fix: - Client IPs now based on segment exposure: internal segments get only internal clients, external/both segments get mixed traffic with ~60% external IPs for internet-facing servers Proxy access format (infrastructure only, no traffic generation yet): - New format definition: W3C Extended Log Format with cache result, CONNECT support, full URL, authenticated username - New emitter: ProxyEmitter (HostMultiplexEmitter, per-proxy routing) - New eval parser: ProxyAccessParser for W3C Extended format - Registered in engine, validation, and emitter __init__ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e56001b commit 0aa3528

9 files changed

Lines changed: 275 additions & 10 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Parser for HTTP/HTTPS forward proxy access logs (W3C Extended format)."""
2+
3+
import re
4+
from collections.abc import Iterator
5+
from datetime import datetime, timezone
6+
from pathlib import Path
7+
8+
from . import LogParser, ParsedRecord, register_parser
9+
10+
# W3C Extended format:
11+
# date time c-ip cs-username cs-method cs-uri sc-status sc-bytes cs-bytes time-taken "cs(User-Agent)" cs-host rs(Content-Type) s-cache-result
12+
_PROXY_PATTERN = re.compile(
13+
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+' # timestamp
14+
r'(\S+)\s+' # client_ip
15+
r'(\S+)\s+' # username
16+
r'(\S+)\s+' # method
17+
r'(\S+)\s+' # url
18+
r'(\d+)\s+' # status_code
19+
r'(\d+)\s+' # sc_bytes
20+
r'(\d+)\s+' # cs_bytes
21+
r'(\d+)\s+' # time_taken
22+
r'"([^"]*)"\s+' # user_agent
23+
r'(\S+)\s+' # host
24+
r'(\S+)\s+' # content_type
25+
r'(\S+)' # cache_result
26+
)
27+
28+
29+
@register_parser
30+
class ProxyAccessParser(LogParser):
31+
format_name = "proxy_access"
32+
33+
def can_parse(self, path: Path) -> bool:
34+
return path.name == "proxy_access.log"
35+
36+
def parse_file(self, path: Path) -> Iterator[ParsedRecord]:
37+
with open(path) as f:
38+
for i, line in enumerate(f, 1):
39+
line = line.strip()
40+
if not line or line.startswith('#'):
41+
continue
42+
yield self._parse_line(line, i)
43+
44+
def _parse_line(self, line: str, line_number: int) -> ParsedRecord:
45+
fields: dict = {}
46+
errors: list[str] = []
47+
timestamp = None
48+
49+
match = _PROXY_PATTERN.match(line)
50+
if not match:
51+
errors.append(f"Line does not match proxy access format")
52+
return ParsedRecord(
53+
source_format=self.format_name,
54+
raw=line, fields=fields,
55+
timestamp=None, parse_errors=errors,
56+
line_number=line_number,
57+
)
58+
59+
ts_str = match.group(1)
60+
try:
61+
timestamp = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
62+
except ValueError:
63+
errors.append(f"Invalid timestamp: {ts_str}")
64+
65+
fields["timestamp"] = ts_str
66+
fields["client_ip"] = match.group(2)
67+
username = match.group(3)
68+
fields["username"] = username if username != '-' else None
69+
fields["method"] = match.group(4)
70+
fields["url"] = match.group(5)
71+
fields["status_code"] = int(match.group(6))
72+
fields["sc_bytes"] = int(match.group(7))
73+
fields["cs_bytes"] = int(match.group(8))
74+
fields["time_taken"] = int(match.group(9))
75+
ua = match.group(10)
76+
fields["user_agent"] = ua if ua != '-' else None
77+
fields["host"] = match.group(11)
78+
ct = match.group(12)
79+
fields["content_type"] = ct if ct != '-' else None
80+
cr = match.group(13)
81+
fields["cache_result"] = cr if cr != '-' else None
82+
83+
return ParsedRecord(
84+
source_format=self.format_name,
85+
raw=line, fields=fields,
86+
timestamp=timestamp, parse_errors=errors,
87+
line_number=line_number,
88+
)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: proxy_access
2+
version: "1.0"
3+
description: "HTTP/HTTPS forward proxy access log (W3C Extended Log Format)"
4+
category: host
5+
6+
fields:
7+
- name: timestamp
8+
type: timestamp
9+
required: true
10+
description: "Request timestamp"
11+
12+
- name: client_ip
13+
type: ip_address
14+
required: true
15+
description: "Original client IP address (internal user)"
16+
17+
- name: username
18+
type: string
19+
required: false
20+
description: "Authenticated proxy username"
21+
22+
- name: method
23+
type: string
24+
required: true
25+
description: "HTTP method (GET, POST, CONNECT, HEAD, PUT, DELETE)"
26+
27+
- name: url
28+
type: string
29+
required: true
30+
description: "Full destination URL or host:port for CONNECT"
31+
32+
- name: protocol
33+
type: string
34+
required: true
35+
description: "HTTP protocol version"
36+
37+
- name: status_code
38+
type: integer
39+
required: true
40+
description: "HTTP response status code"
41+
constraints:
42+
min_value: 100
43+
max_value: 599
44+
45+
- name: sc_bytes
46+
type: integer
47+
required: false
48+
description: "Server to client bytes"
49+
50+
- name: cs_bytes
51+
type: integer
52+
required: false
53+
description: "Client to server bytes"
54+
55+
- name: time_taken
56+
type: integer
57+
required: false
58+
description: "Request duration in milliseconds"
59+
60+
- name: user_agent
61+
type: string
62+
required: false
63+
description: "Client User-Agent string"
64+
65+
- name: host
66+
type: string
67+
required: true
68+
description: "Destination hostname"
69+
70+
- name: content_type
71+
type: string
72+
required: false
73+
description: "Response Content-Type"
74+
75+
- name: cache_result
76+
type: string
77+
required: false
78+
description: "Cache result (HIT, MISS, NONE, DENIED)"
79+
80+
output:
81+
format: text
82+
file_extension: ".log"
83+
encoding: utf-8
84+
header_template: |
85+
#Software: EvidenceForge Proxy Log
86+
#Version: 1.0
87+
#Fields: date time c-ip cs-username cs-method cs-uri sc-status sc-bytes cs-bytes time-taken cs(User-Agent) cs-host rs(Content-Type) s-cache-result
88+
template: |
89+
{{ timestamp.strftime('%Y-%m-%d %H:%M:%S') }} {{ client_ip }} {{ username if username else '-' }} {{ method }} {{ url }} {{ status_code }} {{ sc_bytes if sc_bytes else 0 }} {{ cs_bytes if cs_bytes else 0 }} {{ time_taken if time_taken else 0 }} "{{ user_agent if user_agent else '-' }}" {{ host }} {{ content_type if content_type else '-' }} {{ cache_result if cache_result else '-' }}

src/evidenceforge/generation/emitters/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from evidenceforge.generation.emitters.ecar import EcarEmitter
2020
from evidenceforge.generation.emitters.syslog import SyslogEmitter
2121
from evidenceforge.generation.emitters.bash_history import BashHistoryEmitter
22+
from evidenceforge.generation.emitters.proxy import ProxyEmitter
2223
from evidenceforge.generation.emitters.snort import SnortEmitter
2324
from evidenceforge.generation.emitters.sysmon import SysmonEventEmitter
2425
from evidenceforge.generation.emitters.web import WebEmitter
@@ -43,6 +44,7 @@
4344
"EcarEmitter",
4445
"SyslogEmitter",
4546
"BashHistoryEmitter",
47+
"ProxyEmitter",
4648
"SnortEmitter",
4749
"SysmonEventEmitter",
4850
"WebEmitter",
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""HTTP/HTTPS forward proxy access log emitter (W3C Extended format)."""
2+
3+
from typing import Any
4+
5+
from evidenceforge.generation.emitters.host_base import HostMultiplexEmitter
6+
7+
8+
class ProxyEmitter(HostMultiplexEmitter):
9+
"""Emitter for forward proxy access logs (W3C Extended Log Format).
10+
11+
Per-host FQDN directory routing: each proxy server gets its own access log.
12+
"""
13+
14+
_log_filename = "proxy_access.log"
15+
_supported_types: set[str] = set() # raw-only via generate_raw()
16+
17+
def _dispatch(self, event_data: dict[str, Any]) -> None:
18+
"""Route proxy access event to per-host file."""
19+
rendered = self._render_event(event_data)
20+
host_fqdn = event_data.pop('_host_fqdn', '')
21+
self.emit_to_host(rendered, host_fqdn)
22+
23+
def _render_event(self, event_data: dict[str, Any]) -> str:
24+
"""Render proxy access log entry in W3C Extended format."""
25+
context = {
26+
'timestamp': event_data.get('timestamp'),
27+
'client_ip': event_data.get('client_ip'),
28+
'username': event_data.get('username'),
29+
'method': event_data.get('method'),
30+
'url': event_data.get('url'),
31+
'status_code': event_data.get('status_code'),
32+
'sc_bytes': event_data.get('sc_bytes'),
33+
'cs_bytes': event_data.get('cs_bytes'),
34+
'time_taken': event_data.get('time_taken'),
35+
'user_agent': event_data.get('user_agent'),
36+
'host': event_data.get('host'),
37+
'content_type': event_data.get('content_type'),
38+
'cache_result': event_data.get('cache_result'),
39+
}
40+
rendered = self._template.render(**context)
41+
return rendered.strip()

src/evidenceforge/generation/engine.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
EcarEmitter,
3434
SyslogEmitter,
3535
BashHistoryEmitter,
36+
ProxyEmitter,
3637
SnortEmitter,
3738
SysmonEventEmitter,
3839
WebEmitter,
@@ -211,6 +212,7 @@ def _initialize(self) -> None:
211212
'bash_history': BashHistoryEmitter,
212213
'snort_alert': SnortEmitter,
213214
'web_access': WebEmitter,
215+
'proxy_access': ProxyEmitter,
214216
}
215217

216218
# Build per-format sensor hostname mapping (expand group names)
@@ -224,7 +226,7 @@ def _initialize(self) -> None:
224226
_ZEEK_FORMATS = {k for k in emitter_classes if k.startswith("zeek_")}
225227
# Network sensor formats get per-sensor dirs; host-based formats get per-host FQDN dirs
226228
_SENSOR_FORMATS = _ZEEK_FORMATS | {'snort_alert'}
227-
_HOST_FORMATS = {'windows_event_security', 'windows_event_sysmon', 'ecar', 'syslog', 'bash_history', 'web_access'}
229+
_HOST_FORMATS = {'windows_event_security', 'windows_event_sysmon', 'ecar', 'syslog', 'bash_history', 'web_access', 'proxy_access'}
228230

229231
for format_name in sorted(formats_to_generate):
230232
if format_name not in emitter_classes:
@@ -1279,6 +1281,36 @@ def _execute_typed_event(
12791281

12801282
return malicious_event
12811283

1284+
def _get_system_exposure(self, system) -> str:
1285+
"""Get the network exposure for a system based on its segment.
1286+
1287+
Returns 'internal', 'external', or 'both'. Defaults to 'both' if
1288+
no network config exists (backward compat).
1289+
"""
1290+
if not self.scenario.environment.network:
1291+
return 'both'
1292+
import ipaddress as _ipa
1293+
sys_ip = _ipa.ip_address(system.ip)
1294+
for seg in self.scenario.environment.network.segments:
1295+
net = _ipa.ip_network(seg.cidr, strict=False)
1296+
if sys_ip in net:
1297+
return seg.exposure
1298+
return 'internal'
1299+
1300+
@staticmethod
1301+
def _generate_external_client_ip(rng) -> str:
1302+
"""Generate a random external (non-RFC1918) IP for web server clients."""
1303+
while True:
1304+
ip = f'{rng.randint(1, 223)}.{rng.randint(0, 255)}.{rng.randint(0, 255)}.{rng.randint(1, 254)}'
1305+
first = int(ip.split('.')[0])
1306+
if first == 10 or first == 127:
1307+
continue
1308+
if ip.startswith('172.') and 16 <= int(ip.split('.')[1]) <= 31:
1309+
continue
1310+
if ip.startswith('192.168.'):
1311+
continue
1312+
return ip
1313+
12821314
def _make_domain_sid(self, rid: int | None = None) -> str:
12831315
"""Generate a SID using the scenario's domain SID prefix."""
12841316
for sid in self.activity_generator.sid_registry.values():
@@ -2554,7 +2586,7 @@ def _generate_system_traffic(self, current_hour: datetime) -> None:
25542586
},
25552587
)
25562588

2557-
# Web access logs: 10-30 requests per hour on systems with web services
2589+
# Web access logs: 10-30 requests per hour on systems with web_server role
25582590
if 'web_access' in self.emitters:
25592591
_WEB_PATHS = [
25602592
('/', 'GET', 200), ('/index.html', 'GET', 200),
@@ -2572,15 +2604,26 @@ def _generate_system_traffic(self, current_hour: datetime) -> None:
25722604
'python-requests/2.31.0',
25732605
]
25742606
for sys_obj in systems:
2575-
if not any(p in svc.lower() for svc in (sys_obj.services or []) for p in ('http', 'iis', 'nginx', 'apache', 'web')):
2607+
if 'web_server' not in (sys_obj.roles or []):
25762608
continue
25772609
num_reqs = rng.randint(10, 30)
2578-
other_ips = [s.ip for s in systems if s.ip != sys_obj.ip]
2610+
2611+
# Determine client IP pool based on segment exposure
2612+
internal_ips = [s.ip for s in systems if s.ip != sys_obj.ip]
2613+
exposure = self._get_system_exposure(sys_obj)
25792614
for _ in range(num_reqs):
25802615
offset = rng.randint(0, 3599)
25812616
ts = current_hour + timedelta(seconds=offset)
25822617
path, method, status = rng.choice(_WEB_PATHS)
2583-
client_ip = rng.choice(other_ips) if other_ips else '10.0.0.1'
2618+
if exposure == 'external':
2619+
client_ip = self._generate_external_client_ip(rng)
2620+
elif exposure == 'both':
2621+
if rng.random() < 0.6:
2622+
client_ip = self._generate_external_client_ip(rng)
2623+
else:
2624+
client_ip = rng.choice(internal_ips) if internal_ips else '10.0.0.1'
2625+
else: # internal
2626+
client_ip = rng.choice(internal_ips) if internal_ips else '10.0.0.1'
25842627
self.activity_generator.generate_raw(
25852628
time=ts, target_format='web_access', system=sys_obj, fields={
25862629
'timestamp': ts, 'client_ip': client_ip,

src/evidenceforge/models/scenario.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ class System(BaseModel):
118118
type: str = Field(..., pattern="^(workstation|server|domain_controller)$")
119119
assigned_user: str | None = None
120120
services: list[str] = Field(default_factory=list)
121+
roles: list[str] = Field(default_factory=list, description="System roles: forward_proxy, web_server, dns_server, mail_server, etc.")
121122

122123
@field_validator("ip")
123124
@classmethod
@@ -428,6 +429,7 @@ class NetworkSegment(BaseModel):
428429
cidr: str
429430
description: str = ""
430431
systems: list[str] = Field(default_factory=list)
432+
exposure: Literal["internal", "external", "both"] = "internal"
431433

432434
@field_validator("cidr")
433435
@classmethod

src/evidenceforge/validation/schema.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ def _validate_event_sequences(self) -> None:
284284
'zeek_conn', 'zeek_dns', 'zeek_http', 'zeek_ssl', 'zeek_files',
285285
'zeek_dhcp', 'zeek_ntp', 'zeek_weird', 'zeek_x509',
286286
'zeek_ocsp', 'zeek_pe', 'zeek_packet_filter', 'zeek_reporter',
287-
'ecar', 'syslog', 'bash_history', 'snort_alert', 'web_access',
287+
'ecar', 'syslog', 'bash_history', 'snort_alert', 'web_access', 'proxy_access',
288288
}
289289

290290
for idx, event in enumerate(self.scenario.storyline):
@@ -399,7 +399,7 @@ def _validate_output_formats(self) -> None:
399399

400400
known_output_formats = (
401401
set(FORMAT_GROUPS.keys())
402-
| {"ecar", "syslog", "bash_history", "snort_alert", "web_access"}
402+
| {"ecar", "syslog", "bash_history", "snort_alert", "web_access", "proxy_access"}
403403
)
404404
_group_members = {}
405405
for group, members in FORMAT_GROUPS.items():

tests/integration/test_format_definitions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,8 @@ def test_load_all_formats(self):
401401
"""Test that all format definitions load successfully."""
402402
formats = load_all_formats()
403403

404-
# Phase 1 (2) + Phase 2.2 (5) + Phase 5.3 (1) + Zeek expansion (11) = 19 formats
405-
assert len(formats) == 20
404+
# Phase 1 (2) + Phase 2.2 (5) + Phase 5.3 (1) + Zeek expansion (11) + proxy_access (1) = 21
405+
assert len(formats) == 21
406406
assert "windows_event_security" in formats
407407
assert "zeek_conn" in formats
408408
assert "ecar" in formats

tests/unit/test_zeek_format_accuracy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,4 +560,4 @@ def test_all_formats_load(self):
560560
"""All format YAMLs load successfully."""
561561
from evidenceforge.formats import load_all_formats
562562
formats = load_all_formats()
563-
assert len(formats) == 20
563+
assert len(formats) == 21

0 commit comments

Comments
 (0)