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,recipientEmailAddressordeviceId,sha1). The docs list these as separate values but the API rejects them individually. allowFileResponseActionandblockFileResponseActionrequiredeviceGroupNames— the docs say it’s optional but the API returns 400 without it.forceUserPasswordResetResponseActionuses SID-based identifiers (accountSid, etc.), not ObjectId-based as the docs state. The API rejectsaccountObjectId.- NRT (near real-time / continuous) schedule is now supported via
-period "NRT". - Two new validation helpers
Test-ImpactedAssetandTest-ResponseActionare exported for pre-flight checks (Claude wrote pre-flight, I barely speak english at this point lol).
Does it matter?

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
| Function | Description |
|---|---|
Get-GraphAccessToken | Get a token via Az context (managed identity/interactive) or SPN client credentials |
Get-GraphHeader | Build the auth header from a token |
Get-DetectionRules | List all rules, optionally save each to a JSON file |
Get-DetectionRule | Get a single rule by ID, optionally save to file |
New-DetectionRule | Create a rule from parameters or a JSON file |
Update-DetectionRule | Update a rule from parameters or a JSON file |
Remove-DetectionRule | Delete a rule by ID |
Test-ImpactedAsset | Validate an impacted asset hashtable against the Graph API schema |
Test-ResponseAction | Validate 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.type | Valid identifiers |
|---|---|---|
| Device | #microsoft.graph.security.impactedDeviceAsset | deviceId, deviceName, remoteDeviceName, targetDeviceName, destinationDeviceName |
| User | #microsoft.graph.security.impactedUserAsset | accountObjectId, accountSid, accountUpn, accountName, accountDomain, accountId, initiatingProcessAccountObjectId, initiatingProcessAccountSid, initiatingProcessAccountUpn, initiatingProcessAccountName, initiatingProcessAccountDomain, servicePrincipalId, servicePrincipalName, targetAccountUpn, targetDeviceAddress, requestAccountSid, onPremSid |
| Mailbox | #microsoft.graph.security.impactedMailboxAsset | recipientEmailAddress, 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(requiresisolationType:fullorselective)collectInvestigationPackageResponseActionrunAntivirusScanResponseActioninitiateInvestigationResponseActionrestrictAppExecutionResponseAction
File actions:
stopAndQuarantineFileResponseAction(identifier:deviceId,sha1ordeviceId,initiatingProcessSHA1— comma-separated combined value)allowFileResponseAction(identifier:sha1/sha256/etc., requiresdeviceGroupNames)blockFileResponseAction(identifier:sha1/sha256/etc., requiresdeviceGroupNames)
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):
hardDeleteResponseActionsoftDeleteResponseActionmoveToInboxResponseActionmoveToDeletedItemsResponseActionmoveToJunkResponseAction
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 value | Meaning |
|---|---|
NRT | Near real-time (continuous) |
1H | Every hour |
3H | Every 3 hours |
12H | Every 12 hours |
24H | Every 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.