Skip to content

Repository files navigation

Terraform Azure Defender for Cloud

Microsoft Defender for Cloud as code: plans, integrations, contacts, continuous export, and custom controls, behind one validated map per concern.

CI Release Terraform Registry License


Overview

Defender for Cloud is configured in a handful of unrelated places, most of them subscription singletons with no name of their own, which is why so much of it ends up set by hand in the portal and then drifts. This module covers the nine writable resources as one composable call.

  • Plans are keyed by resource type. subscription_pricing is a map whose key IS the resource type, so a plan cannot be declared twice for one type and the classic duplicate-entry collapse cannot happen. standard_tier_resource_types is exported separately, because the set of billable plans is the thing worth reviewing in a pull request.
  • Removing an entry is a change, not a no-op. Deleting a subscription_pricing entry resets that type to Free, which turns the plan OFF. Deleting a settings entry disables the integration. Both are documented on the variables rather than discovered in production.
  • Assessments compose with their definitions. An entry in assessments names a policy_key from assessment_policies, and the module resolves it to the generated id. That is what orders the two correctly without a hand-maintained depends_on. A cross-variable validation catches a key that does not exist at plan time, rather than letting it surface as an index error naming neither variable.
  • The gotchas are enforced, not described. An EventHub action without a connection string and a LogicApp action without a trigger URL both fail at plan, because the provider accepts them and Azure rejects them. check blocks warn about the configurations that deploy cleanly and then do nothing: a storage override that was never declared as an override, an automation that will not appear under Continuous Export, CloudPosture enabled with no extensions, and the Sentinel integration enabled with no workspace bound.

What is deliberately not here

  • azurerm_security_center_auto_provisioning is deprecated in the provider and removed in v5.0, because Microsoft retired Log Analytics agent auto-provisioning in November 2024. Including it would build a known breakage into a module pinned at ~> 4.0.
  • azurerm_security_center_server_vulnerability_assessment_virtual_machine is per virtual machine, and the subscription level setting now accepts only MdeTvm, so the per-VM third party scanner path is legacy. It belongs with the resource it targets.

Permissions

settings, security_contacts, workspace and server_vulnerability_assessment_provider all require Owner on the subscription. Contributor is not enough, and the failure is a plain authorization error that does not say which of them caused it.

Usage

module "defender_for_cloud" {
  source  = "libre-devops/defender-for-cloud/azurerm"
  version = "~> 1.0"

  subscription_pricing = {
    VirtualMachines = {}
    StorageAccounts = { subplan = "DefenderForStorageV2" }
    KeyVaults       = {}
    Arm             = {}
  }

  settings = {
    Sentinel = true
  }

  workspace = {
    scope        = "/subscriptions/${data.azurerm_client_config.current.subscription_id}"
    workspace_id = module.law.workspace_ids["law-ldo-uks-prd-soc"]
  }

  security_contacts = {
    soc = { email = "soc@example.com" }
  }
}

Examples

  • examples/minimal - the smallest valid call, and deliberately the least invasive one: a single security contact, no plan changes, nothing billable.
  • examples/complete - every supported input exercised, with its supporting workspace and storage account, applying and destroying in one run. Plans are pinned to Free because an unattended example must not start billing.

Developing

Local work needs PowerShell 7+ and just, because the recipes wrap the LibreDevOpsHelpers PowerShell module (the same engine the libre-devops/terraform-azure action runs in CI). Install just with brew install just, or uv tool add rust-just then uv run just <recipe>.

Run just to list recipes: just update-ldo-pwsh (install or force-update LibreDevOpsHelpers from PSGallery), just validate, just scan (Trivy only), just pwsh-analyze (PSScriptAnalyzer only), just plan, just apply, just destroy, just e2e, just test, and just docs (the plan/apply/destroy recipes mirror the action, including the storage firewall dance; just e2e applies an example then always destroys it, defaulting to minimal, so nothing is left running). Releasing is also just: just increment-release [patch|minor|major] bumps, tags, and publishes a GitHub release, and the Terraform Registry picks up the tag.

The test suite is plan-time and offline: terraform init -backend=false && terraform test needs no credentials. One run applies rather than plans, because the assessment definition id it asserts on is computed and therefore unknowable at plan time; the provider is mocked, so that apply is still entirely local.

Security scan exceptions

