Forge Detection Language

Your detections are text.
Here is the grammar.

Every Qvasir detection is written in FDL, the Forge Detection Language — human-readable YAML, versioned in git, bound to the real fields of a real source. No canonical field taxonomy in the middle, no translation layer to go stale, no opaque rule store between you and your own logic. This page shows you actual production rules, not a syntax summary.

Why not Sigma

We started on Sigma. Here is what moved us.

Sigma is a genuinely good thing for this industry, and Qvasir was built on it for its first year. The reasons we left are architectural rather than a complaint about the project or its community.

Sigma rules match on canonical field names and rely on a per-backend pipeline to translate those into whatever your data actually calls things. That translation is where the truth goes missing: a rule can be valid, compile cleanly, and match nothing — because the field it assumes is one your source never populates. The failure is silent, and silence looks exactly like safety.

FDL binds a detection to the fields a specific source actually carries, validated against a schema measured from your own events. If a field is not there, you find out while writing the rule.

Four more, in short:

  • Typed operators. Field types come from the source schema, so a type-mismatched modifier is rejected at parse time rather than at 3am.
  • Correlation inline. Sequences and thresholds live in the same document as the match, not a second correlation file in a different dialect.
  • An entity model. Rules declare which fields are users, hosts, processes and files, and what role each plays. Correlation and scoring are built on that, and it has no equivalent in Sigma.
  • Confidence separate from severity. "How bad if real" and "how sure are we" are different questions; collapsing them into one level throws away the distinction that triage runs on.

On portability

The honest counter-argument to all of this is portability, and it deserves a straight answer. Publishing and distributing rule packages is a good idea and we intend to do it.

But a shared rule only holds if the data underneath has the same shape — and that is a claim about your telemetry that somebody has to check. Qvasir checks it on arrival. A rule that cannot work against your sources is reported as a visibility gap rather than deployed to sit silent.

Today there is no Sigma import path. If you have an existing Sigma library and that matters to your evaluation, tell us — it is a decision we are actively weighing, and the interesting version of it uses your measured schema to say which of your existing rules would actually fire here.

A rule, top to bottom

Detecting lateral movement via WMIC.

This is a real production rule from a demonstration environment, abridged only by removing empty optional blocks. Nothing has been simplified for the page.

rules/remote_process_creation_via_wmic.yaml
fdl_version: 1.7.0
title: Remote Process Creation via WMIC
name: windows_remote_process_creation_via_wmic
version: 1.2.2
author: demo.user
level: high
confidence: high
tags:
  - attack.execution
  - attack.t1047

source:
  logsource_name: Microsoft - Windows Security Event Log Workstation
  ocsf_class: Process Activity
  cluster_id: f6144d24-…
  event_group: event_id_4688

entities:
  - {field: event_data.SubjectUserName, type: user, role: actor}
  - {field: event_data.ParentProcessName, type: file, role: actor}
  - {field: computer, type: host, role: observer}
  - {field: event_data.NewProcessName, type: file, role: context}

detect:
  selection:
    event_id: 4688
    event_data.NewProcessName|endswith|ci: \wmic.exe
    event_data.CommandLine|contains|all|ci|windash:
      - '/node:'
      - process call create
  filter_system_account:
    event_data.SubjectUserName|endswith: $
  filter_known_parent:
    event_data.ParentProcessName|endswith|in:
      tunable(safe_parent_processes)
  condition: selection and not filter_system_account
             and not filter_known_parent

risk:
  base: 45
  modifiers:
    - {l2: {entity_has_prior_tp: true}, multiply: 1.5}

falsepositives:
  - Legitimate remote administration scripts used by system
    administrators or management tools (e.g. SCCM, PDQ Deploy)
    may use `wmic.exe` for remote process creation.
  • source binds the rule to one measured event shape. Not "Windows logs" in the abstract — a specific logsource and a specific cluster of events within it, whose field schema has been measured from real traffic. This is the line that makes portability a checkable claim rather than an assumption.
  • entities is what correlation and scoring are built on. Each declaration says which field carries which kind of thing, and what part it plays. The engine resolves events to those entities and reasons about them over time.
  • The detection is specific, not a keyword. It is not "wmic ran" — it requires wmic.exe and /node: and process call create. Remote execution, not local use.
  • windash handles an evasion class declaratively. Attackers substitute dash characters — /node:, -node:, en-dashes — to slip past literal matching. One modifier covers the equivalence class, rather than a hand-maintained list of variants that someone has to remember to extend.
  • tunable(...) is where deployment-specific values live. The rule ships with an empty allowlist; your admin tooling goes in the tunable, not into a fork of the rule. The logic and the local truth stay separable, which is what makes the rule updatable.
  • Risk is composed, not fixed. A base score, modified by what the platform knows — here, that this entity has produced a confirmed true positive before.
  • The false-positive note names a real product. We would rather publish that than a reassuring adjective. A rule that claims it never misfires is a rule nobody has run.

Correlation

Two rules, one chain, and no join key to maintain.

Obfuscated PowerShell followed by a service installed from a user-writable path is a well-worn persistence pattern. Individually each half is ordinary; together, within ten minutes on one host, they are worth waking someone for. Here are both halves and the rule that joins them.

