Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Microsoft Sentinel Built-in Anomaly Rule Terraform Module

Sets whether one of Microsoft's shipped anomaly detections is enabled, and which mode it runs in (azurerm_sentinel_alert_rule_anomaly_built_in). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Destroy

🧩 Overview

  • 🎚️ 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.
  • ⚠️ enabled and mode are 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 way Production does.
  • 🧭 Identify the target rule by name or display_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.

📅 Azure portal retirement - March 31, 2027

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 emits management_moves_to_the_defender_portal so the date reaches plan output and inventory reports rather than living only in documentation.

Reference: https://learn.microsoft.com/azure/sentinel/overview#microsoft-sentinel-in-the-azure-portal-retirement-timeline

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

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;
Loading

🧬 What this module builds

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;
Loading

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.

✅ Provider / Versions

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.
  • name and display_name are 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.
  • enabled and mode are required — the provider supplies no defaults.
  • mode is Production or Flighting.
  • Every observation setting is read-only here. Tuning requires the anomaly-duplicate module.
  • Anomaly rules live under securityMLAnalyticsSettings, a different ARM child type from the alertRules the scheduled, NRT and template-driven rules create.
  • No tags. Tag the workspace.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy.

🔑 Required Azure RBAC Roles / Permissions

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.

Azure Prerequisites

  • The Log Analytics workspace already onboarded to Microsoft Sentinel — wire log_analytics_workspace_id from that module's workspace_id output 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_connector and 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.

📁 Module Structure

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

⚙️ Quick Start

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 a terraform destroy against this module.

🔌 Cross-Module Contract

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.

📚 Example Library

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 the alertRules that 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 destroy here turns off a Microsoft-authored detection. It does not hand the rule back to its previous state, and it does not restore whatever enabled and mode were 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 add prevent_destroy — lifecycle is not valid inside a module block. A CanNotDelete lock 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_name on the first apply, read name out of this module's outputs, then switch to name and 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, where enabled defaults to true. 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 way Production does. 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_detecting is the one to assert on. is_production_mode alone would pass for a disabled rule; enabled alone 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 — the sentinel-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 min and max in 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 in Production means 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 in Production means the same detection is active twice, with two thresholds and two streams of incidents. Nothing in either module enforces a choice, and nothing warns.

💡 Flighting on the original is usually better than enabled = 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_id is what links the two. It is emitted by this module and consumed as built_in_rule_id by 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_each lives 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.

⚠️ The for_each map makes it easy to disable a lot of detections in one edit. A review of this configuration should read the anomaly_posture output, 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
  }
}

🔒 detecting is the assertion. Neither enabled nor production alone is sufficient — one passes for a disabled rule, the other for a flighting one (example 5).

💡 what and cadence belong 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.

⚠️ version is 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.

ℹ️ needs is 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"

⚠️ Set enabled and mode to 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: a display_name that no longer matches, or a name from 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 in Flighting so it can be compared, the threshold checked against the original's min/max, and a review output built from is_actively_detecting rather than from enabled alone.

⚠️ What no plan will tell you: whether the required data connectors are configured, whether the tuned threshold is inside the permitted range, whether 120 is a better threshold than the shipped one, or how long the rule's own frequency means you should wait before judging.

💡 tuned_is_default is the sharpest line in that output: true on a rule that exists specifically to carry tuned settings means the tuning never took effect.

📥 Inputs

Input Type Default Notes
log_analytics_workspace_id string — Required. Force-new. Shape-validated; from the onboarding module.
display_name string null Force-new. ⚠️ Exactly one of this and 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."
  }
}

🧾 Outputs

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.

🧠 Architecture Notes

  • The destroy semantics lead the documentation because they are the opposite of what the operation looks like. A terraform destroy reads as "remove the thing I made"; here it disables a Microsoft-authored detection and does not restore its prior state. terraform state rm is named as the operation that actually means "stop managing this", because that is what someone reaching for destroy usually wants.

  • enabled and mode are required with no defaults, and the reasoning distinguishes this module from its siblings. Elsewhere in the family enabled defaults to true because 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_detecting combines 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_connector is 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/max values 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_id is 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 Production means 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.

🧱 Design Principles

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 rm is probably what you want.
  • Before the first apply: check required_data_connector against the connectors you actually have.
  • Before reporting a rule as active: assert is_actively_detecting, not enabled.
  • Before tuning: this module cannot. Use the duplicate, and decide what the original does.

🚀 Runbook

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 rm to stop managing without changing posture.
  • ⚠️ Both identifying fields are force-new, so a reworded display name shows up as a replacement.
  • ℹ️ enabled and mode update in place.
  • ℹ️ Do not judge a newly-enabled rule too quickly — check frequency; a daily cadence means a day.
  • ℹ️ A CanNotDelete lock at the workspace scope is the available destroy protection; prevent_destroy is not available to a module caller.

🧪 Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • log_analytics_workspace_id is a Microsoft.OperationalInsights/workspaces ARM Resource ID — a bare GUID is rejected;
  • exactly one of name and display_name is set — neither and both are both rejected;
  • neither handle is an empty or whitespace-only string;
  • mode is Production or Flighting;
  • enabled and mode are present at all, since neither has a default;
  • no output is sensitive, because nothing sensitive is accepted;
  • the module declares no provider block.

💡 These were proved by evaluating the conditions in terraform console inside the module — which does fire root-module variable validations, unlike terraform validate on a calling configuration. Setting neither handle fired the check; setting both fired it too; a workspace ARM Resource ID with display_name and mode = "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.

💬 Example Output

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.

💡 name is populated even though the rule was identified by display_name — which is how you learn the stable handle (example 3).

📊 required_data_connector is the cross-check: without SigninLogs arriving, all the green flags above mean nothing.

🔍 Troubleshooting

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).

🔗 Related Docs

💙 "Infrastructure as Code should be standardized, consistent, and secure."