Defender for Cloud as Code¶
Azure sets Microsoft Defender for Cloud one subscription at a time. The
azure-defenderproject decides it in one place instead: a set of defaults for every subscription undermg-intermediate-root(exceptmg-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.
- Security Operations
- Defender Plans: which plans to choose, and why.
- Defender for Cloud Secure Score
- Manage Defender for Cloud: the hands-on guide.
- Microsoft Learn: Defender for Cloud
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.tffinds 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
azapiprovider. Each resource names its own subscription, so one provider covers them all. TheazurermDefender 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
defaultsnornot_managedfails 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_reasonandreview_by. Every override needs areason. - Switched-off checks must exist. Each ID in
disabled-checks.yamlis 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
expiresdate 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: areview_bydate 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 inexemptions.yaml, such as one made by hand.platform_resource_groups: a subscription with norg-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'sfullstrategy 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.