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 (detectorId) is appended to the description as
        "[<UUID>]". Use -DescriptionTagPrefix to add a prefix (e.g. "[PREFIX:<UUID>]")
        or -NoDescriptionTag to suppress the tag entirely.
 
        The function automatically detects whether the rule already exists (by detectorId
        or by scanning descriptions for the UUID tag) and issues 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 isEnabled = $false regardless of the file value.
 
    .PARAMETER NoDescriptionTag
        When set, the "[<UUID>]" tag is NOT appended to the description.
 
    .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 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.
 
    .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
    )

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

    process {
        try {
            # ── 1. Load the file ────────────────────────────────────────────
            $extension = [System.IO.Path]::GetExtension($InputFile).ToLowerInvariant()
            switch ($extension) {
                { $_ -in '.yaml', '.yml' } {
                    $yamlObj = Import-CustomDetectionYamlFile -FilePath $InputFile
                    $convertParams = @{ YamlObject = $yamlObj }
                    if ($SkipIdentifierValidation) {
                        $convertParams['SkipIdentifierValidation'] = $true
                    }
                    $jsonObj = ConvertFrom-CustomDetectionYamlToJson @convertParams
                }
                '.json' {
                    $jsonObj = Import-CustomDetectionJsonFile -FilePath $InputFile
                    # Ensure it's a mutable hashtable
                    if ($jsonObj -isnot [hashtable]) {
                        $jsonObj = $jsonObj | ConvertTo-Json -Depth 10 | ConvertFrom-Json -AsHashtable
                    }
                }
                default {
                    throw "Unsupported file extension '$extension'. Use .yaml, .yml, or .json."
                }
            }

            $detectorId = $jsonObj.detectorId
            if (-not $detectorId) {
                throw "The input file does not contain a detectorId (guid). Cannot deploy."
            }

            # ── 1b. Apply parameter file (prepend/append/replace variables) ──
            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."
                    }
                }
            }

            # ── 2. Apply overrides ──────────────────────────────────────────
            if ($Disabled) {
                $jsonObj.isEnabled = $false
            }

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

            # ── 3. 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
                }
            }

            # ── 4. Discover if rule already exists ──────────────────────────
            $existingRuleId = $null
            $existingRule = $null

            # Try by detectorId first (cached)
            $existingRuleId = Get-CustomDetectionIdByDetectorId -DetectorId $detectorId -ErrorAction SilentlyContinue

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

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

            # ── 5. Flatten local rule for comparison ────────────────────────
            $localFlat = @{
                displayName = $jsonObj.displayName
                isEnabled   = $jsonObj.isEnabled
                queryText   = $jsonObj.queryCondition.queryText
                period      = [string]$jsonObj.schedule.period
                title       = $jsonObj.detectionAction.alertTemplate.title
                description = $jsonObj.detectionAction.alertTemplate.description
                severity    = $jsonObj.detectionAction.alertTemplate.severity
                category    = $jsonObj.detectionAction.alertTemplate.category
            }

            # ── 6. Create or update ────────────────────────────────────────
            $ruleName = $jsonObj.displayName

            if ($existingRule) {
                # Check for actual changes
                $hasChanges = Compare-CustomDetection -Local $localFlat -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"
                    Invoke-MgGraphRequestWithRetry -Method PATCH -Uri $uri -Body $jsonObj | 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')) {
                    $response = Invoke-MgGraphRequestWithRetry -Method POST -Uri $baseUri -Body $jsonObj
                    $newId = $response.id
                    Write-Verbose "Created rule '$ruleName' (Id: $newId, DetectorId: $detectorId)."

                    [PSCustomObject]@{
                        Action     = 'Created'
                        RuleName   = $ruleName
                        RuleId     = $newId
                        DetectorId = $detectorId
                    }
                }
            }
        } catch {
            Write-Error "Error deploying detection rule from '$InputFile': $($_.Exception.Message) / $($_.ErrorDetails.Message)"
            throw
        }
    }
}