Public/Invoke-AACM365Assessment.ps1

function Invoke-AACM365Assessment {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Microsoft 365 tenant discovery and security posture - Entra ID,
        Microsoft 365 and Intune - read from the Microsoft Graph REST API
        with one token, with zero-trust findings, as a Spectre.Console view,
        an object, CSV files and a tabbed, interactive HTML report.
    .DESCRIPTION
        Read-only, Microsoft Graph REST only: no AzureAD, MSOnline,
        AzureADPreview or Microsoft.Graph modules. Every call uses the one
        Graph token of the Connect-AAC sign-in; about 25 calls run in
        parallel, each followed through its pages.
 
        -Section picks what is read (everything by default):
          Entra tenant details and directory sync, licences,
                        security defaults, user and guest settings
                        (authorization policy), cross-tenant access,
                        Conditional Access policies and named locations,
                        admin roles - active and eligible (PIM) - with each
                        admin's MFA registration, every user's MFA
                        registration, authentication methods, identity
                        providers - and:
                          MFA coverage registered and phishing-
                                                 resistant MFA by members,
                                                 admins and guests; the MFA
                                                 policy's exclusions
                          emergency access break-glass accounts (excluded
                                                 from every policy, or by
                                                 name): two, cloud-only,
                                                 Global Admin, passkey
                          dangling admins privileged roles held by
                                                 deleted, disabled, guest,
                                                 idle, synced accounts or apps
                          role overlap Global Admin plus more, three
                                                 or more roles, active and
                                                 eligible at once
                          legacy authentication actual IMAP, POP, SMTP AUTH,
                                                 ActiveSync... sign-ins in
                                                 the last -SignInDays
                          user consent who can consent to apps, and
                                                 the admin consent workflow
                          security defaults off with no MFA, or on when
                                                 Conditional Access is
                                                 licensed
                          PIM role settings MFA, approval, justification
                                                 and maximum duration on
                                                 activation; permanent
                                                 assignments
                          groups who is in the groups excluded
                                                 from Conditional Access and
                                                 the groups holding roles
                          access reviews of admin roles and guests
                          directory sync password hash sync,
                                                 accidental deletion
                                                 prevention
          Threat protection (with Entra and Microsoft365) risky users and
                        risk detections (Identity Protection), Microsoft
                        Defender XDR incidents
          Applications (with Entra) app registrations and enterprise
                        apps: secrets and certificates expired, expiring or
                        long-lived; over-privileged permissions (application
                        permissions on Microsoft Graph and delegated
                        consents, rated Critical, High, Medium); redirect
                        URIs that are dangling (their host looked up in DNS),
                        wildcard or not HTTPS
          Microsoft365 domains, Microsoft Secure Score and its controls
                        (with the gap and how to fix each), SharePoint and
                        OneDrive sharing settings, audit logging (Purview
                        audit log search, from its Secure Score control, and
                        the Entra audit log)
          Intune tenant settings, enrollment restrictions,
                        compliance policies, endpoint security policies
                        (antivirus, firewall, disk encryption, EDR, attack
                        surface reduction, account protection), managed
                        devices (compliance, encryption, stale), the
                        Entra ID devices no MDM manages, and app protection
                        (MAM) for personal iOS and Android devices
          Microsoft365 (also) licensed users with no activity in 30 days
 
        PERMISSIONS. A Graph token carries the permissions consented to the
        app you sign in with. Connect-AAC's default (the Azure CLI) can read
        the directory but not Conditional Access, Intune or Secure Score.
        For everything in one token, sign in with an app that has the
        delegated (or, for a service principal, application) permissions
        -ListPermission prints - for example Microsoft Graph Command Line
        Tools, once an admin has consented:
 
            Connect-AAC -ClientId 14d82eec-204b-4c2f-b7e8-296a70dab67e `
                -Scope ((Invoke-AACM365Assessment -ListPermission) + 'offline_access', 'openid', 'profile')
 
        Your account also needs a directory role that can read them (Global
        Reader, Security Reader plus Intune Administrator or reader roles).
        COVERAGE. The Coverage tab lists every lens - Assessed, Partly
        assessed, Not assessed (with Graph's reason, the permission and any
        licence it needs) or Not in this run - and what Microsoft Graph
        doesn't reach at all (Exchange Online, Defender for Office 365,
        Purview, Teams, Defender for Cloud Apps, Sentinel...), each with
        what would cover it. The rest of the report is made whatever is
        refused.
 
        What you get depends on where the command runs:
          at the prompt tiles, the security settings, Conditional Access,
                           the privileged role assignments, what couldn't
                           be read and the findings, a page at a time
          piped onward the AAC.M365Assessment object, with every table
                           as a property
          -PassThru the view and the object
          -NoDisplay the object only
        -CsvPath (a folder) writes a CSV per table. -HtmlPath writes a
        tabbed report - Overview, Findings, Entra ID, Microsoft 365,
        Applications, Threat protection, Intune, Coverage - where every
        table can be searched, filtered, grouped
        and downloaded, and a row opens all its details.
    .PARAMETER Section
        What to read: Entra, Microsoft365, Intune. All by default.
    .PARAMETER StaleDays
        A device that hasn't checked in (Intune) or signed in (Entra ID) for
        more than this many days is stale. 90 by default.
    .PARAMETER SignInDays
        Look for sign-ins with legacy authentication protocols in the last
        this many days (7 by default; Entra ID keeps sign-ins 30 days with
        P1 or P2).
    .PARAMETER ListPermission
        Return the Microsoft Graph permissions the report needs (as scopes
        for Connect-AAC -Scope), and read nothing.
    .PARAMETER CsvPath
        A folder to write a CSV per table to.
    .PARAMETER HtmlPath
        Write the tabbed, interactive HTML report to this file.
    .PARAMETER Title
        The HTML report's title.
    .PARAMETER PassThru
        Show the view and also return the object.
    .PARAMETER NoDisplay
        Return the object without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once instead of a page at a time.
    .EXAMPLE
        Connect-AAC -ClientId 14d82eec-204b-4c2f-b7e8-296a70dab67e -Scope ((Invoke-AACM365Assessment -ListPermission) + 'offline_access', 'openid', 'profile')
        Invoke-AACM365Assessment -HtmlPath .\out\M365.html -CsvPath .\out\m365
        Signs in once with every permission the report needs, then writes the HTML report and a CSV per table.
    .EXAMPLE
        Invoke-AACM365Assessment -Section Entra
        Only Entra ID: settings, Conditional Access, admin roles and MFA registration.
    .EXAMPLE
        (Invoke-AACM365Assessment -NoDisplay -Section Entra).Registration | Where-Object { $_.Admin -eq 'Yes' -and $_.MfaRegistered -eq 'No' }
        The administrators with no MFA method registered.
    .EXAMPLE
        (Invoke-AACM365Assessment -NoDisplay -Section Intune -StaleDays 30).ManagedDevices | Where-Object Stale -EQ 'Yes' | Export-Csv .\stale.csv
        The Intune devices that haven't checked in for 30 days.
    .OUTPUTS
        AAC.M365Assessment (piped onward, or with -PassThru or -NoDisplay)
        System.String (with -ListPermission)
    #>

    [CmdletBinding(DefaultParameterSetName = 'Assess')]
    [OutputType('AAC.M365Assessment', ParameterSetName = 'Assess')]
    [OutputType([string], ParameterSetName = 'Permission')]
    param(
        [Parameter(ParameterSetName = 'Assess')]
        [Parameter(ParameterSetName = 'Permission')]
        [ValidateSet('Entra', 'Microsoft365', 'Intune')]
        [string[]] $Section = @('Entra', 'Microsoft365', 'Intune'),

        [Parameter(ParameterSetName = 'Assess')]
        [ValidateRange(1, 3650)]
        [int] $StaleDays = 90,

        [Parameter(ParameterSetName = 'Assess')]
        [ValidateRange(1, 30)]
        [int] $SignInDays = 7,

        [Parameter(Mandatory, ParameterSetName = 'Permission')]
        [switch] $ListPermission,

        [Parameter(ParameterSetName = 'Assess')]
        [string] $CsvPath,

        [Parameter(ParameterSetName = 'Assess')]
        [string] $HtmlPath,

        [Parameter(ParameterSetName = 'Assess')]
        [string] $Title = 'Microsoft 365 security posture',

        [Parameter(ParameterSetName = 'Assess')]
        [switch] $PassThru,

        [Parameter(ParameterSetName = 'Assess')]
        [switch] $NoDisplay,

        [Parameter(ParameterSetName = 'Assess')]
        [switch] $NoPaging
    )

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

    $queries = Get-AACM365AssessmentQuery -Section $Section
    if ($ListPermission) {
        return @($queries.Values | ForEach-Object { "https://graph.microsoft.com/$($_.Permission)" } | Sort-Object -Unique)
    }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $showView = $interactive -and -not ($CsvPath -or $HtmlPath)
    $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    # Read here, not inside the progress block (it runs in Invoke-AACProgress's scope).
    $request = @{ Section = @($Section); StaleDays = $StaleDays; SignInDays = $SignInDays; CsvPath = & $resolve $CsvPath; HtmlPath = & $resolve $HtmlPath; Title = $Title; Queries = $queries }

    if ($interactive) {
        Write-AACRule -Title 'Azure Admin Console :: Microsoft 365 security posture' -Color 'deepskyblue3_1'
    }
    $state = Invoke-AACProgress -ScriptBlock {
        # One Graph token for every call: the Connect-AAC sign-in's.
        $null = Get-AACAccessToken -Resource 'https://graph.microsoft.com'
        # A progress line per batch, one after another. The callback runs
        # inside Invoke-AACHttpBatch, whose -Request would hide $request: it
        # uses nothing but its arguments.
        $read = Read-AACM365Graph -Query $request.Queries -SignInDays $request.SignInDays -OnProgress {
            param($Batch, $What, $Done, $Total, $Waiting, $Finished)
            if ($Finished) { Update-AACProgress -Id "graph $Batch" -Complete -Description "$($Batch): read $Done of $Total" }
            elseif (-not $What) { Update-AACProgress -Id "graph $Batch" -Total $Total -Description "$($Batch): reading $Total$(if ($Waiting) { " ($Waiting)" })" }
            else { Update-AACProgress -Id "graph $Batch" -Total $Total -Increment 1 -Description "$($Batch): read $What ($Done of $Total)$(if ($Waiting) { "; still reading $Waiting" })" }
        }
        if (-not $read.Data.Count) {
            $problem = [System.InvalidOperationException]::new("Microsoft Graph refused every read: $(@($read.Errors.Values | Select-Object -First 1))")
            $problem.Data['AACHint'] = 'Sign in with an app that has the permissions -ListPermission prints: Connect-AAC -ClientId 14d82eec-204b-4c2f-b7e8-296a70dab67e -Scope ((Invoke-AACM365Assessment -ListPermission) + ''offline_access'', ''openid'', ''profile'')'
            throw $problem
        }

        Update-AACProgress -Id 'assess' -Indeterminate -Description 'Assessing identity, data and devices'
        $assessment = ConvertTo-AACM365Assessment -Data $read.Data -Errors $read.Errors -Query $request.Queries -StaleDays $request.StaleDays -SignInDays $request.SignInDays -Resolved $read.Resolved -Fallback $read.Fallbacks
        $stats = $assessment.Stats
        Update-AACProgress -Id 'assess' -Complete -Description ('{0}: Secure Score {1}; MFA registered {2}; {3} Conditional Access polic(ies) on; {4} critical/high, {5} medium finding(s)' -f $(if ($stats.Tenant) { $stats.Tenant } else { 'Tenant' }), $(if ($null -ne $stats.SecureScore) { "$($stats.SecureScore)%" } else { 'n/a' }), $(if ($null -ne $stats.MfaRegistered) { "$($stats.MfaRegistered)%" } else { 'n/a' }), $stats.ConditionalAccessOn, ($stats.Critical + $stats.High), $stats.Medium)

        $detail = [ordered]@{
            Tenant   = "$($stats.Tenant) ($($stats.TenantId))"
            Sections = $request.Section -join ', '
            Read     = "$($stats.Read) of $($stats.Read + $stats.NotRead + @($assessment.Permissions | Where-Object Status -EQ 'Not asked').Count) Graph endpoints$(if ($stats.NotRead) { " ($($stats.NotRead) refused: see Permissions)" })"
            Stale    = "no check-in or sign-in for $($request.StaleDays) days"
        }
        if ($request.CsvPath) {
            Update-AACProgress -Id 'csv' -Indeterminate -Description 'Writing the CSV files'
            $files = @(Write-AACM365AssessmentCsv -Assessment $assessment -Path $request.CsvPath)
            Update-AACProgress -Id 'csv' -Complete -Description "CSV: $($files.Count) file(s) in $($request.CsvPath)"
        }
        $null = Invoke-AACExport -HtmlPath $request.HtmlPath -WriteHtml {
            Write-AACM365AssessmentHtml -Assessment $assessment -Path $request.HtmlPath -Title $request.Title -Detail $detail
        }
        @{ Assessment = $assessment; Scope = $detail }
    }

    $assessment = $state.Assessment
    if ($showView) {
        Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock {
            Show-AACM365AssessmentView -Assessment $assessment -Scope $state.Scope
        }
    }
    elseif ($interactive) {
        foreach ($notice in @($assessment.Notices)) { Write-AACMarkup "[grey58]$([Spectre.Console.Markup]::Escape($notice))[/]" }
    }
    if ($returnObjects) {
        $result = [ordered]@{ PSTypeName = 'AAC.M365Assessment'; Tenant = $assessment.Stats.Tenant; TenantId = $assessment.Stats.TenantId; SecureScore = $assessment.Stats.SecureScore }
        foreach ($key in 'Findings', 'Settings', 'ConditionalAccess', 'NamedLocations', 'RoleAssignments', 'Registration', 'AuthenticationMethods', 'Licenses', 'IdentityProviders', 'Domains', 'SecureScoreControls', 'EnrollmentRestrictions', 'CompliancePolicies', 'EndpointSecurity', 'ManagedDevices', 'EntraDevices', 'MfaCoverage', 'EmergencyAccess', 'PrivilegedAccounts', 'AppCredentials', 'AppPermissions', 'RedirectUris', 'LegacyAuthentication', 'PimRoleSettings', 'GroupExposure', 'AccessReviews', 'RiskyUsers', 'RiskDetections', 'Incidents', 'InactiveUsers', 'AppProtection', 'Coverage', 'Permissions', 'Notices') { $result[$key] = @($assessment[$key]) }
        $result['Stats'] = [pscustomobject]$assessment.Stats
        [pscustomobject]$result
    }
}