public/Get-MsecDefenderAlert.ps1

function Get-MsecDefenderAlert {
    <#
    .SYNOPSIS
        One row per Defender XDR alert across every workload - endpoint, Office 365,
        identity, cloud apps and DLP - with the incident it belongs to.

    .DESCRIPTION
        Alerts are the detections; incidents are the groupings Defender builds from them.
        Get-MsecDefenderIncident answers "what is being investigated"; this answers "what
        actually fired", which is the level at which a noisy detector or an unworked queue
        becomes visible.

        SERVICESOURCE IS OFTEN 'unknownFutureValue', AND THAT IS THE API, NOT THE DATA. Graph
        returns that placeholder for a source the API version does not have a name for yet.
        Measured on a live tenant, 231 of 569 alerts in ninety days - 40% - came back that
        way. It is reported verbatim rather than guessed at or folded into 'other', because
        the alternative is inventing a source attribution that Microsoft did not make.
        ProductName and DetectionSource are carried alongside and are often populated when
        ServiceSource is not.

        STATUS VOCABULARY DIFFERS FROM INCIDENTS. An alert is 'new', 'inProgress' or
        'resolved'; an incident is 'active', 'inProgress', 'resolved' or 'redirected'. An
        alert is never 'active'. Filtering both with the same string finds nothing in one of
        them, silently.

        RESOLVEDAYS IS $null WHILE AN ALERT IS OPEN, never 0 - the same reasoning as the
        incident command. Here it is computed from ResolvedUtc, which Graph populates
        properly, rather than inferred from the last update.

        EVIDENCE IS NOT FLATTENED. Every alert carries an evidence array - devices, users,
        files, IP addresses, mailboxes - with a different shape per entity type. Flattening it
        would either lose most of it or produce a column set that changes per row, so the
        count is reported and the array stays on Raw.evidence for anything that needs it.

    .PARAMETER Days
        How far back to look at CREATION time. Default 30.

    .PARAMETER Severity
        Only these severities: informational, low, medium, high.

    .PARAMETER Status
        Only these statuses: new, inProgress, resolved.

    .PARAMETER ServiceSource
        Only alerts from these workloads, matched case-insensitively against ServiceSource -
        e.g. microsoftDefenderForEndpoint, microsoftDefenderForOffice365, dataLossPrevention.

    .PARAMETER UpdatedWithin
        Alerts last TOUCHED within this many days, whatever their age. Use this for triage
        history: -Days finds alerts RAISED in a window, so an alert raised a year ago and
        worked last week is absent from it at every value short of 365. Replaces the
        created-date filter rather than narrowing it.

    .PARAMETER Classification
        The recorded triage verdict. 'unknown' is what an untouched alert carries, so
        -Classification unknown selects the UNtriaged set.

    .PARAMETER Determination
        The recorded cause behind the classification, e.g. notMalicious, securityTesting,
        malware. 'unknown' means nobody recorded one.

    .PARAMETER TriagedBy
        Matches assignedTo. NOT always a person - 'Suppression rule' and
        'MdcAlertsResourceProvider' appear here too, and neither is a human verdict. Name the
        analysts explicitly when the output is going to be learned from.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec
        Get-MsecDefenderAlert -Days 90 -Severity high | Sort-Object CreatedUtc -Descending

    .EXAMPLE
        # The unworked queue: high-severity alerts nobody has picked up.
        Get-MsecDefenderAlert -Days 90 -Severity high -Status new |
            Format-Table CreatedUtc, Title, ServiceSource, IncidentId

    .EXAMPLE
        # Which detectors produce the most noise.
        Get-MsecDefenderAlert -Days 90 | Group-Object Title |
            Sort-Object Count -Descending | Select-Object -First 15

    .OUTPUTS
        One PSCustomObject per alert, PSTypeName 'MsecDefenderAlert'.

    .NOTES
        Needs Connect-Msec. Documented as 'SecurityAlert.Read.All'; measured on a live tenant
        the endpoint also answers for an app holding SecurityIncident.Read.All and
        SecurityEvents.Read.All, both of which New-MsecApp grants - so it works today without
        an extra consent. If a tenant answers 403, that permission is the one to add.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [ValidateRange(1, 365)]
        [int] $Days = 30,

        # FILTERS ON WHEN THE ALERT WAS LAST TOUCHED, NOT WHEN IT WAS RAISED, and the two
        # answer different questions. -Days finds alerts RAISED in a window; an alert raised a
        # year ago and triaged last week is not in that result at any -Days value short of 365.
        # Asking "what has been worked recently" with -Days therefore returns a confident,
        # wrong answer - which is how a tenant with 507 classified alerts was first read as
        # having almost none. Use this for triage history; use -Days for alert volume.
        [ValidateRange(1, 365)]
        [int] $UpdatedWithin,

        # NO DATE FILTER AT ALL, for the question "what is open, however old". -Days and
        # -UpdatedWithin both cap at 365, so neither can answer it, and reaching for a large
        # -Days is not the same thing: an item older than the cap is invisible rather than
        # merely old. That gap is how a backlog gets reported as empty.
        #
        # The result is still bounded - by Graph's own retention on these endpoints, not by
        # anything chosen here - so "any age" means "as far back as the service will answer".
        [Parameter()]
        [switch] $AnyAge,

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

        # NOT the incident vocabulary - see the note in the description.
        [ValidateSet('new', 'inProgress', 'resolved')]
        [string[]] $Status,

        [string[]] $ServiceSource,

        # The triage verdict someone recorded. 'unknown' is the value an untouched alert
        # carries, so -Classification unknown is the UNtriaged set rather than a missing one.
        [ValidateSet('unknown', 'falsePositive', 'truePositive', 'informationalExpectedActivity')]
        [string[]] $Classification,

        [ValidateSet('unknown', 'apt', 'malware', 'securityPersonnel', 'securityTesting',
                     'unwantedSoftware', 'other', 'multiStagedAttack', 'compromisedAccount',
                     'phishing', 'maliciousUserActivity', 'notMalicious',
                     'lineOfBusinessApplication', 'confirmedActivity')]
        [string[]] $Determination,

        # Who the alert is assigned to. Note that this is NOT always a person: measured on a
        # live tenant, 'Suppression rule' and 'MdcAlertsResourceProvider' appear here on
        # hundreds of alerts, and treating those as human verdicts would mean learning from a
        # suppression rule rather than from an analyst.
        [string[]] $TriagedBy
    )

    Assert-MsecSession

    # -UpdatedWithin REPLACES the created-date filter rather than narrowing it. Sending both
    # would ask for alerts raised AND touched in the window, which silently drops every old
    # alert worked recently - the exact set -UpdatedWithin exists to find.
    if ($AnyAge) {
        $dateFilter = $null
    }
    elseif ($PSBoundParameters.ContainsKey('UpdatedWithin')) {
        $sinceStr = (Get-Date).ToUniversalTime().AddDays(-$UpdatedWithin).ToString('yyyy-MM-ddTHH:mm:ssZ')
        $dateFilter = "lastUpdateDateTime ge $sinceStr"
    }
    else {
        $startStr = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')
        $dateFilter = "createdDateTime ge $startStr"
    }

    try {
        $path = if ($dateFilter) { "/v1.0/security/alerts_v2?`$filter=$dateFilter" } else { '/v1.0/security/alerts_v2' }
        $alerts = @(Invoke-MsecGraphRequest -Path $path -All)
    }
    catch {
        if ($_.Exception.Message -match '403|Forbidden') {
            throw "Forbidden when calling /security/alerts_v2. The msec app needs the 'SecurityAlert.Read.All' application permission (admin consent required). Original error: $($_.Exception.Message)"
        }
        throw
    }

    $parse = {
        param($value)
        if ($value) { [datetime]::Parse($value, $null, [System.Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() } else { $null }
    }

    # Evidence is polymorphic - device, user, ip, file, mailbox, url, process, each with its
    # own fields - so it is flattened to one readable identifier per item. The full objects
    # stay on Raw: this is for reading and for handing to something that has to reason about
    # the alert, not a replacement for the detail.
    $describe = {
        param($e)
        $kind = ([string]$e.'@odata.type') -replace '#microsoft\.graph\.security\.', '' -replace 'Evidence$', ''
        $who  = @(
            $e.deviceDnsName, $e.hostName, $e.userPrincipalName,
            $e.userAccount.userPrincipalName, $e.userAccount.accountName,
            $e.ipAddress, $e.fileDetails.fileName, $e.url,
            $e.primaryAddress, $e.imageFile.fileName, $e.appId
        ) | Where-Object { $_ } | Select-Object -First 1
        $verdict = [string]$e.verdict
        $text = if ($who) { "$kind`: $who" } else { $kind }
        if ($verdict -and $verdict -ne 'unknown') { "$text ($verdict)" } else { $text }
    }

    foreach ($a in $alerts) {
        if ($Severity       -and [string]$a.severity       -notin $Severity) { continue }
        if ($Status         -and [string]$a.status         -notin $Status)   { continue }
        if ($ServiceSource  -and [string]$a.serviceSource  -notin $ServiceSource) { continue }
        if ($Classification -and [string]$a.classification -notin $Classification) { continue }
        if ($Determination  -and [string]$a.determination  -notin $Determination) { continue }
        if ($TriagedBy      -and [string]$a.assignedTo     -notin $TriagedBy) { continue }

        $created  = & $parse $a.createdDateTime
        $resolved = & $parse $a.resolvedDateTime

        [PSCustomObject]@{
            PSTypeName        = 'MsecDefenderAlert'
            Id                = [string] $a.id
            # The alert's id in the product that raised it. For endpoint alerts this is the key
            # into the Defender for Endpoint API, which is the only place a comment can be
            # written - Graph has no writable comment on an alert. See Set-MsecDefenderAlert.
            ProviderAlertId   = [string] $a.providerAlertId
            Title             = [string] $a.title
            Severity          = [string] $a.severity
            Status            = [string] $a.status
            Category          = [string] $a.category
            # 'unknownFutureValue' is Graph's placeholder, not a workload - see the help.
            ServiceSource     = [string] $a.serviceSource
            DetectionSource   = [string] $a.detectionSource
            ProductName       = [string] $a.productName
            IncidentId        = [string] $a.incidentId
            AssignedTo        = [string] $a.assignedTo
            Classification    = [string] $a.classification
            Determination     = [string] $a.determination
            CreatedUtc        = $created
            FirstActivityUtc  = & $parse $a.firstActivityDateTime
            LastActivityUtc   = & $parse $a.lastActivityDateTime
            ResolvedUtc       = $resolved
            # $null while open, never 0.
            ResolveDays       = if ($created -and $resolved) { [math]::Round(($resolved - $created).TotalDays, 1) } else { $null }
            # Counted, not flattened - the shape differs per entity type. Raw.evidence has it.
            EvidenceCount     = @($a.evidence).Count
            # Readable entities - 'Device: vie0172 (suspicious)', 'User: alex@contoso.com'.
            Evidence          = @(foreach ($e in @($a.evidence)) { & $describe $e })
            MitreTechniques   = (@($a.mitreTechniques) -join '; ')
            ThreatDisplayName = [string] $a.threatDisplayName
            AlertWebUrl       = [string] $a.alertWebUrl
            Raw               = $a
        }
    }
}