Writing custom rules¶
Custom rules run in the same detection engine as the built-in rules. They use the same document format and the same validation, schedule, exclusions, mutes, alerts and cases.
This page covers:
- the rule document and its fields;
- the SQL contract and its restrictions;
- worked examples;
- how to validate, test, save and edit a rule in the console or through the local API.
How rules are scheduled and evaluated is described in Rules and detection.
The rule document¶
A rule is one document with the fields below. Built-in rules are YAML files. Custom rules are sent to the local API as JSON, and the console builds that JSON for you. The field names are the same in both. Unknown fields are rejected.
| Field | Required | Allowed values |
|---|---|---|
schema_version |
yes | Must be 6. Older custom rules (schema 2 to 4) are listed with "migration required". They do not run, and they cannot be enabled until you rewrite them for a typed source. |
kind |
yes | event_match: one alert per returned row. threshold: one alert per run when at least min_count rows are returned. |
min_count |
threshold only | An integer from 2 to 1000. Must be absent (or null) for event_match. |
evidence_semantics |
yes | Must be exact. |
rule_version |
yes | A positive integer. A new custom rule starts at 1, and each save must be exactly the previous version + 1. |
rule_id |
yes | Custom rules: must start with local. and be 7 to 128 characters. Allowed characters are A-Z a-z 0-9 . _ -. The console generates local.<slug of the name>. |
rule_name |
yes | Non-empty, up to 256 bytes. Shown on alerts and in tray notifications. |
description |
yes | Non-empty, up to 4,096 bytes. |
category |
no | A token of up to 64 characters (A-Z a-z 0-9 . _ -). Custom rules may use any token. Built-in rules are limited to ATT&CK tactic names and malware. |
required_telemetry |
no | Up to 6 distinct source names: kernel_process_etw, registry_etw, file_etw, usn, artifact, windows_event_log. Descriptive only. |
attack_techniques |
no | Up to 32 distinct IDs of the form T1234 or T1234.567. |
confidence |
yes | high, medium or low. |
severity |
yes | critical, high, medium, low or informational. Informational alerts never create cases or notifications. |
priority |
yes | 1 (runs every 2 min), 2 (every 8 min) or 3 (every 15 min). |
timeframe |
yes | 2m, 8m or 15m. Bounds how much history the query may read. For threshold rules it must equal the run interval: P1 2m, P2 8m, P3 15m. |
query |
yes | One DuckDB SELECT, up to 16 KiB. See The SQL contract. |
builtin_exclusions |
no | Up to 16 items {predicate, reason}: the predicate up to 512 bytes, the reason up to 256 bytes. These are versioned with the rule. See Exclusions. |
investigation_playbook |
yes | Non-empty, up to 16,384 bytes. Shown with the rule and used by the AI investigator. |
alert_description |
yes | The alert text template, up to 4,096 bytes, with at least one {{placeholder}}. See Alert text templating. |
The whole serialized document may be at most 64 KiB.
YAML quoting
In YAML, write query, description and investigation_playbook as block scalars (| or >-). Quote alert_description if it contains : (a colon followed by a space). Otherwise the document does not parse.
The SQL contract¶
FreeDR validates every rule query when the rule is saved, and again every time the rule runs. It binds the query, without running it, against empty copies of the evidence tables in an in-memory DuckDB, with external file and network access disabled.
What the query may read¶
- Exactly one typed source in the top-level
FROM. The source must be one ofkernel_process_etw,registry_etw,file_etw,usn,artifactorwindows_event_log. The cross-sourceeventsview and theevent_logview that Hunt offers are not allowed in rules. - Inside the query, the source name means only the new rows of this run, after built-in and user exclusions are applied. A subquery on the same source name therefore sees only those candidate rows.
- Subqueries may read:
<source>_context(for examplekernel_process_etw_context): the same source without exclusions, limited to rows whose event time is withintimeframebefore the earliest candidate and 60 seconds after the latest;- any other typed source, with the same time bound;
artifact_path_state: one row per collected file path with its latestsha256,authenticode_status,signer,file_size,modified_atand PE fields. This relation is not time-bounded.
- Any other table name fails. That includes physical
*_v5tables,information_schema,duckdb_*andsqlite_*.
What the query may not contain¶
- More than one statement.
PRAGMA,SET,ATTACH,COPYand other non-SELECTstatements are not allowed. - CTEs (
WITH), anywhere in the query. - Top-level joins. Use
EXISTS (...)or scalar subqueries instead. - Table functions, including file and glob readers.
UNNEST(<column>)of a list column is allowed. - An
INTERVALliteral longer than the rule'stimeframe, or anINTERVALthat is not a literal.
Output columns¶
The result columns must be exactly these, in this order, with these names:
event_id: text. UseCAST(event_id AS VARCHAR) AS event_id.occurred_at: integer microseconds. Useepoch_us(occurred_at) AS occurred_at.- One column for each distinct
{{placeholder}}inalert_description, in the order each placeholder first appears in the text.
There may be no other columns. For a threshold rule, {{match_count}} is filled in by the engine and must not be returned by the query.
Other rules for the result:
- Result values must be boolean, integer, floating point or text. Cast other types (timestamps, BLOBs, lists, UUIDs) to
VARCHAR, or useto_json(...). - Every placeholder column must be non-
NULLon every returned row. ANULLfails the run, so wrap values incoalesce(...). - The engine reads at most 1,000 rows per run, and a query that returns more fails the run. End your query with
LIMIT 1000, as the built-in rules do. With that limit, matches beyond 1,000 in one window are not alerted.
Event types and columns¶
Use the console's Hunt schema view or GET /api/v1/hunt/schema for the full column list of each source. The event_type codes used most often:
| Source | event_type values |
|---|---|
kernel_process_etw |
1 process start, 2 process exit, 3 image load |
registry_etw |
1 value set, 2 value delete, 4 key delete, 5 key security set, 6 key created |
file_etw |
1 create/open (with created = 1 only for a confirmed new file), 2 write, 3 rename, 4 delete, 5 set info, 10 read |
Process starts carry stamped lineage columns: parent_image_path, parent_command_line, parent_start_at, grandparent_image_path and user_name. Other process, registry and file events, and Event Log types with an acting process, carry actor_image_path, actor_command_line, actor_start_at, actor_user_sid and actor_user_name. These columns are filled at collection time from live process state. NULL means unknown, never "no parent".
windows_event_log stores each event type in generic typed slots (text_01…text_12, u64_01…u64_12, blob_01…blob_04) plus text_list_01, the list of named fields. The slot layout differs per event_type_id. The shipped Event Log rules are the best reference for a given event.
Executable-name tests should compare the file name, not the whole path, because a process start can carry only the kernel image name when the full path is not yet known:
lower(parse_filename(coalesce(image_path, ''), false, 'backslash')) IN ('certutil.exe')
Alert text templating¶
alert_description is plain text with {{name}} placeholders:
- A placeholder name is 1 to 64 characters of
A-Z a-z 0-9 _. A malformed placeholder, or a stray{{or}}in the template, is rejected when the rule is validated. Braces inside substituted values are kept literally. - Each value is inserted once, left to right. A value is never scanned again, so a captured
{{x}}in event data is not expanded. - Carriage returns and line feeds in a value become spaces.
- Any other control character except tab fails the run with
render_failure. - The rendered text may be at most 4,096 bytes. Longer text fails the run.
To avoid render failures, the built-in rules sanitize and shorten attacker-controlled text in the query. You can copy this pattern:
regexp_replace(left(coalesce(c.command_line, ''), 512),
'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x{9f}]', '�', 'g') AS command_line
The rendered description is stored with the alert and shown in the alert list. It is never shown in tray notifications; those show only the rule name.
Examples¶
Event match on process starts¶
schema_version: 6
kind: event_match
evidence_semantics: exact
rule_version: 1
rule_id: local.encoded-powershell
rule_name: PowerShell started with an encoded command
description: >-
powershell.exe or pwsh.exe started with -e, -en, -enc or -EncodedCommand.
Renamed copies of PowerShell are not matched.
category: execution
required_telemetry:
- kernel_process_etw
attack_techniques:
- T1059.001
- T1027
confidence: medium
severity: high
priority: 1
timeframe: 2m
query: |
SELECT CAST(p.event_id AS VARCHAR) AS event_id,
epoch_us(p.occurred_at) AS occurred_at,
regexp_replace(left(coalesce(p.command_line, ''), 512), '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x{9f}]', '�', 'g') AS command_line,
coalesce(p.parent_image_path, 'unknown parent') AS parent
FROM kernel_process_etw AS p
WHERE p.event_type = 1
AND lower(parse_filename(coalesce(p.image_path, ''), false, 'backslash')) IN ('powershell.exe', 'pwsh.exe')
AND regexp_matches(lower(coalesce(p.command_line, '')), '\s-(e|en|enc|encodedcommand)\s')
LIMIT 1000
builtin_exclusions: []
investigation_playbook: |-
1. Decode the command (command_line). A download, an Invoke-Expression or a hidden window is bad.
2. Who started it (parent)? Office, a browser or a script host is bad; a management agent you know is good.
Verdict: malicious if it fetched or ran a payload; benign for a known tool; otherwise suspicious.
alert_description: 'Encoded PowerShell: {{command_line}} (parent {{parent}})'
The placeholders appear as command_line and then parent, so the query returns event_id, occurred_at, command_line, parent in that order.
Correlating with another source¶
This example alerts when a process starts from a Temp folder and its executable was created as a new file within the rule's 8-minute timeframe:
SELECT CAST(p.event_id AS VARCHAR) AS event_id,
epoch_us(p.occurred_at) AS occurred_at,
p.image_path AS image_path
FROM kernel_process_etw AS p
WHERE p.event_type = 1
AND p.image_path ILIKE 'C:\Users\%\AppData\Local\Temp\%.exe'
AND EXISTS (SELECT 1 FROM file_etw AS f
WHERE f.event_type = 1 AND f.created = 1
AND lower(f.path) = lower(p.image_path)
AND f.occurred_at <= p.occurred_at)
LIMIT 1000
Here file_etw is a secondary source. It holds only rows from up to timeframe before the earliest candidate, so a file created earlier than that is not seen. A correlation that needs a later event must be written from that later event's side, looking back.
Threshold¶
A threshold rule returns every row that qualifies. The engine then raises one alert if at least min_count rows came back. The alert is anchored to the newest returned event, and {{match_count}} is the number of rows. The built-in "Many Failed Logons for One Account or From One Source" rule (kind: threshold, min_count: 10, priority: 2, timeframe: 8m) uses:
alert_description: '{{match_count}} failed logons in one interval; newest for {{account}} from {{source}} (logon type {{logon_type}}).'
Its query returns event_id, occurred_at, account, source, logon_type. It does not return match_count.
Creating a rule in the console¶
- Open Alert rules and choose New rule.
- Optionally start from a template: Blank rule, Process execution, Registry persistence or Repeated process starts.
- Fill in the fields:
- name, rule kind (and threshold count), severity, confidence and category;
- ATT&CK techniques (comma-separated) and required telemetry;
- description, Alert text (
alert_description) and Investigation note (investigation_playbook).
- Write the SQL in the query box. You can also use the column picker and Generate SQL / Add condition to build a simple filter.
- Optionally add built-in exclusions. They are versioned with the rule.
-
Choose Run every (priority) and Look back (
timeframe): 2, 8 or 15 minutes. A threshold rule's look-back must match its run interval. -
Choose Validate rule. Validation runs the same checks the engine runs.
- Choose Test rule to evaluate the draft against the last 24 hours or 7 days of retained evidence. The test applies the rule's active user exclusions and creates no alerts. It shows:
- the match count;
- a histogram over the test window (flagged when the query hit its 1,000-row limit);
- up to 25 sample events with the alert text each would produce.
- Save with Save disabled (to review first) or Save & enable.
If AI assistance is configured, Draft as rule on the Hunt page turns a plain-language description into a draft rule. The draft opens in this editor. Nothing is saved until you save it.
Managing custom rules through the local API¶
All mutating calls need the console session and the CSRF token. The token is returned by GET /api/v1/detections/rules.
| Action | Request |
|---|---|
| Validate | POST /api/v1/detections/rules/validate with {"schema_version":1,"document":{…}}. Returns {"valid":true} or {"valid":false,"error":"…"}. |
| Test | POST /api/v1/detections/rules/test with {"schema_version":1,"document":{…},"start_us":…,"end_us":…}. Returns match_count, would_alert, up to 25 samples with alert_preview, truncated and match_histogram. |
| Create | POST /api/v1/detections/rules/custom with {"schema_version":1,"csrf_token":"…","document":{…},"enabled":true\|false}. rule_version must be 1. |
| Edit | PUT /api/v1/detections/rules/custom/{rule_id} with {"schema_version":1,"csrf_token":"…","expected_rule_version":N,"document":{… "rule_version":N+1 …}}. Returns 409 if another save happened first. |
| Version history | GET /api/v1/detections/rules/{rule_id}/versions returns up to 100 stored versions with their save times. The console can restore an older version by saving it as a new version. |
| Enable / disable | PATCH /api/v1/detections/rules/{rule_id}/state |
Each save stores a new immutable version. Rules run their latest version.
When an edit changes the rule's source table, FreeDR handles it as follows:
- The rule is disabled.
- Its user exclusions and entity mutes are removed, because they name the old source's columns. Each removal is written to the tuning audit with the detail "rule source changed".
- Whole-rule mutes are kept.
There is no API or console action that deletes a custom rule. Disable it instead.