This module is scanned with Trivy; HIGH and CRITICAL findings fail the build. Any waiver is a deliberate, reviewed decision, never a way to quiet a finding that should be fixed. Waivers live in .trivyignore.yaml (the machine-applied source of truth, passed to Trivy with --ignorefile) and are mirrored in the table below so the reason is auditable.

Trivy ID Resource Finding Justification
None

To add an exception: add an entry to .trivyignore.yaml (id, optional paths to scope it, and a statement recording why), then add a matching row here. Where the finding is out of this module's scope, point the justification at the Libre DevOps module that does address it (for example the private-endpoint module). Both the file and this table are reviewed in the pull request.

Reference

The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.

Requirements

Name Version
terraform >= 1.9.0, < 2.0.0
azurerm >= 4.0.0, < 5.0.0

Providers

Name Version
azurerm >= 4.0.0, < 5.0.0

Modules

No modules.

Resources

Name Type
azurerm_security_center_assessment.this resource
azurerm_security_center_assessment_policy.this resource
azurerm_security_center_automation.this resource
azurerm_security_center_contact.this resource
azurerm_security_center_server_vulnerability_assessments_setting.this resource
azurerm_security_center_setting.this resource
azurerm_security_center_storage_defender.this resource
azurerm_security_center_subscription_pricing.this resource
azurerm_security_center_workspace.this resource

Inputs

Name Description Type Default Required
assessment_policies Custom assessment definitions, keyed by a caller chosen key. An assessment policy is the
DEFINITION of a control: what it checks, how bad it is, and how to fix it. It puts your own
controls into the same posture surface as the Microsoft ones.

A definition on its own reports nothing. Pair it with an entry in var.assessments, which is what
records a result against a specific resource.
map(object({
display_name = string
description = string

severity = optional(string, "Medium")
categories = optional(set(string))
threats = optional(set(string))
implementation_effort = optional(string)
user_impact = optional(string)
remediation_description = optional(string)
}))
{} no
assessments Assessment results, keyed by a caller chosen key. An assessment records the outcome of a control
against one target resource.

Name the definition with policy_key to use a policy this module created, which is what wires the
dependency correctly; or with assessment_policy_id to point at a definition that already exists.
Exactly one of the two is required.

status code is Healthy, Unhealthy or NotApplicable. Anything other than Healthy should carry a
cause and description, because that text is what an operator reads in the portal and it is the
only explanation they get.
map(object({
target_resource_id = string

policy_key = optional(string)
assessment_policy_id = optional(string)

status = object({
code = string
cause = optional(string)
description = optional(string)
})

additional_data = optional(map(string))
}))
{} no
automations Workflow automations (the portal calls this Continuous Export), keyed by automation name. This
is the Defender for Cloud equivalent of a Sentinel automation rule: it matches findings and
sends them somewhere.

A source names the kind of data that triggers the automation. Its rule_sets filter that data:
rules WITHIN a rule set are ANDed, and separate rule sets are ORed. A source with no rule sets
matches everything of that kind, which is usually what you want for an export and rarely what
you want for a Logic App.

An action names where the data goes. Workspace and EventHub are exports; LogicApp is the one
that runs something. A LogicApp action needs trigger_url and an EventHub action needs
connection_string, both of which are secrets, so pass them from a data source or key vault
rather than a literal.

For the automation to appear in the portal under Continuous Export, at least one action must be
a Workspace or EventHub export.
map(object({
resource_group_name = string
location = string
tags = optional(map(string))

description = optional(string)
enabled = optional(bool, true)
scopes = list(string)

actions = list(object({
resource_id = string
type = optional(string)
connection_string = optional(string)
trigger_url = optional(string)
}))

sources = list(object({
event_source = string

rule_sets = optional(list(object({
rules = list(object({
property_path = string
property_type = string
operator = string
expected_value = string
}))
})), [])
}))
}))
{} no
security_contacts Security contacts, keyed by contact name. These receive Defender for Cloud alert notifications.

A contact is a notification path, not a ticketing path: it emails a human and carries no state.
Where an alert must produce a tracked outcome, wire the alert into a workflow through
var.automations instead, and treat the contact as the human courtesy copy.

Requires Owner on the subscription.
map(object({
email = string
phone = optional(string)

alert_notifications = optional(bool, true)
alerts_to_admins = optional(bool, true)
}))
{} no
server_vulnerability_assessment_provider The vulnerability assessment provider for servers, or null to leave the setting unmanaged.
MdeTvm (Defender for Endpoint threat and vulnerability management) is the only supported value
since the retirement of the third party scanners.

