Skip to content

libre-devops/terraform-msgraph-xdr-custom-detection-rules

Repository files navigation

Terraform MSGraph XDR Custom Detection Rules

Microsoft Defender XDR custom detection rules as code: analysts author one YAML file per rule, Terraform validates every field at plan time and deploys through the Microsoft Graph security/rules/detectionRules API using the Microsoft/msgraph provider. A curated baseline ships in the box (the same catalog and engine shape as the Libre DevOps policy and Sentinel workbook modules), and destructive automated response actions sit behind an explicit gate.

CI Release Terraform Registry License


Detections as code, analyst first

A detection engineer should review a detection the way a developer reviews code: one file, one rule, one diff. This module makes that the deployment contract:

custom-detections/
  identity/
    impossible-travel-vpn-gap.yaml
  endpoint/
    certutil-remote-download.yaml
  email/
    forwarding-rule-to-external.yaml

Every *.yaml (or *.yml) under the directory you pass as custom_detections_dir becomes one custom detection rule; the first level folder is its category, the analyst facing logical grouping that also drives the rules_by_category and mitre_coverage outputs. A rule file looks like this:

# yaml-language-server: $schema=https://raw.githubusercontent.com/libre-devops/terraform-msgraph-xdr-custom-detection-rules/main/schema/custom-detection.schema.json
display_name: Certutil used to download remote content
status: enabled            # enabled | disabled (default enabled)
frequency: PT3H            # PT0S (Continuous) | PT1H | PT3H | PT12H | PT24H
query: |
  DeviceProcessEvents
  | where FileName =~ "certutil.exe"
  | where ProcessCommandLine has_any ("urlcache", "-split") and ProcessCommandLine has "http"
  | project Timestamp, ReportId, DeviceId, DeviceName, AccountSid, SHA256, ProcessCommandLine
alert:
  severity: medium         # informational | low | medium | high
  recommended_actions: Recover and detonate the downloaded content.
  mitre:
    - tactic: CommandAndControl
      techniques: [T1105]
  custom_details:
    CommandLine: ProcessCommandLine
  entity_mappings:
    hosts:
      - device_id_column: DeviceId
        name_column: DeviceName
    files:
      - sha256_column: SHA256
device_groups: [Workstations-Corp]   # optional scoping

The same schema works as HCL through custom_rules for rules a stack composes or generates, and the module's own catalog/ baseline uses it too: one validator, one normaliser, three sources, deployed simultaneously (YAML directory, HCL rules and the baseline coexist in one call; rule ids must be unique across all three, and the plan names any collision). Alongside the machine schema, schema/custom-detection-reference.yaml is the annotated human companion: every field with its defaults, allowed values, and the live API caveats, ready to copy from.

Brownfield tenants adopt in two commands (LibreDevOpsHelpers 2.6.0+): Export-LdoCustomDetectionRule -OutDir ./custom-detections turns every hand made rule in the tenant into these YAML files (server ids kept so plans line up; legacy shapes converted best endeavours with TODO comments), then after a plan, Invoke-LdoTerraformGraphImportFromPlan -PlanJson ./plan.json imports them all into state.

Every field checked, every error named

The plan fails with every problem across every file in one message, each naming the offending file and field: unknown attributes at any nesting level, missing required fields, enum and format violations (severity, status, ISO 8601 schedule against allowed_frequencies, T1059.001 style technique ids, the 14 enterprise ATT&CK tactics), non list or non mapping shapes, and duplicate rule ids across sources. The allowed shapes are extracted from the Graph beta $metadata (all 17 entity mapping types, all 16 automated action groups) and mirrored in schema/custom-detection.schema.json, so editors with yaml-language-server flag mistakes while the analyst types, and CI can pre-check files with the same schema before Terraform runs.

