Public/Deploy-CustomDetection.ps1

function Deploy-CustomDetection {
    <#
    .SYNOPSIS
        Creates or updates a Defender XDR custom detection rule from a YAML or JSON file.
 
    .DESCRIPTION
        Reads a detection rule from a YAML or JSON file, optionally overrides severity,
        title prefix, and enabled state, then deploys it to Microsoft Defender XDR via
        the Microsoft Graph API.
 
        By default the YAML/JSON guid is appended to the description as "[<UUID>]".
        Use -DescriptionTagPrefix to add a prefix (e.g. "[PREFIX:<UUID>]") or
        -NoDescriptionTag to suppress the tag entirely.
 
        New rules are sent with the id "rule-<guid>", and the guid also travels
        in the description tag. The API assigns an id when none is sent, whatever
        the reference says, and rejects one that starts with a digit. The function finds an existing rule through that id, through the
        tag, or, with -NoDescriptionTag, through the display name. When the rule
        list carries none of them it asks for "rule-<guid>" directly, since the
        list can lag behind a create or a delete. A found rule gets a
        PATCH (update) instead of a POST (create). Before
        updating it compares the local rule against the remote version and skips the
        call when nothing changed.
 
    .PARAMETER InputFile
        Path to the input YAML (.yaml/.yml) or JSON (.json) file.
 
    .PARAMETER Severity
        Override the alert severity. Valid values: Informational, Low, Medium, High.
 
    .PARAMETER TitlePrefix
        Optional string prepended to the displayName / alertTitle.
        Example: -TitlePrefix '[PREFIX] ' produces "[PREFIX] My Rule".
 
    .PARAMETER Disabled
        Deploy the rule with status = disabled regardless of the file value.
 
    .PARAMETER NoDescriptionTag
        When set, the "[<UUID>]" tag is NOT appended to the description. A rule
        that does not carry the rule-<guid> id is then matched by its display name alone.
 
    .PARAMETER DescriptionTagPrefix
        Prefix placed before the UUID inside the tag, e.g. 'PREFIX' produces "[PREFIX:<UUID>]".
        Ignored when -NoDescriptionTag is set.
 
    .PARAMETER ParameterFile
        Path to a YAML parameter file that can prepend/append text to the query
        and replace %%VARIABLE%% or %%VARIABLE:DEFAULT%% placeholders.
 
        The file may contain:
          PrependQuery: text added to the beginning of the query
          AppendQuery: text added to the end of the query
          ReplaceQueryVariables: key-value pairs for placeholder substitution
 
    .PARAMETER Force
        Skip the change-detection check and always push the rule to the API.
 
    .PARAMETER SkipMitreTechniqueValidation
        Skip the pre-deployment check that verifies all listed MITRE ATT&CK techniques are supported
        by XDR for each tactic. Use this to deploy rules that include techniques not
        yet reflected in the local XDR technique mapping.
 
    .PARAMETER WhatIf
        Shows what changes would be made without actually applying them.
 
    .PARAMETER Confirm
        Prompts for confirmation before creating or updating each rule.
 
    .EXAMPLE
        Deploy-CustomDetection -InputFile '.\input.yaml'
 
        Deploys the rule; appends "[<guid>]" to the description.
 
    .EXAMPLE
        Deploy-CustomDetection -InputFile '.\input.yaml' -DescriptionTagPrefix 'PREFIX'
 
        Deploys the rule; appends "[PREFIX:<guid>]" to the description.
 
    .EXAMPLE
        Deploy-CustomDetection -InputFile '.\input.yaml' -NoDescriptionTag -Disabled
 
        Deploys the rule in disabled mode without a description tag.
 
    .EXAMPLE
        Deploy-CustomDetection -InputFile '.\input.yaml' -Severity High -TitlePrefix '[PREFIX] '
 
        Deploys with severity override and a title prefix.
 
    .EXAMPLE
        Deploy-CustomDetection -InputFile '.\input.yaml' -ParameterFile '.\params.yaml'
 
        Deploys the rule and applies query transformations from the parameter file.
 
    .EXAMPLE
        Deploy-CustomDetection -InputFile '.\input.yaml' -SkipMitreTechniqueValidation
 
        Deploys the rule without validating the MITRE techniques against the XDR category mapping.
 
    .NOTES
        Requires the Microsoft.Graph.Authentication module and an active Graph API session.
        Use Connect-MgGraph before calling this function.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, HelpMessage = 'Path to the input YAML or JSON file')]
        [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
        [string]$InputFile,

        [Parameter(HelpMessage = 'Override alert severity')]
        [ValidateSet('Informational', 'Low', 'Medium', 'High')]
        [string]$Severity,

        [Parameter(HelpMessage = 'String prepended to the rule display name')]
        [string]$TitlePrefix,

        [Parameter(HelpMessage = 'Deploy the rule in disabled mode')]
        [switch]$Disabled,

        [Parameter(HelpMessage = 'Do not append a UUID tag to the description')]
        [switch]$NoDescriptionTag,

        [Parameter(HelpMessage = 'Prefix inside the description tag, e.g. PREFIX produces [PREFIX:<UUID>]')]
        [string]$DescriptionTagPrefix,

        [Parameter(HelpMessage = 'Path to a YAML parameter file for query variable replacement')]
        [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
        [string]$ParameterFile,

        [Parameter(HelpMessage = 'Skip change-detection and always push')]
        [switch]$Force,

        [Parameter(HelpMessage = 'Allow identifiers not listed in the official documentation (emits a warning instead of throwing)')]
        [switch]$SkipIdentifierValidation,

        [Parameter(HelpMessage = 'Skip the MITRE ATT&CK technique validation against the XDR category mapping')]
        [switch]$SkipMitreTechniqueValidation
    )

    begin {
        Assert-MgGraphConnection
        $baseUri = 'https://graph.microsoft.com/beta/security/rules/detectionRules'
    }

    process {
        try {
            #region Load the file
            $extension = [System.IO.Path]::GetExtension($InputFile).ToLowerInvariant()
            $yamlObj = switch ($extension) {
                { $_ -in '.yaml', '.yml' } {
                    Import-CustomDetectionYamlFile -FilePath $InputFile
                }
                '.json' {
                    # Normalise JSON input through the YAML shape so legacy files deploy with the current body
                    $rawJson = Import-CustomDetectionJsonFile -FilePath $InputFile
                    ConvertFrom-CustomDetectionJsonToYaml -JsonObject $rawJson -ValidateIdentifiers:(-not $SkipIdentifierValidation)
                }
                default {
                    throw "Unsupported file extension '$extension'. Use .yaml, .yml, or .json."
                }
            }

            $convertParams = @{ YamlObject = $yamlObj }
            if ($SkipIdentifierValidation) {
                $convertParams['SkipIdentifierValidation'] = $true
            }
            $jsonObj = ConvertFrom-CustomDetectionYamlToJson @convertParams

            # The body carries the client id rule-<guid>. The bare guid is the tag and the lookup key
            $detectorId = "$($jsonObj.id)" -replace '^rule-', ''
            if (-not $detectorId) {
                throw "The input file does not contain a guid. Cannot deploy."
            }
            #endregion

            #region Apply parameter file
            if ($PSBoundParameters.ContainsKey('ParameterFile')) {
                $originalQuery = $jsonObj.queryCondition.queryText
                $resolvedQuery = Resolve-QueryVariables -QueryText $originalQuery -ParameterFilePath $ParameterFile
                $jsonObj.queryCondition.queryText = $resolvedQuery
                Write-Verbose "Applied parameter file '$ParameterFile' to query."
            } else {
                # Check if the query contains %%VARIABLE%% placeholders without a parameter file
                $queryText = $jsonObj.queryCondition.queryText
                $placeholders = [regex]::Matches($queryText, '%%([^%:]+?)(?::([^%]*?))?%%')
                if ($placeholders.Count -gt 0) {
                    $withDefault = @()
                    $withoutDefault = @()
                    foreach ($ph in $placeholders) {
                        $varName = $ph.Groups[1].Value
                        if ($ph.Groups[2].Success) {
                            $withDefault += $varName
                        } else {
                            $withoutDefault += $varName
                        }
                    }
                    $withDefault = $withDefault | Select-Object -Unique
                    $withoutDefault = $withoutDefault | Select-Object -Unique

                    # Resolve defaults inline
                    if ($withDefault.Count -gt 0) {
                        $jsonObj.queryCondition.queryText = [regex]::Replace($queryText, '%%([^%:]+?):([^%]*?)%%', '$2')
                        $defaultNames = $withDefault -join ', '
                        Write-Information "Query placeholder(s) ($defaultNames) resolved to their default values because no -ParameterFile was specified."
                    }

                    # Warn for placeholders without defaults
                    if ($withoutDefault.Count -gt 0) {
                        $warnNames = $withoutDefault -join ', '
                        Write-Warning "Query contains variable placeholder(s) ($warnNames) without default values and no -ParameterFile was specified. These placeholders will not be replaced."
                    }
                }
            }
            #endregion

            #region Validate MITRE technique coverage
            if (-not $SkipMitreTechniqueValidation) {
                # YAML input is validated as written, so a listed parent counts and a derived one does not. JSON input is normalised first, so its derived parents are validated too
                $mitreResult = Test-CustomDetectionMitreTechnique -InputObject $yamlObj
                if (-not $mitreResult.IsValid) {
                    $invalidList = $mitreResult.InvalidTechniques -join ', '
                    throw "MITRE technique(s) not supported by XDR for category '$($mitreResult.Category)': $invalidList. Use -SkipMitreTechniqueValidation to bypass this check."
                }
            }
            #endregion

            #region Apply overrides
            if ($Disabled) {
                $jsonObj.status = 'disabled'
            }

            if ($PSBoundParameters.ContainsKey('Severity')) {
                $jsonObj.detectionAction.alertTemplate.severity = $Severity.ToLowerInvariant()
            }

            if ($PSBoundParameters.ContainsKey('TitlePrefix')) {
                $currentName = $jsonObj.displayName
                if (-not $currentName.StartsWith($TitlePrefix)) {
                    $jsonObj.displayName = "$TitlePrefix$currentName"
                }
                $currentTitle = $jsonObj.detectionAction.alertTemplate.title
                if ($currentTitle -and -not $currentTitle.StartsWith($TitlePrefix)) {
                    $jsonObj.detectionAction.alertTemplate.title = "$TitlePrefix$currentTitle"
                }
            }
            #endregion

            #region Build and apply description tag
            if (-not $NoDescriptionTag) {
                $tag = if ($PSBoundParameters.ContainsKey('DescriptionTagPrefix') -and $DescriptionTagPrefix) {
                    "[$DescriptionTagPrefix`:$detectorId]"
                } else {
                    "[$detectorId]"
                }

                $desc = $jsonObj.detectionAction.alertTemplate.description
                # Remove any existing tag pattern before appending
                $tagPattern = '\s*\[[^\]]*' + [regex]::Escape($detectorId) + '\]'
                if ($desc) {
                    $desc = [regex]::Replace($desc, $tagPattern, '').TrimEnd()
                    $jsonObj.detectionAction.alertTemplate.description = "$desc $tag"
                } else {
                    $jsonObj.detectionAction.alertTemplate.description = $tag
                }
            }
            #endregion

            #region Discover existing rule
            $existingRule = $null
            $existingRuleId = $null
            $listUnavailable = $false

            # The three lookups read the rule list. When it does not answer, the client id is the only lookup left
            try {
                # Try by rule id first (cached)
                $existingRuleId = Get-CustomDetectionIdByDetectorId -DetectorId $detectorId -ErrorAction SilentlyContinue

                # Fallback: scan all rules for UUID tag in description
                if (-not $existingRuleId) {
                    Write-Verbose "Guid '$detectorId' not found by ID lookup. Scanning descriptions for UUID tag..."
                    $existingRuleId = Get-CustomDetectionIdByDescriptionTag -DescriptionTag $detectorId -WarningAction SilentlyContinue
                    if ($existingRuleId) {
                        Write-Verbose "Found matching detection by description tag: Rule Id '$existingRuleId'."
                    } else {
                        Write-Verbose "No existing rule carries the tag '$detectorId'."
                    }
                }

                # A rule without the client id and without a tag can only be matched by name. The API keeps names unique
                if (-not $existingRuleId -and $NoDescriptionTag) {
                    $byName = Get-CustomDetectionIds | Where-Object { $_.DisplayName -eq $jsonObj.displayName } | Select-Object -First 1
                    if ($byName) {
                        $existingRuleId = $byName.Id
                        Write-Warning "Rule '$($jsonObj.displayName)' was matched by its display name alone. Without the tag a renamed file creates a new rule and leaves this one behind."
                    }
                }
            } catch {
                if (-not (Test-CustomDetectionListFailure -ErrorRecord $_)) { throw }
                $listUnavailable = $true
                Write-Warning "The rule list did not answer. Rule '$($jsonObj.displayName)' is looked up by its client id only."
            }
            # The list can lag behind a create or a delete, so the client id is asked for directly before a create
            if (-not $existingRuleId) {
                $byId = Get-CustomDetectionByClientId -Guid $detectorId
                if ($byId) {
                    $existingRuleId = $byId.id
                    $existingRule = $byId
                    Write-Verbose "Found the rule through its client id '$existingRuleId', which the list did not carry."
                }
            }

            if (-not $existingRuleId -and $listUnavailable) {
                throw "The rule list is unavailable and rule '$($jsonObj.displayName)' was not found by its client id. Nothing was created."
            }
            if (-not $existingRuleId) {
                Write-Verbose "The rule will be created."
            }

            # Fetch the full existing rule if we found one
            if ($existingRuleId -and -not $existingRule) {
                $existingRule = Get-CustomDetection -DetectionId $existingRuleId
            }
            #endregion

            #region Create or update
            $ruleName = $jsonObj.displayName

            if ($existingRule) {
                # Check for actual changes
                $hasChanges = Compare-CustomDetection -Local $jsonObj -Remote $existingRule

                if (-not $hasChanges -and -not $Force) {
                    Write-Verbose "Rule '$ruleName' (Id: $existingRuleId) is up-to-date. Skipping update."
                    return [PSCustomObject]@{
                        Action     = 'Skipped'
                        RuleName   = $ruleName
                        RuleId     = $existingRuleId
                        DetectorId = $detectorId
                        Reason     = 'No changes detected'
                    }
                }

                # Update existing rule via PATCH
                if ($PSCmdlet.ShouldProcess("Rule '$ruleName' (Id: $existingRuleId)", 'Update detection rule')) {
                    $uri = "$baseUri/$existingRuleId"
                    $patchBody = Complete-CustomDetectionPatchBody -Body $jsonObj -Remote $existingRule
                    Invoke-MgGraphRequestWithRetry -Method PATCH -Uri $uri -Body $patchBody | Out-Null
                    Write-Verbose "Updated rule '$ruleName' (Id: $existingRuleId)."

                    [PSCustomObject]@{
                        Action     = 'Updated'
                        RuleName   = $ruleName
                        RuleId     = $existingRuleId
                        DetectorId = $detectorId
                    }
                }
            } else {
                # Create new rule via POST
                if ($PSCmdlet.ShouldProcess("Rule '$ruleName'", 'Create detection rule')) {
                    # The API assigns an id when none is sent and rejects one that starts with a digit, hence the prefixed client id in the body
                    $response = Invoke-MgGraphRequestWithRetry -Method POST -Uri $baseUri -Body $jsonObj
                    $newId = $response.id
                    # The created rule joins the cached list, so the next rule does not ask the list again
                    $created = [ordered]@{ id = $newId; detectorId = $response.detectorId; displayName = $jsonObj.displayName; detectionAction = $jsonObj.detectionAction }
                    Add-CustomDetectionIdsCacheEntry -Entry (ConvertTo-CustomDetectionIdEntry -Rule $created)
                    Write-Verbose "Created rule '$ruleName' (Id: $newId, Guid: $detectorId)."

                    [PSCustomObject]@{
                        Action     = 'Created'
                        RuleName   = $ruleName
                        RuleId     = $newId
                        DetectorId = $detectorId
                    }
                }
            }
            #endregion
        }
        catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] {
            $exMsg = $_.Exception.Message

            if ($_.ErrorDetails.Message -and $_.ErrorDetails.Message -match '(\{.+\})\s*$') {
                try {
                    $errorBody = $Matches[1] | ConvertFrom-Json
                    if ($errorBody.error.message) {
                        $exMsg = $exMsg -replace '\(([^)]+)\)\.?\s*$', "($($errorBody.error.message))"
                    }
                }
                catch {
                    # JSON parsing failed; keep original message
                }
            }

            Write-Error "Error deploying detection rule from '$InputFile': $exMsg"
            Write-Debug "$(($jsonObj | ConvertTo-Json -Depth 10))"
            throw
        }
        catch {
            Write-Error "Unexpected error deploying detection rule from '$InputFile': $_"
            Write-Debug "$(($jsonObj | ConvertTo-Json -Depth 10))"
            throw
        }
    }
}