Expressions
The ExprStr grammar for dynamic values in scenarios and jobs.
Tracemill uses a lightweight expression language called ExprStr for dynamic value generation in scenario state, job state, workload bindings, emit fields, and event-type defaults.
Syntax
| Syntax | Meaning | Example |
|---|---|---|
literal | String, number, bool, null, or nested object/array | "us-east-1", 443, true |
gen.<type>() | Built-in generator | gen.uuid(), gen.ipv4() |
gen.<type>(k=v, ...) | Generator with parameters | gen.int(min=0, max=100) |
fn.<name>(input) | Pure transform of a positional input — literal, ref.*, or another expression (nesting allowed) | fn.upper(fn.md5(ref.image)) |
fn.<name>(input, k=v, ...) | Transform with named params | fn.prefix(ref.uuid, n=8) |
ref.<var> | Read a resolved state variable | ref.source_ip |
ref.<var>.<path> | Dot-path into an object variable | ref.user.name |
pool.<id> | Draw a full record from a pool | pool.iam-users |
pool.<id>.<field> | Draw a record, extract a field | pool.iam-users.email |
pool.<id>[] | Whole pool: all entries in order as a list | pool.iam-users[] |
Quoting parameter values
Named parameter values may be wrapped in double quotes to include characters
that would otherwise be parsed as syntax — most commonly a comma, which
normally separates parameters. Inside quotes, commas and brackets are literal;
use \" for a literal double quote and \\ for a literal backslash (other
backslash sequences pass through untouched, so Windows paths and Go time
layouts need not be doubled). Quoting is opt-in: unquoted values keep their
existing behaviour, so regex patterns and other bracketed values are unchanged.
# A custom timestamp layout containing commas must be quoted, otherwise the
# commas split the parameter list and parsing fails:
scan_completed_at: gen.timestamp(format="Jan 2, 2006, 3:04:05 PM", offset=-8h, jitter=1h)Resolution Rules
- Scenario
stateis resolved once per scenario run in topological order - Emit
fieldsare resolved per emit step against the (immutable) scenario state — fields cannot reference each other, onlyref.*into state - Event-type
defaultsare resolved per emit in topological order, before being merged under the step's emit fields (step fields win on conflict) - Job
stateis resolved once at job start and is available to workload bindings viaref.* - A workload
loop: ref.<state-key>reads one top-level job-state value after job state resolves and before any workload starts. The value must be a non-negative integer; other expressions and nested ref paths are not accepted forloop. - Workload
bindingsare re-evaluated on every iteration of aloop:workload (and on every combo of amatrix:workload), sogen.*andpool.*expressions in bindings produce fresh values each iteration - Circular references in scenario
stateare detected at load/compile time; cycles in jobstate, event-typedefaults, and workloadbindingsare detected when those scopes are evaluated ref.<var>resolution depends on the call site:- Scenario
state→ other scenario state vars. Validated at load time. - Job
state→ other job state vars. Validated at evaluation time (per run). - Event-type
defaults→ other defaults entries (scenario state is not in scope). Validated per emit. - Emit
fields→ the scenario's resolved state. Validated per emit. - Workload
bindings→ ambient job state. Validated per binding evaluation (per iteration / per matrix combo). - Workload
loop→ one top-level job state var. Resolved once per run before workload execution. - Nested
ref.*leaves inside an object/arraystate:value (scenario or job) participate in the same dependency graph as top-levelref.*in the same scope, so per-scope validation timing applies unchanged.
- Scenario
pool.*expressions are only valid in job state and workload bindings, not in scenario state or fieldspool.<id>[]resolves to every pool entry in order as a list (records forcsv, scalars forstring_list/ip_range), bypassing the pool's sampling mode.[]is only valid as the exact trailing suffix on a bare pool ID —pool.<id>.<field>[]orpool.<id>[].fieldare rejected. The list is capped at 100,000 entries.- Nested state values evaluate. String leaves nested inside an object or array
state:value (scenario or job) parse as ExprStr and resolve as part of the owning state variable —event_name: gen.uuid()inside a record draws a UUID, and${ref.region}inside a list element interpolates against the rest of state. Resolution timing matches top-level state: once per run for scenarios, once at job start for jobs (distinct from emitfields:, which evaluate per emit). The state-variable dependency graph spans nested leaves too, so cycles and undefined refs surface as load-time (scenario) or evaluation-time (job) errors with the offending leaf path. Event-typedefaults:are the exception: nested values there stay literal — only top-level default leaves are ExprStr.
Generators
Generators produce realistic random values on each run. Parameters are optional unless noted; (d) marks the default.
Identity
| Generator | Description | Example output |
|---|---|---|
gen.uuid() | Random UUID v4 | f47ac10b-58cc-4372-a567-0e02b2c3d479 |
Network
| Generator | Description | Example output |
|---|---|---|
gen.ipv4() | Random globally routable IPv4 address (see IPv4 classes) | 45.33.32.156 |
gen.ipv6() | Random IPv6 address | 2001:db8::1 |
gen.mac() | Random MAC address | 00:1a:2b:3c:4d:5e |
gen.port() | Random port (1024–65535) | 8443 |
gen.domain() | Random domain name | example.org |
gen.url() | Random URL | https://example.com/path |
gen.hostname() | Random hostname (see Hostname classes) | DESKTOP-A1B2C3D |
Time
| Generator | Description |
|---|---|
gen.timestamp() | Current time in RFC3339 (default) |
gen.timestamp(format=...) | Named format: iso8601, RFC3339Nano, unix, millis, micros, nanos, epoch_float, RFC1123, ANSIC, wineventlog, sysmon, syslog, apache, kitchen, or a custom Go layout string |
gen.timestamp(offset=...) | Shift from current time: positive (2h30m) is in the future, negative (-6h) is in the past |
gen.timestamp(truncate=...) | Truncate to a time boundary before applying offset: 1h snaps to the start of the current hour, 24h snaps to midnight UTC (truncation is relative to the UTC zero time) |
gen.timestamp(jitter=...) | Add a symmetric random spread after offset: 30m varies the result by up to 30 minutes in either direction |
All params are optional and combinable. Operation order: truncate → offset → jitter → format. A custom Go layout that contains commas (e.g. Jan 2, 2006, 3:04:05 PM) must be double-quoted — see Quoting parameter values.
People / Identity
| Generator | Description |
|---|---|
gen.username() | Random username |
gen.email() | Random email address |
gen.user_agent() | Random HTTP User-Agent string |
gen.country() | Random country name |
gen.timezone() | Random IANA timezone |
Numerics
| Generator | Description |
|---|---|
gen.int() | Random integer 0–1,000,000 |
gen.int(min=N, max=N) | Random integer in custom range |
gen.float() | Random float 0.0–1.0 |
gen.float(min=N, max=N) | Random float in custom range |
Hex
| Generator | Description |
|---|---|
gen.hex(len=N) | Random lowercase hex string of length N (1–4096) |
gen.hex(len=N, case=upper) | Random uppercase hex string |
Use gen.hex for fields that need a random hex value uncorrelated with any other state — Windows Activity GUIDs, request correlation IDs, opaque ETags. For hash fields that should be consistent with other state (e.g. the same file's hash across multiple events), use the fn.* hash functions over a state variable instead.
Patterns
| Generator | Description |
|---|---|
gen.regex(pattern=...) | Random string matching an RE2 pattern |
Use gen.regex for arbitrary string formats that the other generators don't cover — provider resource identifiers, opaque tokens, structured IDs.
state:
guardrail_id: gen.regex(pattern=[a-z0-9]+) # AWS Bedrock guardrailIdentifier
vpc_id: gen.regex(pattern=vpc-[0-9a-f]{8}) # structured resource ID
env_host: gen.regex(pattern=(prod|staging|dev)-[a-z]{4})Length is expressed through the pattern's own quantifiers:
- Bounded quantifiers are honored exactly:
{8}→ 8 repetitions,{8,12}→ 8–12. - Unbounded quantifiers fall back to defaults:
+repeats 1–16,*repeats 0–16,{n,}repeats n–(n+16). - Anchors (
^,$,\b) are no-ops — output is generated, not matched. .yields visible ASCII (printable, excluding space) so values don't carry stray whitespace. Character classes prefer printable ASCII, but a class with no printable-ASCII members (e.g.\p{Han}) draws from its own runes so the value still matches.
HTTP
| Generator | Description |
|---|---|
gen.http_method() | Random HTTP method |
gen.http_status() | Random HTTP status code |
gen.http_status(class=2xx|4xx|5xx) | Status code from a specific class |
AWS / Cloud
| Generator | Description |
|---|---|
gen.aws_region() | Random AWS region (e.g. us-east-1) |
gen.aws_account_id() | Random 12-digit AWS account ID |
gen.aws_arn(class=...) | Random AWS ARN. Classes: user(d), role, assumed-role, lambda, instance, bucket, log-group, ecs-task. Optional name=<str> to pin the resource name. |
gen.aws_identity(type=...) | CloudTrail userIdentity map. Types: IAMUser(d), AssumedRole, AWSService, FederatedUser, Root, AWSAccount. Optional accountId, userName. |
gen.aws_access_key(...) | AWS access key object. Params: userName, accountId, status=Active(d)|Inactive, type=Permanent(d)|Temporary. |
IPv4 classes
gen.ipv4() returns a globally routable address: every block the IANA special-purpose registry marks Globally Reachable: False, plus multicast, is excluded. Special-purpose blocks that are globally reachable (AS112, AMT) stay in range. A multicast or loopback source address reads as wrong to anyone looking at the telemetry, and a private one is dropped outright by a detection that filters RFC1918 — when a job pins one address across a burst, that loses the whole run rather than a single event.
| Param | Example output | Range |
|---|---|---|
none, or class=routable (d) | 45.33.32.156 | Everything except 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 |
class=private | 10.14.203.7 | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
class=loopback | 127.0.44.19 | 127.0.0.0/8 |
class=linklocal | 169.254.88.3 | 169.254.0.0/16 |
class=cgnat | 100.97.5.212 | 100.64.0.0/10 |
class=any | 239.12.8.4 | The whole 32-bit space, unfiltered |
cidr=<ipv4/prefix> | 203.0.113.42 | The prescribed network |
class and cidr are mutually exclusive. Host bits in a cidr argument are ignored, so cidr=203.0.113.77/24 means the same as cidr=203.0.113.0/24; for a prefix wider than /31 the network and broadcast addresses are skipped, matching what an ip_range pool enumerates.
Every draw is uniform across the addresses in range, so a class built from several blocks picks each in proportion to its size — under class=private, 10.0.0.0/8 is 256× larger than 192.168.0.0/16 and comes up that much more often. Use cidr= when a scenario needs a specific network.
state:
# Routable public source — the default, and what most events want
attacker_ip: gen.ipv4()
# Internal address for lateral movement inside the estate
internal_ip: gen.ipv4(class=private)
# A known scanning network
scanner_ip: gen.ipv4(cidr=45.33.32.0/24)Hostname classes
gen.hostname() generates realistic hostnames for different platform styles via the class parameter.
| Class | Example | Params |
|---|---|---|
windows-workstation (default) | DESKTOP-A1B2C3D | domain — append domain for FQDN (e.g. domain=corp.local → DESKTOP-A1B2C3D.corp.local) |
windows-server | DC01, SQL14 | domain — append domain for FQDN |
ec2-private | ip-10-0-1-42.ec2.internal | region — AWS region (default: us-east-1). Uses ip-...ec2.internal for us-east-1, ip-...REGION.compute.internal for others. |
ec2-resource | i-0a3f7c9e12b456d89.ec2.internal | region — AWS region (default: us-east-1). Uses instance-ID-based naming. |
ec2-public-ipv4 | ec2-54-201-100-1.compute-1.amazonaws.com | region — AWS region (default: us-east-1). Uses compute-1 for us-east-1, REGION.compute for others. |
linux | web-prod-us-east-07 | — |
state:
# Windows workstation FQDN
computer: gen.hostname(class=windows-workstation, domain=corp.local)
# EC2 private DNS in us-west-2
ec2_host: gen.hostname(class=ec2-private, region=us-west-2)
# Linux infrastructure hostname
server: gen.hostname(class=linux)Functions
fn.* expressions are pure, deterministic transforms over a single positional input plus optional named parameters. Use them when shaping field values that need normalisation, slicing, or trimming.
Functions never produce random data — that's gen.*'s job. The positional input can be a literal, a ref.*, or another expression: fn.*, gen.*, a ${ref.*} interpolation, or (in job state) pool.*. Calls compose freely, so transforms can be chained inline:
state:
image: "C:\\Users\\victim\\Downloads\\payload.exe"
image_md5: fn.upper(fn.md5(ref.image))Intermediate state vars are still a good idea when the same value is reused — they keep emit fields short and make the dependency graph easy to scan:
state:
user: gen.username()
user_lower: fn.lower(ref.user)
user_short: fn.prefix(ref.user_lower, n=4)pool.* remains scoped to job state and workload bindings; wrapping it in fn.* does not smuggle it into scenario state or emit fields.
String functions (Tier 1)
| Expression | Description |
|---|---|
fn.lower(s) | Lowercase the input (Unicode-aware) |
fn.upper(s) | Uppercase the input (Unicode-aware) |
fn.trim(s) | Strip leading/trailing Unicode whitespace |
fn.prefix(s, n=N) | First N runes of s. Clamps to the input's rune length. |
fn.suffix(s, n=N) | Last N runes of s. Clamps to the input's rune length. |
Hash functions
| Expression | Description |
|---|---|
fn.md5(s) | MD5 hash of s as lowercase hex (32 chars) |
fn.sha1(s) | SHA-1 hash of s as lowercase hex (40 chars) |
fn.sha256(s) | SHA-256 hash of s as lowercase hex (64 chars) |
fn.sha512(s) | SHA-512 hash of s as lowercase hex (128 chars) |
Hash functions are deterministic: the same input always produces the same digest. That's exactly what you want for events that should agree on a file's hash across emits — compute the hash once in scenario state: and reference the same state variable from every emit:
state:
file_path: "C:\\Users\\victim\\Downloads\\invoice.exe"
file_md5: fn.md5(ref.file_path)
file_sha256: fn.sha256(ref.file_path)For an unrelated random hex value (e.g. an Activity GUID where no underlying content is being modelled), use gen.hex instead.
Encoding functions
| Expression | Description |
|---|---|
fn.base64(s, encoding=E, prefix_hex=H) | Standard base64 of the bytes H (hex, optional) followed by s encoded as E: utf8 (default) or utf16le |
Some records carry a binary blob that embeds a value a job binds, such as a recipient address inside an Exchange rule's base64 entry id. Rebuild the blob from the bound value so the two stay consistent; a fixed header goes in prefix_hex:
state:
recipient: "collector@mail-archive.example"
entry_text: "${ref.recipient}\u0000SMTP\u0000${ref.recipient}\u0000"
entry_id: fn.base64(ref.entry_text, encoding=utf16le, prefix_hex=00000000812b1fa4bea310199d6e00dd010f540200000180)Type coercion
Inputs that resolve to scalar values (string, integer, float, bool) are coerced to their string form. Map/array inputs return an error rather than silently rendering as map[k:v]-style strings — this catches mistakes like passing a full gen.aws_identity() result where a single field was meant.
Errors
fn.prefix and fn.suffix require a non-negative integer n. A missing, non-integer, or negative n produces a clear runtime error scoped to the function call site.