Skip to content

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 -ro identity.
  • ARM_CLIENT_ID_RW: client ID of the -rw identity.
  • ARM_TENANT_ID and ARM_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ΒΆ

  1. Run the pipeline on main.
  2. 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.
  3. Press play on apply.

5. The nightly scheduleΒΆ

  1. In GitLab, Build β†’ Pipeline schedules, add a nightly schedule on main.
  2. 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:

defaults:
  KeyVaults:
    enabled: true

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:

Defender offers plans that config/plans.yaml doesn't mention: <Name>.
  1. Look the plan up in Defender for Cloud β†’ Environment settings.
  2. Add it to defaults, on or off with a reason. Or, if there's nothing to decide (retired or always free), add it to not_managed with a one-line reason.
  3. 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.

  1. Find the check's policyDefinitionReferenceId (on the recommendation's policy definition).
  2. Add it with its title and reason:

    checks:
      identityDesignateMoreThanOneOwnerMonitoring:
        title: "There should be more than one owner assigned to subscriptions"
        reason: "No standing Owner role assignments by design. Owner is granted just in time through the PIM-eligible platform group."
    
  3. Disable it in EPAC as well. Set prodEffect to Disabled on its row of Definitions/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.


πŸ“ 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:

  sentinel_alert_sync:
    enabled: true

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, UDP or *.
  • 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:

  1. Add an entry. The resource group must exist, in a subscription this project manages.
  2. Merge and apply. The connector exists, but has no access yet.
  3. 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:

  1. Give the -rw identity a tenant-scope grant, in azure-service-principals-platform. This is an identity change, reviewed on its own.
  2. Fill in the file:

    enabled: true
    reason: "Sensitive data discovery is on for storage."
    info_type_ids: [<purview-info-type-guid>]
    threshold_label_id: <purview-label-guid>
    threshold_label_order: <label-order-number>
    
  3. 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.

  1. Create override.tf:

    terraform { backend "local" {} }
    
  2. Sign in and plan:

    az login
    terraform init -reconfigure
    ARM_USE_OIDC=false terraform 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 β†’ shows each plan. A switched-off or exempted check can take up to about 24 hours to leave the recommendations list.


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.