Microsoft Defender for Cloud as code: plans, integrations, contacts, continuous export, and custom controls, behind one validated map per concern.
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_pricingis 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_typesis 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_pricingentry resets that type toFree, which turns the plan OFF. Deleting asettingsentry disables the integration. Both are documented on the variables rather than discovered in production. - Assessments compose with their definitions. An entry in
assessmentsnames apolicy_keyfromassessment_policies, and the module resolves it to the generated id. That is what orders the two correctly without a hand-maintaineddepends_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
EventHubaction without a connection string and aLogicAppaction without a trigger URL both fail at plan, because the provider accepts them and Azure rejects them.checkblocks 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,CloudPostureenabled with no extensions, and the Sentinel integration enabled with no workspace bound.
azurerm_security_center_auto_provisioningis 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_machineis per virtual machine, and the subscription level setting now accepts onlyMdeTvm, so the per-VM third party scanner path is legacy. It belongs with the resource it targets.
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.
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/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 toFreebecause an unattended example must not start billing.
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.
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.
The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.
| Name | Version |
|---|---|
| terraform | >= 1.9.0, < 2.0.0 |
| azurerm | >= 4.0.0, < 5.0.0 |
| Name | Version |
|---|---|
| azurerm | >= 4.0.0, < 5.0.0 |
No modules.
| 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({ |
{} |
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({ |
{} |
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({ |
{} |
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({ |
{} |
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({ |
{} |
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({ |
{} |
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({ |
null |
no |
| 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. |