#Microsoft Defender XDR #Graph API #Custom Detection Rules #PowerShell

Defender XDR - Custom Detection Rules PowerShell Module

Updated 18/02/2026

So the original version of this module only supported a single impacted asset type and one response action types. Actually doing all that work seemed like a major waste of time, but then I remembered this AI-vibe-coding-thing everyone is on about and I had my friend Claude help me out a bit. So now the module has been rewritten to support all 3 impacted asset types (Device, User, Mailbox) and all 16 response action types from the Graph API.

This introduces some breaking changes (sorry, not sorry) where the old -identifier and -isolationType parameters have been replaced with -impactedAssets and -responseActions which accept hashtable arrays. Claude wanted me to tell you that “this gives full flexibility to mix and match any combination of assets and actions” like I’m trying to sell you something.

Some findings from live API testing:

  • Email and file actions require combined comma-separated identifiers (e.g. networkMessageId,recipientEmailAddress or deviceId,sha1). The docs list these as separate values but the API rejects them individually.
  • allowFileResponseAction and blockFileResponseAction require deviceGroupNames — the docs say it’s optional but the API returns 400 without it.
  • forceUserPasswordResetResponseAction uses SID-based identifiers (accountSid, etc.), not ObjectId-based as the docs state. The API rejects accountObjectId.
  • NRT (near real-time / continuous) schedule is now supported via -period "NRT".
  • Two new validation helpers Test-ImpactedAsset and Test-ResponseAction are exported for pre-flight checks (Claude wrote pre-flight, I barely speak english at this point lol).

Does it matter?

doesnotmatter

There we have it.

Introduction

Follow-up to my previous post on the same topic, Defender XDR Custom Detection Rules Push/Pull via API. Basically what I’ve done is consolidated the individual scripts into a single PowerShell module (CustomDetectionRuleModule.psm1) and added some functions to make it a bit easier to use.

What’s in the module

FunctionDescription
Get-GraphAccessTokenGet a token via Az context (managed identity/interactive) or SPN client credentials
Get-GraphHeaderBuild the auth header from a token
Get-DetectionRulesList all rules, optionally save each to a JSON file
Get-DetectionRuleGet a single rule by ID, optionally save to file
New-DetectionRuleCreate a rule from parameters or a JSON file
Update-DetectionRuleUpdate a rule from parameters or a JSON file
Remove-DetectionRuleDelete a rule by ID
Test-ImpactedAssetValidate an impacted asset hashtable against the Graph API schema
Test-ResponseActionValidate a response action hashtable against the Graph API schema

Every function accepts an optional -token parameter. If omitted, it falls back to Invoke-MgGraphRequest, which if you’ve read the previous post doesn’t quite work all that well (yet, still).

Impacted asset types

The module supports all three impacted asset types from the Graph API. Each asset is passed as a hashtable with an @odata.type and an identifier referencing a column in your KQL query result.

Asset type@odata.typeValid identifiers
Device#microsoft.graph.security.impactedDeviceAssetdeviceId, deviceName, remoteDeviceName, targetDeviceName, destinationDeviceName
User#microsoft.graph.security.impactedUserAssetaccountObjectId, accountSid, accountUpn, accountName, accountDomain, accountId, initiatingProcessAccountObjectId, initiatingProcessAccountSid, initiatingProcessAccountUpn, initiatingProcessAccountName, initiatingProcessAccountDomain, servicePrincipalId, servicePrincipalName, targetAccountUpn, targetDeviceAddress, requestAccountSid, onPremSid
Mailbox#microsoft.graph.security.impactedMailboxAssetrecipientEmailAddress, senderFromAddress, senderMailFromAddress, senderDisplayName, deliveryAction, networkMessageId, recipientObjectId, reportId, urlDomain

Response action types

All 16 response actions are supported, organized by target:

Device actions (identifier: deviceId):

  • isolateDeviceResponseAction (requires isolationType: full or selective)
  • collectInvestigationPackageResponseAction
  • runAntivirusScanResponseAction
  • initiateInvestigationResponseAction
  • restrictAppExecutionResponseAction

File actions:

  • stopAndQuarantineFileResponseAction (identifier: deviceId,sha1 or deviceId,initiatingProcessSHA1 — comma-separated combined value)
  • allowFileResponseAction (identifier: sha1/sha256/etc., requires deviceGroupNames)
  • blockFileResponseAction (identifier: sha1/sha256/etc., requires deviceGroupNames)

User actions:

  • markUserAsCompromisedResponseAction (identifier: accountObjectId/initiatingProcessAccountObjectId/servicePrincipalId/recipientObjectId)
  • disableUserResponseAction (identifier: accountSid/initiatingProcessAccountSid/requestAccountSid/onPremSid)
  • forceUserPasswordResetResponseAction (identifier: accountSid/initiatingProcessAccountSid/requestAccountSid/onPremSid — docs say ObjectId but API rejects it)

