Defender XDR - Custom Detection Rules Push/Pull via API

A little primer to pushing and pulling new content via the graph beta API

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.

Required permissions

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

API endpoints

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.


RequestBody for different actions

Create / POST

{
  "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"
      }
    ]
  }
}

Update / PATCH

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.

Response (from GET)

{
  "@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.

Preparing for testing

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.

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.

Get-DetectionRule

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).

New-DetectionRule

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.

Update-DetectionRule

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[]}

Full code

Last thoughts

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.

Tags: Defender XDR, Graph API, Custom Detection Rules
Share: Twitter LinkedIn