Public/Invoke-NSPM365IocHunt.ps1

function Invoke-NSPM365IocHunt {
    <#
    .SYNOPSIS
        Hunts one or more Microsoft 365 / Entra ID tenants for an incident's indicators and writes a severity-sorted Findings.csv with raw evidence.
    .DESCRIPTION
        Read-only unless -DisableIocApps. Per tenant, for the indicator set named by -IocSet
        (Get-NSPIocSet):
 
          1. The incident's service principals (by app ID, owner tenant, display name prefix):
             still enabled, directory roles held, application permissions granted.
          2. Every other service principal holding a privileged directory role (review list).
          3. ApplicationManagement and RoleManagement directory audits since -Since: an app giving
             a role to itself is Critical; IOC apps acting or granted roles, credentials added by
             applications, and IOC user agents are flagged.
          4. Secrets and certificates added since -Since on every app registration and service
             principal; High when an application (not a person) added it.
          5. Sign-ins (beta): IOC apps' service principal sign-ins, any sign-in from the IOC IPs,
             and whether a credential added in the window has signed in, and from where.
          6. Users created since -Since.
 
        Output: <OutputPath>\Findings.csv sorted by severity, and <OutputPath>\<tenant>\*.json raw
        audit and sign-in data, so the evidence outlives log retention (7 days without Entra ID
        P1, 30 with). Tenants without P1 cannot read sign-ins through Graph: the run flags them,
        and their Service principal sign-ins must be exported from the Entra portal by hand.
 
        Every tenant is checked against the tenant asked for (a domain is resolved to its tenant
        ID first); a mismatch is reported as a warning and nothing else is reported for it.
 
        -DisableIocApps disables (never deletes) the enabled service principals whose app ID is in
        the set; apps matched only by owner tenant or name are reported, not touched. It confirms
        per app (ConfirmImpact High); -WhatIf still writes the report.
 
        Sign-in goes through NSP.M365.Graph: delegated (Connect-NSPGraph), or app-only with
        -ClientId and -CertificateThumbprint (Connect-NSPGraphAppOnly).
 
        Not covered: mailbox and file access (Unified Audit Log), and Graph directory reads, which
        Microsoft does not log. A clean result does not prove nothing was read.
    .PARAMETER IocSet
        The indicator set: a name from Get-NSPIocSet (SkyKick), or the path to a .psd1 of the same shape.
    .PARAMETER TenantId
        One or more tenant IDs or domains, each connected to in turn.
    .PARAMETER TenantCsv
        CSV with a TenantId column and an optional Name column.
    .PARAMETER UseCurrentConnection
        The tenant of the current Graph session (connects when there is none). The default.
    .PARAMETER ClientId
        App-only: a multi-tenant app consented in each tenant with Application.Read.All,
        Directory.Read.All, AuditLog.Read.All, RoleManagement.Read.Directory (plus
        Application.ReadWrite.All for -DisableIocApps). Needs -CertificateThumbprint and a tenant.
    .PARAMETER Since
        Start of the hunt window (UTC unless it carries an offset). Defaults to the set's.
    .PARAMETER AdditionalIocIPs
        IPs published after the set was written.
    .PARAMETER AdditionalIocAppIds
        App IDs published after the set was written. These are disabled by -DisableIocApps too.
    .PARAMETER OutputPath
        Report folder. Defaults to .\<set>IOC_<timestamp>.
    .PARAMETER SkipSignIns
        Skip the sign-in log queries.
    .PARAMETER DisableIocApps
        Disable the set's enabled service principals, confirming each.
    .PARAMETER PassThru
        Also return the findings.
    .EXAMPLE
        Invoke-NSPM365IocHunt -IocSet SkyKick -UseCurrentConnection
 
        Your own tenant first.
    .EXAMPLE
        Invoke-NSPM365IocHunt -IocSet SkyKick -TenantCsv .\tenants.csv -ClientId $partnerAppId -CertificateThumbprint $thumbprint
    .EXAMPLE
        Invoke-NSPM365IocHunt -IocSet SkyKick -TenantId contoso.onmicrosoft.com -DisableIocApps -WhatIf
    .EXAMPLE
        Invoke-NSPM365IocHunt -IocSet SkyKick -TenantId $tenantId -AdditionalIocIPs '203.0.113.7'
    .NOTES
        Delegated (GDAP) access needs Global Reader plus Security Reader (or Reports Reader) to
        read, and Cloud Application Administrator for -DisableIocApps. A delegated sign-in to a
        client tenant needs the "Microsoft Graph Command Line Tools" app consented there; on
        AADSTS650051 or AADSTS65001 use -ClientId/-CertificateThumbprint with your partner app.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Current')]
    param(
        [Parameter(Mandatory)]
        [ArgumentCompleter({ Get-ChildItem -Path (Join-Path (Get-Module NSP.M365.Investigation).ModuleBase 'IocSets') -Filter "$($args[2])*.psd1" | ForEach-Object BaseName })]
        [string]$IocSet,

        [Parameter(ParameterSetName = 'TenantList', Mandatory)]
        [string[]]$TenantId,

        [Parameter(ParameterSetName = 'TenantCsv', Mandatory)]
        [ValidateScript({ Test-Path -LiteralPath $_ })]
        [string]$TenantCsv,

        [Parameter(ParameterSetName = 'Current')]
        [switch]$UseCurrentConnection,

        [string]$ClientId,
        [string]$CertificateThumbprint,
        [switch]$UseDeviceCode,

        [datetime]$Since,
        [string[]]$AdditionalIocIPs = @(),
        [string[]]$AdditionalIocAppIds = @(),
        [string]$OutputPath,

        [switch]$SkipSignIns,
        [switch]$DisableIocApps,
        [switch]$PassThru
    )

    $ErrorActionPreference = 'Stop'
    if ([bool]$ClientId -ne [bool]$CertificateThumbprint) { throw 'App-only sign-in needs both -ClientId and -CertificateThumbprint.' }
    if ($ClientId -and $PSCmdlet.ParameterSetName -eq 'Current') { throw 'App-only sign-in needs a tenant: use -TenantId or -TenantCsv.' }

    $ioc = Get-NSPIocSet -Name $IocSet -AdditionalIPs $AdditionalIocIPs -AdditionalAppIds $AdditionalIocAppIds
    $start = if (-not $PSBoundParameters.ContainsKey('Since')) { $ioc.Since }
    elseif ($Since.Kind -eq [DateTimeKind]::Unspecified) { [datetime]::SpecifyKind($Since, [DateTimeKind]::Utc) }
    else { $Since.ToUniversalTime() }
    if (-not $OutputPath) { $OutputPath = Join-Path (Get-Location).ProviderPath ('{0}IOC_{1}' -f $ioc.Name, (Get-Date -Format 'yyyyMMdd_HHmmss')) }
    if (-not (Test-Path -LiteralPath $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force -WhatIf:$false -Confirm:$false | Out-Null }
    $OutputPath = (Resolve-Path -LiteralPath $OutputPath).ProviderPath

    Import-NSPM365Graph
    $scopes = @($script:NSPHuntScopes)
    if ($DisableIocApps) { $scopes += 'Application.ReadWrite.All' }

    $hunt = @{
        Ioc          = $ioc
        Since        = $start
        SinceIso     = $start.ToString('yyyy-MM-ddTHH:mm:ssZ')
        OutputPath   = $OutputPath
        TenantLabel  = ''
        Findings     = New-Object System.Collections.Generic.List[object]
        AppRoleCache = @{}
    }

    $targets = switch ($PSCmdlet.ParameterSetName) {
        'TenantList' { @($TenantId | ForEach-Object { [pscustomobject]@{ Tenant = $_; Name = $_ } }) }
        'TenantCsv' {
            @(Import-Csv -LiteralPath $TenantCsv | Where-Object { $_.TenantId } | ForEach-Object {
                    $name = if ($_.PSObject.Properties.Name -contains 'Name' -and $_.Name) { $_.Name } else { $_.TenantId }
                    [pscustomobject]@{ Tenant = $_.TenantId.Trim(); Name = $name }
                })
        }
        default { @([pscustomobject]@{ Tenant = $null; Name = 'current' }) }
    }

    Write-Host "$($ioc.Title)" -ForegroundColor Cyan
    Write-Host "IOC hunt '$($ioc.Name)' (indicators as of $($ioc.Updated)). Window: $($hunt.SinceIso) -> now. Tenants: $(@($targets).Count). Output: $OutputPath" -ForegroundColor Cyan
    foreach ($note in $ioc.Notes) { Write-Host " Note: $note" -ForegroundColor DarkGray }

    foreach ($target in $targets) {
        $hunt.TenantLabel = $target.Name
        try {
            $expected = if ($target.Tenant) { Resolve-NSPHuntTenantId -Tenant $target.Tenant } else { $null }
            Connect-NSPHuntTenant -TenantId $expected -Scopes $scopes -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint -UseDeviceCode:$UseDeviceCode
            $result = Invoke-NSPHuntTenant -Hunt $hunt -ExpectedTenantId $expected -SkipSignIns:$SkipSignIns
        } catch {
            $message = $_.Exception.Message
            if ($message -match 'AADSTS650051|AADSTS65001') { $message += ' (The Microsoft Graph Command Line Tools app is not consented in this tenant: use -ClientId/-CertificateThumbprint with your partner app.)' }
            Add-NSPHuntFinding -Hunt $hunt -Severity Warning -Check 'Tenant run' -Object $target.Name -Detail "Failed: $message"
            continue
        }

        if (-not $DisableIocApps) { continue }
        foreach ($sp in @($result.IocServicePrincipals | Where-Object { $_.accountEnabled -and $ioc.Apps.ContainsKey(([string]$_.appId).ToLowerInvariant()) })) {
            $object = "$($sp.displayName) ($($sp.appId))"
            if (-not $PSCmdlet.ShouldProcess("$object in $($result.TenantLabel)", 'Disable service principal (accountEnabled = false)')) { continue }
            try {
                Invoke-NSPGraphRequest -Method PATCH -Uri "servicePrincipals/$($sp.id)" -Body @{ accountEnabled = $false } | Out-Null
                Add-NSPHuntFinding -Hunt $hunt -Severity Info -Check 'Remediation' -Object $object -Detail 'Service principal DISABLED by this run.'
            } catch {
                Add-NSPHuntFinding -Hunt $hunt -Severity Warning -Check 'Remediation' -Object $object -Detail "Disable failed: $($_.Exception.Message)"
            }
        }
    }

    $sorted = @($hunt.Findings | Sort-Object @{ Expression = { $script:NSPHuntSeverityOrder[$_.Severity] } }, Tenant, TimeUtc)
    $csv = Join-Path $OutputPath 'Findings.csv'
    $sorted | Export-Csv -LiteralPath $csv -NoTypeInformation -Encoding UTF8 -WhatIf:$false -Confirm:$false

    Write-Host ''
    Write-Host '=== Summary ===' -ForegroundColor Cyan
    $sorted | Group-Object Tenant | ForEach-Object {
        $group = $_.Group
        [pscustomobject]@{
            Tenant   = $_.Name
            Critical = @($group | Where-Object Severity -EQ 'Critical').Count
            High     = @($group | Where-Object Severity -EQ 'High').Count
            Medium   = @($group | Where-Object Severity -EQ 'Medium').Count
            Warning  = @($group | Where-Object Severity -EQ 'Warning').Count
            Low      = @($group | Where-Object Severity -EQ 'Low').Count
        }
    } | Format-Table -AutoSize | Out-Host
    Write-Host "Findings: $csv"
    Write-Host "Raw evidence JSON: $OutputPath\<tenant>\"
    if ($PassThru) { $sorted }
}