Values are also normalised on a best endeavours basis, because analysts author these files: keys stay strict (they are the schema contract editors autocomplete), values are forgiving. status, severity and isolation_type are case insensitive; frequency and technique ids uppercase themselves (pt1h becomes PT1H, t1110 becomes T1110); tactics resolve case and separator insensitively (credential access, credential-access and CredentialAccess all deploy as CredentialAccess, and the British DefenceEvasion maps to the API's DefenseEvasion). Truly unknown values still fail the plan with the canonical list named, and the editor schema keeps nudging the canonical spellings.

Two advisory checks warn without failing: a query that does not visibly return Timestamp and ReportId (custom detections must output them; rename projections can hide them from the token scan), and a rule with no MITRE mapping (it would vanish from coverage reporting).

Built on the current API shape

Microsoft is removing the legacy detection rule properties on 2026-10-01 (isEnabled, detectorId, lastRunDetails, alertTemplate.category, mitreTechniques, impactedAssets, responseActions). This module never used them: it builds on status, tactics with nested techniques, entityMappings, and automatedActions from day one. Custom detection rules are beta only today, so api_version defaults to beta; it is a plain variable, so flip it to v1.0 the day Microsoft promotes the API, without waiting for a module release.

Live API reality, encoded

The beta docs and the live service disagree in places; every divergence below was hit on a real tenant and is now encoded in the module rather than left for you to find:

  • One tactic per rule. The docs model tactics as a collection; the service 400s on more than one ("Only one tactic is currently supported"). Authoring keeps the full list, the body sends only the first tactic, and the rules / mitre_coverage outputs report everything authored, ready to send in full the day the API accepts it.
  • Mail message mappings have a mandatory column combination. Network message id, recipient and sender must map together (a lesser pair 400s with "at least one mandatory field combination"); the validator enforces it and subject is recommended.
  • Deletion is eventually consistent. The API accepts the delete, then the read path can keep returning the rule for many minutes (the portal shows the same lag), and the provider polls that read path before declaring success. The rule resource carries a 30 minute delete timeout and transient error retries by default (timeouts, retry_error_message_regex); parallel deletes share one lag window.
  • Portability is a catalog quality bar. Catalog rules only use hunting tables that resolve in any Defender XDR tenant (Device*, Email*, Identity*, CloudApp*). Entra specific tables like AadSignInEventsBeta only resolve when that data flows into XDR, so a rule on them fails both remote validation and the create elsewhere; the in graph validation catches this before anything deploys, which is exactly its job.

Automated response actions are a deliberate opt in

A detection rule can act on real assets when it fires: isolate devices, disable users, quarantine files, delete mail. The module refuses any rule carrying automated_actions, from any source, until the call sets allow_automated_actions = true, and the failure names the files. Turning the gate on is a reviewed decision for the calling stack, never a side effect of dropping a file into a folder.

The curated baseline

baseline_enabled (on by default) deploys the reviewed starter pack from catalog/, currently six high signal, low noise rules across endpoint/, identity/, email/ and cloud-apps/ (Office spawning encoded PowerShell, LSASS dump tooling, failed sign-in bursts, legacy authentication sign-ins, delivered malware verdict mail, mass cloud app downloads). None carry automated actions. Tune or drop each through baseline_overrides (disable, park in status = disabled tuning mode, reschedule, retune severity, scope to device groups); the baseline_catalog_keys output lists the keys. Rules deploy with client provided ids (the file name, unless the file sets id), so plans are stable, and id_prefix namespaces everything a call owns for side by side deployments.

Validation ladder (CI/CD)

  1. Editor: yaml-language-server against schema/custom-detection.schema.json while typing.
  2. Plan: this module's validator, offline, every field, every file, one aggregated failure.
  3. CI, local lint: KQL syntax parsing (Kusto.Language) via the LibreDevOpsHelpers gate, fast feedback on pull requests before anything touches the tenant.
  4. Apply, in graph: remote_query_validation (on by default) runs every rule's query against the tenant's real advanced hunting schema through the Graph v1.0 security/runHuntingQuery action, inside the Terraform graph and before the rule resource itself. Queries run verbatim by default (the endpoint caps result sizes server side); remote_validation_append_take can append a trailing | take 1 for queries that tolerate it, off by default because appending an operator can interact badly with a query that already ends in one. The response schema (the query's output columns) is tracked in state, and a query change replaces its validation action, so re-validation happens exactly when a query changes. A missing table or column fails the apply with the rule named.
  5. Apply, the create: the Graph detection rule create is the final authority (it validates the query and the required result columns server side).

Permissions

Path Permission
Deploy rules (app or delegated) CustomDetection.ReadWrite.All
Remote query validation (in graph, default on; also usable from CI) ThreatHunting.Read.All

Delegated callers also need a Defender XDR role that manages detections (Security Administrator, or unified RBAC Detection tuning (Manage)). The provider authenticates from the environment (Azure CLI locally, OIDC in CI) like the azuread provider.

Outputs made for a SOC

mitre_coverage rolls the authored tactics and techniques up into ATT&CK coverage per rule ids, ready for a coverage report or workbook; rules_by_category and rules expose the analyst view; ids / ids_zipmap follow the estate composition conventions.

Requirements

Name Version
terraform >= 1.9.0, < 2.0.0
msgraph >= 0.1.0, < 1.0.0

Providers

Name Version
msgraph >= 0.1.0, < 1.0.0
terraform n/a

Modules

No modules.

Resources

Name Type
msgraph_resource.detection_rules resource
msgraph_resource_action.validate_queries resource
terraform_data.schema_guard resource

Inputs

Name Description Type Default Required
allow_automated_actions Gate for automated response actions. Automated actions run against real assets when a rule fires
(isolate a device, disable a user, quarantine a file, delete mail, and so on), so no rule from any
source (baseline, YAML directory, or custom_rules) may carry an automated_actions block until this
is explicitly set true. The plan fails, naming the offending files, when a rule declares actions
while the gate is off. Turning the gate on is a deliberate, reviewed decision for the calling stack.
bool false no
allowed_frequencies The ISO 8601 durations a rule's frequency may use. Defaults to the schedules Defender XDR custom
detections support: every 1, 3, 12, or 24 hours (PT1H, PT3H, PT12H, PT24H, with P1D accepted as the
24 hour spelling) plus PT0S for Continuous (near real time). Narrow this list to enforce an
organisational floor (for example, forbid Continuous), or extend it if the service starts accepting
new schedules before this module catches up.
list(string)
[
"PT0S",
"PT1H",
"PT3H",
"PT12H",
"PT24H",
"P1D"
]
no
api_version Microsoft Graph API version used for every detection rule call. Custom detection rules are
currently beta only, so the default is beta; flip to v1.0 when Microsoft promotes the API. Never
hardcoded so consumers are not pinned to this module's release cadence.
string "beta" no
baseline_enabled Deploy the curated baseline detections shipped in this module's catalog/ directory (the same shape
as the policy module's baseline: calling the module gets a reviewed starter pack for free). Each
baseline rule is a YAML file under catalog// and can be tuned or dropped per rule through
baseline_overrides. Set false to deploy only your own rules.
bool true no
baseline_overrides Per rule tuning of the baseline, keyed by the baseline rule id (the id field in the catalog YAML,
which defaults to the file name; the baseline_catalog_keys output lists every key). Recognised
attributes per entry, all optional:

- enabled (bool): false drops the rule entirely.
- status (string): enabled or disabled, overriding the catalog value (deploy a rule in a disabled,
tuning-mode state without forking the catalog).
- frequency (string): override the schedule (validated against allowed_frequencies).
- severity (string): override the alert severity.
- device_groups (list(string)): scope the rule to specific device groups.

Unknown keys, and keys that match no baseline rule, fail the plan.
any {} no
custom_detections_dir Root directory of analyst authored detection rules, picked up per file: every *.yaml or *.yml under
// becomes one custom detection rule, and the first level folder name is the rule's
category (its logical grouping for humans and for the mitre_coverage / rules_by_category outputs).
Files directly in the root land in the "uncategorised" category. Each file follows the schema in
schema/custom-detection.schema.json (every field is validated at plan time with the offending file
named). Null deploys no directory rules.
string null no
custom_rules Detection rules authored directly in HCL, keyed by rule id, using exactly the same schema as the
YAML files (display_name, query, frequency, alert {severity, mitre, entity_mappings, ...},
device_groups, automated_actions, extra_body; a category attribute is also accepted since HCL rules
have no folder to derive it from). YAML files are the analyst path; this input exists for rules
that are generated or composed by the calling stack. Validated by the same engine as the files.
any {} no
hunting_api_version Microsoft Graph API version for the remote query validation calls (security/runHuntingQuery).
Defaults to v1.0, where the hunting API is generally available; independent of api_version because
the detection rule API and the hunting API promote separately.
string "v1.0" no
id_prefix Optional prefix prepended to every rule id (baseline and custom), namespacing the rules this module
call owns, for example per environment or per stack ("soc-dev-"). The rule id is client provided on
create and doubles as the Terraform key, so the prefix keeps parallel deployments from colliding.
Null applies no prefix.
string null no
remote_query_validation Run every rule's KQL against the tenant through the Graph runHuntingQuery action inside the
Terraform graph, before the rule is created or updated. This proves tables and columns against the
real advanced hunting schema server side (queries run verbatim by default; see
remote_validation_append_take), and the response schema (the query's output columns) is tracked in
state. A query change replaces
its validation action, so re-validation happens exactly when a query changes. The applying
principal needs ThreatHunting.Read.All; set false to opt out (for example, a principal with only
CustomDetection.ReadWrite.All).
bool true no
remote_validation_append_take Append a trailing "| take 1" to each remote validation query. Off by default on purpose: appending
an operator can interact badly with queries that already end in a take or limit, or whose final
operator matters, so the default runs every query verbatim and relies on the hunting endpoint's own
server side result caps. Enable it deliberately when your queries tolerate a trailing take and you
want validation to return as little data as possible.
bool false no
remote_validation_timespan ISO 8601 timespan the remote validation queries look back over. Validation needs schema soundness,
not data, so the default PT1H keeps the scanned window (and the tenant load) minimal; widen it if
you want validation to double as a smoke test over real data.
string "PT1H" no
retry_error_message_regex Regular expressions the provider retries on when a Graph call fails, applied to the detection rule
resource and the validation actions. Transient service noise is retried by default; the provider
retries matching errors with backoff until the operation timeout bounds it (the provider exposes no
retry count knob, so the timeout is the ceiling). Set null to disable retries.
list(string)
[
"(?i)too many requests",
"(?i)service unavailable",
"(?i)internal server error",
"(?i)timeout",
"(?i)temporarily unavailable"
]
no
timeouts Operation timeouts for the detection rule resource. Deletion is EVENTUALLY CONSISTENT server side
(proven live: the API accepts the delete, then the provider polls the read path, which can keep
returning the rule for many minutes; the portal shows the same lag), so the delete default is 30
minutes; deletes run in parallel, so a destroy shares one lag window rather than paying it per
rule. Transient errors retry per retry_error_message_regex.
object({
create = optional(string, "10m")
delete = optional(string, "30m")
read = optional(string, "5m")
update = optional(string, "10m")
})
{} no

Outputs

Name Description
baseline_catalog_keys Every baseline rule id in this module's catalog (the keys baseline_overrides accepts), whether or not the baseline is enabled.
ids Map of rule id to the Graph detection rule id (client provided, so normally identical; kept as a map for composition symmetry with the estate).
ids_zipmap Map of rule id to {name, id} for easy composition, matching the estate wide zipmap convention.
mitre_coverage ATT&CK coverage rolled up from the authored mitre blocks: tactics and techniques each map to the rule ids that cover them. Feeds the coverage report and workbooks.
rules Metadata for every deployed rule: display name, category, source (baseline, custom_detections_dir, or custom_rules), the file it came from, status, schedule, severity, and its MITRE tactics and techniques.
rules_by_category Rule ids grouped by category (the analyst facing logical separation: the first level folder for file rules, the category attribute or "custom" for HCL rules).

About

🕵️ Defender XDR custom detection rules as code: analyst authored YAML per rule, plan-time schema validation, curated baseline, gated response actions, via Microsoft Graph

Topics

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors