Rules and detection¶
FreeDR detects threats by running SQL rules over the telemetry it stores in its local DuckDB evidence store. Each rule is a YAML (or JSON) document that holds one DuckDB SELECT over one typed data source, plus metadata such as severity, confidence, ATT&CK techniques and an alert text template. When a rule matches, FreeDR stores an alert. Related alerts are then grouped into cases (see Alerts and cases).
This page covers:
- the built-in rule pack that ships with the agent;
- how and when rules run;
- how to tell whether detection is healthy.
Related pages:
- Built-in rule catalog: every shipped rule in one table.
- Writing custom rules: the rule document format, SQL restrictions and how to save a rule.
- Exclusions and mutes: built-in exclusions, your own exclusions, AI-proposed exclusions and time-boxed mutes.
The built-in rule pack¶
| Property | Value |
|---|---|
| Pack | core-windows, version 0.3.0 ("Built-in Windows detection content") |
| Rules | 345 |
| Rule schema | All rules use schema_version: 6 and evidence_semantics: exact |
| Rule kinds | 342 event_match (one alert per matching event), 3 threshold (one alert per run when enough events match) |
| Delivery | Compiled into the agent binary. Nothing is loaded from disk at runtime, so a missing or edited file on the host cannot change the shipped rules. |
| Editing | Built-in rules are read-only. You can enable or disable them, add your own exclusions, and mute them. You cannot change their SQL or metadata. |
| Default state | Every built-in rule is enabled until you disable it. |
Each rule has its own rule_version (from 1 to 11 in the current pack). FreeDR uses the version to tell matches apart: it gives an alert from version 3 of a rule a different identity than an alert from version 2 on the same event. See How alerts are created.
Categories and severities¶
The category of a built-in rule must be one of the ATT&CK enterprise tactics below, or malware for Microsoft Defender verdicts that have no tactic. Custom rules may use any category token.
| Category | Rules | Critical | High | Medium | Low | Informational |
|---|---|---|---|---|---|---|
| defense_evasion | 115 | 1 | 80 | 22 | 12 | 0 |
| persistence | 83 | 1 | 34 | 29 | 19 | 0 |
| credential_access | 42 | 3 | 32 | 7 | 0 | 0 |
| execution | 27 | 0 | 15 | 9 | 1 | 2 |
| command_and_control | 18 | 0 | 13 | 4 | 1 | 0 |
| impact | 17 | 0 | 12 | 5 | 0 | 0 |
| discovery | 14 | 0 | 3 | 7 | 4 | 0 |
| lateral_movement | 9 | 0 | 2 | 4 | 3 | 0 |
| privilege_escalation | 6 | 1 | 4 | 1 | 0 | 0 |
| malware | 5 | 1 | 2 | 1 | 1 | 0 |
| collection | 3 | 0 | 3 | 0 | 0 | 0 |
| exfiltration | 3 | 0 | 0 | 3 | 0 | 0 |
| initial_access | 3 | 0 | 0 | 2 | 1 | 0 |
| Total | 345 | 7 | 200 | 94 | 42 | 2 |
Declared confidence: 66 high, 253 medium, 26 low. Confidence is stored on each alert and you can filter alerts by it. It does not change how alerts are grouped into cases or whether you are notified.
The severity decides what happens after a match:
| Severity | Creates a case automatically | Automatic AI investigation of the case | Tray notification | Counts as "urgent" |
|---|---|---|---|---|
| critical | yes | yes, if AI is configured | yes, if enabled for this level | yes |
| high | yes | yes, if AI is configured | yes, if enabled for this level | yes |
| medium | yes | yes, if AI is configured | if your minimum is medium or low | no |
| low | yes | no (cases with low severity are not auto-investigated) | if your minimum is low | no |
| informational | no | no | never | no |
Informational alerts are stored and listed like any other alert, but they never enter case automation. The two informational built-in rules are "New PowerShell Instance Created" and "Suspicious command shell start". For details see Alerts and cases.
Data sources¶
Every rule reads exactly one typed source in its FROM clause. Each typed source is one table in the evidence store.
| Source table | What it holds | Built-in rules |
|---|---|---|
kernel_process_etw |
Process starts, exits and image loads from the kernel process ETW provider, with parent and actor lineage stamped at collection time | 159 |
windows_event_log |
Windows Event Log records (Security, System, Sysmon, PowerShell, Defender, Task Scheduler and others) | 97 |
registry_etw |
Registry value sets and deletes, key creates and deletes, and key security changes from the registry ETW provider | 59 |
file_etw |
File create/open, write, rename, delete, set-info and read operations from the file ETW provider | 25 |
usn |
NTFS change journal (USN) records | 3 |
artifact |
Collected file content facts: hash, Authenticode status, signer and PE header fields | 2 |
A few rules also read history from their own source (<source>_context, 7 rules), another typed source (3 rules), or artifact_path_state, which holds the latest collected facts for each file path (7 rules). The catalog shows these in its "Also reads" column.
Each rule also lists the telemetry it depends on in required_telemetry; 339 built-in rules declare it. This is descriptive metadata only. FreeDR does not check that the source is actually collecting. A rule whose source is off simply never matches. The console's Coverage view groups rules by technique and repeats this warning: declared coverage does not prove that an activity would be detected.
MITRE ATT&CK mapping¶
- 333 of the 345 rules declare at least one ATT&CK technique in
attack_techniques. The pack covers 178 distinct technique and sub-technique IDs, across 87 parent techniques. - The 12 rules without a technique are mostly the Microsoft Defender verdict rules (category
malware) plus a few task-deletion and PowerShell rules. - The most frequent techniques are T1059.001 (PowerShell, 32 rules), T1003.001 (LSASS memory, 26), T1112, T1562.001 and T1547.001 (20 each), T1204.002 (19) and T1105 (17).
- A rule can list at most 32 technique IDs, each in the form
T1234orT1234.567.
The mapping is metadata. You can use it in the console to search rules and to build the Coverage view. It does not change how FreeDR detects.
Where the rules come from¶
Most shipped rules are translations of public detection content into FreeDR's typed SQL. Mainly that is SigmaHQ Sigma rules; others come from the mdecrevoisier Sigma collection, Hayabusa, Elastic, Splunk and Velociraptor. Where a rule was translated, a comment at the top of its YAML file names the frozen upstream file, its SHA-256 and its upstream ID. Some rules have no such header; these were written for FreeDR or carry no upstream reference.
Each rule's description explains what the translation keeps and what it cannot evaluate from FreeDR's telemetry. For example, PE OriginalFileName is not retained, so a renamed copy of a binary is outside the scope of name-based process rules.
How detection runs¶
Schedule¶
Every rule has a priority, and the priority sets how often the rule runs:
| Priority | Runs every | Built-in rules |
|---|---|---|
| P1 | 2 minutes | 114 |
| P2 | 8 minutes | 188 |
| P3 | 15 minutes | 43 |
The detection engine runs three independent lanes, one per priority. On each tick a lane:
- runs every enabled built-in rule of that priority;
- runs the latest version of every enabled custom rule of that priority, after validating it again (a custom rule that no longer validates is skipped and marks detection degraded).
Limits:
- At most 16 rule runs execute at the same time.
- Each rule runs one window at a time. If the previous run of a rule is still going, the new tick is recorded as a
busy_skip.
Which events a run looks at¶
Detection is incremental and works on ingest time:
- Each rule keeps a cursor, which is the ingest time (
written_at) up to which it has already evaluated. - A run evaluates the rows of its source written after the cursor, up to the current ingest boundary. When the run succeeds, the cursor advances.
- When a rule has no cursor, it looks back over its
timeframe. This happens the first time a rule runs and after itsrule_versionchanges. - Each event is therefore evaluated once per rule version, even when ingestion is delayed.
Runs read only the recent ("hot") part of the evidence store and never open the older Parquet files. This keeps the cost of a run independent of how long FreeDR has been running. If a rule falls so far behind that its cursor is older than the oldest hot row (for example after a long failure backoff), it evaluates what hot still holds. It then records the gap (hot_gap_end_us) in its run history and moves on. Hot retention therefore has a minimum of 45 minutes.
Timeframe¶
A rule's timeframe must be 2m, 8m or 15m. It bounds how much history the rule may correlate with:
- Context reads.
<source>_contextand other typed sources contain only rows whose event time lies between the earliest new candidate minus the timeframe and the latest candidate plus 60 seconds. - Literals. An
INTERVALliteral longer than the timeframe is rejected when the rule is loaded. - Threshold rules. A threshold rule's timeframe must equal its run interval, for example
8mfor P2. All 3 built-in threshold rules follow this.
SQL over typed tables¶
A rule query is ordinary DuckDB SQL, with these restrictions:
- one
SELECT; - exactly one typed source in
FROM; - no joins, CTEs or table functions;
- no other tables.
In the query, the source name (for example kernel_process_etw) means the new rows of this run, after exclusions are applied. <source>_context means the time-bounded history of the same source. The query must return event_id and occurred_at first, then one column for each {{placeholder}} in the alert text. See Writing custom rules for the full contract.
A run may return at most 1,000 rows:
- The built-in rules end in
LIMIT 1000, so further matches in the same window are not alerted. - A query without its own limit that returns more than 1,000 rows fails the run.
Late, failed and skipped runs¶
Each run gets a 5-second budget.
Late runs. A run that overruns its budget is late, not failed. Nothing is recorded as a failure, the cursor does not move, and the next tick retries the same window with a doubled budget, up to 64 times the original (320 seconds). Only one extended-budget retry runs at a time, so slow rules cannot take every database reader.
Run outcomes. Every other run is recorded in the run history with one of these outcomes:
| Outcome | Meaning | Counted as |
|---|---|---|
success |
The window was evaluated and its alerts were committed | successful |
evaluation_failure |
The query failed. Examples: invalid SQL against current data, a NULL value in a placeholder column, or more than 1,000 rows |
failed |
render_failure |
The alert text could not be rendered. Examples: a control character in a value, or a description over 4 KiB | failed |
persistence_failure |
The alerts could not be written to the control store | failed |
cancelled |
The service shut down during the run | failed |
busy_skip |
The previous run of the same rule was still going | skipped |
backoff_skip |
The rule is waiting out a failure backoff | skipped |
Failure backoff.
- After a failure, the rule waits one cadence. Each further consecutive failure doubles the wait, up to 32 cadences.
- A success resets the counter.
- The cursor is not advanced by a failure, so the window is retried later.
- A shutdown cancellation is recorded but does not start a backoff.
Detection health¶
The Alert rules page header shows whether the engine is healthy, plus its successful and failed run counts. Selecting a rule shows its recent runs.
The same data is available from the local API:
-
GET /api/v1/detections/healthreturns:Field Meaning rulesBuilt-in plus custom rule count successful_runs,failed_runs,skipped_runsLifetime totals from the run history last_completed_at_usWhen the last run completed degraded,last_runtime_errorSet when a rule fails, a custom rule is invalid, the database worker is unavailable, or a control-store check fails late_runsEvaluations that overran their budget since the service started lagging_rulesRules whose current window is still waiting for a late result sqlite_healthResult of the integrity check of the control store, which runs every 5 minutes case_automationProcessed count, backlog flag and last error of the alert-to-case worker -
GET /api/v1/detections/runs?rule_id=<id>&limit=<1-100>returns the latest runs. Each run includes its window, start and end times, outcome, match count, alerts inserted and anyhot_gap_end_us. GET /api/v1/detections/ruleslists every rule with its enabled state and its last run.
Run history is control data. It expires with the evidence retention period (retention_days, 90 days by default), but the lifetime health totals are kept.
Enabling and disabling rules¶
You can enable or disable any rule, built-in or custom, from the rule list (including several at once) or with PATCH /api/v1/detections/rules/{rule_id}/state ({"schema_version":1,"csrf_token":"…","enabled":true|false}).
- The state is stored locally, and every change is kept in an append-only history.
- A disabled rule is skipped. It does not count as a failure.
- When you enable it again, it continues from its cursor. If that cursor is older than the hot data, the run records a gap as described above.