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

SyntaxMeaningExample
literalString, number, bool, null, or nested object/array"us-east-1", 443, true
gen.<type>()Built-in generatorgen.uuid(), gen.ipv4()
gen.<type>(k=v, ...)Generator with parametersgen.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 paramsfn.prefix(ref.uuid, n=8)
ref.<var>Read a resolved state variableref.source_ip
ref.<var>.<path>Dot-path into an object variableref.user.name
pool.<id>Draw a full record from a poolpool.iam-users
pool.<id>.<field>Draw a record, extract a fieldpool.iam-users.email
pool.<id>[]Whole pool: all entries in order as a listpool.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 state is resolved once per scenario run in topological order
  • Emit fields are resolved per emit step against the (immutable) scenario state — fields cannot reference each other, only ref.* into state
  • Event-type defaults are resolved per emit in topological order, before being merged under the step's emit fields (step fields win on conflict)
  • Job state is resolved once at job start and is available to workload bindings via ref.*
  • 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 for loop.
  • Workload bindings are re-evaluated on every iteration of a loop: workload (and on every combo of a matrix: workload), so gen.* and pool.* expressions in bindings produce fresh values each iteration
  • Circular references in scenario state are detected at load/compile time; cycles in job state, event-type defaults, and workload bindings are 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/array state: value (scenario or job) participate in the same dependency graph as top-level ref.* in the same scope, so per-scope validation timing applies unchanged.
  • pool.* expressions are only valid in job state and workload bindings, not in scenario state or fields
  • pool.<id>[] resolves to every pool entry in order as a list (records for csv, scalars for string_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>[] or pool.<id>[].field are 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 emit fields:, 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-type defaults: 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

GeneratorDescriptionExample output
gen.uuid()Random UUID v4f47ac10b-58cc-4372-a567-0e02b2c3d479

Network

GeneratorDescriptionExample output
gen.ipv4()Random globally routable IPv4 address (see IPv4 classes)45.33.32.156
gen.ipv6()Random IPv6 address2001:db8::1
gen.mac()Random MAC address00:1a:2b:3c:4d:5e
gen.port()Random port (1024–65535)8443
gen.domain()Random domain nameexample.org
gen.url()Random URLhttps://example.com/path
gen.hostname()Random hostname (see Hostname classes)DESKTOP-A1B2C3D

Time

GeneratorDescription
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

GeneratorDescription
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

GeneratorDescription
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

GeneratorDescription
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

GeneratorDescription
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

GeneratorDescription
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

GeneratorDescription
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.

ParamExample outputRange
none, or class=routable (d)45.33.32.156Everything 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=private10.14.203.710.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
class=loopback127.0.44.19127.0.0.0/8
class=linklocal169.254.88.3169.254.0.0/16
class=cgnat100.97.5.212100.64.0.0/10
class=any239.12.8.4The whole 32-bit space, unfiltered
cidr=<ipv4/prefix>203.0.113.42The 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.

Usage examples
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.

ClassExampleParams
windows-workstation (default)DESKTOP-A1B2C3Ddomain — append domain for FQDN (e.g. domain=corp.local → DESKTOP-A1B2C3D.corp.local)
windows-serverDC01, SQL14domain — append domain for FQDN
ec2-privateip-10-0-1-42.ec2.internalregion — AWS region (default: us-east-1). Uses ip-...ec2.internal for us-east-1, ip-...REGION.compute.internal for others.
ec2-resourcei-0a3f7c9e12b456d89.ec2.internalregion — AWS region (default: us-east-1). Uses instance-ID-based naming.
ec2-public-ipv4ec2-54-201-100-1.compute-1.amazonaws.comregion — AWS region (default: us-east-1). Uses compute-1 for us-east-1, REGION.compute for others.
linuxweb-prod-us-east-07—
Usage examples
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)

ExpressionDescription
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

ExpressionDescription
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

ExpressionDescription
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.