public/New-MsecDefenderDetectionRule.ps1

function New-MsecDefenderDetectionRule {
    <#
    .SYNOPSIS
        Creates a Defender XDR custom detection rule from an advanced hunting query, after
        running the query to check it works. Runs as YOU, not as the msec app.

    .DESCRIPTION
        THE QUERY IS RUN BEFORE THE RULE IS CREATED, AND THAT IS THE POINT. A custom detection
        whose query is malformed, references a table this tenant does not have, or omits the
        columns the entity mapping names, is accepted by the portal and by this API and then
        fails on its schedule - at which point Defender eventually marks it autoDisabled. The
        rule sits in the list looking live. So the query is executed first, through the app's
        read-only hunting access, and creation is refused if it does not run.

        ROW COUNT IS CHECKED TOO, because the other way a new detection goes wrong is working
        perfectly and matching eight hundred things. The count over the validation window is
        reported, and a query that matches a lot warns before anything is created - one alert
        per match is how a detection gets switched off by the people it pages.

        TWO IDENTITIES, DELIBERATELY. Validation reads through the app certificate
        (ThreatHunting.Read.All); creation writes through your delegated session from
        Connect-MsecAdmin. The app cannot create detections and is not asked to - a read-only
        app that could add alert rules is not read-only in any sense that matters.

        REQUIRES Connect-MsecAdmin -Scope CustomDetection.ReadWrite.All. That is the only
        permission the API accepts; there is no lesser one. The signed-in user also needs
        Detection tuning (Manage), Security Administrator, or Security Operator.

        TIMESTAMP IS ALWAYS REQUIRED IN THE QUERY, and so is every column named by an entity
        mapping. Defender rejects a rule whose mapping points at a column the query does not
        project, but the message does not say which, so both are checked here against the real
        result and the missing column is named.

    .PARAMETER DisplayName
        The rule name, as it appears in the portal.

    .PARAMETER Query
        The advanced hunting KQL. Must project Timestamp and the entity columns below.

    .PARAMETER QueryPath
        A .kql file to read the query from instead of -Query. Keeps rules in version control.

    .PARAMETER Description
        Why this rule exists and what to do when it fires. The only explanation that travels
        with the rule, so it is mandatory here even though the API treats it as optional.

    .PARAMETER AlertTitle
        Title of the alert raised on a match. Defaults to the rule name.

    .PARAMETER Severity
        informational, low, medium or high. Defaults to informational.

    .PARAMETER Frequency
        How often it runs: 1h, 3h, 12h or 24h. Defaults to 24h.

    .PARAMETER DeviceIdColumn
        Column holding the device id, mapped as the impacted host. Defaults to DeviceId; pass
        an empty string for a query with no device.

    .PARAMETER DeviceNameColumn
        Column holding the device name. Defaults to DeviceName when the query projects it.

    .PARAMETER RecommendedActions
        What the responder should do. Shown on the alert.

    .PARAMETER Tactic
        MITRE ATT&CK tactic, e.g. 'DefenseEvasion'.

    .PARAMETER Technique
        MITRE technique ids, e.g. 'T1562.009'. Requires -Tactic.

    .PARAMETER Disabled
        Create the rule switched off.

    .PARAMETER Id
        Client-supplied rule id. Derived from the display name when omitted.

    .PARAMETER MaxExpectedRows
        Warn when validation returns more rows than this. Default 50.

    .EXAMPLE
        Connect-MsecAdmin -Scope CustomDetection.ReadWrite.All
        New-MsecDefenderDetectionRule -DisplayName 'Allow-listed publisher certificate rotated' `
            -QueryPath ./cert-rotation.kql -Severity informational -Frequency 24h `
            -Description 'A publisher we allow-list by certificate started signing with a new one. Create an indicator for the new thumbprint, then add it to ApprovedThumbprints in this rule.' `
            -WhatIf

    .OUTPUTS
        PSCustomObject describing the created rule, PSTypeName 'MsecDefenderDetectionRule'.

    .NOTES
        Beta endpoint: custom detection rules are beta-only in Microsoft Graph at present.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $DisplayName,

        [Parameter(Mandatory, ParameterSetName = 'Query')]
        [ValidateNotNullOrEmpty()]
        [string] $Query,

        [Parameter(Mandatory, ParameterSetName = 'File')]
        [ValidateNotNullOrEmpty()]
        [string] $QueryPath,

        # Mandatory although the API allows it to be empty - see the help.
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Description,

        [string] $AlertTitle,

        [ValidateSet('informational', 'low', 'medium', 'high')]
        [string] $Severity = 'informational',

        [ValidateSet('1h', '3h', '12h', '24h')]
        [string] $Frequency = '24h',

        [string] $DeviceIdColumn = 'DeviceId',

        [string] $DeviceNameColumn = 'DeviceName',

        [string] $RecommendedActions,

        [string] $Tactic,

        [string[]] $Technique,

        [switch] $Disabled,

        [string] $Id,

        [int] $MaxExpectedRows = 50
    )

    Assert-MsecSession       # the app, for validation
    # Naming the scope here is what turns "403 at the write" into "you connected without this
    # consent" before anything is attempted. Connect-MsecAdmin requests it by default.
    Assert-MsecAdminSession -Scope 'CustomDetection.ReadWrite.All'

    if ($Technique -and -not $Tactic) {
        throw 'A technique without a tactic is rejected by Defender. Pass -Tactic as well.'
    }

    if ($PSCmdlet.ParameterSetName -eq 'File') {
        if (-not (Test-Path -LiteralPath $QueryPath)) { throw "Query file not found: $QueryPath" }
        $Query = Get-Content -LiteralPath $QueryPath -Raw
    }
    if (-not $AlertTitle) { $AlertTitle = $DisplayName }

    # The API requires a client-supplied id. Derived so callers do not have to invent one, and
    # kept stable from the name so re-running with the same name collides loudly rather than
    # silently creating a second identical rule.
    if (-not $Id) {
        $Id = ($DisplayName.ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-')
        if (-not $Id) { $Id = "detection-$([guid]::NewGuid().ToString('N').Substring(0,8))" }
    }

    $isoFrequency = switch ($Frequency) {
        '1h'  { 'PT1H';  break }
        '3h'  { 'PT3H';  break }
        '12h' { 'PT12H'; break }
        default { 'P1D' }
    }

    # ---- validation: run the query before creating anything ----------------------------
    Write-Verbose 'Validating the query through advanced hunting before creating the rule.'
    $rows = $null
    try {
        $rows = @(Search-MsecDefenderHunting -Query $Query -Days 1 -ErrorAction Stop)
    }
    catch {
        throw ("The query does not run, so the rule was NOT created. Defender would have accepted it and " +
               "then failed on its schedule, eventually marking it autoDisabled while it still appeared " +
               "in the rule list. Original error: $($_.Exception.Message)")
    }

    # Columns are only observable when at least one row came back. A query that matches nothing
    # today is the NORMAL case for a good detection, so this is a verbose note, not a failure -
    # refusing here would block exactly the rules worth creating.
    if ($rows.Count) {
        $columns = @($rows[0].PSObject.Properties.Name | Where-Object { $_ -notmatch '@odata' })
        $required = @('Timestamp') + @($DeviceIdColumn, $DeviceNameColumn | Where-Object { $_ })
        $missing = @($required | Where-Object { $_ -notin $columns })
        if ($missing.Count) {
            throw ("The query runs but does not project: $($missing -join ', '). Defender rejects a rule " +
                   "whose entity mapping names a column the query does not return, and its message does not " +
                   "say which one. Projected columns: $($columns -join ', ').")
        }
        Write-Verbose "Query returned $($rows.Count) row(s) over 1 day; required columns present."
        if ($rows.Count -gt $MaxExpectedRows) {
            Write-Warning ("The query matched $($rows.Count) row(s) in the last day. This rule would raise " +
                           "roughly that many alerts per run. Tune it before relying on it - a detection that " +
                           "pages people hundreds of times is one somebody switches off.")
        }
    }
    else {
        Write-Verbose 'Query ran and matched nothing over 1 day. Column names could not be checked against a result - that is expected for a detection that is meant to be quiet.'
    }

    # ---- build and create ---------------------------------------------------------------
    $alertTemplate = [ordered]@{
        title       = $AlertTitle
        description = $Description
        severity    = $Severity
    }
    if ($RecommendedActions) { $alertTemplate['recommendedActions'] = $RecommendedActions }

    $hosts = @()
    if ($DeviceIdColumn -or $DeviceNameColumn) {
        $hostMap = [ordered]@{}
        if ($DeviceIdColumn)   { $hostMap['deviceIdColumn'] = $DeviceIdColumn }
        if ($DeviceNameColumn) { $hostMap['nameColumn']     = $DeviceNameColumn }
        $hosts = @($hostMap)
    }
    if ($hosts.Count) { $alertTemplate['entityMappings'] = [ordered]@{ hosts = $hosts } }

    if ($Tactic) {
        $alertTemplate['tactics'] = @(
            [ordered]@{
                tactic     = $Tactic
                techniques = @(foreach ($t in @($Technique)) { @{ technique = $t } })
            }
        )
    }

    $body = [ordered]@{
        '@odata.type'  = '#microsoft.graph.security.detectionRule'
        id             = $Id
        displayName    = $DisplayName
        description    = $Description
        status         = if ($Disabled) { 'disabled' } else { 'enabled' }
        queryCondition = [ordered]@{ queryText = $Query }
        schedule       = [ordered]@{ frequency = $isoFrequency }
        detectionAction = [ordered]@{ alertTemplate = $alertTemplate }
    }

    $operation = "Create $($body.status) detection rule running every $Frequency at $Severity severity"
    if (-not $PSCmdlet.ShouldProcess($DisplayName, $operation)) { return }

    try {
        $created = Invoke-MsecAdminGraphRequest -Path '/beta/security/rules/detectionRules' -Method POST -Body $body
    }
    catch {
        $detail = "$($_.Exception.Message)"
        if ($detail -match '403|Forbidden') {
            throw ("Forbidden creating the detection rule. Connect-MsecAdmin must have been given " +
                   "-Scope CustomDetection.ReadWrite.All - there is no read-only or lesser scope for this API - " +
                   "and your account needs Detection tuning (Manage), Security Administrator or Security " +
                   "Operator. This is YOUR authorization, not the msec app's. Original error: $detail")
        }
        if ($detail -match '409|Conflict|already exists') {
            throw "A detection rule with id '$Id' already exists. Pass a different -Id, or update the existing rule instead of creating a second one. Original error: $detail"
        }
        throw "Could not create the detection rule: $detail"
    }

    [PSCustomObject]@{
        PSTypeName      = 'MsecDefenderDetectionRule'
        Id              = [string] $created.id
        DisplayName     = [string] $created.displayName
        Description     = [string] $created.description
        Status          = [string] $created.status
        IsRunning       = ("$($created.status)" -eq 'enabled')
        Frequency       = [string] $created.schedule.frequency
        NextRun         = if ($created.schedule.nextRunDateTime) { [datetime] $created.schedule.nextRunDateTime } else { $null }
        AlertTitle      = [string] $created.detectionAction.alertTemplate.title
        Severity        = [string] $created.detectionAction.alertTemplate.severity
        Category        = [string] $created.detectionAction.alertTemplate.category
        Query           = [string] $created.queryCondition.queryText
        CreatedBy       = [string] $created.createdBy
        CreatedUtc      = if ($created.createdDateTime) { [datetime] $created.createdDateTime } else { $null }
        LastModifiedBy  = [string] $created.lastModifiedBy
        LastModifiedUtc = if ($created.lastModifiedDateTime) { [datetime] $created.lastModifiedDateTime } else { $null }
        ValidationRows  = $rows.Count
        Raw             = $created
    }
}