Skip to content

Defender for Cloud as Code

Azure sets Microsoft Defender for Cloud one subscription at a time. The azure-defender project decides it in one place instead: a set of defaults for every subscription under mg-intermediate-root (except mg-sandboxes), plus a few overrides, each with a reason. Every decision lives in a YAML file. A new subscription is picked up on the next run, with no code change.


What it decides

Each file in config/ holds one kind of decision.

File Decides
plans.yaml Which Defender plans are on, with sub-plans and add-ons
security-contacts.yaml Who Defender emails, and from which alert severity
integrations.yaml Links to Defender for Endpoint, Defender for Cloud Apps and Sentinel
disabled-checks.yaml Checks switched off for good, because they never apply
exemptions.yaml A check knowingly left open on one scope, for up to 90 days
suppressions.yaml Alerts silenced on purpose, for up to 90 days
continuous-export.yaml What each subscription sends to the central Log Analytics workspace
recommendation-owners.yaml Who owns open recommendations, and when they're due
resource-overrides.yaml Per-resource Storage and Servers settings, picked by rules
jit.yaml Just-in-time VM access, picked by rules
custom-recommendations.yaml Our own checks, written in KQL
devops-connectors.yaml GitLab, GitHub or Azure DevOps connectors
sensitivity.yaml Tenant-wide data sensitivity settings

The last five start empty or off. They change nothing until a rule is added.


How a setting reaches a subscription

flowchart LR
    Y[config/*.yaml<br/>defaults + overrides] --> L[locals.tf<br/>defaults, then the<br/>subscription's override]
    MG[mg-intermediate-root<br/>minus mg-sandboxes] --> S[subscriptions.tf<br/>every subscription<br/>below the scope]
    S --> L
    L --> A[azapi resources<br/>one per subscription<br/>per setting]
    A --> D[(Defender for Cloud<br/>on each subscription)]
  • subscriptions.tf finds every subscription below the scope management group, so nobody lists them by hand.
  • Overrides can name a subscription by display name or by ID.
  • Settings are written with the azapi provider. Each resource names its own subscription, so one provider covers them all. The azurerm Defender resources would need one provider block per subscription, and Terraform can't loop over providers.

Update, not own

Most Defender settings already exist on every subscription: plans, contacts, integrations, the ASC Default policy assignment. The project patches them with azapi_update_resource.

Removing an entry leaves Azure as it was. Terraform stops managing it, but the last value stays. To turn something off, set enabled: false.

Exemptions, suppressions, recommendation owners, continuous export and connectors are different. The project creates them, so removing an entry deletes them.


Rules the plan enforces

The design goal is that every setting is a decision someone made on purpose. The plan fails when a decision is missing.

  • Every plan must be decided. The plan reads the live list of plans from Azure. A plan that's in neither defaults nor not_managed fails the plan. So a new plan from Microsoft forces a decision.
  • Off needs a reason. A plan, integration or export that's off needs off_reason and review_by. Every override needs a reason.
  • Switched-off checks must exist. Each ID in disabled-checks.yaml is checked against the live rule set. A typo, or a check Microsoft removed, fails the plan.
  • Exemptions and suppressions expire. Each needs an owner, a ticket and an expires date at most 90 days ahead. Renewing means a new MR.
  • One rule per resource. A resource matched by two rules of the same kind fails the plan.
  • No silent gaps. Resources are found with Resource Graph, up to 1000 per kind per subscription. More than that fails the plan rather than missing some.

Some things are warnings, not failures, so the nightly run keeps working while someone looks:

  • overdue_reviews: a review_by date in the past.
  • expiring: an exemption or suppression that has expired, or expires within 30 days.
  • unmanaged_exemptions: an exemption on ASC Default that isn't in exemptions.yaml, such as one made by hand.
  • platform_resource_groups: a subscription with no rg-platform-<name>, so it gets no continuous export.

Two policy assignments, two owners

Defender's checks reach each subscription through two policy assignments.

Assignment Owned by Feeds
psa-sec-mcsb-001 at mg-intermediate-root EPAC Regulatory compliance dashboard
SecurityCenterBuiltIn (ASC Default) on each subscription Defender itself. azure-defender patches it. Secure Score and Defender recommendations

This shapes two choices:

  • Disabled checks go on ASC Default here, and in EPAC's MCSB CSV. Keep both lists in step, or the check still shows through the other one.
  • Exemptions made here go on ASC Default only. They don't go on psa-sec-mcsb-001, because EPAC's full strategy would delete exemptions it doesn't manage. Use EPAC's own exemptions file for that one.

disabled-checks.yaml took over from the older azure-mcsb-overrides project in November 2026.

Where EPAC fits

EPAC's psa-sec-defender-001 initiative still runs at mg-intermediate-root. It keeps Foundational CSPM on and a security contact set, and it audits continuous export. It doesn't deploy the export: Terraform makes it, so nothing changes outside code.


Continuous export

Every subscription sends its Defender data to the central workspace, log-platform-prod in rg-monitoring-prod:

  • Alerts of every severity.
  • Recommendations, the Secure Score and its controls, and regulatory compliance.
  • A weekly full snapshot, not only changes, so the workspace holds the whole picture for audits.

The export lives in each subscription's rg-platform-<name>, which subscription vending creates. No paid plan is on yet, so Defender raises few alerts. The export is cheap, and it builds up score history ready for when plans go on.


Recommendation owners

Every open recommendation gets an owner and a due date, counted from when Defender first finds it:

  • High: 30 days. This matches the analyzer's desired state (SEC-01).
  • Medium: 90 days.
  • Low: 180 days.

The owner gets a weekly email. The grace period is off, so a late recommendation still counts against the Secure Score, and the score matches what the analyzer reports.


Pipeline and identities

azure-defender is Tier 1: CI applies, with a split between a read-only and a write identity. It uses the shared terraform-deployment-pipeline.yml. CI plans on the MR, plans again on main after merge, and the apply job waits for someone to press play.

Identity Roles at mg-intermediate-root Federated credential trusts
sp-pla-tf-azure-defender-ro Reader Any branch (MR plans)
sp-pla-tf-azure-defender-rw Reader, Security Admin, Resource Policy Contributor main only

Both are defined in azure-service-principals-platform (pla-tf-azure-defender.tf). Entra enforces the split, not the pipeline file. Nobody can push to main directly, so only merged code can change Defender.

The read-only identity can't take the state lease, so MR plans run with -lock=false.

A pipeline schedule on main runs every night. Its plan picks up new subscriptions and anything changed outside code, and applying it brings them back to the defaults.

What the identity can't reach

sensitivity.yaml is tenant-wide. Security Admin at mg-intermediate-root doesn't reach tenant scope, so turning it on needs a separate, reviewed identity change first.


Where things stand (November 2026)

  • No paid plans are on. Every plan is off with the reason "cost decision pending" and a review date of 9 February 2027. See Defender Plans for which to choose.
  • Security contacts and integrations match what was set before the project.
  • One GitLab connector, gitlab-grinntec-cloud, is in code on the free tier.
  • Continuous export is on for every subscription that has a platform resource group.