Sets whether one of Microsoft's shipped anomaly detections is enabled, and which mode it runs in (
azurerm_sentinel_alert_rule_anomaly_built_in). Targetshashicorp/azurerm ~> 4.0.
- 🎚️ This module creates no detection. Microsoft ships the anomaly rules; this resource sets whether one is enabled and which mode it runs in.
- 🔴 A built-in anomaly rule cannot be deleted, so removing this resource DISABLES it — it does not restore whatever state the rule was in before Terraform managed it.
⚠️ enabledandmodeare required with no defaults, because every apply restates the posture of a control that already exists.⚠️ mode = "Flighting"runs the rule in evaluation — enabled, configured, and not contributing the wayProductiondoes.- 🧭 Identify the target rule by
nameordisplay_name— exactly one. The provider marks both optional, so setting neither is accepted and then fails. - 📊 Everything else is read-only and emitted, including the data connectors the rule needs in order to produce anything.
- 🔒 No credential is accepted and none is emitted.
💡 Why it matters: Five inputs, and none of them creates anything. What this module really controls is whether a Microsoft-authored detection is contributing — and the two ways it can fail to contribute look completely different in a configuration while being identical in effect.
Microsoft states that after March 31, 2027, Microsoft Sentinel will no longer be supported in the Azure portal and will be available only in the Microsoft Defender portal, and that customers using Microsoft Sentinel in the Azure portal will be redirected to the Defender portal. Since July 2025 many new customers are onboarded and redirected to the Defender portal automatically.
This does not change what this module manages.
azurerm_sentinel_*are ARM resources and Terraform talks to ARM, not to a portal, so these resources continue to exist and stay manageable from code across that date. What moves is the management experience - runbooks, screenshots, analyst training, and any procedure that ends in a human clicking through Sentinel in the Azure portal. Plan that transition on its own schedule; this module emitsmanagement_moves_to_the_defender_portalso the date reaches plan output and inventory reports rather than living only in documentation.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart LR
law["terraform-azurerm-log-analytics-workspace: Sentinel has no workspace of its own. Everything here lives on a Log Analytics workspace, and its RETENTION decides how far back any detection can look."]
onboard["terraform-azurerm-sentinel-log-analytics-workspace-onboarding: THE GATE. Nothing else in this family works until it exists, and DESTROYING IT OFFBOARDS SENTINEL from the workspace."]
wsid["AND WIRE log_analytics_workspace_id FROM THIS MODULE'S workspace_id OUTPUT, not from the workspace module's id. Both carry the same value, but reading it through here makes Terraform ORDER ONBOARDING FIRST. The provider's own examples chain it this way."]
gotcha["NOTE THE FIELD NAME TRAP: azurerm_log_analytics_workspace exports BOTH id AND an attribute literally named workspace_id. Every module in this family wants the ARM RESOURCE ID, not the GUID. A bare GUID is rejected at plan time."]
cmk["and customer_managed_key_enabled is PERMANENT FOR THE WORKSPACE: once onboarded with it true, the workspace cannot be onboarded again with it false. It is also force-new, so a plan that looks like an ordinary replacement can leave the workspace offboarded and unable to come back the way you asked."]
sched["terraform-azurerm-sentinel-alert-rule-scheduled: THE WORKHORSE. Your KQL, on a schedule. query_period must be at least query_frequency or events between runs are never examined."]
nrt["terraform-azurerm-sentinel-alert-rule-nrt: the same shape MINUS the schedule, evaluated continuously. event_grouping is REQUIRED here, entity mappings cap at 5 combined not 10, and alert_rule_template_version is force-new."]
nrtq["BUT NRT RESTRICTS THE QUERY: joins, wide unions and long aggregations are the usual casualties, and NOTHING CHECKS THAT AT PLAN TIME. Build it in the analytics-rule wizard first; porting a scheduled rule's query straight in is the common way to get a rule that will not run."]
ms["terraform-azurerm-sentinel-alert-rule-ms-security-incident: PROMOTES alerts another Microsoft product already raised. Detects nothing itself. product_filter takes LEGACY product names, so Microsoft Defender for Endpoint is REJECTED and Microsoft Defender Advanced Threat Protection is accepted."]
fusion["terraform-azurerm-sentinel-alert-rule-fusion: Microsoft's MULTI-STAGE attack correlation. Its name argument is DEPRECATED and removed in provider v5.0, so this module neither accepts nor emits it."]
mlba["terraform-azurerm-sentinel-alert-rule-machine-learning-behavior-analytics: Microsoft's behaviour model. Needs a LEARNING PERIOD, so early silence is not a misconfiguration."]
ti["terraform-azurerm-sentinel-alert-rule-threat-intelligence: matches logs against threat-intelligence INDICATORS, and supplies none. With no indicator source it is a detection that CANNOT FIRE, and nothing about it looks wrong."]
tmpl["these three are TEMPLATE-DRIVEN: the alert_rule_template_guid IS the detection, so repointing it is a different detection rather than a retune, and enabled is the only thing that updates in place"]
anom["terraform-azurerm-sentinel-alert-rule-anomaly-built-in: TOGGLES a rule Microsoft ships. It creates nothing, so REMOVING THIS RESOURCE DISABLES THE RULE rather than releasing it."]
dup["terraform-azurerm-sentinel-alert-rule-anomaly-duplicate: a TUNED COPY, because a built-in rule's thresholds are read-only. Anything you do not override is INHERITED from the built-in rule."]
pair["so the usual shape is a PAIR, and it needs a decision the provider will not make: running the original AND a tuned duplicate in Production means the same detection active twice with two sets of thresholds"]
mode["and BOTH anomaly modules take a REQUIRED mode: Production or FLIGHTING. Flighting runs in evaluation - enabled, configured, and not contributing. Right while tuning, wrong to leave behind."]
gaps["THE FAMILY'S REAL RISK IS NOT AN OPEN DOOR, IT IS A SILENT DETECTION. enabled false, create_incident_enabled false, suppression_enabled true, mode Flighting, a trigger threshold above zero, a severity_filter missing High - each looks reasonable alone and each leaves a rule that passes a review asking IS IT CONFIGURED and fails one asking DOES IT WORK."]
emit["which is why every module here emits derived posture flags - is_active, creates_incidents, is_production_mode - and the two query modules gather them into a single detection_gaps output"]
law -->|"id"| onboard
cmk -->|"decide before the first apply"| onboard
onboard -->|"workspace_id"| wsid
gotcha -->|"read this first"| wsid
wsid -->|"log_analytics_workspace_id"| sched
wsid -->|"log_analytics_workspace_id"| nrt
wsid -->|"log_analytics_workspace_id"| ms
wsid -->|"log_analytics_workspace_id"| fusion
wsid -->|"log_analytics_workspace_id"| mlba
wsid -->|"log_analytics_workspace_id"| ti
wsid -->|"log_analytics_workspace_id"| anom
wsid -->|"log_analytics_workspace_id"| dup
nrtq -->|"unvalidatable"| nrt
tmpl -->|"shape"| fusion
tmpl -->|"shape"| mlba
tmpl -->|"shape"| ti
anom -->|"settings_definition_id becomes built_in_rule_id"| dup
pair -->|"disable or flight the original"| dup
mode -->|"required"| anom
mode -->|"required"| dup
gaps -->|"so"| emit
emit -->|"assert on these"| sched
emit -->|"assert on these"| nrt
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class onboard keystone;
class sched,nrt,ms,fusion,mlba,ti,anom,dup me;
class law,wsid,gotcha,cmk,nrtq,tmpl,pair,mode,gaps,emit sib;
flowchart TB
nothing["READ THIS FIRST: THIS MODULE CREATES NO DETECTION. Microsoft ships the anomaly rules. This resource sets whether one of them is ENABLED and which MODE it runs in, and nothing else."]
ro["everything else about the rule is READ-ONLY here and emitted for review: its frequency, its MITRE tactics and techniques, its required data connectors, and all four kinds of observation setting with their permitted ranges"]
destroy["AND A BUILT-IN ANOMALY RULE CANNOT BE DELETED, so REMOVING THIS RESOURCE DISABLES THE RULE. It does not restore whatever state the rule was in before Terraform managed it - a terraform destroy here TURNS OFF A MICROSOFT-AUTHORED DETECTION, and no plan says so in those words."]
req["so enabled and mode are BOTH REQUIRED WITH NO DEFAULTS. There is no leave-it-alone value to default to, because every apply restates the posture of a control that already exists."]
mode["mode is Production or FLIGHTING. Flighting runs the rule in EVALUATION - it is enabled, it appears configured, and it does not contribute the way Production does. Right while assessing a rule's noise, wrong to leave behind."]
both["so is_actively_detecting combines the two flags, because a DISABLED rule and a FLIGHTING rule look very different in a configuration and are EQUIVALENT IN EFFECT: neither contributes anything."]
handles["IDENTIFY THE TARGET RULE BY name OR display_name - EXACTLY ONE. The provider marks BOTH optional, so a configuration setting NEITHER is accepted and then fails; this module requires one at plan time."]
stable["prefer name, the ARM GUID: display names are MICROSOFT'S and Microsoft can reword them, and because both fields are force-new a renamed rule shows up as a PROPOSED REPLACEMENT rather than a usable error. Discover either with the azurerm_sentinel_alert_rule_anomaly DATA SOURCE."]
conn["THE MOST USEFUL OUTPUT HERE IS required_data_connector. An enabled, production-mode anomaly rule whose required connectors are NOT configured is SILENT, and the rule's own state gives no hint - so cross-check it against the connectors actually deployed in the workspace."]
tune["and if you need to change a threshold you CANNOT do it here - the observation settings are read-only. Duplicate the rule with the anomaly-duplicate module and tune the copy; settings_definition_id is the value that module takes as built_in_rule_id."]
arm["note also that anomaly rules live under securityMLAnalyticsSettings, a DIFFERENT ARM child type from the alertRules the other rule modules in this family create"]
this["terraform-azurerm-sentinel-alert-rule-anomaly-built-in"]
keystone["azurerm_sentinel_alert_rule_anomaly_built_in.this"]
nothing -->|"so"| ro
ro -->|"and"| destroy
destroy -->|"therefore"| req
req -->|"and"| mode
mode -->|"so"| both
both -->|"posture"| this
handles -->|"and"| stable
stable -->|"addressing"| this
conn -->|"coverage"| this
tune -->|"and"| arm
arm -->|"shape"| this
this -->|"sets the state of a rule Microsoft ships"| keystone
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this me;
class keystone keystone;
class nothing,ro,destroy,req,mode,both,handles,stable,conn,tune,arm sib;
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_sentinel_alert_rule_anomaly_built_in.this |
1 | The keystone. Sets state on a rule that already exists. |
timeouts block |
0..1 | All four operations. |
Five inputs, no nested blocks but timeouts, and eleven read-only outputs describing a rule this module did not create.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Azure resource provider | Microsoft.SecurityInsights — under securityMLAnalyticsSettings, not alertRules |
| Provider block | None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication. |
Schema notes that bite — confirmed against the live provider schema and its documentation:
- 🔴 A built-in anomaly rule cannot be deleted. The provider states it: deleting a Terraform-managed built-in anomaly rule disables it.
nameanddisplay_nameare both optional in the schema, so setting neither is accepted by the provider and then fails. This module requires exactly one.- Both identifying fields are force-new, so a rule Microsoft has reworded appears as a proposed replacement rather than a usable error.
enabledandmodeare required — the provider supplies no defaults.modeisProductionorFlighting.- Every observation setting is read-only here. Tuning requires the anomaly-duplicate module.
- Anomaly rules live under
securityMLAnalyticsSettings, a different ARM child type from thealertRulesthe scheduled, NRT and template-driven rules create. - No
tags. Tag the workspace. lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy.
| Operation | Role | Scope |
|---|---|---|
| Creating, updating or deleting an analytics rule | Microsoft Sentinel Contributor | the resource group containing the Log Analytics workspace, or the workspace |
| Reading rules and their results | Microsoft Sentinel Reader | the same scope |
Contributor or Owner at the same scope also work, and are broader than needed.
ℹ️ Sentinel's roles are workspace-scoped, and there is no per-rule permission — so a team that may tune one anomaly rule may disable all of them.
- The Log Analytics workspace already onboarded to Microsoft Sentinel — wire
log_analytics_workspace_idfrom that module'sworkspace_idoutput so Terraform orders the two correctly. - The built-in anomaly rule already present in the workspace. Microsoft ships them with Sentinel; a rule that does not exist cannot be identified by either handle.
- The data connectors the rule requires, which this module can report but not configure — see
required_data_connectorand example 6. - Workspace retention long enough for the rule's own frequency to be meaningful. Anomaly rules run on Microsoft's cadence, which this module cannot change.
terraform-azurerm-sentinel-alert-rule-anomaly-built-in/
├── providers.tf # required_version + the pinned azurerm provider. No provider block.
├── variables.tf # log_analytics_workspace_id, name XOR display_name,
│ # enabled (required), mode (required), timeouts
├── main.tf # the keystone, one resource
├── outputs.tf # id, the two posture flags, and eleven read-only facts
├── README.md # this document
├── SCOPE.md # the cross-module contract and the destroy-semantics rationale
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "anomaly_scanning" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-anomaly-built-in.git?ref=v1.0.0"
# ⚠️ From the ONBOARDING module, not the workspace module.
log_analytics_workspace_id = module.sentinel.workspace_id
# Exactly one of these. See example 3.
display_name = "Anomalous scanning activity"
# Both required — no defaults. See example 4.
enabled = true
mode = "Production"
}ℹ️ The caller configures the provider, its authentication, and the mandatory
features {}block. This module declares none of them.
⚠️ Read example 2 before writing aterraform destroyagainst this module.
Consumes
| Input | Type | Source module |
|---|---|---|
log_analytics_workspace_id |
string |
the Sentinel onboarding module → workspace_id |
name or display_name |
string |
the azurerm_sentinel_alert_rule_anomaly data source — exactly one |
enabled |
bool |
caller — required, no default |
mode |
string |
caller — required; Production or Flighting |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The rule's Resource ID, under securityMLAnalyticsSettings. |
review, imports |
name / display_name |
Both handles; name is computed when identified by display name. |
review |
enabled / mode |
The posture as set. | security review |
is_production_mode |
Derived — false means the rule is in evaluation. |
security review |
is_actively_detecting |
Derived — enabled and in production mode. | security review |
description / frequency |
Microsoft's metadata. Read-only. | review |
tactics / techniques |
Microsoft's MITRE classification. Read-only. | coverage reporting |
required_data_connector |
The connectors the rule needs to produce anything. | security review |
settings_definition_id |
The value the anomaly-duplicate module takes as built_in_rule_id. |
the duplicate module |
anomaly_version / anomaly_settings_version |
Microsoft's versions. Read-only. | change review |
threshold_observation and the other three observation kinds |
The shipped settings, with permitted ranges. Read-only. | tuning review |
No credential is accepted and none is emitted.
1 · 🎚️ What this module actually controls
Microsoft ships the anomaly rules with Sentinel. This resource sets:
enabled - whether the rule runs
mode - Production or Flighting
Everything else is READ-ONLY here and emitted for review:
description frequency tactics techniques required_data_connector
settings_definition_id anomaly_version anomaly_settings_version
threshold / single_select / multi_select / prioritized_exclude observations
ℹ️ So this is not a rule-authoring module. There is no query, no severity, no entity mapping — the detection is Microsoft's, including its cadence and its MITRE classification.
💡 The read-only outputs are the point of using Terraform here at all: they put Microsoft's own description of what the rule detects, what data it needs and what could be tuned into your state, where a review can read them.
⚠️ If you need to change a threshold, you cannot do it here. Duplicate the rule with the anomaly-duplicate module and tune the copy (example 7).
ℹ️ Anomaly rules live under
securityMLAnalyticsSettings, a different ARM child type from thealertRulesthat the scheduled, NRT and template-driven modules create. That is why their outputs look so different from the rest of the family.
2 · 🔴 A destroy disables the rule — it does not release it
The provider states it plainly:
"A Built-in Anomaly Alert Rule cannot be deleted. Deleting a Terraform managed
Built-in Anomaly Alert Rule will cause the Built-in Anomaly Alert Rule to be
disabled."
🔴 So
terraform destroyhere turns off a Microsoft-authored detection. It does not hand the rule back to its previous state, and it does not restore whateverenabledandmodewere before Terraform started managing it.
⚠️ And no plan says so in those words. The plan shows a resource being destroyed; the effect is a detection going quiet.
💡 So removing this module from a configuration is a security change, not a cleanup. If the intent is "stop managing this rule with Terraform", the operation you want is
terraform state rm, which leaves the rule exactly as it is:
terraform state rm 'module.anomaly_scanning.azurerm_sentinel_alert_rule_anomaly_built_in.this'
⚠️ A caller cannot addprevent_destroy—lifecycleis not valid inside amoduleblock. ACanNotDeletelock at the workspace scope is the available control, and it protects the whole Sentinel estate rather than this rule alone.
3 · 🧭 Exactly one handle, and which one to prefer
# ✅ By display name — readable.
display_name = "Anomalous scanning activity"# ✅ By ARM name — stable.
name = "0f2c0e6b-...-a1b2c3d4e5f6"# ❌ Neither. Accepted by the provider, then fails. Rejected here at plan.
# ❌ Both. Ambiguous. Also rejected.Error: Invalid value for variable
Set exactly ONE of `name` or `display_name` to identify the built-in anomaly
rule. The provider marks both optional, so setting neither is accepted and then
fails; setting both is ambiguous. Use `name` (the ARM GUID) where you have it —
it does not change when Microsoft rewords a display name.
💡 Prefer
name. Display names are Microsoft's, and Microsoft can reword them — at which point this module can no longer find its target. Because both fields are force-new, that shows up as a proposed replacement rather than an error you can act on.
ℹ️ Discover either handle with the data source:
data "azurerm_sentinel_alert_rule_anomaly" "scanning" {
log_analytics_workspace_id = module.sentinel.workspace_id
display_name = "Anomalous scanning activity"
}💡 A useful pattern: identify by
display_nameon the first apply, readnameout of this module's outputs, then switch tonameand keep it. The switch is force-new on both fields, so do it deliberately.
4 · ⚠️ `enabled` and `mode` are required on purpose
enabled = true
mode = "Production"ℹ️ The provider supplies no defaults, and this module does not invent any. That is the right shape here: the module does not create a detection, it restates the posture of one that already exists. There is no "leave it alone" value to default to.
💡 The consequence is deliberate: every apply of this module is an explicit statement about a security control. You cannot write a minimal call that quietly accepts whatever Microsoft's default happens to be.
⚠️ Contrast the rest of this family, whereenableddefaults totrue. Those modules create a rule, so a default describes the thing being created. Here it would describe a change to something that already exists.
ℹ️ Both fields update in place — the identifying fields are the force-new ones.
5 · ⚠️ `Flighting` is the setting most easily misread
mode = "Production" # the rule contributes normally
mode = "Flighting" # ⚠️ runs in EVALUATION
⚠️ A flighting rule is enabled, appears configured, and does not contribute the wayProductiondoes. It is the correct mode while you are assessing a rule's noise level, and the wrong mode to leave behind.
🔴 The two failure modes look completely different and are identical in effect:
enabled = false, mode = "Production" -> contributes nothing
enabled = true, mode = "Flighting" -> contributes nothing
💡 Which is why the module emits both a
mode-only flag and a combined one:
output "posture" {
value = {
production = module.anomaly_scanning.is_production_mode # false = flighting
detecting = module.anomaly_scanning.is_actively_detecting # enabled AND production
}
}ℹ️
is_actively_detectingis the one to assert on.is_production_modealone would pass for a disabled rule;enabledalone would pass for a flighting one.
6 · 📊 `required_data_connector` is the most useful output here
output "needs" { value = module.anomaly_scanning.required_data_connector }needs = [
{
"connector_id" = "AzureActiveDirectory"
"data_types" = ["SigninLogs"]
},
]
🔴 An enabled, production-mode anomaly rule whose required connectors are not configured is silent — and the rule's own state gives no hint at all.
enabled = true,mode = "Production",is_actively_detecting = true, and nothing arriving.
💡 So cross-check this output against the data connectors actually deployed in the workspace. It is the only place Microsoft's own statement of the rule's data dependency appears in your state.
⚠️ This module cannot configure connectors and cannot check them. They are separate resources — thesentinel-data-connector-*modules in this suite — and no validation here can see whether they exist.
ℹ️ The same reasoning applies to
frequency: the rule runs on Microsoft's cadence, so a rule with a daily frequency will not surface anything within an hour of being enabled, and that is not a fault.
7 · Tuning: you cannot, here
output "what_could_be_tuned" {
value = module.anomaly_scanning.threshold_observation
}what_could_be_tuned = [
{
"name" = "Device count threshold"
"description" = "Number of devices scanned"
"min" = "10"
"max" = "500"
"value" = "50"
},
]
ℹ️ Read-only here. The observation settings are shipped by Microsoft and this resource cannot change them — which is exactly why the anomaly-duplicate module exists.
💡 The
minandmaxin that output are the only place the permitted range appears. The duplicate module cannot enforce them, because the bounds belong to the rule being duplicated rather than to the configuration — so this output is the reference you tune against.
✅ The pattern:
module "anomaly_scanning_tuned" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-anomaly-duplicate.git?ref=v1.0.0"
log_analytics_workspace_id = module.sentinel.workspace_id
built_in_rule_id = module.anomaly_scanning.settings_definition_id
display_name = "Anomalous scanning activity (tuned)"
enabled = true
mode = "Flighting" # start in evaluation
threshold_observations = [{ name = "Device count threshold", value = "120" }]
}
⚠️ And then decide what happens to the original — running both inProductionmeans the same underlying detection is active twice, with two sets of thresholds. See example 8.
8 · The original-and-duplicate pair
# The built-in rule, stepped back once the tuned copy is live.
module "anomaly_scanning" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-anomaly-built-in.git?ref=v1.0.0"
log_analytics_workspace_id = module.sentinel.workspace_id
display_name = "Anomalous scanning activity"
enabled = true
mode = "Flighting" # ⚠️ or enabled = false — see below
}
module "anomaly_scanning_tuned" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-anomaly-duplicate.git?ref=v1.0.0"
log_analytics_workspace_id = module.sentinel.workspace_id
built_in_rule_id = module.anomaly_scanning.settings_definition_id
display_name = "Anomalous scanning activity (tuned)"
enabled = true
mode = "Production"
}
⚠️ Leaving both inProductionmeans the same detection is active twice, with two thresholds and two streams of incidents. Nothing in either module enforces a choice, and nothing warns.
💡
Flightingon the original is usually better thanenabled = false: the rule keeps running in evaluation, so if the tuned copy turns out to be wrong you can compare rather than starting from nothing.
🔴 And note the asymmetry on destroy. The duplicate can be deleted; the original cannot. So if you disable the original in favour of the duplicate and later delete the duplicate, the detection is off entirely — the original does not re-enable itself.
ℹ️
settings_definition_idis what links the two. It is emitted by this module and consumed asbuilt_in_rule_idby the duplicate.
9 · Managing several anomaly rules
locals {
anomaly_rules = {
scanning = "Anomalous scanning activity"
rdp = "Anomalous RDP login detected"
data_xfer = "Anomalous data transfer"
}
}
module "anomaly" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-anomaly-built-in.git?ref=v1.0.0"
for_each = local.anomaly_rules
log_analytics_workspace_id = module.sentinel.workspace_id
display_name = each.value
enabled = true
mode = "Production"
}
output "anomaly_posture" {
value = { for k, m in module.anomaly : k => m.is_actively_detecting }
}ℹ️
for_eachlives in the caller — the resource addresses one rule, and the module reflects that.
💡 Keying by a short slug rather than by display name keeps the keys stable: if Microsoft rewords a rule, you change the value rather than re-indexing the collection. Which matters, because both handles are force-new.
⚠️ Thefor_eachmap makes it easy to disable a lot of detections in one edit. A review of this configuration should read theanomaly_postureoutput, not the module block.
ℹ️ Each instance also emits its own
required_data_connector, so{ for k, m in module.anomaly : k => m.required_data_connector }is a quick way to see what the whole set depends on.
10 · What a review should assert
output "anomaly_review" {
value = {
detecting = module.anomaly_scanning.is_actively_detecting # enabled AND Production
production = module.anomaly_scanning.is_production_mode # false = flighting
needs = module.anomaly_scanning.required_data_connector
what = module.anomaly_scanning.description # what it detects
cadence = module.anomaly_scanning.frequency # Microsoft's, not yours
tactics = module.anomaly_scanning.tactics
version = module.anomaly_scanning.anomaly_version
}
}🔒
detectingis the assertion. Neitherenablednorproductionalone is sufficient — one passes for a disabled rule, the other for a flighting one (example 5).
💡
whatandcadencebelong in a review because a display name rarely says what a rule looks for, and because "why haven't we seen anything?" is usually answered by a daily frequency rather than a fault.
⚠️ versionis worth watching across applies. A change is Microsoft updating the detection underneath you — the logic and the shipped settings both move, and this module has no say in it.
ℹ️
needsis the cross-check against your deployed connectors (example 6).
11 · Importing a rule already toggled in the portal
terraform import 'module.anomaly_scanning.azurerm_sentinel_alert_rule_anomaly_built_in.this' \
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-sentinel/providers/Microsoft.OperationalInsights/workspaces/law-sentinel-prod/providers/Microsoft.SecurityInsights/securityMLAnalyticsSettings/0f2c0e6b-...-a1b2c3d4e5f6"
⚠️ Setenabledandmodeto match reality before the first plan. They are required arguments, and a mismatch means the first apply changes a security control's posture as a side effect of adopting it.
💡 Toggling anomaly rules in the portal is the common starting point, so importing is often more appropriate than creating. Import, confirm an empty plan, and only then change anything.
ℹ️ The ID's last segment is the rule's ARM
name— which is also the stable handle example 3 recommends, so an import is a convenient way to learn it.
⚠️ If the plan proposes a replacement rather than showing no changes, the identifying handle is wrong: adisplay_namethat no longer matches, or anamefrom a different workspace.
12 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-sentinel-prod"
location = "eastus"
}
module "law" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-workspace.git?ref=v1.0.0"
name = "law-sentinel-prod"
resource_group_name = module.rg.name
location = module.rg.location
sku = "PerGB2018"
retention_in_days = 90
}
# ── The gate ──────────────────────────────────────────────────────────────────
module "sentinel" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-log-analytics-workspace-onboarding.git?ref=v1.0.0"
workspace_id = module.law.id
}
# ── Microsoft's shipped anomaly detections, posture stated explicitly ─────────
locals {
anomaly_rules = {
scanning = "Anomalous scanning activity"
rdp = "Anomalous RDP login detected"
}
}
module "anomaly" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-anomaly-built-in.git?ref=v1.0.0"
for_each = local.anomaly_rules
log_analytics_workspace_id = module.sentinel.workspace_id
display_name = each.value
# Required, no defaults — every apply restates the posture (example 4).
enabled = true
mode = "Production"
}
# ── One of them tuned, via a duplicate, starting in evaluation (examples 7, 8) ─
module "anomaly_scanning_tuned" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-anomaly-duplicate.git?ref=v1.0.0"
log_analytics_workspace_id = module.sentinel.workspace_id
# The link between the pair.
built_in_rule_id = module.anomaly["scanning"].settings_definition_id
display_name = "Anomalous scanning activity (tuned)"
enabled = true
mode = "Flighting" # compare against the original before promoting
threshold_observations = [{
name = "Device count threshold"
# ⚠️ Within the min/max in module.anomaly["scanning"].threshold_observation.
value = "120"
}]
}
# ── A scheduled rule, for contrast: this one you author yourself ──────────────
module "rule_impossible_travel" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-scheduled.git?ref=v1.0.0"
name = "impossible-travel"
log_analytics_workspace_id = module.sentinel.workspace_id
display_name = "Impossible travel sign-in"
severity = "High"
query = file("${path.module}/queries/impossible_travel.kql")
query_frequency = "PT15M"
query_period = "PT1H"
tactics = ["InitialAccess"]
incident = { create_incident_enabled = true }
}
output "detection_posture" {
value = {
# The assertion that matters for anomaly rules (example 5).
anomaly_detecting = { for k, m in module.anomaly : k => m.is_actively_detecting }
# What each anomaly rule needs in order to produce anything (example 6).
anomaly_needs = { for k, m in module.anomaly : k => m.required_data_connector }
# The tuned copy: is the tuning actually in effect?
tuned_is_default = module.anomaly_scanning_tuned.is_default_settings
tuned_detecting = module.anomaly_scanning_tuned.is_actively_detecting
# And the rule we wrote ourselves.
scheduled_gaps = module.rule_impossible_travel.detection_gaps
}
}🔒 What the composition gets right: posture stated explicitly on every anomaly rule rather than defaulted, the pair linked through
settings_definition_id, the tuned duplicate starting inFlightingso it can be compared, the threshold checked against the original'smin/max, and a review output built fromis_actively_detectingrather than fromenabledalone.
⚠️ What no plan will tell you: whether the required data connectors are configured, whether the tuned threshold is inside the permitted range, whether120is a better threshold than the shipped one, or how long the rule's own frequency means you should wait before judging.
💡
tuned_is_defaultis the sharpest line in that output:trueon a rule that exists specifically to carry tuned settings means the tuning never took effect.
| Input | Type | Default | Notes |
|---|---|---|---|
log_analytics_workspace_id |
string |
— | Required. Force-new. Shape-validated; from the onboarding module. |
display_name |
string |
null |
Force-new. name. |
name |
string |
null |
Force-new. The ARM GUID — the stable handle. |
enabled |
bool |
— | Required, no default. |
mode |
string |
— | Required. Production / Flighting, validated. |
timeouts |
object(...) |
null |
All four operations. |
There is no tags variable — the provider exposes none on Sentinel anomaly rules.
Full schemas
variable "name" {
type = string
default = null
# The exactly-one check lives here, referencing display_name in one direction only —
# two variables validating each other is rejected as a cycle.
validation {
condition = ((var.name != null ? 1 : 0) + (var.display_name != null ? 1 : 0)) == 1
error_message = "Set exactly ONE of `name` or `display_name` … Use `name` (the ARM GUID) where you have it — it does not change when Microsoft rewords a display name."
}
}
variable "enabled" {
type = bool
# No default, deliberately: this module does not create a detection, it restates the posture of
# one that already exists — so there is no "leave it alone" value to default to.
}
variable "mode" {
type = string
validation {
condition = contains(["Production", "Flighting"], var.mode)
error_message = "mode must be either Production or Flighting."
}
}| Output | Description | Sensitive |
|---|---|---|
id |
The rule's Resource ID, under securityMLAnalyticsSettings. |
no |
name / display_name |
Both handles. | no |
log_analytics_workspace_id |
The workspace. | no |
enabled / mode |
The posture as set. | no |
is_production_mode |
Derived — false means flighting. |
no |
is_actively_detecting |
Derived — the flag to assert on. | no |
description / frequency |
Microsoft's metadata. | no |
tactics / techniques |
Microsoft's MITRE classification. | no |
required_data_connector |
What the rule needs to produce anything. | no |
settings_definition_id |
Feeds the duplicate module's built_in_rule_id. |
no |
anomaly_version / anomaly_settings_version |
Microsoft's versions. | no |
threshold_observation / single_select_observation / multi_select_observation / prioritized_exclude_observation |
Shipped settings, with permitted ranges. | no |
management_moves_to_the_defender_portal |
Always true - Sentinel's Azure portal experience retires 2027-03-31 and moves to the Microsoft Defender portal; the ARM resources are unaffected | no |
🔒 No credential is accepted and none is emitted.
-
The destroy semantics lead the documentation because they are the opposite of what the operation looks like. A
terraform destroyreads as "remove the thing I made"; here it disables a Microsoft-authored detection and does not restore its prior state.terraform state rmis named as the operation that actually means "stop managing this", because that is what someone reaching for destroy usually wants. -
enabledandmodeare required with no defaults, and the reasoning distinguishes this module from its siblings. Elsewhere in the familyenableddefaults totruebecause those modules create a rule and the default describes the thing being created. Here it would describe a change to something that already exists — so there is no defensible default, and every apply is an explicit statement about a security control. -
is_actively_detectingcombines two flags because either alone gives a false pass. A disabled production-mode rule and an enabled flighting rule contribute exactly nothing, and look completely different in a configuration. A single flag that requires both is the only honest assertion. -
required_data_connectoris surfaced as the module's most useful output despite being read-only. An enabled, production-mode rule with every posture flag green is still silent if its connectors are absent, and nothing in the rule's own state hints at it. This output is the only place Microsoft's statement of the data dependency reaches your state file. -
The read-only observation outputs are kept rather than trimmed. They are how a caller discovers what could be tuned, and their
min/maxvalues are the only place the permitted range for a duplicate's threshold appears — a range the duplicate module cannot enforce, because it belongs to the rule rather than the configuration. -
The exactly-one check names both handles and recommends one. The trade — readability against stability — is not visible from either field in isolation, and the consequence of getting it wrong is a proposed replacement rather than an error, because both fields are force-new.
-
settings_definition_idis emitted specifically to feed the duplicate module. The pair — original plus tuned copy — is the intended way to change an anomaly rule's thresholds, and the link between them is this one value. -
The double-detection risk is documented on both modules and enforced by neither. Running the original and a tuned duplicate in
Productionmeans the same detection active twice. "Usually you want the original flighted" is not "always", so the modules state the trade rather than making the choice — but they state it, because nothing else will.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Accidental disabling | destroy semantics documented; state rm named as the alternative |
destroy anyway, knowingly |
| Implicit posture | enabled and mode required with no defaults |
— |
| Flighting mistaken for active | is_production_mode and is_actively_detecting both emitted |
leave it flighting, knowingly |
| Silent rules | required_data_connector surfaced for cross-checking |
— |
| Ambiguous targeting | neither-or-both handles rejected at plan, with a recommendation | — |
| Reworded display names | name recommended as the stable handle, with the force-new consequence stated |
use display_name, knowingly |
| Untunable settings | read-only observations emitted, with the duplicate module named | — |
| Double detection | documented on both modules; not enforced | run both in Production, knowingly |
| Wrong-value pastes | the workspace's GUID attribute rejected at plan | — |
| Secrets | none accepted, none emitted | — |
- Before a destroy: it disables the detection.
terraform state rmis probably what you want. - Before the first apply: check
required_data_connectoragainst the connectors you actually have. - Before reporting a rule as active: assert
is_actively_detecting, notenabled. - Before tuning: this module cannot. Use the duplicate, and decide what the original does.
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the source to a tag —
?ref=v1.0.0— never a branch. - Plan-only from here. A human applies from CI.
- 🔴 A destroy disables a Microsoft-authored detection. Use
terraform state rmto stop managing without changing posture. ⚠️ Both identifying fields are force-new, so a reworded display name shows up as a replacement.- ℹ️
enabledandmodeupdate in place. - ℹ️ Do not judge a newly-enabled rule too quickly — check
frequency; a daily cadence means a day. - ℹ️ A
CanNotDeletelock at the workspace scope is the available destroy protection;prevent_destroyis not available to a module caller.
terraform validate and terraform fmt -check are the offline gate. They confirm:
log_analytics_workspace_idis aMicrosoft.OperationalInsights/workspacesARM Resource ID — a bare GUID is rejected;- exactly one of
nameanddisplay_nameis set — neither and both are both rejected; - neither handle is an empty or whitespace-only string;
modeisProductionorFlighting;enabledandmodeare present at all, since neither has a default;- no output is sensitive, because nothing sensitive is accepted;
- the module declares no
providerblock.
💡 These were proved by evaluating the conditions in
terraform consoleinside the module — which does fire root-module variable validations, unliketerraform validateon a calling configuration. Setting neither handle fired the check; setting both fired it too; a workspace ARM Resource ID withdisplay_nameandmode = "Flighting"passed and read back correctly.
What only plan and apply exercise:
- whether the workspace exists and is onboarded to Sentinel;
- whether a built-in anomaly rule matching the supplied handle exists in the workspace;
- whether the identity holds Microsoft Sentinel Contributor.
What no Terraform command checks at any stage:
- whether the rule's required data connectors are configured — the difference between an active rule and a silent one;
- whether Microsoft has reworded the display name you are matching on;
- whether the rule's cadence makes its silence expected rather than a fault;
- whether a shipped threshold suits your environment — that judgement needs the duplicate module and time.
Outputs:
anomaly_settings_version = 1
anomaly_version = "1.0.3"
description = "Identifies a source performing scanning activity against an unusual number of devices."
display_name = "Anomalous scanning activity"
enabled = true
frequency = "P1D"
id = "/subscriptions/00000000-.../providers/Microsoft.SecurityInsights/securityMLAnalyticsSettings/0f2c0e6b-...-a1b2c3d4e5f6"
is_actively_detecting = true
is_production_mode = true
mode = "Production"
name = "0f2c0e6b-...-a1b2c3d4e5f6"
required_data_connector = [
{
"connector_id" = "AzureActiveDirectory"
"data_types" = ["SigninLogs"]
},
]
settings_definition_id = "0f2c0e6b-...-a1b2c3d4e5f6"
tactics = ["Discovery"]
✅
is_actively_detecting = true— enabled and in production mode. Neither flag alone would tell you that.
⚠️ frequency = "P1D"is the answer to "why haven't we seen anything yet?" more often than any fault is. The cadence is Microsoft's.
💡
nameis populated even though the rule was identified bydisplay_name— which is how you learn the stable handle (example 3).
📊
required_data_connectoris the cross-check: withoutSigninLogsarriving, all the green flags above mean nothing.
| Symptom | Cause | Fix |
|---|---|---|
| Plan rejects the identifying handle | Neither name nor display_name was set, or both were. |
Exactly one (example 3). |
Plan rejects log_analytics_workspace_id |
The workspace's workspace_id GUID attribute was passed. |
Use the onboarding module's workspace_id. |
| A first apply fails saying the workspace is not onboarded | Nothing ordered onboarding first. | Wire from the onboarding module. |
| Apply fails saying the rule was not found | The display name does not match, or the rule is not in this workspace. | Look it up with the anomaly data source (example 3). |
| A destroy silenced a detection | That is what destroying this resource does. | Re-apply with enabled = true; use state rm next time (example 2). |
| The plan proposes a replacement after no config change | Microsoft reworded the display name you match on. | Switch to name (example 3). |
| The rule is enabled and nothing appears | Required data connectors absent, or the cadence has not elapsed. | Check required_data_connector and frequency (examples 6, 10). |
| A rule reported as active is not contributing | mode = "Flighting". |
Assert is_actively_detecting (example 5). |
| Wanted to change a threshold | Observation settings are read-only here. | Use the anomaly-duplicate module (example 7). |
| Two streams of incidents from one detection | The original and a tuned duplicate are both in Production. |
Flight or disable one (example 8). |
| Deleted the duplicate and the detection went quiet | The original had been disabled in its favour. | Re-enable the original (example 8). |
| An import proposes a replacement | The handle is wrong, or enabled/mode do not match reality. |
Correct them before applying (example 11). |
Wanted prevent_destroy |
lifecycle is not valid inside a module block. |
Use a workspace-scope lock (example 2). |
azurerm_sentinel_alert_rule_anomaly_built_in— provider documentation, including the cannot-be-deleted note.azurerm_sentinel_alert_rule_anomaly— the data source for discovering handles.azurerm_sentinel_alert_rule_anomaly_duplicate— the tuning path of examples 7 and 8.- Anomaly detection analytics rules — what the modes do, and how tuning works in the portal.
- Use customizable anomalies — the detections Microsoft ships.
- Microsoft Sentinel roles and permissions — the RBAC table above.
- Sibling modules:
terraform-azurerm-sentinel-log-analytics-workspace-onboarding,...-alert-rule-anomaly-duplicate,...-alert-rule-scheduled,...-alert-rule-nrt,...-alert-rule-ms-security-incident,...-alert-rule-fusion,...-alert-rule-machine-learning-behavior-analytics,...-alert-rule-threat-intelligence. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."