Email actions (identifier: networkMessageId,recipientEmailAddress — comma-separated combined value):

  • hardDeleteResponseAction
  • softDeleteResponseAction
  • moveToInboxResponseAction
  • moveToDeletedItemsResponseAction
  • moveToJunkResponseAction

API gotcha: File (stopAndQuarantine) and email response actions require combined comma-separated identifiers, not individual values. The docs list them separately but the API rejects single values.

Schedule periods

Period valueMeaning
NRTNear real-time (continuous)
1HEvery hour
3HEvery 3 hours
12HEvery 12 hours
24HEvery 24 hours

Authenticating with an SPN

The module also supports authenticating with a service principal directly if needed. Without a token it defaults to using your current Azure context and requires the Az-module like before.

1. Create the app registration and SPN

# Create app registration
az ad app create --display-name "CustomDetectionRules" --sign-in-audience "AzureADMyOrg"

# Note the appId from the output, then create the service principal
az ad sp create --id "<appId>"

2. Assign CustomDetection.ReadWrite.All

# Get the Microsoft Graph SP and the role ID
$graphSp = az ad sp list --filter "appId eq '00000003-0000-0000-c000-000000000000'" --query "[0].{id: id, appRoles: appRoles}" -o json | ConvertFrom-Json
$role = $graphSp.appRoles | Where-Object { $_.value -eq 'CustomDetection.ReadWrite.All' }

# Get your SPN's object ID
$sp = az ad sp list --filter "appId eq '<appId>'" --query "[0].id" -o tsv

# Assign the role (this also grants admin consent)
@{ principalId = $sp; resourceId = $graphSp.id; appRoleId = $role.id } | ConvertTo-Json | Out-File role-body.json -Encoding utf8NoBOM

az rest --method POST --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$sp/appRoleAssignments" --body "@role-body.json" --headers "Content-Type=application/json"

3. Create a client secret

az ad app credential reset --id "<appId>" --display-name "cdr-secret"

Save the password from the output!

4. Get a token and use it

Import-Module .\CustomDetectionRuleModule.psm1

$token = Get-GraphAccessToken -ClientId "<appId>" -ClientSecret "<password>" -TenantId "<tenantId>"

# List all rules
Get-DetectionRules -token $token

# Get a single rule
Get-DetectionRule -ruleId "60" -token $token

# Download all rules to disk
Get-DetectionRules -token $token -OutputPath "./rules"

# Create a rule with device asset + isolate action
New-DetectionRule -displayName "Isolate compromised device" -isEnabled $false `
    -queryText "DeviceProcessEvents | where FileName == 'cmd.exe' | take 10" -period "24H" `
    -alertTitle "CMD Execution" -alertDescription "Suspicious process" `
    -severity "high" -category "Execution" -mitreTechniques @("T1059") `
    -impactedAssets @(
        @{ '@odata.type' = '#microsoft.graph.security.impactedDeviceAsset'; identifier = 'deviceId' }
    ) `
    -responseActions @(
        @{ '@odata.type' = '#microsoft.graph.security.isolateDeviceResponseAction'; identifier = 'deviceId'; isolationType = 'full' }
    ) -token $token

# Create a rule with email actions
New-DetectionRule -displayName "Phishing response" -isEnabled $false `
    -queryText "EmailEvents | take 1" -period "3H" `
    -alertTitle "Phishing" -alertDescription "Phishing email" `
    -severity "high" -category "InitialAccess" -mitreTechniques @("T1566") `
    -impactedAssets @(
        @{ '@odata.type' = '#microsoft.graph.security.impactedMailboxAsset'; identifier = 'recipientEmailAddress' }
    ) `
    -responseActions @(
        @{ '@odata.type' = '#microsoft.graph.security.softDeleteResponseAction'; identifier = 'networkMessageId,recipientEmailAddress' }
    ) -token $token

# Create a rule with file actions (deviceGroupNames is required)
New-DetectionRule -displayName "Block malicious file" -isEnabled $false `
    -queryText "DeviceFileEvents | take 1" -period "3H" `
    -alertTitle "Malicious file" -alertDescription "File blocked" `
    -severity "medium" -category "Execution" -mitreTechniques @("T1059") `
    -impactedAssets @(
        @{ '@odata.type' = '#microsoft.graph.security.impactedDeviceAsset'; identifier = 'deviceId' }
    ) `
    -responseActions @(
        @{ '@odata.type' = '#microsoft.graph.security.blockFileResponseAction'; identifier = 'sha256'; deviceGroupNames = @('All') }
    ) -token $token

# Update a rule
Update-DetectionRule -ruleId "192" -displayName "updated name" -severity "medium" -token $token

# Create from a JSON file
New-DetectionRule -InputFile "./rules/My_New_Rule.json" -token $token

# Delete a rule
Remove-DetectionRule -ruleId "192" -token $token

The existing Az context flow still works the same as before, simply call Get-GraphAccessToken without parameters.


For the full details on the API, request bodies and response formats, see the previous post.