Public/Get-AACLegacyAuthentication.ps1

function Get-AACLegacyAuthentication {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Finds legacy and high-risk authentication across Azure and Entra ID -
        resources that still accept keys, SAS, local accounts, passwords or
        old TLS, legacy-protocol sign-ins, legacy authentication left
        unblocked, implicit grant and password flows, client secrets and
        SMS - with the risk and what to move to.
    .DESCRIPTION
        Azure (Resource Graph, and Resource Manager for App Service):
          Keys and SAS storage shared keys; local authentication on
                            Cosmos DB, Service Bus, Event Hubs, Relay, Event
                            Grid, SignalR, Web PubSub, App Configuration,
                            Azure AI services, AI Search, Automation, IoT
                            Hub, Batch, Log Analytics and Application
                            Insights; Redis access keys
          Passwords SQL authentication, PostgreSQL passwords, the
                            container registry admin user, Linux password
                            login, App Service basic authentication (SCM and
                            FTP publishing credentials)
          Local accounts AKS local accounts, AKS without Entra ID
          Old TLS TLS 1.0/1.1 on storage, SQL and Redis; Redis's
                            non-TLS port
          Legacy authorization Key Vault access policies
        Entra ID (Microsoft Graph, unless -SkipEntra):
          Legacy protocols sign-ins over Exchange ActiveSync, IMAP, POP,
                            SMTP AUTH, EWS, MAPI, Outlook Anywhere,
                            Autodiscover and other clients in the last
                            -SignInDays - successful (High) or failed
                            (password spraying); whether Conditional Access
                            or security defaults block legacy authentication
          Legacy flows app registrations with the implicit grant or
                            public client flows (ROPC, device code)
          Client secrets app registrations with secrets and no
                            certificate
          Weak MFA SMS and voice enabled
 
        Each finding (AAC.LegacyAuthentication) has its Area (Azure or Entra
        ID), severity, the resource or protocol, the detail and the fix.
        Read-only; Reader in Azure, and for Entra ID AuditLog.Read.All (with
        Entra ID P1 for sign-ins), Policy.Read.All and Application.Read.All.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER ResourceGroupName
        Only Azure resources in these resource groups.
    .PARAMETER ResourceType
        Only Azure resources of these types; wildcards work.
    .PARAMETER Area
        Only Azure or only EntraID.
    .PARAMETER Severity
        Only these severities (High, Medium, Low).
    .PARAMETER SignInDays
        How many days of sign-ins to read for legacy protocols. Default 7.
    .PARAMETER SkipEntra
        Don't read Entra ID (Microsoft Graph): Azure resources only.
    .PARAMETER SkipAppService
        Don't read the App Service publishing credential policies (one call
        per app).
    .PARAMETER CsvPath
        Write the findings to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the findings.
    .PARAMETER NoDisplay
        Return the findings without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACLegacyAuthentication
        Legacy authentication in Azure and Entra ID, the riskiest first.
    .EXAMPLE
        Get-AACLegacyAuthentication -Area EntraID -SignInDays 30 -HtmlPath .\out\LegacyAuth.html
        A month of legacy-protocol sign-ins and the tenant's settings, as an HTML report.
    .EXAMPLE
        Get-AACLegacyAuthentication -SkipEntra -NoDisplay | Group-Object Finding | Sort-Object Count -Descending
        Which key- and password-based patterns are most common in Azure.
    .OUTPUTS
        AAC.LegacyAuthentication
    #>

    [CmdletBinding()]
    [OutputType('AAC.LegacyAuthentication')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [string[]] $ResourceGroupName,

        [SupportsWildcards()]
        [string[]] $ResourceType,

        [ValidateSet('Azure', 'EntraID')]
        [string[]] $Area,

        [ValidateSet('High', 'Medium', 'Low')]
        [string[]] $Severity,

        [ValidateRange(1, 30)]
        [int] $SignInDays = 7,

        [switch] $SkipEntra,

        [switch] $SkipAppService,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Legacy authentication',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $readAzure = -not $Area -or $Area -contains 'Azure'
    $readEntra = -not $SkipEntra -and (-not $Area -or $Area -contains 'EntraID')
    $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); SignInDays = $SignInDays; Azure = $readAzure; Entra = $readEntra; AppService = $readAzure -and -not $SkipAppService }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Legacy authentication' -Color 'orange3' }
    $outcome = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
        $q = Get-AACLegacyAuthenticationQuery -ResourceGroupName $request.ResourceGroupName -SignInDays $request.SignInDays
        $read = @{ Rows = @{}; Errors = @{} }
        $publishing = @{}
        $notices = [System.Collections.Generic.List[string]]::new()
        if ($request.Azure) {
            Update-AACProgress -Id 'read' -Total $q.Graph.Count -Description 'Reading how Azure resources authenticate'
            $read = Invoke-AACGraphBatch -Query $q.Graph -SubscriptionId $scope.GraphScope -AllowFailure @($q.Graph.Keys) -OnProgress {
                param($QueryName, $Done, $Total)
                Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $QueryName ($Done of $Total)"
            }
            Update-AACProgress -Id 'read' -Complete -Description "Read $($q.Graph.Count - $read.Errors.Count) of $($q.Graph.Count) kinds of resource"
            $sites = @($read.Rows['sites'] | Where-Object { $_ -and $scope.Names.Contains(([string]$_['subscriptionId']).ToLowerInvariant()) })
            if ($request.AppService -and $sites.Count) {
                if ($sites.Count -gt 500) { $notices.Add("$($sites.Count) apps: the publishing credentials of the first 500 were read."); $sites = @($sites | Select-Object -First 500) }
                $uris = [ordered]@{}
                foreach ($s in $sites) { $uris[[string]$s['id']] = "$($s['id'])/basicPublishingCredentialsPolicies?api-version=2023-12-01" }
                Update-AACProgress -Id 'sites' -Total $uris.Count -Description "Reading the publishing credentials of $($uris.Count) app(s)"
                $answers = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($SiteDone, $SiteTotal) Update-AACProgress -Id 'sites' -Increment 1 }
                foreach ($id in $uris.Keys) {
                    $answer = $answers[$uris[$id]]
                    if (-not $answer -or $answer.Error) { $publishing[$id] = $null; continue }
                    $allow = @{}
                    foreach ($item in @($answer.Items | Where-Object { $_ })) { $allow[[string]$item['name']] = [string]$item['properties']['allow'] -eq 'True' }
                    $publishing[$id] = @{ Ftp = [bool]$allow['ftp']; Scm = [bool]$allow['scm'] }
                }
                Update-AACProgress -Id 'sites' -Complete -Description ('Read the publishing credentials of {0:N0} app(s)' -f @($publishing.Values | Where-Object { $null -ne $_ }).Count)
            }
        }
        $entra = $null
        if ($request.Entra) {
            Update-AACProgress -Id 'entra' -Total $q.Entra.Count -Description 'Reading sign-ins and policies from Entra ID'
            $entra = Read-AACGraphQuery -Query $q.Entra -OnDone { param($Key, $Failure, $GraphDone, $GraphTotal) Update-AACProgress -Id 'entra' -Increment 1 }
            Update-AACProgress -Id 'entra' -Complete -Description "Read $($q.Entra.Count - $entra.Errors.Count) of $($q.Entra.Count) Entra ID queries"
        }
        $result = ConvertTo-AACLegacyAuthentication -Read $read -Entra $entra -Publishing $publishing -Protocols $q.Protocols -SignInDays $request.SignInDays -SubscriptionName $scope.Names
        @{ Result = $result; Scope = $scope; Notices = @($result.Notices) + $notices.ToArray() }
    }

    $findings = @($outcome.Result.Findings)
    if ($ResourceType) { $findings = @($findings | Where-Object { $t = $_.ResourceType; $_.Area -ne 'Azure' -or @($ResourceType | Where-Object { $t -like $_ }).Count }) }
    if ($Severity) { $findings = @($findings | Where-Object { $Severity -contains $_.Severity }) }

    $rank = Get-AACSeverityRank
    $high = @($findings | Where-Object Severity -EQ 'High').Count
    $azureCount = @($findings | Where-Object Area -EQ 'Azure').Count
    $entraCount = @($findings | Where-Object Area -EQ 'Entra ID').Count
    $resources = @($findings | Where-Object Area -EQ 'Azure' | ForEach-Object { $_.ResourceId } | Select-Object -Unique).Count
    $successful = 0; foreach ($f in @($findings | Where-Object { $_.Category -eq 'Legacy protocols' -and $_.Finding -like 'Successful*' })) { $successful += [int]$f.Count }
    $report = @{
        Subtitle = 'Legacy and high-risk authentication: keys, passwords, old protocols and old TLS'
        Facts    = [ordered]@{ Scope = $outcome.Scope.Label; 'Entra ID' = $(if ($readEntra) { "read (sign-ins: the last $SignInDays day(s))" } else { 'not read' }) }
        Status   = $(if ($high) { 'Failed' } elseif ($findings.Count) { 'Warning' } else { 'Success' })
        Headline = $(if ($findings.Count) { "$($findings.Count) finding(s): $high high; $resources Azure resource(s) accept keys, passwords or old TLS$(if ($readEntra) { "; $successful successful legacy sign-in(s)" })" } else { 'No legacy authentication found.' })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $high; Label = 'high'; Tone = $(if ($high) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Severity = 'High' } }
            @{ Value = '{0:N0}' -f $azureCount; Label = 'in Azure'; Tone = $(if ($azureCount) { 'warn' } else { 'good' }); Table = 'findings'; Filters = @{ Area = 'Azure' } }
            @{ Value = '{0:N0}' -f $resources; Label = 'resources'; Tone = 'info' }
            @{ Value = '{0:N0}' -f $entraCount; Label = 'in Entra ID'; Tone = $(if ($entraCount) { 'warn' } else { 'good' }); Table = 'findings'; Filters = @{ Area = 'Entra ID' } }
            @{ Value = $(if ($readEntra) { '{0:N0}' -f $successful } else { '-' }); Label = 'legacy sign-ins (successful)'; Tone = $(if ($successful) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Category = 'Legacy protocols' } }
            @{ Value = '{0:N0}' -f @($findings | Where-Object Category -EQ 'Old TLS').Count; Label = 'old TLS'; Tone = 'warn'; Table = 'findings'; Filters = @{ Category = 'Old TLS' } }
        )
        Notices  = @($outcome.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'By severity'; Kind = 'donut'; CenterLabel = 'findings'; Items = @(foreach ($s in 'High', 'Medium', 'Low') { $n = @($findings | Where-Object Severity -EQ $s).Count; if ($n) { @{ Label = $s; Value = $n; Tone = $rank.Tone[$s]; Filter = $s } } }); Table = 'findings'; Column = 'Severity' }
            @{ Title = 'By pattern'; Items = @($findings | Group-Object Finding | Sort-Object Count -Descending | Select-Object -First 12 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'findings'; Column = 'Finding'; Tone = 'warn'; Console = $true }
            @{ Title = 'By kind'; Items = @($findings | Group-Object Category | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'findings'; Column = 'Category'; Tone = 'violet' }
        )
        Tables   = @(
            @{ Id = 'findings'; Title = 'Legacy authentication'; Section = 'Findings'; Rows = $findings; Noun = 'findings'; GroupBy = @('Severity', 'Area', 'Category', 'Finding', 'Subscription'); ConsoleLimit = 30
                Empty = 'No legacy authentication found.'; EmptyStatus = 'Success'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Area'; Label = 'Area'; Facet = $true; Console = $true }
                    @{ Key = 'Finding'; Label = 'Finding'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Category'; Label = 'Kind'; Facet = $true }
                    @{ Key = 'Impact'; Label = 'Impact'; Type = 'wide' }
                    @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Effort'; Label = 'Effort'; Type = 'badge'; Tones = @{ Low = 'good'; Medium = 'warn'; High = 'bad' }; Facet = $true }
                    @{ Key = 'ResourceType'; Label = 'Type'; Type = 'type'; Facet = $true }
                    @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                    @{ Key = 'Link'; Label = 'Docs'; Type = 'link'; Text = 'Docs' }
                ) }
        )
        Hint     = '-Area Azure or EntraID narrows it; -SignInDays reads more sign-ins; -SkipEntra skips Microsoft Graph; -NoDisplay returns the findings; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $findings -Noun 'finding' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $findings -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}