Jumping straight into this one, custom detection rules are similar to analytic rules in Microsoft Sentinel, but allow us a limited option of response actions instead of automation rules and playbooks currently.
With the integration of Microsoft Sentinel into the Defender XDR portal and more support for Defender and Sentinel actions in Graph, it might be a sign of the times to come.
As listed out in the deployment guide and the API doc, we need the following permissions to deploy and get custom detection rules:
Credential type | Permissions |
---|---|
User | Security Admin, Security Operator (also needs the manage security setting role in Unified RBAC if turned on), Unified RBAC Security Settings Manage |
Application | CustomDetection.ReadWrite.All |
There are 5 API endpoints that are relevant to this flow:
Endpoint | URL | Description | Quota |
---|---|---|---|
List detectionRules | GET /security/rules/detectionRules |
Get multiple rules | 10 rules per minute per application, 300 rules per hour per application, 600 rules per hour per tenant |
Get detectionRules | GET /security/rules/detectionRules/{ruleId} |
Get a single rule | 100 rules per minute per application, 1,500 rules per hour per application, 1,800 rules per hour per tenant |
Create detectionRules | POST /security/rules/detectionRules |
Create a single rule | 10 rules per minute per application, 1,500 rules per hour per application, 1,800 rules per hour per tenant |
Update detectionRules | PATCH /security/rules/detectionRules/{ruleId} |
Update a single rule | 100 rules per minute per application, 1,500 rules per hour per application, 1,800 rules per hour per tenant |
Delete detectionRules | DELETE /security/rules/detectionRules/{ruleId} |
Delete a single rule | 100 rules per minute per application, 1,500 rules per hour per application, 1,800 rules per hour per tenant |
Just going of the quotas, hopefully this is increased when the API gets out of beta, because during testing we already ran into throttling issues here.
{
"displayName": "Some rule name",
"isEnabled": true,
"queryCondition": {
"queryText": "DeviceProcessEvents | take 1"
},
"schedule": {
"period": "12H"
},
"detectionAction": {
"alertTemplate": {
"title": "Some alert title",
"description": "Some alert description",
"severity": "medium",
"category": "Execution",
"recommendedActions": null,
"mitreTechniques": [],
"impactedAssets": [
{
"@odata.type": "#microsoft.graph.security.impactedDeviceAsset",
"identifier": "deviceId"
}
]
},
"organizationalScope": null,
"responseActions": [
{
"@odata.type": "#microsoft.graph.security.isolateDeviceResponseAction",
"identifier": "deviceId",
"isolationType": "full"
}
]
}
}
Follows the same as the one for post, but not all properties can be updated:
Provide the properties of a microsoft.graph.security.detectionRule to update, and those properties only. The properties that can be updated are specified in the following table:
Property | Type | Description |
---|---|---|
displayName | String | Optional. |
isEnabled | Boolean | Optional. |
detectionAction/alertTemplate/title | String | Optional. |
detectionAction/alertTemplate/category | String | Optional. |
detectionAction/alertTemplate/description | String | Optional. |
detectionAction/alertTemplate/recommendedActions | String | Optional. Provide ‘null’ to delete the existing response actions |
detectionAction/alertTemplate/severity | microsoft.graph.alertSeverity | Optional. |
detectionAction/alertTemplate/impactedAssets | microsoft.graph.security.impactedAsset | Optional. Provide ‘null’ to delete the existing impacted assets. |
detectionAction/responseActions | microsoft.graph.security.responseAction | Optional. |
detectionAction/organizationalScope | microsoft.graph.security.organizationalScope | Optional. |
queryCondition/queryText | String | Optional. |
schedule/period | String | Optional. |
{
"@odata.type": "#microsoft.graph.security.detectionRule",
"id": "7506",
"displayName": "ban file",
"isEnabled": true,
"createdBy": "NaderK@winatptestlic06.ccsctp.net",
"createdDateTime": "2021-02-28T16:28:15.3863467Z",
"lastModifiedDateTime": "2023-05-24T09:26:11.8630516Z",
"lastModifiedBy": "GlobalAdmin@unifiedrbactest3.ccsctp.net",
"detectorId": "67895317-b2a8-4ac3-8f8b-fa6b7765f2fe",
"queryCondition": {
"queryText": "DeviceFileEvents\r\n| where Timestamp > ago(1h)\r\n| where FileName == \"ifz30zlx.dll\"",
"lastModifiedDateTime": null
},
"schedule": {
"period": "24H",
"nextRunDateTime": "2023-06-26T08:52:06.1766667Z"
},
"lastRunDetails": {
"lastRunDateTime": "2023-06-25T08:52:06.1766667Z",
"status": null,
"failureReason": null,
"errorCode": null
},
"detectionAction": {
"alertTemplate": {
"title": "unwanted dll",
"description": "test",
"severity": "low",
"category": "Malware",
"recommendedActions": null,
"mitreTechniques": [],
"impactedAssets": []
},
"organizationalScope": null,
"responseActions": [
{
"@odata.type": "#microsoft.graph.security.restrictAppExecutionResponseAction",
"identifier": "deviceId"
},
{
"@odata.type": "#microsoft.graph.security.initiateInvestigationResponseAction",
"identifier": "deviceId"
},
{
"@odata.type": "#microsoft.graph.security.collectInvestigationPackageResponseAction",
"identifier": "deviceId"
},
{
"@odata.type": "#microsoft.graph.security.runAntivirusScanResponseAction",
"identifier": "deviceId"
},
{
"@odata.type": "#microsoft.graph.security.isolateDeviceResponseAction",
"isolationType": "full",
"identifier": "deviceId"
},
{
"@odata.type": "#microsoft.graph.security.blockFileResponseAction",
"identifier": "sha1",
"deviceGroupNames": []
}
]
}
}
One curious thing here that we will touch on later is the fact that the example leaves "impactedAssets": []
empty. This is the single most annoying bug we’ve encountered (someone on my team discovered this), but it’s a required field when creating custom detection rules. When working with the GET-portion of the API, however, it seems to be completely random if you get one, four or any impacted assets in the return. I get that it’s a beta API, still annoying.
To get an idea of what the impactedAssets
-field means, we can peruse some learn docs and find that it’s a column in our results where we expect to find the impacted asset. This becomes a whole thing, since it’s directly related to the response actions. We’re not always wanting to configure a response action, but we still need an impacted asset for some reason.
Now, to make it a lot easier for myself when I’m creating the push-portion of the script, I’m going to create a detection rule in the UI.
Here, we can find some information about the fields we need to supply (can also see here) and other nifty stuff such as what categories there are:
With that created, let’s move on to the functions.
The core idea here is to have a function that pulls content, a Get
, and a function that performs the push, New
. We can add a parameter that refers to the ruleId
in the API endpoints to handle updates as well, but in a separate Update
-function.
We’re going to mainly use Invoke-MgGraphRequest
to run our API-calls. This means we also need to use ` Connect-MgGraph -Scopes CustomDetection.ReadWrite.All` to connect to our tenant, so make sure you have installed and imported the Graph-module for this part.
Instead of making one query for getting all rules, I’ve made it very simple and have a single query that takes -ruleId
as a parameter, this way we can either query one or all. If you want to query a list, you can also loop through a list using a foreach
.
function Get-DetectionRule {
param (
[Parameter(Mandatory = $false)]
[string]$token,
[Parameter(Mandatory = $false)]
[string]$ruleId
)
# If specific rule id, get only one rule - if not default to all
if($ruleId) {
if ($token) {
$headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
}
$result = Invoke-RestMethod -Method Get -Uri "https://graph.microsoft.com/beta/security/rules/detectionRules/$ruleId" -Header $headers
} else {
$result = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/security/rules/detectionRules/$ruleId"
}
} else {
if ($token) {
$headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
}
$result = Invoke-RestMethod -Method Get -Uri "https://graph.microsoft.com/beta/security/rules/detectionRules" -Header $headers
} else {
$result = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/security/rules/detectionRules"
}
}
if(!$result) {
Write-Host "No detection rules found or an error occurred."
return $null
}
return $result
}
Running this without any input (given that we have correct permissions) will return all custom detection rules (I only have one):
$rules = Get-DetectionRule
The custom detection rule I created above has the ID 50, so let’s run with that:
$rule = Get-DetectionRule -RuleId 50
$rule
Name Value
---- -----
queryCondition {queryText, lastModifiedDateTime}
lastModifiedBy its me
createdDateTime 01/06/2025 16:05:28
@odata.context https://graph.microsoft.com/beta/$metadata#security/rules/detectionRules/$entity
id 50
schedule {nextRunDateTime, period}
detectionAction {organizationalScope, responseActions, alertTemplate}
isEnabled True
detectorId 827adbcb-1c2f-4940-b980-37989396715e
lastModifiedDateTime 01/06/2025 16:05:28
displayName TestDetection
lastRunDetails {failureReason, errorCode, lastRunDateTime, status}
Now, let’s translate that to JSON and see what we should put into our New
-function:
$rule | ConvertTo-Json -Depth 3
Which returns the following:
{
"queryCondition": {
"queryText": "DeviceInfo\r\n| summarize (Timestamp, ReportId)=arg_max(Timestamp, ReportId), count() by DeviceId",
"lastModifiedDateTime": null
},
"lastModifiedBy": "me",
"createdDateTime": "\/Date(1748793928796)\/",
"@odata.context": "https://graph.microsoft.com/beta/$metadata#security/rules/detectionRules/$entity",
"createdBy": "me",
"id": "50",
"schedule": {
"nextRunDateTime": "\/Date(1748880329503)\/",
"period": "24H"
},
"detectionAction": {
"organizationalScope": null,
"responseActions": [
],
"alertTemplate": {
"impactedAssets": "",
"title": "Test alert",
"severity": "low",
"recommendedActions": "Test",
"category": "Malware",
"mitreTechniques": "",
"description": "Test description"
}
},
"isEnabled": true,
"detectorId": "827adbcb-1c2f-4940-b980-37989396715e",
"lastModifiedDateTime": "\/Date(1748793928796)\/",
"displayName": "TestDetection",
"lastRunDetails": {
"failureReason": null,
"errorCode": null,
"lastRunDateTime": "\/Date(1748793929503)\/",
"status": "completed"
}
}
This gives us some input to create our New
-function! Also, let me direct your eyes to the impactedAssets
which is empty (surprise surprise).
One thing about the POST
api that’s very annoying is that it’s not currently returning good error messages. I spent about two hours trying to figure out what was throwing 400’s at my face (most likely an uppercase L in severity low
it seems). Once I got around that, I’m now stuck at internal errors - very fun. Troubleshooting internal server error 500 messages is the bane of all joy.
At some point I got a bit tired (at this point I was still just using the Invoke-GraphRequest
-cmdlet to send my requests) and started looking for some inspiration. I stumbled across this snippet which updates existing custom detection to NRT rules. I could steal some of this and try to modify my code a bit - to also support running with access tokens from an application.
Based on the usage of iwr
or Invoke-RestMethod
in the example I’m stea… borrowing from, we’re going to need an access token. We’ll modify the example and create a function for this:
function Get-AccessToken {
param(
[Parameter(Mandatory = $true)]
[string]$tenantId,
[Parameter(Mandatory = $true)]
[string]$clientId,
[Parameter(Mandatory = $true)]
[string]$clientSecret
)
$global:token = ""
$graphResource = 'https://graph.microsoft.com/'
$oAuthUri = "https://login.windows.net/$TenantId/oauth2/token"
$authBody = [Ordered]@{
resource = $graphResource
client_id = $clientId
client_secret = $clientSecret
grant_type = 'client_credentials'
}
Write-Host "[A] Authenticating to tenant's $TenantId Graph API"
try {
$authResponse = Invoke-RestMethod -Method Post -Uri $oAuthUri -Body $authBody -ErrorAction Stop
$token = $authResponse.access_token
} catch {
Write-Host -ForegroundColor Red "[!] Authentication failed: $_"
exit 1
}
Write-Host "[*] Authentication successful"
Write-Host "[*] Access token obtained successfully"
return $token
}
With this, we can create an app registration in our tenant, set a secret and give it the CustomDetection.ReadWrite.All
permissions.
$token = Get-AccessToken -tenantId $tenantId -clientId $clientId -clientSecret $clientSecret
[A] Authenticating to tenant's xxx Graph API
[*] Authentication successful
[*] Access token obtained successfully
With this, we can use the following code to create new custom detection rules:
function New-DetectionRule {
param (
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$displayName,
[Parameter(Mandatory = $true)]
[bool]$isEnabled,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$queryText,
[Parameter(Mandatory = $true)]
[ValidateSet("0","1H", "3H", "12H", "24H")]
[string]$period,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$alertTitle,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$alertDescription,
[Parameter(Mandatory = $true)]
[ValidateSet("informational", "low", "medium", "high")]
[string]$severity,
[Parameter(Mandatory = $true)]
[ValidateSet("Malware","Execution", "Discovery", "Lateral Movement", "Persistence", "PrivilegeEscalation", "DefenseEvasion", "CredentialAccess", "Collection", "Exfiltration", "CommandAndControl", "SuspiciousActivity", "Unwanted Software", "Ransomware", "Exploit")]
[string]$category,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$identifier,
[string[]]$mitreTechniques = @(),
$recommendedActions = $null,
[ValidateSet("full", "selective")]
[string]$isolationType,
[Parameter(Mandatory = $false)]
[string]$token
)
$body = @{
displayName = $displayName
isEnabled = $isEnabled
queryCondition = @{
queryText = $queryText
}
schedule = @{
period = $period
}
detectionAction = @{
alertTemplate = @{
title = $alertTitle
description = $alertDescription
severity = $severity.ToLower()
category = $category
recommendedActions = $recommendedActions
mitreTechniques = $mitreTechniques
impactedAssets = @(
@{
'@odata.type' = '#microsoft.graph.security.impactedDeviceAsset'
identifier = $identifier
}
)
}
organizationalScope = $null
responseActions = @(
)
}
}
if($isolationType) {
$body.detectionAction.responseActions += @{
'@odata.type' = '#microsoft.graph.security.isolateDeviceResponseAction'
identifier = $identifier
isolationType = $isolationType
}
}
$jsonBody = $body | ConvertTo-Json -Depth 10
if($token) {
Write-Host "Using provided access token for authentication."
$Headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
}
$return = Invoke-RestMethod -Method POST -Uri "https://graph.microsoft.com/beta/security/rules/detectionRules" -Body $jsonBody -Headers $Headers
} else {
$Headers = @{"Content-Type" = "application/json"}
$return = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/beta/security/rules/detectionRules" -Body $jsonBody -Headers $Headers
}
if(!$return) {Write-Host "Failed to create detection rule or an error occurred."
return $jsonBody
}
return $return
}
Using it with the token option yields very good results:
$return = New-DetectionRule -displayName "TestRule2345" -isEnabled $true -queryText "DeviceInfo| summarize (Timestamp, ReportId)=arg_max(Timestamp, ReportId), count() by DeviceId" -period "3H" -alertTitle "TestRuleTitle" -alertDescription "Kore wa test desu" -identifier "deviceId" -severity Low -category Execution -token $token
Using provided access token for authentication.
And we can see it’s created in the UI:
While using it without the token option (which then uses Graph cmdlet Invoke-MgGraphRequest
) throws an internal error, even if we modify only the fields that would throw errors for duplication issues.
Trigger warning - this one also suffers from the same issues that I had with the New
-function, the Graph cmdlet doesn’t work (throws error 500) and so this currently will only work if you have a -token $token
provided.
function Update-DetectionRule {
param (
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$ruleId,
[Parameter(Mandatory = $false)]
[ValidateNotNullOrEmpty()]
[string]$displayName,
[Parameter(Mandatory = $false)]
[bool]$isEnabled,
[Parameter(Mandatory = $false)]
[ValidateNotNullOrEmpty()]
[string]$queryText,
[Parameter(Mandatory = $false)]
[ValidateSet("0","1H", "3H", "12H", "24H")]
[string]$period,
[Parameter(Mandatory = $false)]
[ValidateNotNullOrEmpty()]
[string]$alertTitle,
[Parameter(Mandatory = $false)]
[ValidateNotNullOrEmpty()]
[string]$alertDescription,
[Parameter(Mandatory = $false)]
[ValidateSet("informational", "low", "medium", "high")]
[string]$severity,
[Parameter(Mandatory = $false)]
[ValidateSet("Malware","Execution", "Discovery", "Lateral Movement", "Persistence", "PrivilegeEscalation", "DefenseEvasion", "CredentialAccess", "Collection", "Exfiltration", "CommandAndControl", "SuspiciousActivity", "Unwanted Software", "Ransomware", "Exploit")]
[string]$category,
[Parameter(Mandatory = $false)]
[ValidateNotNullOrEmpty()]
[string]$identifier,
[string[]]$mitreTechniques = @(),
$recommendedActions = $null,
[ValidateSet("full", "selective")]
[string]$isolationType,
[Parameter(Mandatory = $false)]
[string]$token
)
$body = @{}
if ($PSBoundParameters.ContainsKey('displayName')) {
$body.displayName = $displayName
}
if ($PSBoundParameters.ContainsKey('isEnabled')) {
$body.isEnabled = $isEnabled
}
if ($PSBoundParameters.ContainsKey('queryText')) {
$body.queryCondition = @{ queryText = $queryText }
}
if ($PSBoundParameters.ContainsKey('period')) {
$body.schedule = @{ period = $period }
}
$detectionAction = @{}
$alertTemplate = @{}
if ($PSBoundParameters.ContainsKey('alertTitle')) {
$alertTemplate.title = $alertTitle
}
if ($PSBoundParameters.ContainsKey('alertDescription')) {
$alertTemplate.description = $alertDescription
}
if ($PSBoundParameters.ContainsKey('severity')) {
$alertTemplate.severity = $severity.ToLower()
}
if ($PSBoundParameters.ContainsKey('category')) {
$alertTemplate.category = $category
}
if ($PSBoundParameters.ContainsKey('recommendedActions')) {
$alertTemplate.recommendedActions = $recommendedActions
}
if ($PSBoundParameters.ContainsKey('mitreTechniques')) {
$alertTemplate.mitreTechniques = $mitreTechniques
}
if ($PSBoundParameters.ContainsKey('identifier')) {
$alertTemplate.impactedAssets = @(
@{
'@odata.type' = '#microsoft.graph.security.impactedDeviceAsset'
identifier = $identifier
}
)
}
if ($alertTemplate.Count -gt 0) {
$detectionAction.alertTemplate = $alertTemplate
}
$detectionAction.organizationalScope = $null
$detectionAction.responseActions = @()
if ($PSBoundParameters.ContainsKey('isolationType') -and $PSBoundParameters.ContainsKey('identifier')) {
$detectionAction.responseActions += @{
'@odata.type' = '#microsoft.graph.security.isolateDeviceResponseAction'
identifier = $identifier
isolationType = $isolationType
}
}
if ($detectionAction.Count -gt 0) {
$body.detectionAction = $detectionAction
}
$jsonBody = $body | ConvertTo-Json -Depth 10
if($token) {
Write-Host "Using provided access token for authentication."
$Headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
}
$return = Invoke-RestMethod -Method PATCH -Uri "https://graph.microsoft.com/beta/security/rules/detectionRules/$ruleId" -Body $jsonBody -Headers $Headers
} else {
$Headers = @{"Content-Type" = "application/json"}
$return = Invoke-MgGraphRequest -Method PATCH -Uri "https://graph.microsoft.com/beta/security/rules/detectionRules/$ruleId" -Body $jsonBody -Headers @{"Content-Type" = "application/json"}
}
if(!$return) {
Write-Host "Failed to update detection rule or an error occurred."
return $body
}
Write-Host "Detection rule updated successfully."
return $return
}
And if we run it, it works (as intended):
Update-DetectionRule -ruleId 51 -displayName "UPDATE BABY" -token $token
Using provided access token for authentication.
Detection rule updated successfully.
@odata.context : https://graph.microsoft.com/beta/$metadata#security/rules/detectionRules/$entity
detectorId : 3882fdc2-d867-483c-9126-96ff17c62090
id : 51
displayName : UPDATE BABY
isEnabled : True
createdBy : customdetectionrules
createdDateTime : 01/06/2025 17:46:01
lastModifiedDateTime : 01/06/2025 17:59:10
lastModifiedBy : customdetectionrules
queryCondition : @{queryText=DeviceInfo| summarize (Timestamp, ReportId)=arg_max(Timestamp, ReportId), count() by DeviceId; lastModifiedDateTime=}
schedule : @{period=3H; nextRunDateTime=01/06/2025 20:49:49}
lastRunDetails : @{lastRunDateTime=01/06/2025 17:49:49; status=; failureReason=; errorCode=}
detectionAction : @{organizationalScope=; alertTemplate=; responseActions=System.Object[]}
This API still has some weird issues - implementing a full push/pull CI/CD pipelines will probably not work in it’s current form. Still just in BETA though, so will revisit this later most likely.
If anyone knows how to fix the internal error stuff hmu.