the two members, abridged
# ── member 1 ─────────────────────────────
name: powershell_encoded_command_parameter_binding
level: medium
confidence: high
tags: [attack.defense_evasion, attack.t1027.010,
       attack.execution, attack.t1059.001]

entities:
  - {field: user_name, type: user, role: actor}
  - {field: computer, type: host, role: observer}

detect:
  selection_event: {event_id: 4103}
  selection_encoded_param:
    event_data.Payload|re|ci: parameterbinding.*?name\s*=\s*
      ['"]?\b(e|en|enc|encodedcommand)\b['"]?
  filter_system_principals:
    user_name|in|ci: [SYSTEM, LOCAL SERVICE, NETWORK SERVICE]
  filter_computer_accounts:
    user_name|endswith: $
  condition: selection_event and selection_encoded_param
             and not (filter_computer_accounts
                      or filter_system_principals
                      or filter_whitelisted_users)

# ── member 2 ─────────────────────────────
name: windows_service_installed_from_suspicious_location
level: high
confidence: medium

entities:
  - {field: host, type: host, role: observer}
  - {field: event_data.ServiceName, type: service, role: target}
  - {field: event_data.ServiceFileName, type: file, role: target}

detect:
  selection_event: {event_id: 4697}
  selection_path:
    event_data.ServiceFileName|contains|ci:
      - C:\Users\
      - C:\ProgramData\
      - \AppData\
      - \Temp\
      - C:\PerfLogs\
  condition: selection_event and selection_path
             and not filter_known_good

risk:
  base: 45
  modifiers:
    - {l1: {service_name_entropy|gt: 4.5}, add: 20}
    - {l1: {event_data.SubjectUserName|endswith: $}, add: -15}
  • Look at the host field in each. One calls it computer, the other calls it host — different sources, different vendors, different names. Neither was changed to accommodate the other.
  • The regex catches the abbreviations. PowerShell accepts any unambiguous prefix of a parameter name, so -EncodedCommand, -enc, -en and -e are all the same thing to the interpreter and all different to a naive string match. (e|en|enc|encodedcommand) covers the family.
  • Machine accounts are filtered, deliberately. This is why platform-driven deployment — SCCM, Intune — does not reach the chain below. It is also why the false-positive note on that chain can be specific about what does.
  • Risk can move in both directions. High-entropy service names add 20; a machine-account subject subtracts 15. Scoring is evidence, not a severity label.
  • service_name_entropy is computed, not logged. Shannon entropy over the service name, available to the rule as if it were a field. Randomly-generated service names are a real signal and no source emits that measurement.
rules/attack_chain_obfuscated_powershell_to_suspicious_service_installation.yaml
fdl_version: 1.7.0
title: 'Attack Chain: Obfuscated PowerShell to Suspicious
        Service Installation'
version: 1.1.0
level: critical
confidence: high

detect: null          # nothing to match — this rule joins signals

correlate:
  type: temporal_ordered
  rules:
    - powershell_encoded_command_parameter_binding
    - windows_service_installed_from_suspicious_location
  group_by_entity: [host]
  timespan: 10m
  signal_mode: correlate_only

suppress:
  window: 6h
  per: [host]

falsepositives:
  - RMM agents running in a user context (ConnectWise, NinjaOne,
    Datto) that stage via encoded PowerShell and register a
    service in the same session.
  - Package managers and their wrappers — Chocolatey, winget
    scripts, vendor bootstrap installers — which legitimately
    combine encoded PowerShell with a service install inside
    the correlation window.
  - In-house deployment scripts that drop a service executable
    into ProgramData or a user-writable path.
  - 'Not expected from SCCM/Intune: the PowerShell member
    filters SYSTEM, LOCAL SERVICE, NETWORK SERVICE and machine
    accounts, so platform-driven deployment does not reach
    this chain.'
  • group_by_entity: [host] — not a field name. This is the part worth pausing on. The chain does not say "join where computer equals host." It groups by the host entity, and the engine resolves each member's declared field to an identity before correlating. Two rules written months apart against different vendors correlate correctly with no mapping table and nothing to keep in sync.
  • Keyed on host rather than user, on purpose. Event 4697 carries SYSTEM as its subject whenever the service install follows a privilege escalation. A user-keyed window would have missed exactly the case this rule exists for — the attacker who escalated. The host is the entity that genuinely spans both stages.
  • The whole chain is one document. No second correlation file in another dialect, reviewable in the same pull request as the logic it joins.
  • Read the false-positive list. It is longer than the detection. That is the honest shape of a correlation rule, and the tuning it implies is work the platform does from observed results rather than leaving to you.

Status

Versioned, and honest about it.

The grammar is at 1.7

Every rule declares the fdl_version it targets, so a rule and the grammar it was written against never drift apart silently. Breaking changes take a version bump and a migration note.

It is still moving

FDL is young and has not yet been through the hands of many detection engineers other than ours. We would rather say that than imply a stability we have not earned yet.

The specification will be published

Once the grammar has survived contact with pilot deployments, the full specification goes out publicly — versioned, with its changelog. Your detection logic should not depend on a document only we can read.

Rules shown are real production detections from a demonstration environment, abridged by removing empty optional blocks and truncating internal identifiers. Author metadata has been replaced with a placeholder. Nothing in the detection logic has been altered or simplified.