Requires Owner on the subscription. Removing this disables the setting.
string null no
settings Defender for Cloud integration toggles, keyed by setting name, with the value being whether the
integration is enabled. These are the switches that decide where Defender for Cloud sends its
findings, so they are the ones that quietly break an estate when they drift.

Sentinel controls bi-directional alert sync with Microsoft Sentinel. MCAS and the WDATP family
control the Defender for Cloud Apps and Defender for Endpoint integrations.

Requires Owner on the subscription. Removing an entry disables that setting rather than leaving
it as it was.
map(bool) {} no
storage_defender Per storage account Defender settings, keyed by a caller chosen key. Use this to turn malware
scanning and sensitive data discovery on for a specific account, or to opt one account out of
the subscription default.

override_subscription_settings_enabled is the switch that decides whether this entry is an
override at all. Leave it off and the subscription plan wins.

malware_scanning_on_upload_cap_gb_per_month caps spend on that account. It is a cap, not a
budget alert: scanning stops for the rest of the month once it is reached, so a low cap silently
stops scanning rather than warning anyone.
map(object({
storage_account_id = string

malware_scanning_on_upload_enabled = optional(bool)
malware_scanning_on_upload_cap_gb_per_month = optional(number)
sensitive_data_discovery_enabled = optional(bool)
override_subscription_settings_enabled = optional(bool)
scan_results_event_grid_topic_id = optional(string)
}))
{} no
subscription_pricing Microsoft Defender for Cloud plans, keyed by the resource type the plan covers. This is the
coverage lever and the cost lever in one place: moving a type to Standard turns the plan on for
EVERY resource of that type in the subscription and starts billing immediately.

Deleting an entry does not leave the plan as it was; the provider resets that resource type to
Free on destroy, which turns the plan OFF. Removing an entry is therefore a coverage change,
not a no-op.

subplan selects a tier within a plan where one exists (for example DefenderForStorageV2 on
StorageAccounts). Values vary per plan and are not enumerable here, so this module does not
validate them. Changing subplan replaces the resource.

extensions turn individual capabilities on within a plan (for example AgentlessVmScanning or
SensitiveDataDiscovery on CloudPosture). An extension not listed is not enabled.
map(object({
tier = optional(string, "Standard")
subplan = optional(string)

extensions = optional(map(object({
additional_properties = optional(map(string))
})), {})
}))
{} no
workspace The Log Analytics workspace Defender for Cloud reports into, or null to leave the setting
unmanaged. scope is the subscription or management group the binding applies to.

Set this when you want Defender for Cloud data landing in your own workspace rather than the
default one, which is the prerequisite for a Sentinel workspace seeing that data through the
Defender for Cloud connector.

Requires Owner on the subscription.
object({
scope = string
workspace_id = string
})
null no

Outputs

Name Description
assessment_ids Map of the caller's key to the assessment id.
assessment_policies The full custom assessment definition objects, keyed by the caller's key.
assessment_policy_ids Map of the caller's key to the definition id. Pass one of these to another module that records assessments against the same control.
assessment_policy_names Map of the caller's key to the generated definition name, which Azure allocates rather than accepting from the caller.
assessments The full assessment result objects, keyed by the caller's key.
automation_ids Map of automation name to its id. Not sensitive, unlike the automation objects themselves, which carry the trigger URLs and connection strings.
automations The full workflow automation objects, keyed by automation name.
security_contact_ids Map of contact name to its id.
security_contacts The full security contact objects, keyed by contact name.
server_vulnerability_assessment The server vulnerability assessment setting object, or null when unmanaged.
server_vulnerability_assessment_id The id of the server vulnerability assessment setting, or null when unmanaged.
settings The full integration toggle objects, keyed by setting name.
settings_ids Map of setting name to its id.
standard_tier_resource_types The resource types currently on the Standard (billable) tier. Useful as the input to a cost report, and as the thing to diff in a pull request when a plan changes.
storage_defender The full per storage account Defender objects, keyed by the caller's key.
storage_defender_ids Map of the caller's key to its id.
subscription_pricing The full Defender plan objects, keyed by resource type.
subscription_pricing_ids Map of resource type to the id of its Defender plan.
workspace The workspace binding object, or null when the binding is unmanaged.
workspace_id The id of the workspace binding, or null when the binding is unmanaged.

About

🛡️ Microsoft Defender for Cloud as code: plans, integrations, contacts, continuous export and custom controls, behind one validated map per concern

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages