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 SkipMitreTechniqueValidation Skip the pre-deployment check that verifies all listed MITRE ATT&CK techniques are supported by XDR for the selected alert category. 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() 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." } #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) { $mitreCheckObj = [PSCustomObject]@{ alertCategory = $jsonObj.detectionAction.alertTemplate.category mitreTechniques = $jsonObj.detectionAction.alertTemplate.mitreTechniques } $mitreResult = Test-CustomDetectionMitreTechnique -InputObject $mitreCheckObj -WarningAction SilentlyContinue if (-not $mitreResult.IsValid) { $invalidList = $mitreResult.InvalidTechniques -join ', ' throw "MITRE technique(s) not supported by XDR for category '$($mitreCheckObj.alertCategory)': $invalidList. Use -SkipMitreTechniqueValidation to bypass this check." } } #endregion #region 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" } } #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 $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 } #endregion #region 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 } #endregion #region 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 } } } #endregion } catch { Write-Error "Error deploying detection rule from '$InputFile': $($_.Exception.Message)" Write-Verbose "$($_.ErrorDetails.Message)" Write-Debug "$(($jsonObj | ConvertTo-Json -Depth 10))" throw } } } |