| description | Scenario Schema Reference |
|---|
This document describes the EvidenceForge scenario file schema, including Phase 2.4 enhanced fields.
Scenario files are YAML documents that define the environment, users, systems, personas, and storyline for log generation. All fields marked "Phase 2.4+" are optional and backward compatible with Phase 1 scenarios.
version: "1.0"
name: scenario-name # Alphanumeric, dash, underscore
description: |
Multi-line scenario description
environment: ...
personas: [...] # Optional
time_window: ...
baseline_activity: ...
logon_grace_period: "30m" # Optional (default: "30m") — suppresses "no prior logon" warnings within this duration of time_window.start
observation_profile: complete # Optional (default: complete) — named source-observation profile
storyline: [...] # Optional
red_herrings: [...] # Optional: suspicious-but-benign events for analyst training
output: ...environment:
description: "Corporate office network"
timezone:
default: "America/New_York"
systems: # Optional pattern-based overrides
"EU-*": "Europe/London"
"AP-*": "Asia/Tokyo"
users: [...]
systems: [...]
service_accounts: [...] # Optional: extra account names valid as storyline actors
stale_accounts: # Optional: inactive accounts that generate background noise
- username: former.employee
last_active: "2023-11-15"
reason: "Transferred to another office"
- username: svc_old_crm
last_active: "2024-01-02"
reason: "CRM system decommissioned"
groups: [...] # OptionalStale accounts generate multiple types of background evidence: failed network logons (~15%/hour), Kerberos pre-auth failures (4771, status 0x12) on DCs (~5%/hour), scheduled task failures (batch logon type 4, ~3%/hour), and service startup failures (type 5, first hour only). Remote Windows failed-auth attempts use data-driven auth realism profiles for 4625 field shape, DC-side 4771/4776 validation-path selection, and matching established/reset-after-payload network evidence when sensors can see the traffic. Each field:
username: Account name (must not collide with active users or service_accounts)last_active: ISO date when the account was last active (context only, not used by engine)reason: Why the account is stale (context only, for ground truth documentation)
All internal timestamps are stored in UTC. The timezone configuration controls output formatting.
- default: Applied to all systems unless overridden (default:
"UTC") - systems: Pattern-based overrides using fnmatch glob syntax (
*,?,[seq])- First matching pattern wins
- Unmatched hostnames use the default
Valid timezone names are any pytz timezone (e.g., America/New_York, Europe/London, Asia/Tokyo, UTC).
users:
- username: jsmith # Required: alphanumeric, dash, underscore
full_name: "Jane Smith" # Required
email: jane@example.com # Required
groups: ["developers"] # Optional
enabled: true # Optional (default: true)
persona: developer # Optional: reference to persona name
primary_system: WS-01 # Required: reference to system hostnameprimary_system is operationally important, not just descriptive. The compiled world model uses it to place the user's interactive activity, choose realistic remote-admin source hosts, and decide when server activity should be modeled as SSH/RDP/network access instead of a local console session.
systems:
- hostname: WS-01 # Required: RFC 1123 compliant
ip: "10.0.1.10" # Required: IPv4 or IPv6
os: "Windows 10" # Required
type: workstation # Required: workstation|server|domain_controller
assigned_user: jsmith # Optional: reference to username
services: ["IIS"] # Optional
roles: [web_server] # Optional: forward_proxy, web_server, dns_server, mail_serverroles and services materially affect realism. They feed the compiled world model that drives infrastructure discovery, proxy routing, legitimate lateral-movement patterns, and whether remote access should look like SSH, RDP, or generic network activity.
proxy:
mode: transparent # Optional: transparent|explicit (default: transparent)
listener_port: 8080 # Optional: explicit-mode proxy listener (default: 8080)environment.proxy controls how systems with roles: [forward_proxy] appear in network evidence:
transparentpreserves direct-looking client-to-origin Zeek/IDS traffic while still generating proxy access logs.explicitmodels PAC/browser-configured proxy behavior by replacing the logical client-to-origin connection with two concrete legs: client-to-proxy onlistener_port, then proxy-to-origin on the destination port. Sensor placement determines which leg each Zeek/IDS/firewall source sees. Denied proxy requests stop at the proxy and do not emit a proxy-to-origin leg.
If proxy_access is requested and environment.proxy is omitted, validation warns and defaults to transparent. If mode: explicit is set without listener_port, validation warns and defaults to 8080.
The roles field declares a system's function in the network. The engine uses roles to generate both outbound traffic (connections the host initiates) and inbound traffic (connections the host receives):
web_server— outbound: database queries, LDAP auth, API calls; inbound: HTTPS/HTTP from external clients and internal users. Human inbound traffic is generated as browsing sessions: top-level page views consume thewebtraffic-rate budget, and required assets/API calls fan out from each page load.database— outbound: replication, updates; inbound: SQL queries from web/app serversmail_server— outbound: SMTP relay, LDAP lookups; inbound: SMTP from internet, webmail from usersfile_server— outbound: Kerberos/LDAP auth; inbound: SMB file access from workstations. File-server roles also increase baseline SMB target selection beyond normal DC SYSVOL/GPO traffic.domain_controller— outbound: inter-DC replication; inbound: Kerberos/LDAP/DNS from all hostsforward_proxy— routes outbound HTTP/HTTPS traffic through this system; generates proxy access logs with CONNECT entries for HTTPS, cache hit/miss status, and full destination URLsdns_server— DNS resolution target
Inbound traffic is constrained by network topology: DMZ hosts receive substantial external traffic, while internal servers only receive connections from other internal systems. The firewall policy determines what gets permitted vs denied — denied connection attempts still produce firewall deny records and source-side sensor visibility.
For server and infrastructure hosts, pair roles with realistic services whenever possible. roles tell the engine what the host is for; services help the world model infer concrete protocols and destinations (for example, PostgreSQL vs MSSQL, web stack vs proxy stack, SSH-capable Linux admin targets, and so on).
Segments can declare their internet exposure via the exposure field:
network:
segments:
- name: workstations
cidr: "10.0.1.0/24"
exposure: internal # Only internal clients (default)
- name: dmz
cidr: "10.0.2.0/24"
exposure: both # Internal + external clientsValues: internal (default), external, both. Affects web server client IP generation — both and external segments produce a mix of internal and external client IPs in web access logs.
Sensors define monitoring infrastructure. Each sensor type produces different log formats:
network:
sensors:
- type: network # network | ids | firewall
name: core-tap
hostname: zeek01 # Output directory name (falls back to name)
monitoring_segments: [corporate_lan, server_vlan]
direction: bidirectional # bidirectional | inbound | outbound
placement: span # span mirrors segment traffic | tap observes uplink/boundary traffic
log_formats: [zeek] # Format groups or individual formatsspan sensors can see traffic where either endpoint belongs to a monitored segment, including same-segment traffic. tap sensors do not see same-segment traffic. When a TAP monitors multiple internal segments, internal cross-segment traffic is visible only if both endpoint segments are monitored; external/boundary traffic remains visible when either side is monitored.
Firewall sensors produce Cisco ASA syslog records for permitted and denied connections. They require explicit policy rules to determine what traffic is allowed vs denied.
- type: firewall
name: fw01
hostname: fw01
monitoring_segments: [workstations, servers, dmz]
placement: tap
direction: bidirectional
log_formats: [cisco_asa]
interfaces: # Map segment names to ASA interface names
workstations: inside
servers: inside
dmz: dmz
default_action: deny # deny (default) | permit
deny_ratio: 5.0 # Deny events per allow event in baseline (default: 5.0)
threat_detection_rate: 10 # Deny rate (drops/sec) triggering 733100 alerts (0=disabled)
nat_rules:
- type: dynamic_pat
src: [workstations, servers]
mapped_ip: 45.83.220.1
- type: static
real_ip: 172.16.0.5
mapped_ip: 45.83.220.5
policy: # Ordered rules — first match wins
- {src: external, dst: dmz, ports: [80, 443]}
- {src: workstations, dst: any}
- {src: servers, dst: external, ports: [80, 443, 53]}
- {src: servers, dst: servers}Policy rules (FirewallRule):
src/dst: segment name,"external"(IPs not in any segment), specific IP, CIDR notation, or"any"ports: list of port numbers, or empty list /"any"for all portsaction:"permit"(default) or"deny"
The public_cidrs field on NetworkConfig declares the org's public IP address blocks. External scan/probe traffic targets these ranges instead of internal IPs, and legitimate inbound connections use VIPs (static NAT mapped_ip values) as the wire-level destination.
network:
public_cidrs: ["45.83.220.0/28"] # Optional — auto-derived from VIPs if omitted
segments: [...]
sensors: [...]Auto-derivation: When public_cidrs is empty, VIPs from static NAT rules are grouped by /24 prefix to create scan target ranges. For example, VIPs 45.83.220.10 and 45.83.220.14 produce ["45.83.220.0/24"].
Inbound traffic flow: External clients connect to VIPs (public IPs). The NAT engine translates to real (internal) IPs per sensor — outside Zeek sees VIPs, inside Zeek sees real IPs, ASA shows both in Built/Teardown records.
- Rules are evaluated in order; first match wins (like real ACLs)
- Traffic not matching any rule is subject to
default_action
Interfaces: Map segment names to ASA interface names (e.g., inside, outside, dmz). IPs not in any mapped segment resolve to "outside".
Threat detection: The ASA emitter automatically tracks per-source-IP deny rates and fires 733100 alerts when both burst (default 10 drops/sec over 20s) and average (default 5 drops/sec over 60s) thresholds are exceeded. Set threat_detection_rate: 0 to disable.
NAT rules: Define Network Address Translation behavior for the firewall. Each rule in the nat_rules list supports:
type:dynamic_pat(many:1 with port translation) orstatic(1:1 IP mapping)src: segment name(s), IP, or CIDR. Accepts a string or list.mapped_ip: the post-NAT IP addressreal_ip: for static NAT, the specific internal IP being mapped
Dynamic PAT: all traffic from matching segments shares one external IP with port translation. Static NAT: bidirectional 1:1 mapping, enables inbound connections to DMZ servers via public IP. NAT only applies to permitted connections that cross segment boundaries; denied connections are not NATted.
When a system has the database role, the engine determines the DB protocol from services:
services: [postgresql]→ PostgreSQL on port 5432services: [mysql]orservices: [mariadb]→ MySQL on port 3306services: [mssql]orservices: [sqlserver]→ MSSQL on port 1433
When services is empty, the engine infers from OS: Linux → PostgreSQL, Windows → MSSQL. Traffic generation only routes database connections to hosts running the matching DB engine — a PostgreSQL host never receives MSSQL traffic, even in mixed-DB environments.
External inbound traffic requires the target host to be reachable from the internet:
- Hosts with static NAT VIP → External clients connect to the VIP; NAT translates per sensor
- Hosts with a public IP (non-RFC1918, e.g., cloud) → External clients connect directly
- RFC1918 hosts without a VIP → External inbound is silently skipped (unreachable)
If a system needs external inbound traffic, either configure a static NAT rule with mapped_ip or assign it a public IP address.
The engine manages user sessions with exact transport-type matching. When a storyline or baseline requests a session on a host, the engine:
- Checks for an existing session with the exact
session_kind(interactive, network, ssh, rdp) - If no match, creates a new session with the appropriate transport evidence (SSH syslog, RDP 4624 type 10, etc.)
Built-in accounts (SYSTEM, LOCAL SERVICE, NETWORK SERVICE) and service accounts always use local system sessions — they never fabricate remote logon evidence.
Sessions marked as storyline_protected (by storyline events that depend on them) are immune to baseline logoff, even if logoff was already planned for the same hour.
The engine automatically generates realistic failed logon patterns without scenario configuration:
- Password typos (~5% of interactive logons): 1-2 failed attempts (4625) immediately before a successful logon (4624) for the same user. Simulates mistyped complex passwords.
- Remote failed auth: network 4625 events use data-driven Windows auth realism profiles for LogonProcessName/auth package, DC-side 4771/4776 validation-path selection, and matching sensor-visible connection evidence. Auth-bearing connections are established or reset after payload; SYN-only probes are reserved for scans/unreachable services without host auth evidence.
- Stale scheduled tasks: Periodic failed batch logons (type 4) from plausible service accounts on deterministic hosts. Fires every 1-2 hours, representing forgotten tasks with expired credentials.
- Management software sweeps: 1-2 times per business day, a management tool tries a disabled credential across 5-15 servers in quick succession. All fail with "account disabled."
These patterns augment the explicit stale_accounts feature, which generates additional failures from accounts you define. Together they produce a realistic ratio of failed-to-successful authentication events.
Personas define user behavior patterns for activity generation. EvidenceForge includes 15 pre-built personas (developer, analyst, sysadmin, executive, etc.) that are resolved automatically by name — reference them in user definitions without needing to define them inline. Define personas inline only if you need to customize behavior beyond what the pre-built library provides; inline definitions override pre-built ones with the same name.
personas:
- name: developer # Required: unique identifier
description: "Software developer who codes and browses" # Required
typical_activities: # Optional list of activity strings
- coding
- web_browsing
work_hours: "9am-5pm" # Optional (default: "9am-5pm")
application_usage: # Optional
- vscode
- chrome
risk_profile: low # Optional: low|medium|high (default: "medium")The work_hours field supports these formats:
"9am-5pm"- Basic range"8:30am-5:30pm"- Half-hour precision"9am-5pm (lunch 12pm-1pm)"- With lunch break"8:30am-5:30pm (lunch 12:30pm-1:30pm)"- Both combined
Work hours are automatically parsed into a work_hours_parsed dict containing:
start: Start hour as float (e.g., 9.0, 8.5)end: End hour as float (e.g., 17.0, 17.5)lunch: Tuple of (start, end) if specified, else nullhours: List of active integer hours (excluding lunch)peak_hours: Mid-morning and mid-afternoon hours
The browsing_intensity field controls how much HTTP traffic a persona generates per browsing session. It affects proxy log depth (number of page loads and subresource cascades) for baseline web activity. Inbound web_server background traffic uses the separate web_session_profiles.yaml visitor mix: traffic_rates.web counts top-level visitor actions, then page assets and same-origin API calls fan out automatically.
personas:
- name: developer
browsing_intensity: normal # Optional: light | normal | heavy (default: "normal")| Value | Behavior |
|---|---|
light |
1 page load, few subresources (CSS, 1-2 images) |
normal |
1-2 page loads, typical subresource cascade |
heavy |
2-4 page loads, full subresource cascades (JS, CSS, images, fonts, API calls) |
Available on persona definitions and as a per-user override on user entries. Per-user override takes precedence over the persona default:
users:
- username: marcus.chen
persona: developer
browsing_intensity: heavy # Overrides developer persona's default
primary_system: WS-DEV-01These fields are for future LLM expansion (Phase 3.1) and are not required:
personas:
- name: developer
# ... Phase 1 fields above ...
expanded_activities: # Phase 2.4+: LLM-populated activity sequences
- activity_type: process_code
sequence:
- action: open_ide
app: VS Code
- action: edit_files
duration_minutes: 30
temporal_pattern: morning_focus
frequency: daily
activity_intensity: # Phase 2.4+: Per-activity events/hour overrides
process_code: 20
connection_web: 5expanded_activities items must have:
activity_type(required): Maps to baseline activity typessequence(optional): List of action stepstemporal_pattern(optional): When this activity typically occursfrequency(optional): How often (hourly, daily, weekly)
time_window:
start: "2024-01-15T10:00:00Z" # Required: ISO 8601 UTC
end: "2024-01-15T18:00:00Z" # Either end OR duration required
duration: "8h" # Supports: "10h", "3d", "2h30m", "5m30s", "500ms"
warmup: "8h" # Optional (default "8h"). Minimum 1 hour.The warmup field controls a pre-generation phase that runs before start to pre-populate
internal state (DNS cache, process trees, active sessions, Kerberos tickets, Hawkes timing kernels).
Events generated during warm-up update state but are not written to output files. This makes
the first minutes of output look like a running system rather than a cold start. Minimum 1 hour;
default 8 hours covers a full day/night transition for maximum realism.
All storyline and red_herrings times should fall inside the configured time_window. For
example, if the final storyline step is scheduled at +36h, set duration longer than 36 hours
so baseline logs, proxy/firewall evidence, and attack traces cover the same collection horizon.
eforge validate warns when a storyline step falls outside the window.
baseline_activity:
description: "Normal office activity"
intensity: medium # low|medium|high (events/user/hour)
variation: low # low|medium|high (timing variation)Intensity mapping: low=5, medium=15, high=40 events/user/hour.
observation_profile: complete # complete | enterprise_standard | messy_collectionobservation_profile selects a named source-observation profile from
config/activity/observation_profiles.yaml. The default complete profile preserves
training-friendly perfect source coverage and correlation. Non-default profiles may introduce
deterministic source-level missingness and source-native delays while preserving canonical truth:
they can make evidence visible, delayed, dropped, filtered, or out_of_window, but they
must not create contradictory users, PIDs, ports, hashes, UIDs, or session identifiers across
sources. GROUND_TRUTH.md records source evidence status for instructors, and
OBSERVATION_MANIFEST.json records the same source-observation contract for automated eval.
Storyline events define specific actions at specific times. Each entry declares what happened (activity, for documentation/GROUND_TRUTH.md) and what events to generate (events list with typed, validated fields).
storyline:
- id: evt-lateral-pth # Required: unique event identifier — must be unique across all storyline events.
# Any string format is valid. Prefer descriptive labels (e.g., "evt-lateral-pth",
# "evt-c2-beacon-day2") but sequential IDs (e.g., "evt-001") are also fine.
time: "+2h30m" # Required: ISO 8601 or relative offset (d/h/m/s/ms)
actor: john.doe # Required: username, built-in account (SYSTEM/root), or service_account
system: WS-01 # Required: system hostname
activity: "lateral movement via pass-the-hash" # Required: human-readable description (GROUND_TRUTH.md)
events: # Required: typed event declarations
- type: logon
source_ip: "10.0.1.20"
logon_type: 3
- type: process
process_name: "C:\\Windows\\System32\\cmd.exe"
command_line: "cmd.exe /c whoami"Each event in the events list has a type field that selects a validated schema. Unknown fields are rejected at load time.
| Type | Generates | Required Fields | Optional Fields |
|---|---|---|---|
process |
4688, Sysmon 1, eCAR PROCESS | process_name |
command_line, supplementary (auto/none) |
logon |
4624, target-host 4672 for elevated sessions, eCAR LOGIN | logon_type (default 3), source_ip |
|
failed_logon |
4625, eCAR LOGIN failure | source_ip, logon_type (default 3) |
|
logoff |
4634, eCAR LOGOUT | ||
connection |
Zeek conn, eCAR FLOW, + web_access/zeek_http when service: http |
dst_ip |
dst_port (default 443), hostname (domain for DNS/SSL SNI), service, source_ip, method, uri, status_code, user_agent |
ssh_session |
Zeek conn + syslog sshd + eCAR | source_ip |
|
rdp_session |
Zeek conn + 4624 type 10 + eCAR | source_ip |
|
account_created |
4720 (on DC) | target_username |
target_sid |
account_deleted |
4726 (on DC) | target_username |
target_sid |
group_member_added |
4728/4732/4756 (on DC) | group_name, member_name |
scope (global/local/universal) |
service_installed |
4697, eCAR SERVICE/CREATE | service_name, service_file_name |
service_account |
scheduled_task_created |
4698 | task_name |
task_content |
log_cleared |
1102 | ||
create_remote_thread |
Sysmon 8, eCAR THREAD/REMOTE_CREATE | target_process |
|
dhcp_lease |
Zeek dhcp.log | mac_address, requested_ip |
|
port_scan |
ASA 106023 (bulk denies) | target_ips or target_segment |
source_ip, target_count, ports, protocol, scan_rate |
beacon |
Zeek conn/proxy/ASA (periodic connections) | dst_ip, interval, one of end_time/duration/count |
action (allow/deny), hostname, service, protocol, source_ip, method, uri, user_agent, referrer, status_code, orig_bytes, resp_bytes, jitter (default: 0.15) |
dns_query |
Zeek dns.log + conn.log, Sysmon 22 | query |
qtype, rcode, ttl, answer (required for NOERROR), source_ip |
web_scan |
web_access + Zeek HTTP (bulk HTTP requests) | dst_ip, rate, one of end_time/duration/count |
preset (nikto/dirb/gobuster/sqlmap/nmap_http), paths, hostname, user_agent, jitter (default: 0.4) |
credential_spray |
Windows 4625/4776 or syslog auth | target_accounts, interval, one of end_time/duration/count |
pattern (spray/brute_force/stuffing), source_ip, logon_type, success, jitter (default: 0.5) |
dga_queries |
Zeek dns.log + conn.log (bulk DGA) | interval, one of end_time/duration/count |
length_range, charset, tld, seed, rcode_distribution, answer_ip, source_ip, jitter (default: 0.3) |
dns_tunnel |
Zeek dns.log + conn.log (encoded exfil) | base_domain, interval, one of end_time/duration/count |
encoding (base32/base64/hex), qtype (TXT/NULL/CNAME), label_length, payload, payload_size, source_ip, jitter (default: 0.25) |
explicit_credentials |
Windows 4648 (explicit credential usage) | target_username |
target_server, process_name, source_ip |
workstation_lock |
Windows 4800 (workstation locked) | ||
workstation_unlock |
Windows 4801 + 4624 type 7 (unlock + re-auth) | ||
raw |
Any single format | target_format, fields |
For process events, prefer full process image paths when you know them. Bare executable names are accepted and are normalized through the configured application/process catalog during generation. If a scenario needs a custom install path, add or update the relevant configuration overlay rather than putting an ad hoc path in one storyline event.
All event types also accept optional technique (MITRE ATT&CK ID) and description (human-readable detail) fields for GROUND_TRUTH.md enrichment.
Red herrings are suspicious-but-benign events that create false leads for analysts. They use the same event types as the storyline but are documented in a separate "Red Herrings" section of GROUND_TRUTH.md with their benign explanations.
red_herrings:
- id: rh-afterhours-admin
time: "+3h"
actor: sarah.oconnell # Must be in users list
system: DC-01
activity: "After-hours server maintenance"
explanation: "Routine sysadmin maintenance performed outside business hours to avoid user impact"
events:
- type: logon
logon_type: 10
source_ip: "10.10.1.15"
- type: process
process_name: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
command_line: "powershell.exe -Command Get-EventLog -LogName System -Newest 50"Each red herring requires:
id: Unique event identifier (must not collide with storyline IDs)time: Same format as storyline (ISO 8601, relative offset, or seconds)actor: Username (must be in users list, service_accounts, or a builtin account)system: Target system hostnameactivity: Human-readable description (appears in Red Herrings section of GROUND_TRUTH.md)explanation: Why this activity is benign (instructor-only context in GROUND_TRUTH.md)events: Same typed event list as storyline (all event types supported)
Red herrings are separate from baseline_activity.suspicious_noise, which auto-generates ambient suspicious patterns (after-hours logins, suspicious CLI, failed logon bursts, etc.) without explicit scenario configuration.
The generation engine automatically emits prerequisite events for certain event types. You do not need to manually specify these — they are generated with realistic timing offsets from config/activity/timing_profiles.yaml:
| Trigger Event | Auto-Generated Prerequisites | Timing |
|---|---|---|
connection (TCP, not port 53) |
DNS query (UDP/53) for destination hostname | network.dns_before_tcp profile before |
logon (Kerberos auth, Windows, not on DC) |
Kerberos TGT (4768) + TGS (4769) on DC | auth.kerberos_before_logon profile before. Elevated-session 4672 is emitted with the target-host 4624. |
rdp_session |
DNS query + connection (port 3389) + logon (type 10) | Connection at event time, logon 50-200ms after |
ssh_session |
DNS query + connection (port 22) + syslog auth | Connection at event time |
process (with admin commands) |
Supplementary audit events (4720, 4726, 4728, 4697, 4698, 1102) inferred from command-line patterns | windows.audit_from_admin_command profile after |
create_remote_thread (targeting lsass) |
Process access (Sysmon Event 10) | process.remote_thread_lsass_access profile before |
When to manually specify these events: Only when they are part of the attack narrative itself (e.g., DNS tunneling exfiltration, Kerberos golden ticket forging, explicit credential dumping via process access). The validator will warn if it detects potentially redundant manual specifications.
The generation engine automatically provides several layers of realism in baseline activity:
Hawkes temporal model: User baseline events use a self-exciting Hawkes process — activity naturally clusters into bursts that taper off, producing realistic human work patterns. Parameters are derived from persona risk_profile (high = intense bursts, low = gentle clusters). System/service traffic uses periodic intervals with small jitter instead.
Storyline typing cadence: Events within a multi-event storyline step are spaced with human typing rhythm (~1.5s between actions, occasional 3-12s thinking pauses) instead of sharing a single timestamp.
Day-of-week variation: Scenarios spanning multiple days show weekly rhythm — Monday login storms, Friday early departures, near-zero weekend activity (only sysadmin/security_analyst/help_desk personas active on Saturday/Sunday).
Stale account evidence: Stale accounts defined in environment.stale_accounts generate not just failed logons but also Kerberos pre-auth failures (4771, status 0x12) on DCs, scheduled task failures (batch logon type 4), and service startup failures (service logon type 5, first hour only).
Legitimate lateral movement: 26 patterns of inter-server traffic are auto-generated based on the environment topology. These include backup agents, monitoring, AD replication, application-to-database connections, config management, and more. Patterns are conditional on having the required infrastructure (assign roles like file_server, database, web_server, mail_server, print_server, dns_server, nfs_server on systems to enable specific patterns).
Compiled world model: Before generation starts, the engine compiles authoritative host and user capabilities from primary_system, assigned_user, roles, and services. That model is then used to place user activity, choose realistic SSH/RDP/network session types, and keep baseline/storyline session bootstrap behavior aligned.
Network-level red herrings: The suspicious noise generator includes network-layer patterns: high-entropy DNS queries (CDN subdomains, DoH providers), unusual outbound connections (cloud backup sync, dev tool endpoints), and scheduled vulnerability scan overlaps. Controlled by baseline_activity.suspicious_noise level.
Entity lifecycle validation: The engine validates that process injection events target existing PIDs and that event timestamps don't precede system boot times. Warnings are logged for impossible sequences.
Process→network correlation: Baseline processes that normally generate network traffic (browsers, Office, dev tools, DB clients) automatically emit corresponding connections (HTTPS, SQL, SSH) 50-500ms after process creation, with the process PID carried for cross-source correlation.
Storyline process+connection pairing: When a storyline process command line references a domain (e.g., Invoke-WebRequest -Uri 'https://cdn-assets-update.com/...'), pair it with a connection event that sets hostname to ensure the domain appears in DNS, SSL, HTTP, and proxy logs. The hostname field on connection and beacon events should be the client-facing DNS name the endpoint actually resolved and sent in HTTP Host, TLS SNI, or proxy CONNECT metadata. Avoid reverse-DNS/PTR artifacts or provider-generated infrastructure names unless the scenario intentionally models the client using that name. Omit hostname for raw-IP C2 (no DNS lookup expected). For realism-bound generated datasets, avoid using reserved documentation domains (example.com, example.net, example.org) as live public infrastructure; use a scenario-owned lab domain or realistic non-reserved domain when public resolver answers and certificates should appear. The validator will warn about unmatched domains.
NTP time synchronization: In AD environments, all domain-joined workstations sync NTP from the domain controller (W32Time service), not from external NIST servers. NTP stratum is stable per server — a DC serving as NTP always reports the same stratum value. External NTP servers are only used for non-domain environments.
Multi-sensor timing realism: When multiple Zeek sensors observe the same connection, each sensor's records use the well-synced network sensor timing profile in config/activity/timing_profiles.yaml. The default profile keeps stable per-sensor clock skew within +/-1.5 ms and per-flow path/capture delay within 50-2000 microseconds. Byte and packet counts remain canonical unless sensor observation variance is explicitly allowed for that source-native row.
Linux syslog depth: Linux hosts generate 18 categories of syslog messages: SSH login/key exchange (70% key / 30% password), package management, systemd timer execution, logrotate detail, journald statistics, plus systemd lifecycle, cron, UFW, logind, and more. Distro-aware (Ubuntu vs RHEL) with appropriate daemon names and paths.
Command diversification: Baseline process commands are parameterized with varied project paths, document names, build configurations, and per-user file references instead of fixed strings.
Realistic process trees: Parent-child relationships are driven by spawn_rules.yaml, which defines valid parent processes for each child executable. CLI tools (dotnet.exe, git.exe, npm.exe, etc.) are parented from shells (cmd.exe, powershell.exe), GUI apps from explorer.exe, and system services from services.exe/svchost.exe. When a valid parent doesn't exist in the user's process history, the engine auto-creates the intermediate chain with realistic timing. Linux processes follow sshd→bash→command chains. Sysmon Event 1 ParentCommandLine is populated from the parent process's actual command line (no longer always "-").
PID allocation: Windows PIDs use a lognormal distribution for gap sizes (mu=1.2, sigma=0.8), producing mostly small gaps with an occasional heavy tail — simulating background process churn consuming PIDs between emitted events. Linux PIDs use a similar but tighter distribution (mu=0.5, sigma=0.6). No fixed choice-set fingerprint.
Per-user bash history: Baseline SSH sessions to Linux servers generate organic admin commands (ls, df -h, ps aux, systemctl status, etc.) for realistic admin users, creating per-user <username>.bash_history files on all Linux hosts. Storyline process events on Linux inject 0-3 organic noise commands around each attack command for realistic interleaving.
Use dhcp_lease for rogue or new devices appearing on the network (e.g., attacker plugging in a device during physical access, or a compromised host requesting a new IP).
- time: "+5m"
actor: attacker
system: ROGUE-LAPTOP
activity: "Rogue device obtains IP via DHCP"
events:
- type: dhcp_lease
mac_address: "00:50:56:a1:b2:c3"
requested_ip: "10.10.10.99"
technique: "T1200 - Hardware Additions"Both mac_address and requested_ip are optional — the engine auto-generates a MAC (using diversified OUI prefixes from network_params.yaml) from the system IP and uses the system's configured IP if omitted. DHCP events include NetworkContext for proper sensor routing. DHCP broadcast is link-local in the generator: it appears on SPAN-style Zeek sensors monitoring the client's segment and does not traverse unrelated TAP/firewall boundaries unless a separate relay/server transaction is modeled.
Use port_scan for network reconnaissance, host sweeps, lateral scans, or worm-like propagation. Generates many firewall deny records (ASA 106023) from a single storyline step.
- time: "+1h"
actor: attacker
system: WEB-EXT-01
activity: "Port scan of server VLAN from compromised DMZ host"
events:
- type: port_scan
target_segment: server_vlan # Or target_ips: ["10.0.20.1", "10.0.20.2"]
target_count: 20 # Sample 20 IPs from the segment
ports: [22, 80, 443, 445, 3389]
protocol: tcp
scan_rate: 50 # 50 connections/second
technique: "T1046 - Network Service Discovery"Fields: source_ip (override scan source; default: uses storyline system IP — useful for external attacker scans). target_ips (explicit list) or target_segment + target_count (sample from CIDR). ports (default: [22, 80, 443, 445, 3389]). protocol (tcp/udp/icmp). scan_rate (connections/second, default: 100).
Denied connections are only visible to sensors on the source side of the firewall. The firewall's drop_mode controls whether Zeek sees S0 (silent drop) or REJ (RST response).
Use beacon for periodic connections — allowed (C2 callbacks through proxy) or denied (firewall-blocked beaconing). Replaces the former blocked_c2 type.
# Allowed beacon through proxy
- time: "+3h"
actor: attacker
system: workstation01
activity: "C2 beacon to attacker infrastructure"
events:
- type: beacon
dst_ip: "45.83.221.30"
dst_port: 443
hostname: "cdn-analytics.example.com"
interval: "5m"
duration: "7d"
jitter: 0.2
action: allow
technique: "T1071.001 - Web Protocols"
# Denied beacon (equivalent to former blocked_c2)
- time: "+5h"
actor: attacker
system: DC-01
activity: "Blocked C2 beaconing — firewall denies outbound from DC"
events:
- type: beacon
dst_ip: "45.83.221.30"
dst_port: 443
interval: "30m"
duration: "12h"
jitter: 0.2
action: deny
technique: "T1071.001 - Web Protocols"Timing fields: start_time (optional, defaults to parent event time), interval (required), one of end_time/duration/count (required), jitter (0.0-1.0, default: 0.15 — beacons are deliberately tight). Connection fields: all connection fields (dst_ip, dst_port, hostname, service, protocol, method, uri, user_agent, referrer, etc.). For hostname, use the client-facing DNS name used by the beacon, not a reverse-DNS/PTR artifact, unless that is intentionally part of the scenario. action: allow (default) or deny. Set referrer to pin the HTTP Referer header for a specific beacon URL (e.g., a phishing page that launched the download). In explicit proxy mode, HTTP/S beacons from hosts routed through a forward_proxy traverse the proxy; denied proxyable beacons stop at the proxy and emit proxy-denied CONNECT/GET evidence rather than direct client-to-origin network evidence.
Use dns_query for standalone DNS lookups with full control over query parameters. Unlike the automatic DNS expansion on connection events, this type lets you specify exact query type, response code, TTL, and answer. Useful for DNS-based reconnaissance, cache poisoning indicators, or any scenario where the DNS query itself is the story.
- time: "+1h"
actor: marcus.chen
system: WS-DEV-01
activity: "DNS reconnaissance — query for mail server"
events:
- type: dns_query
query: "mail.example.com"
qtype: MX
rcode: NOERROR
answer: "10 smtp.example.com"
technique: "T1018 - Remote System Discovery"Fields:
query(required): Domain name to queryqtype(default:A): Query type —A,AAAA,TXT,CNAME,MX,NULL,SRV,PTRrcode(default:NOERROR): Response code —NOERROR,NXDOMAIN,SERVFAIL,REFUSEDttl(optional): Response TTL (auto-generated if omitted)answer(required whenrcode=NOERROR): Response value(s) — string or list of stringssource_ip(optional): Querying host IP (default: storyline system IP)
Use web_scan for automated web scanning attacks (Nikto, DirBuster, Gobuster, SQLMap, Nmap HTTP). Generates high-volume HTTP requests with scanner-realistic patterns, user agents, and status code distributions. Each request produces correlated web_access + Zeek HTTP + Zeek conn records.
- time: "+3h"
actor: SYSTEM
system: WEB-01
activity: "Nikto scan against web server from external attacker"
events:
- type: web_scan
dst_ip: "10.10.20.10"
dst_port: 80
hostname: "portal.example.com"
source_ip: "104.248.71.33"
preset: nikto
rate: 10 # 10 requests/second
duration: "15m"
technique: "T1595.002 - Active Scanning: Vulnerability Scanning"Fields:
dst_ip(required): Target web server IPdst_port(default: 80): Target porthostname(optional): Target domain namesource_ip(optional): Override scanner source IPpreset(optional): Scanner preset —nikto,dirb,gobuster,sqlmap,nmap_httppaths(optional): Custom URI path list —[{uri: "/admin", method: "GET", status: 403}]user_agent(optional): Override the preset's default user agentstatus_codes(optional): Override status code distribution (e.g.,{"404": 0.7, "200": 0.2, "403": 0.1})rate(required): Average requests per second. Withduration/end_time, the engine applies deterministic per-campaign throughput drift so repeated scans with the same nominal rate do not produce identical request totals. With explicitcount, the count remains exact.duration/count/end_time: Termination condition (exactly one required)jitter(default: 0.4): Timing variation — wide variance reflects real-world latency jitter from target server response times
Either preset or paths (or both) must be specified.
Use credential_spray for bulk authentication attacks — password spraying, brute force, or credential stuffing. Generates realistic sequences of failed logon events (Windows 4625/4776 or Linux syslog auth failures) with an optional final successful logon.
- time: "+2h"
actor: SYSTEM
system: DC-01
activity: "Password spray against domain accounts"
events:
- type: credential_spray
source_ip: "185.220.101.34"
pattern: spray
target_accounts: ["marcus.chen", "priya.patel", "sarah.oconnell", "diego.ramirez"]
logon_type: 3
interval: "2s"
duration: "10m"
success:
account: "priya.patel"
after: 8 # Succeed after 8 failures
technique: "T1110.003 - Brute Force: Password Spraying"Fields:
target_accounts(required): List of target usernamessource_ip(optional): Attacker source IPpattern(default:spray): Attack pattern —spray(one password per account),brute_force(many passwords per account),stuffing(one-to-one credential pairs)logon_type(default: 3): Windows logon type for the attemptssuccess(optional): Final successful logon —{account: "username", after: N}whereNis number of failures before successinterval(required): Time between attemptsduration/count/end_time: Termination condition (exactly one required)jitter(default: 0.5): Timing variation — high default reflects self-pacing behavior to evade lockout policies
Use dga_queries for domain generation algorithm (DGA) traffic — algorithmically generated DNS lookups that mostly return NXDOMAIN. Used for botnet/DGA detection training.
- time: "+4h"
actor: SYSTEM
system: WS-DEV-01
activity: "DGA beaconing from infected workstation"
events:
- type: dga_queries
interval: "500ms"
duration: "2h"
jitter: 0.3
tld: ".com"
length_range: [10, 15]
seed: 42
rcode_distribution:
NXDOMAIN: 0.95
NOERROR: 0.05
answer_ip: "45.83.221.99"
technique: "T1568.002 - Dynamic Resolution: Domain Generation Algorithms"Fields:
length_range(default:[8, 15]): Min/max domain label length (1-63)charset(default: lowercase alphanumeric): Character set for domain generationtld(default:.com): Top-level domain suffixseed(optional): Deterministic seed for reproducible domain sequencesrcode_distribution(optional): Response code probabilities (must sum to ~1.0) — e.g.,{"NXDOMAIN": 0.95, "NOERROR": 0.05}answer_ip(required when NOERROR > 0): IP address for successful resolutionssource_ip(optional): Override querying host IPinterval(required): Time between queriesduration/count/end_time: Termination condition (exactly one required)jitter(default: 0.3): Timing variation
Use dns_tunnel for data exfiltration via encoded DNS subdomain labels. Generates DNS queries with encoded payload chunks as subdomains (e.g., aGVsbG8gd29ybGQ.tunnel.evil.com). Useful for DNS exfiltration detection training.
- time: "+6h"
actor: marcus.chen
system: WS-DEV-01
activity: "DNS tunneling exfiltration of stolen credentials"
events:
- type: dns_tunnel
base_domain: "ns1.cdn-analytics.net"
encoding: base64
qtype: TXT
label_length: 30
payload_size: 512
interval: "2s"
duration: "30m"
jitter: 0.1
technique: "T1048.003 - Exfiltration Over Unencrypted Non-C2 Protocol"Fields:
base_domain(required): Tunnel endpoint domain — encoded chunks become subdomains of thisencoding(default:hex): Encoding scheme —base32,base64,hexqtype(default:TXT): DNS query type —TXT,NULL,CNAMElabel_length(default: 30): Max length of each encoded subdomain label (1-63)payload(optional): Fixed payload string to encode and exfiltratepayload_size(default: 256): Random payload size in bytes if nopayloadspecifiedsource_ip(optional): Override querying host IPinterval(required): Time between queriesduration/count/end_time: Termination condition (exactly one required)jitter(default: 0.25): Timing variation
For web-based attack steps (SQL injection, web shell access, etc.), use connection with service: http and dst_port: 80 instead of raw. This produces correlated records across web_access + zeek_http + zeek_conn — a raw event only targets one format.
- time: "+1h10m"
actor: attacker
system: WEB-01
activity: "SQL injection probe against EHR portal"
events:
- type: connection
dst_ip: "10.10.20.10"
dst_port: 80
service: http
source_ip: "104.248.71.33"
method: "GET"
uri: "/ehr/login.php?id=1%27%20OR%201=1--"
status_code: 200
user_agent: "Mozilla/5.0 (compatible; Googlebot/2.1)"HTTP optional fields on connection events: method (GET/POST/etc.), uri, status_code, user_agent, referrer. When these are provided with service: http, the engine generates correlated web_access, zeek_http, and zeek_conn records from a single SecurityEvent. The referrer field defaults to null (auto-generated from the traffic context — search engine, same-origin, social, or blank); set it explicitly for phishing click scenarios or specific referrer chain modeling (e.g., referrer: "https://evil.example.com/page"). The same referrer field is available on beacon events.
Byte and connection state overrides: orig_bytes (originator payload bytes), resp_bytes (responder payload bytes), conn_state (Zeek connection outcome: SF, S0, REJ, etc.). When omitted, the engine auto-sizes bytes based on the event's technique and description fields (exfiltration -> large orig_bytes; C2 -> small bidirectional; download -> large resp_bytes), and defaults conn_state to SF. Set conn_state explicitly to model failed connections (e.g., S0 for a dead C2 channel, REJ for a blocked exfil attempt).
The raw event type targets a specific output format with arbitrary field data. Use it only for events not covered by the typed event specs above. Prefer typed events (especially connection for web access) because raw events bypass cross-source correlation — they produce a single log entry with no matching records in other formats.
- time: "+2h"
actor: attacker
system: WEB-01
activity: "Custom syslog entry"
events:
- type: raw
target_format: syslog
fields:
hostname: WEB-01
app_name: "apache2"
pid: 1234
facility: 3
severity: 6
message: "custom message here"target_format must be a supported format name (e.g., syslog, windows_event_security, ecar, zeek_conn). The fields dict is passed directly to the target emitter without schema validation — ensure field names match the format's expected structure. The event's timestamp is automatically injected if not provided in fields.
When a process event declares a command that would produce additional audit events in a real environment, those correlated events should be explicitly declared in the same step's events list. This ensures complete, realistic log output regardless of what command is being run.
The table below shows common categories of commands and the correlated event types to declare alongside the process event:
| Command Category | Example Commands | Correlated Event Type |
|---|---|---|
| Account creation | net user /add, useradd, New-ADUser, dsadd user |
account_created |
| Account deletion | net user /delete, userdel, Remove-ADUser |
account_deleted |
| Group membership changes | net group /add, net localgroup /add, Add-ADGroupMember, usermod -aG |
group_member_added |
| Service creation | sc create, New-Service, systemctl enable |
service_installed |
| Scheduled task creation | schtasks /Create, at, crontab -e, Register-ScheduledTask |
scheduled_task_created |
| Log clearing | wevtutil cl, Clear-EventLog, rm /var/log/* |
log_cleared |
| Process injection | mimikatz sekurlsa::, reflective DLL injection, process hollowing |
create_remote_thread |
This is not an exhaustive list -- any command that would produce a distinct audit trail should have its correlated events declared explicitly.
The engine automatically infers correlated events for 6 common Windows command patterns when supplementary: auto (the default) is set on a process event:
| Command Pattern | Auto-Inferred Event |
|---|---|
net user <name> /add |
4720 (account created) |
net user <name> /delete |
4726 (account deleted) |
net group "<group>" <user> /add |
4728 (group member added) |
schtasks /Create /TN "<name>" |
4698 (scheduled task created) |
sc create <name> binPath= |
4697 (service installed) |
wevtutil cl Security |
1102 (log cleared) |
This safety net catches common cases, but should not be relied upon as the primary mechanism -- always declare correlated events explicitly. If the same event type is already in the events list, auto-inference skips it (no duplicates). Set supplementary: none to disable auto-inference entirely.
- Always declare the primary action explicitly -- don't rely on inference for the main event
- Declare correlated events for process commands -- if a command creates an account, installs a service, clears logs, etc., add the corresponding event type to the
eventslist - Explicitly declare cross-system events -- inference cannot generate events on other systems (e.g., DC Kerberos for domain logon, RDP logon on target)
- Explicitly declare events when field precision matters -- auto-inference uses auto-generated values (random SIDs); declare explicitly if SIDs must match across steps
- Use explicit events for specialized detection types -- CreateRemoteThread, LSASS access; inference doesn't detect these patterns
Password spray + lateral movement:
- time: "+30m"
actor: attacker
system: WS-01
activity: "Password spray against domain accounts"
events:
- type: failed_logon
source_ip: "185.220.101.34"
- type: failed_logon
source_ip: "185.220.101.34"
- type: logon
source_ip: "185.220.101.34"
logon_type: 3Process with explicit correlated events:
- time: "+1h"
actor: attacker
system: DC-01
activity: "Create backdoor domain account"
events:
- type: process
process_name: "C:\\Windows\\System32\\net.exe"
command_line: "net user svc-audit P@ss! /add /domain"
- type: account_created
target_username: "svc-audit"Service persistence with correlated audit event:
- time: "+1h15m"
actor: attacker
system: WEB-01
activity: "Install malicious service for persistence"
events:
- type: process
process_name: "C:\\Windows\\System32\\sc.exe"
command_line: "sc create evilsvc binPath= C:\\Windows\\Temp\\payload.exe start= auto"
- type: service_installed
service_name: "evilsvc"
service_file_name: "C:\\Windows\\Temp\\payload.exe"Explicit cross-system events:
- time: "+1h30m"
actor: attacker
system: WEB-01
activity: "SSH lateral movement to web server"
events:
- type: ssh_session
source_ip: "10.20.10.13"output:
logs:
- format: windows
- format: zeek
- format: ecar
destination: ./output
compression: false # Optional (default: false)Supported formats: windows, zeek, ecar, syslog, bash_history, snort_alert, cisco_asa, web_access, proxy_access.
proxy_access requires at least one system with roles: [forward_proxy]. If it is requested without a forward proxy system, validation warns because no proxy access log file will be generated. When proxy logs are requested, add environment.proxy.mode to make transparent vs explicit proxy semantics clear. Current proxy behavior assumes TLS interception, so HTTPS can include CONNECT plus inspected request rows; non-intercepting tunnel-only proxy behavior is deferred.
The output.logs list can be scoped to only needed formats for faster generation with long time windows. For example, a 30-day baseline exercise that only needs Zeek conn.log can declare just format: zeek_conn instead of the full zeek group.
The --formats CLI flag provides runtime filtering without modifying the scenario YAML. It intersects with output.logs — only formats present in both are generated. Group names (zeek, windows) are expanded before intersection.
Persona fields are optional with null defaults:
expanded_activities,work_hours_parsed,activity_intensitydefault to nullwork_hours_parsedis auto-populated from thework_hoursstring if not explicitly provided
Breaking change (Phase 8.4): The events field on storyline entries is now required. The old details dict and event_sequence fields have been removed. All storyline entries must use the typed events list format.