Manage Defender for CloudΒΆ
The engineer's guide to changing Microsoft Defender for Cloud settings. Use this runbook to:
- Turn a Defender plan on or off, for every subscription or for one.
- Change who gets Defender's emails.
- Switch a check off for good.
- Exempt one resource from a check, or silence an alert, for a limited time.
- Deploy the project, and keep its nightly run healthy.
- Change a recommendation's owner, or what goes to the central workspace.
- Link Defender to Endpoint, Cloud Apps or Sentinel.
- Set Defender per resource, or open VM ports just in time.
- Add your own checks, connect GitLab, and set data sensitivity.
- Act on the warnings in the plan output.
For how the project is designed and why, see Defender for Cloud as Code. For which plans to choose, see Defender Plans.
Defaults, then overrides
Every file in config/ has defaults, which apply to every subscription under mg-intermediate-root (except mg-sandboxes), and overrides, keyed by subscription name or ID. Change the default when the decision is for everyone. Add an override, with a reason, only where one subscription differs.
The projectsΒΆ
| Project | Lane | Holds |
|---|---|---|
azure-defender |
Tier 1, CI applies | Every Defender decision, one YAML file per kind under config/. |
azure-governance-epac |
Tier 1, CI applies | The MCSB assignment psa-sec-mcsb-001, and the audit-only psa-sec-defender-001. |
azure-service-principals-platform |
A | The pipeline identities sp-pla-tf-azure-defender-ro and -rw (pla-tf-azure-defender.tf). |
Every change follows the same path: edit a file, open an MR, read the plan CI posts, merge, then press play on the apply job on main.
Which file does whatΒΆ
| File | Section |
|---|---|
plans.yaml |
Turn a plan on, off, new plans |
security-contacts.yaml |
Change who gets Defender's emails |
disabled-checks.yaml |
Switch a check off for good |
exemptions.yaml |
Exempt one resource |
suppressions.yaml |
Silence an alert |
recommendation-owners.yaml |
Change a recommendation's owner |
continuous-export.yaml |
Change what goes to the central workspace |
integrations.yaml |
Link Endpoint, Cloud Apps or Sentinel |
resource-overrides.yaml |
Set Defender per resource |
jit.yaml |
Open VM ports just in time |
custom-recommendations.yaml |
Add your own check |
devops-connectors.yaml |
Connect GitLab, GitHub or Azure DevOps |
sensitivity.yaml |
Set data sensitivity |
Deploy the projectΒΆ
π
azure-defender: π.gitlab-ci.yml,versions.tf
The project is already deployed. Use these steps to rebuild it, or to check it's set up right. There are 5 parts.
1. The pipeline identitiesΒΆ
π
azure-service-principals-platform: πpla-tf-azure-defender.tf
Two identities, both signing in with OIDC. No secrets.
| Identity | Roles at mg-intermediate-root |
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 |
The pipeline-identity roster is Tier 0, so azure-service-principals-platform is Lane A: CI only plans, and you apply it yourself. azure-defender itself is Tier 1, so CI applies it.
2. The CI/CD variablesΒΆ
In GitLab, Settings β CI/CD β Variables on azure-defender:
ARM_CLIENT_ID_RO: client ID of the-roidentity.ARM_CLIENT_ID_RW: client ID of the-rwidentity.ARM_TENANT_IDandARM_SUBSCRIPTION_ID.
Don't set ARM_CLIENT_ID itself. It would override the split: .gitlab-ci.yml sets it to the -ro ID on MRs and the -rw ID on main.
3. The stateΒΆ
State lives in container azure-defender of the state account in rg-terraform-state, on the management subscription. The backend is fixed in versions.tf.
4. The first applyΒΆ
- Run the pipeline on
main. - Read the plan. It should show in-place updates to settings Defender already has (plans, contacts, integrations, disabled checks), and new continuous exports and recommendation-owner rules for each subscription.
- Press play on
apply.
5. The nightly scheduleΒΆ
- In GitLab, Build β Pipeline schedules, add a nightly schedule on
main. - Turn on failure emails for the schedule's owner: Settings β Notifications β Failed pipeline. Otherwise a broken run goes unnoticed.
The nightly plan picks up new subscriptions and anything changed outside code.
Turn a plan onΒΆ
π
azure-defender: πconfig/plans.yaml
Plans cost money per resource. Before turning one on, check how many of that resource the subscriptions hold, and put the expected monthly cost in the MR.
For one subscription, add an override:
overrides:
grinntec-website-prod-westeu-00:
StorageAccounts:
enabled: true
reason: "Hosts public uploads."
extensions:
OnUploadMalwareScanning:
CapGBPerMonthPerStorageAccount: "50"
SensitiveDataDiscovery: {}
For every subscription, change the default instead. Remove off_reason and review_by:
A plan that needs a sub-plan takes subplan, for example subplan: P2 on VirtualMachines.
Check the plan: one in-place update to azapi_update_resource.plan["<sub-id>/<Plan>"] per subscription it covers, setting pricingTier = "Standard".
Expect the Secure Score to drop
A new plan brings recommendations that didn't count before. Say so in the MR, so the drop in the weekly governance report isn't mistaken for a regression.
Turn a plan offΒΆ
π
azure-defender: πconfig/plans.yaml
Set enabled: false. Don't delete the entry.
KeyVaults:
enabled: false
off_reason: "No vaults hold production secrets yet."
review_by: 2027-05-01
Removing an entry only stops Terraform managing the plan. It stays on, and billed, at its last setting.
An off plan with no off_reason or review_by fails the plan.
Decide a new plan from MicrosoftΒΆ
When Microsoft adds a plan, the plan fails with:
- Look the plan up in Defender for Cloud β Environment settings.
- Add it to
defaults, on or off with a reason. Or, if there's nothing to decide (retired or always free), add it tonot_managedwith a one-line reason. - Open an MR.
Change who gets Defender's emailsΒΆ
π
azure-defender: πconfig/security-contacts.yaml
defaults:
emails:
- neil@grinntec.net
notify_roles:
enabled: true
roles: [Owner]
alerts:
min_severity: High # Low | Medium | High
attack_paths:
min_risk_level: null # needs Defender CSPM
Use a monitored mailbox. At least one email is required. Vended subscriptions have no standing Owners, so in practice the emails here are the only ones that get alerts.
Check the plan: one in-place update to azapi_update_resource.security_contact per subscription.
Switch a check off for goodΒΆ
π
azure-defender: πconfig/disabled-checks.yaml
Only for a check that can never apply under our operating model. If it could be met one day, or only one resource can't meet it, exempt it instead.
- Find the check's
policyDefinitionReferenceId(on the recommendation's policy definition). -
Add it with its title and reason:
-
Disable it in EPAC as well. Set
prodEffecttoDisabledon its row ofDefinitions/policyAssignments/security/pa-sec-MsftCloudSecurityBenchmarkV2.csv, in a second MR. Otherwise it still fails on the compliance dashboard.
Check the plan: one in-place update to azapi_update_resource.disabled_checks["<sub-id>"] per subscription, changing only the overrides list.
To re-enable a check, remove its entry. The next apply clears it from the override.
Exempt one resourceΒΆ
π
azure-defender: πconfig/exemptions.yaml
For a check that's knowingly left open on one resource, resource group or subscription. Scope it as narrowly as you can.
exemptions:
web-storage-public-access:
check: storageAccountShouldUseAPrivateLinkConnectionMonitoringEffect
scope: /subscriptions/<sub-id>/resourceGroups/rg-website-prod/providers/Microsoft.Storage/storageAccounts/stwebsiteprod
category: Waiver # Waiver | Mitigated
reason: "Public static website content. No private data."
owner: neil@grinntec.net
ticket: https://gitlab.com/grinntec-cloud/.../-/issues/1
expires: 2027-02-01 # at most 90 days ahead
Every field is required. The key is lowercase letters, digits and hyphens.
This covers the Secure Score only
The exemption goes on Defender's ASC Default assignment. The regulatory compliance dashboard comes from EPAC's psa-sec-mcsb-001. To clear it there too, add a matching entry to EPAC's Definitions/policyExemptions/grinntec/exemptions.jsonc, with the same reason and expiry.
To renew, open a new MR with a new expires date. To end it early, delete the entry: the project owns exemptions, so it deletes the Azure exemption too.
Silence an alertΒΆ
π
azure-defender: πconfig/suppressions.yaml
For an alert you've looked at and know is expected, such as a false positive from a backup agent.
suppressions:
web-scanner-false-positive:
subscription: grinntec-website-prod-westeu-00
alert_type: VM_SuspiciousSvchostLocation
reason: FalsePositive # FalsePositive | AcceptableRisk | Other
comment: "Our backup agent runs from this path."
owner: neil@grinntec.net
ticket: https://gitlab.com/grinntec-cloud/.../-/issues/2
expires: 2027-02-01 # at most 90 days ahead
match: # optional
entities.host.hostname: [web-01]
If the alert might be real, follow Incident Response first. Don't suppress it.
Change a recommendation's ownerΒΆ
π
azure-defender: πconfig/recommendation-owners.yaml
Due dates by severity are set in defaults.rules (High 30 days, Medium 90, Low 180). To give one subscription a different owner:
overrides:
grinntec-website-prod-westeu-00:
owner: web-team@grinntec.net
reason: "Website team owns this workload."
The owner must be an email address. They get a weekly email of what's open and what's overdue.
Change what goes to the central workspaceΒΆ
π
azure-defender: πconfig/continuous-export.yaml
The defaults send everything to log-platform-prod. To change one subscription, add an override with a reason. To stop exporting, set enabled: false with off_reason and review_by. An export that's on but sends nothing fails the plan.
Link Endpoint, Cloud Apps or SentinelΒΆ
π
azure-defender: πconfig/integrations.yaml
Three switches, set on every subscription:
| Key | Defender setting | Does |
|---|---|---|
endpoint |
WDATP |
Installs the Defender for Endpoint agent on VMs covered by Defender for Servers |
cloud_apps |
MCAS |
Shares data with Defender for Cloud Apps |
sentinel_alert_sync |
Sentinel |
Two-way alert status sync with Sentinel |
Today endpoint and cloud_apps are on. Sentinel sync is off, because there's no Sentinel workspace. To turn it on once one exists:
Off needs off_reason and review_by. For one subscription only, add an override with a reason.
Check the plan: one in-place update to azapi_update_resource.integration["<sub-id>/sentinel_alert_sync"] per subscription.
Microsoft's legacy endpoint switches (WDATP_EXCLUDE_LINUX_PUBLIC_PREVIEW, WDATP_UNIFIED_SOLUTION) are left alone on purpose.
Set Defender per resourceΒΆ
π
azure-defender: πconfig/resource-overrides.yaml
For some resources to differ from their subscription's plan. Rules pick resources, so one rule can cover thousands.
Picking resources: a match block. Every condition given must hold, and at least one is needed.
subscriptions: names or IDs.resource_groups: names, any case.tags: the resource must carry all of them.resource_ids: full IDs.
Storage (Defender for Storage, per storage account):
storage:
public-uploads:
reason: "Accepts files from the internet."
match:
subscriptions: [grinntec-website-prod-westeu-00]
tags: { PublicUpload: "true" }
settings:
enabled: true
malware_scanning: { enabled: true, cap_gb_per_month: 1000 } # -1 = no cap
sensitive_data_discovery: false
Servers (Defender for Servers, per VM, scale set or Arc machine):
servers:
build-agents-off:
reason: "Short-lived build agents, rebuilt daily from a hardened image."
match:
tags: { Role: build-agent }
settings:
enabled: false # true = Plan 1 for that machine
Check the plan: one azapi_resource.storage_override or azapi_resource.server_override created per matching resource. If the count looks wrong, the match is wrong.
Removing a rule puts its resources back on the subscription's setting.
Not yet run in this tenant
This file is empty today, and several of the APIs are in preview. The first real rule is the test, so read its plan carefully.
Open VM ports just in timeΒΆ
π
azure-defender: πconfig/jit.yaml
Keeps management ports closed until someone asks, for a limited time. Needs Defender for Servers Plan 2 on the VM's subscription: set VirtualMachines to enabled: true with subplan: P2 in plans.yaml first.
Rules pick VMs with the same match block as per-resource settings:
rules:
linux-ssh:
reason: "SSH only from the corporate network, on request."
match:
tags: { Os: linux }
ports:
- { number: 22, protocol: TCP, sources: ["10.0.0.0/8"], max_hours: 3 }
protocol:TCP,UDPor*.sources: the address ranges allowed to ask.max_hours: the longest a request can get, 1 to 24.
Check the plan: one azapi_resource.jit named jit-<rule> per resource group and region the rule covers, listing its VMs.
To ask for access, use Defender for Cloud β Workload protections β Just-in-time VM access, or Connect on the VM in the portal.
Removing a rule deletes its JIT policies, and the ports go back to the VM's own network rules.
Add your own checkΒΆ
π
azure-defender: πconfig/custom-recommendations.yaml
Your own check, written as a Resource Graph (KQL) query, shown in Defender next to Microsoft's. All of them go into one custom standard, grinntec-custom, assigned at mg-intermediate-root.
Needs paid Defender CSPM (CloudPosture in plans.yaml) on every subscription in scope. The plan fails otherwise, so this file stays empty until that's decided.
recommendations:
storage-missing-workload-owner:
title: "Storage accounts should carry the WorkloadOwner tag"
description: "ADR-0001: every resource has an accountable owner."
severity: Medium # High | Medium | Low
remediation: "Add the WorkloadOwner tag in the resource's Terraform."
query: >-
RawEntityMetadata
| where Environment == 'Azure' and Identifiers.Type =~ 'microsoft.storage/storageaccounts'
| extend HealthStatus = iff(isempty(Record.tags.WorkloadOwner), 'UNHEALTHY', 'HEALTHY')
The query must follow Defender's format: see Microsoft's Create custom recommendations guide.
Check the plan: the first entry creates the recommendation, the standard and its assignment. Each later entry adds one recommendation and updates the standard.
Removing an entry deletes the recommendation.
Connect GitLab, GitHub or Azure DevOpsΒΆ
π
azure-defender: πconfig/devops-connectors.yaml
Lets Defender check code platforms for security issues: repo settings, exposed secrets, IaC findings. Basic posture checks are free. More needs Defender CSPM.
One connector is in place today:
connectors:
gitlab-grinntec-cloud:
type: GitLab # GitLab | GitHub | AzureDevOps
resource_group: /subscriptions/<mgmt-sub-id>/resourceGroups/rg-monitoring-prod
location: westeurope
reason: "All platform code lives in the grinntec-cloud GitLab group."
To add one:
- Add an entry. The resource group must exist, in a subscription this project manages.
- Merge and apply. The connector exists, but has no access yet.
- In Defender for Cloud β Environment settings, open the connector and sign in to the platform once. Pick the groups or orgs it may read.
Step 3 is a one-time portal step. Terraform ignores what Defender fills in after it, so the next plan stays clean.
Set data sensitivityΒΆ
π
azure-defender: πconfig/sensitivity.yaml
Tenant-wide: which Microsoft Purview info types and labels Defender treats as sensitive. Only useful with sensitive data discovery (Defender CSPM, or Defender for Storage).
It's off, and can't be turned on yet. The -rw identity's Security Admin at mg-intermediate-root doesn't reach tenant scope.
To turn it on:
- Give the
-rwidentity a tenant-scope grant, inazure-service-principals-platform. This is an identity change, reviewed on its own. -
Fill in the file:
-
Open an MR. The plan should show one update to
azapi_update_resource.sensitivity.
While enabled is false, the project doesn't touch the setting.
Act on plan warningsΒΆ
These show as warnings in the plan output. The pipeline doesn't fail, but each one needs a follow-up MR.
| Warning | Means | Do this |
|---|---|---|
reviews_due |
An off decision is past its review_by |
Decide again. Turn it on, or set a new review_by with an updated reason. |
expiring |
An exemption or suppression expires within 30 days, or has expired | Renew with a new expires, or remove the entry. |
unmanaged_exemptions |
Someone made an exemption on ASC Default by hand | Add it to exemptions.yaml, or delete it in the portal. |
platform_resource_groups |
A subscription has no rg-platform-<name>, so no export |
Check its vending. Vending creates that group. |
Run a plan locallyΒΆ
π
azure-defender: πoverride.tf(git-ignored)
A plan only needs Reader at mg-intermediate-root.
-
Create
override.tf: -
Sign in and plan:
Don't apply locally. Changes go through an MR.
Check it workedΒΆ
# Plans on one subscription
az security pricing list --subscription <sub-id> \
--query "value[?pricingTier=='Standard'].{plan:name, subPlan:subPlan}" -o table
# Checks switched off on ASC Default
az policy assignment show --name SecurityCenterBuiltIn \
--scope /subscriptions/<sub-id> \
--query "overrides[0].selectors[0].in"
In the portal: Defender for Cloud β Environment settings β
When it failsΒΆ
| Symptom | Cause | Fix |
|---|---|---|
plan X is off, so config/plans.yaml needs an off_reason and a review_by date |
An off plan has no reason or date | Add both. |
every plan override in config/plans.yaml needs a reason |
An override has no reason |
Add one. |
lists checks that aren't in the Defender rule set |
A typo in disabled-checks.yaml, or Microsoft removed the check |
Fix the ID, or remove the entry. |
expires must be a date (YYYY-MM-DD) at most 90 days from today |
Expiry too far out | Pick a date within 90 days. Renew later in a new MR. |
Resources matched by more than one rule of the same kind |
Two rules in resource-overrides.yaml or jit.yaml cover one resource |
Narrow one rule's match. |
Resource Graph returned 1000 or more resources |
A rule is too broad for one query | Narrow the rule. |
JIT needs Defender for Servers Plan 2 |
A JIT rule covers a subscription without P2 | Turn on VirtualMachines with subplan: P2 there first. |
Custom recommendations need paid Defender CSPM |
CloudPosture is off somewhere in scope |
Turn it on, or leave custom-recommendations.yaml empty. |
| A plan someone turned on in the portal keeps going back to off | plans.yaml holds it off |
Intended. Turn it on in code. |
| Plan removed from the file but still on, and billed | The entry was deleted, not set to enabled: false |
Add it back as enabled: false, apply, then remove it if you want. |