Private/Write-AACM365AssessmentHtml.ps1

function Write-AACM365AssessmentHtml {
    <#
    .SYNOPSIS
        Writes Invoke-AACM365Assessment's report as one interactive, tabbed
        HTML page: Overview, Findings, Entra ID, Microsoft 365, Intune and
        Permissions.
    .DESCRIPTION
        Every table can be searched, filtered, grouped and downloaded as CSV,
        and a row opens all its fields in a details panel. Tables with
        nothing to show are left out; the Permissions tab says what Graph
        refused and which permission would read it.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Assessment,

        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $Title,

        [System.Collections.IDictionary] $Detail
    )

    $a = $Assessment; $stats = $a.Stats
    $severity = @{ Critical = 'bad'; High = 'bad'; Medium = 'warn'; Low = 'info'; Info = 'neutral' }
    $status = @{ Good = 'good'; Warning = 'bad'; Review = 'warn'; Info = 'neutral'; Unknown = 'neutral' }
    $yes = @{ Yes = 'good'; No = 'warn' }; $yesBad = @{ Yes = 'bad'; No = 'good' }
    $col = { param([string] $Key, [string] $Label, [hashtable] $More = @{}) $c = @{ Key = $Key; Label = $Label }; foreach ($k in $More.Keys) { $c[$k] = $More[$k] }; $c }
    $facet = @{ Facet = $true }; $num = @{ Type = 'number' }; $wide = @{ Type = 'wide' }; $hiddenWide = @{ Type = 'wide'; Hidden = $true }; $mono = @{ Type = 'mono'; Hidden = $true }
    $pct = { param($Value) if ($null -eq $Value) { 'neutral' } elseif ($Value -ge 70) { 'good' } elseif ($Value -ge 40) { 'warn' } else { 'bad' } }

    $tiles = @(
        @{ Value = $(if ($null -ne $stats.SecureScore) { "$($stats.SecureScore)%" } else { '-' }); Label = 'Microsoft Secure Score'; Tone = (& $pct $stats.SecureScore); Table = 'm365-securescore' }
        @{ Value = $(if ($null -ne $stats.MfaRegistered) { "$($stats.MfaRegistered)%" } else { '-' }); Label = "members with MFA registered ($($stats.AdminsWithoutMfa) admin(s) without)"; Tone = $(if ($stats.AdminsWithoutMfa) { 'bad' } else { & $pct $stats.MfaRegistered }); Table = 'm365-registration' }
        @{ Value = "$($stats.ConditionalAccessOn) / $($stats.ConditionalAccess)"; Label = "Conditional Access policies on (security defaults: $(if ($stats.SecurityDefaults) { $stats.SecurityDefaults } else { 'n/a' }))"; Tone = 'info'; Table = 'm365-ca' }
        @{ Value = '{0:N0}' -f $stats.GlobalAdmins; Label = "Global Administrators ($($stats.PrivilegedUsers) privileged accounts)"; Tone = $(if ($stats.GlobalAdmins -gt 5 -or $stats.GlobalAdmins -lt 2) { 'warn' } else { 'good' }); Table = 'm365-roles'; Filters = @{ GlobalAdministrator = 'Yes' } }
        @{ Value = $(if ($stats.ExternalSharing) { $stats.ExternalSharing -replace '\s*\(.*\)$', '' } else { '-' }); Label = "SharePoint and OneDrive sharing$(if ($stats.ExternalSharing -match '\((.*)\)') { " ($($Matches[1]))" })"; Tone = $(if ($stats.ExternalSharing -like 'Anyone*') { 'bad' } else { 'info' }); Table = 'm365-settings'; Filters = @{ Area = 'SharePoint and OneDrive' } }
        @{ Value = '{0:N0}' -f $stats.ManagedDevices; Label = "Intune devices ($($stats.NonCompliant) non-compliant, $($stats.StaleDevices) stale)"; Tone = $(if ($stats.NonCompliant) { 'warn' } else { 'good' }); Table = 'm365-devices' }
        @{ Value = '{0:N0}' -f $stats.UnmanagedDevices; Label = 'unmanaged Entra devices in use'; Tone = $(if ($stats.UnmanagedDevices) { 'warn' } else { 'good' }); Table = 'm365-entra-devices'; Filters = @{ Managed = 'No'; Stale = 'No' } }
        @{ Value = '{0:N0}' -f $stats.DanglingAdmins; Label = "dangling admin accounts ($($stats.RoleOverlap) with overlapping roles; $($stats.EmergencyAccounts) emergency access)"; Tone = $(if ($stats.DanglingAdmins) { 'bad' } else { 'good' }); Table = 'm365-privileged'; Filters = @{ Dangling = 'Yes' } }
        @{ Value = '{0:N0}' -f $stats.RiskyAppPermissions; Label = "apps with Critical or High permissions ($($stats.ExpiringCredentials) credentials expiring, $($stats.LongLivedSecrets) long-lived secrets)"; Tone = $(if ($stats.RiskyAppPermissions) { 'bad' } else { 'good' }); Table = 'm365-app-permissions' }
        @{ Value = '{0:N0}' -f $stats.LegacySignIns; Label = 'successful legacy authentication sign-ins'; Tone = $(if ($stats.LegacySignIns) { 'bad' } else { 'good' }); Table = 'm365-legacy' }
        @{ Value = '{0:N0}' -f ($stats.RiskyUsers + $stats.ActiveIncidents); Label = "risky users and open incidents ($($stats.RiskyUsers) users, $($stats.ActiveIncidents) incidents)"; Tone = $(if ($stats.RiskyUsers + $stats.ActiveIncidents) { 'bad' } else { 'good' }); Table = 'm365-risky-users' }
        @{ Value = "$($stats.LensesAssessed) / $($stats.LensesAssessed + $stats.LensesPartly + $stats.LensesNotAssessed)"; Label = "lenses assessed ($($stats.LensesPartly) partly, $($stats.LensesNotAssessed) not; $($stats.LensesNotCovered) outside Graph)"; Tone = $(if ($stats.LensesNotAssessed) { 'warn' } else { 'good' }); Table = 'm365-coverage' }
        @{ Value = '{0:N0}' -f ($stats.Critical + $stats.High); Label = "Critical and High findings ($($stats.Findings) in all)"; Tone = $(if ($stats.Critical + $stats.High) { 'bad' } elseif ($stats.Findings) { 'warn' } else { 'good' }); Table = 'm365-findings' }
    )
    $charts = @(
        if (@($a.Findings).Count) { @{ Title = 'Findings by severity'; Kind = 'donut'; CenterLabel = 'findings'; Table = 'm365-findings'; Column = 'Severity'; Items = @($a.Findings | Group-Object Severity | Sort-Object { @{ Critical = 0; High = 1; Medium = 2; Low = 3; Info = 4 }[$_.Name] } | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $severity[$_.Name] } }) } }
        if (@($a.Registration).Count) { @{ Title = 'MFA registration'; Kind = 'donut'; CenterLabel = 'users'; Table = 'm365-registration'; Column = 'MfaRegistered'; Items = @($a.Registration | Group-Object MfaRegistered | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $(if ($_.Name -eq 'Yes') { 'good' } else { 'bad' }) } }) } }
        if (@($a.ManagedDevices).Count) { @{ Title = 'Device compliance'; Kind = 'donut'; CenterLabel = 'devices'; Table = 'm365-devices'; Column = 'Compliance'; Items = @($a.ManagedDevices | Group-Object Compliance | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $(switch ($_.Name) { 'Compliant' { 'good' } 'Noncompliant' { 'bad' } 'In Grace Period' { 'warn' } default { 'neutral' } }) } }) } }
        if (@($a.SecureScoreControls).Count) { @{ Title = 'Secure Score: biggest gaps (points)'; Wide = $true; Table = 'm365-securescore'; Column = 'Control'; Tone = 'warn'; Items = @($a.SecureScoreControls | Where-Object Gap | Select-Object -First 12 | ForEach-Object { @{ Label = $_.Control; Value = $_.Gap } }) } }
        if (@($a.Findings).Count) { @{ Title = 'Findings by area'; Table = 'm365-findings'; Column = 'Area'; Tone = 'warn'; Items = @($a.Findings | Group-Object Area | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count } }) } }
    )

    $tables = [System.Collections.Generic.List[hashtable]]::new()
    $add = { param([hashtable] $Table) if (@($Table.Rows).Count) { $tables.Add($Table) } }
    & $add @{ Id = 'm365-findings'; Section = 'Findings'; Title = 'Findings'; Note = 'Zero-trust gaps in identity, data and devices, most severe first.'; Noun = 'findings'; File = 'm365-findings'; Rows = @($a.Findings); GroupBy = @('Area', 'Severity', 'Finding')
        Columns = @((& $col 'Severity' 'Severity' @{ Type = 'badge'; Tones = $severity; Facet = $true }), (& $col 'Area' 'Area' $facet), (& $col 'Finding' 'Finding' $facet), (& $col 'Item' 'Item'), (& $col 'Detail' 'What was found' $wide), (& $col 'Recommendation' 'What to do' $wide), (& $col 'Link' 'Docs' @{ Type = 'link'; Text = 'Docs ↗' })) }
    & $add @{ Id = 'm365-settings'; Section = 'Entra ID'; Title = 'Tenant and security settings'; Note = 'Entra ID, SharePoint and OneDrive, audit logging and Intune settings, each with its status.'; Noun = 'settings'; File = 'm365-settings'; Rows = @($a.Settings); GroupBy = @('Area', 'Status')
        Columns = @((& $col 'Setting' 'Setting'), (& $col 'Value' 'Value' $wide), (& $col 'Status' 'Status' @{ Type = 'badge'; Tones = $status; Facet = $true }), (& $col 'Area' 'Area' $facet), (& $col 'Detail' 'About' $wide)) }
    & $add @{ Id = 'm365-ca'; Section = 'Entra ID'; Title = 'Conditional Access policies'; Noun = 'policies'; File = 'm365-conditional-access'; Rows = @($a.ConditionalAccess); GroupBy = @('State', 'RequiresMfa', 'Blocks')
        Columns = @((& $col 'Policy' 'Policy' $wide), (& $col 'State' 'State' @{ Type = 'badge'; Tones = @{ On = 'good'; 'Report-only' = 'warn'; Off = 'neutral' }; Facet = $true }), (& $col 'Users' 'Users'), (& $col 'Excluded' 'Excluded' $num), (& $col 'Applications' 'Applications' $wide), (& $col 'ClientApps' 'Client apps'), (& $col 'Conditions' 'Conditions' $wide), (& $col 'Grant' 'Grant' $wide), (& $col 'Session' 'Session'), (& $col 'RequiresMfa' 'MFA' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'Blocks' 'Blocks' @{ Facet = $true; Hidden = $true }), (& $col 'Modified' 'Modified' @{ Type = 'date' }), (& $col 'Id' 'ID' $mono)) }
    & $add @{ Id = 'm365-mfa-coverage'; Section = 'Entra ID'; Title = 'MFA coverage'; Note = 'Registered MFA, and phishing-resistant methods (passkeys, Windows Hello, certificates), by kind of user.'; Noun = 'scopes'; File = 'm365-mfa-coverage'; Rows = @($a.MfaCoverage)
        Columns = @((& $col 'Scope' 'Users'), (& $col 'Users' 'Count' $num), (& $col 'MfaRegistered' 'MFA registered' $num), (& $col 'MfaPercent' 'MFA %' @{ Type = 'score' }), (& $col 'PhishingResistant' 'Phishing-resistant' $num), (& $col 'PhishingResistantPercent' 'Phishing-resistant %' @{ Type = 'score' }), (& $col 'Passwordless' 'Passwordless capable' $num), (& $col 'NoMethod' 'No MFA method' @{ Type = 'number'; Tone = 'bad' })) }
    & $add @{ Id = 'm365-emergency'; Section = 'Entra ID'; Title = 'Emergency access accounts'; Note = 'Found by being excluded from every enabled Conditional Access policy, or by name. Two cloud-only Global Administrators with passkeys are recommended.'; Noun = 'accounts'; File = 'm365-emergency-access'; Rows = @($a.EmergencyAccess)
        Columns = @((& $col 'Account' 'Account'), (& $col 'UserPrincipalName' 'User principal name'), (& $col 'DetectedBy' 'Found by'), (& $col 'GlobalAdministrator' 'Global admin' @{ Type = 'badge'; Tones = $yes }), (& $col 'CloudOnly' 'Cloud-only' @{ Type = 'badge'; Tones = $yes }), (& $col 'Enabled' 'Enabled' @{ Type = 'badge'; Tones = $yes }), (& $col 'PhishingResistant' 'Passkey' @{ Type = 'badge'; Tones = $yes }), (& $col 'ExcludedFromPolicies' 'Excluded from CA'), (& $col 'LastSignIn' 'Last sign-in' @{ Type = 'date' })) }
    & $add @{ Id = 'm365-privileged'; Section = 'Entra ID'; Title = 'Privileged accounts'; Note = 'Each account with a privileged role: dangling (deleted, disabled, guest, idle, synced, app) and overlapping roles first.'; Noun = 'accounts'; File = 'm365-privileged-accounts'; Rows = @($a.PrivilegedAccounts); GroupBy = @('Dangling', 'Overlap', 'Type', 'GlobalAdministrator')
        Columns = @((& $col 'Principal' 'Principal'), (& $col 'UserPrincipalName' 'User principal name'), (& $col 'Type' 'Type' $facet), (& $col 'Roles' 'Roles' $wide), (& $col 'RoleCount' 'Roles' $num), (& $col 'GlobalAdministrator' 'Global admin' @{ Facet = $true }), (& $col 'Assignment' 'Assignment' $facet), (& $col 'Dangling' 'Dangling' @{ Type = 'badge'; Tones = $yesBad; Facet = $true }), (& $col 'Issues' 'Issues' $wide), (& $col 'Overlap' 'Overlap' @{ Type = 'badge'; Tones = @{ Yes = 'warn'; No = 'good' }; Facet = $true }), (& $col 'OverlapDetail' 'Overlap detail' $wide), (& $col 'LastSignIn' 'Last sign-in' @{ Type = 'date' }), (& $col 'MfaRegistered' 'MFA' @{ Facet = $true }), (& $col 'Enabled' 'Enabled' @{ Facet = $true; Hidden = $true }), (& $col 'PrincipalId' 'Principal ID' $mono)) }
    & $add @{ Id = 'm365-legacy'; Section = 'Entra ID'; Title = 'Legacy authentication sign-ins'; Note = 'From the sign-in logs, per legacy protocol (up to 200 sign-ins read per protocol).'; Noun = 'protocols'; File = 'm365-legacy-authentication'; Rows = @($a.LegacyAuthentication)
        Columns = @((& $col 'Protocol' 'Protocol'), (& $col 'Successful' 'Successful' @{ Type = 'number'; Tone = 'bad'; Sum = $true }), (& $col 'Failed' 'Failed' @{ Type = 'number'; Sum = $true }), (& $col 'Users' 'Users' $num), (& $col 'TopUsers' 'Top users' $wide), (& $col 'Apps' 'Apps' $wide), (& $col 'Latest' 'Latest' @{ Type = 'datetime' }), (& $col 'Capped' 'Capped')) }
    & $add @{ Id = 'm365-app-permissions'; Section = 'Applications'; Title = 'App permissions'; Note = 'Application permissions on Microsoft Graph and delegated consents to every API, riskiest first. Critical: can take over the tenant; High: tenant-wide access to mail, files or chats.'; Noun = 'permissions'; File = 'm365-app-permissions'; Rows = @($a.AppPermissions); GroupBy = @('App', 'Risk', 'Owner', 'Kind'); Filters = @{ Owner = 'Third party' }
        Columns = @((& $col 'App' 'App'), (& $col 'Permission' 'Permission' @{ Type = 'mono' }), (& $col 'Risk' 'Risk' @{ Type = 'badge'; Tones = @{ Critical = 'bad'; High = 'bad'; Medium = 'warn'; Low = 'info'; Info = 'neutral' }; Facet = $true }), (& $col 'Kind' 'Kind' $facet), (& $col 'Owner' 'Owner' @{ Type = 'badge'; Tones = @{ Microsoft = 'neutral'; 'This tenant' = 'info'; 'Third party' = 'warn' }; Facet = $true }), (& $col 'Publisher' 'Publisher' $facet), (& $col 'Verified' 'Verified publisher' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'Resource' 'API' $facet), (& $col 'Granted' 'Granted' @{ Type = 'date' }), (& $col 'AppId' 'App ID' $mono)) }
    & $add @{ Id = 'm365-app-credentials'; Section = 'Applications'; Title = 'App secrets and certificates'; Note = 'Expiring within 30 days first; long-lived: secrets valid over two years, certificates over three.'; Noun = 'credentials'; File = 'm365-app-credentials'; Rows = @($a.AppCredentials); GroupBy = @('Status', 'Type', 'App')
        Columns = @((& $col 'App' 'App'), (& $col 'Object' 'Object' $facet), (& $col 'Type' 'Type' $facet), (& $col 'Description' 'Description'), (& $col 'Status' 'Status' @{ Type = 'badge'; Tones = @{ Expiring = 'bad'; Expired = 'neutral'; 'Long-lived' = 'warn'; OK = 'good' }; Facet = $true }), (& $col 'End' 'Expires' @{ Type = 'date' }), (& $col 'DaysLeft' 'Days left' $num), (& $col 'ValidityDays' 'Valid for (days)' $num), (& $col 'Start' 'Created' @{ Type = 'date'; Hidden = $true }), (& $col 'AppId' 'App ID' $mono)) }
    & $add @{ Id = 'm365-redirect-uris'; Section = 'Applications'; Title = 'Redirect URIs'; Note = 'Each app registration''s redirect URIs, their host looked up in DNS: dangling, wildcard and non-HTTPS URIs first.'; Noun = 'URIs'; File = 'm365-redirect-uris'; Rows = @($a.RedirectUris); GroupBy = @('Status', 'App', 'Issue'); Filters = @{ Status = 'Issue' }
        Columns = @((& $col 'App' 'App'), (& $col 'Uri' 'Redirect URI' $wide), (& $col 'Platform' 'Platform' $facet), (& $col 'Resolves' 'Resolves in DNS' @{ Type = 'badge'; Tones = @{ Yes = 'good'; No = 'bad'; Unknown = 'neutral' }; Facet = $true }), (& $col 'Issue' 'Issue' $facet), (& $col 'Severity' 'Severity' @{ Type = 'badge'; Tones = $severity; Facet = $true }), (& $col 'Status' 'Status' @{ Facet = $true; Hidden = $true }), (& $col 'Host' 'Host' @{ Hidden = $true }), (& $col 'AppId' 'App ID' $mono)) }
    & $add @{ Id = 'm365-named-locations'; Section = 'Entra ID'; Title = 'Named locations'; Noun = 'locations'; File = 'm365-named-locations'; Rows = @($a.NamedLocations)
        Columns = @((& $col 'Location' 'Location'), (& $col 'Type' 'Type' $facet), (& $col 'Trusted' 'Trusted' @{ Type = 'badge'; Tones = @{ Yes = 'info'; No = 'neutral' }; Facet = $true }), (& $col 'Ranges' 'IP ranges' $wide), (& $col 'Countries' 'Countries' $wide), (& $col 'Modified' 'Modified' @{ Type = 'date' })) }
    & $add @{ Id = 'm365-roles'; Section = 'Entra ID'; Title = 'Admin role assignments'; Note = 'Active and eligible (PIM) assignments; privileged roles first, with each admin''s MFA registration.'; Noun = 'assignments'; File = 'm365-admin-roles'; Rows = @($a.RoleAssignments); GroupBy = @('Role', 'Principal', 'Assignment', 'Privileged')
        Columns = @((& $col 'Role' 'Role' $facet), (& $col 'Principal' 'Principal'), (& $col 'UserPrincipalName' 'User principal name'), (& $col 'PrincipalType' 'Type' $facet), (& $col 'Assignment' 'Assignment' @{ Type = 'badge'; Tones = @{ Active = 'warn'; Eligible = 'good' }; Facet = $true }), (& $col 'Privileged' 'Privileged' @{ Type = 'badge'; Tones = @{ Yes = 'warn'; No = 'neutral' }; Facet = $true }), (& $col 'MfaRegistered' 'MFA registered' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'GlobalAdministrator' 'Global admin' @{ Facet = $true; Hidden = $true }), (& $col 'Scope' 'Scope' @{ Hidden = $true }), (& $col 'PrincipalId' 'Principal ID' $mono)) }
    & $add @{ Id = 'm365-registration'; Section = 'Entra ID'; Title = 'MFA registration'; Note = 'Admins without MFA first.'; Noun = 'users'; File = 'm365-mfa-registration'; Rows = @($a.Registration); GroupBy = @('MfaRegistered', 'UserType', 'Admin', 'DefaultMethod')
        Columns = @((& $col 'User' 'User'), (& $col 'UserPrincipalName' 'User principal name'), (& $col 'UserType' 'Type' $facet), (& $col 'Admin' 'Admin' @{ Facet = $true }), (& $col 'MfaRegistered' 'MFA registered' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'Passwordless' 'Passwordless' @{ Facet = $true }), (& $col 'SsprRegistered' 'SSPR' @{ Facet = $true }), (& $col 'DefaultMethod' 'Default method' $facet), (& $col 'Methods' 'Methods' $wide), (& $col 'MfaCapable' 'MFA capable' @{ Hidden = $true }), (& $col 'Updated' 'Updated' @{ Type = 'date'; Hidden = $true })) }
    & $add @{ Id = 'm365-methods'; Section = 'Entra ID'; Title = 'Authentication methods'; Noun = 'methods'; File = 'm365-authentication-methods'; Rows = @($a.AuthenticationMethods)
        Columns = @((& $col 'Method' 'Method'), (& $col 'State' 'State' @{ Type = 'badge'; Tones = @{ enabled = 'good'; disabled = 'neutral' }; Facet = $true }), (& $col 'PhishingResistant' 'Phishing-resistant' @{ Facet = $true }), (& $col 'Targets' 'For')) }
    & $add @{ Id = 'm365-licenses'; Section = 'Entra ID'; Title = 'Licences'; Noun = 'licences'; File = 'm365-licenses'; Rows = @($a.Licenses)
        Columns = @((& $col 'License' 'Licence (SKU)'), (& $col 'Consumed' 'Assigned' @{ Type = 'number'; Sum = $true }), (& $col 'Enabled' 'Bought' @{ Type = 'number'; Sum = $true }), (& $col 'Available' 'Available' $num), (& $col 'Suspended' 'Suspended' $num), (& $col 'Warning' 'Warning' $num), (& $col 'Status' 'Status' $facet)) }
    & $add @{ Id = 'm365-idps'; Section = 'Entra ID'; Title = 'Identity providers'; Noun = 'providers'; File = 'm365-identity-providers'; Rows = @($a.IdentityProviders); Columns = @((& $col 'Provider' 'Provider'), (& $col 'Type' 'Type'), (& $col 'Id' 'ID' @{ Type = 'mono' })) }
    & $add @{ Id = 'm365-domains'; Section = 'Microsoft 365'; Title = 'Domains'; Noun = 'domains'; File = 'm365-domains'; Rows = @($a.Domains)
        Columns = @((& $col 'Domain' 'Domain'), (& $col 'Default' 'Default' @{ Facet = $true }), (& $col 'Verified' 'Verified' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'Authentication' 'Authentication' @{ Type = 'badge'; Tones = @{ Managed = 'good'; Federated = 'info' }; Facet = $true }), (& $col 'Services' 'Services' $wide), (& $col 'PasswordValidityDays' 'Password expiry (days)')) }
    & $add @{ Id = 'm365-securescore'; Section = 'Microsoft 365'; Title = 'Secure Score controls'; Note = 'Biggest gap first. A row opens the description and how to fix it.'; Noun = 'controls'; File = 'm365-secure-score'; Rows = @($a.SecureScoreControls); GroupBy = @('Category', 'Service', 'Status'); Filters = @{ Status = 'To do' }
        Columns = @((& $col 'Control' 'Control' $wide), (& $col 'Status' 'Status' @{ Type = 'badge'; Tones = @{ Done = 'good'; Partly = 'warn'; 'To do' = 'bad' }; Facet = $true }), (& $col 'Gap' 'Points to gain' @{ Type = 'number'; Format = 'N2'; Tone = 'warn'; Sum = $true }), (& $col 'Score' 'Score' @{ Type = 'number'; Format = 'N2' }), (& $col 'MaxScore' 'Max' @{ Type = 'number'; Format = 'N2' }), (& $col 'Category' 'Category' $facet), (& $col 'Service' 'Service' $facet), (& $col 'UserImpact' 'User impact' $facet), (& $col 'Cost' 'Cost' @{ Facet = $true; Hidden = $true }), (& $col 'Description' 'Description' $hiddenWide), (& $col 'Remediation' 'How to fix' $hiddenWide), (& $col 'ActionUrl' 'Fix it' @{ Type = 'link'; Text = 'Open ↗' })) }
    & $add @{ Id = 'm365-restrictions'; Section = 'Intune'; Title = 'Enrollment restrictions'; Noun = 'restrictions'; File = 'm365-enrollment-restrictions'; Rows = @($a.EnrollmentRestrictions); GroupBy = @('Type', 'Restriction')
        Columns = @((& $col 'Restriction' 'Restriction'), (& $col 'Type' 'Type' $facet), (& $col 'Priority' 'Priority' $num), (& $col 'Platform' 'Platform' $facet), (& $col 'Blocked' 'Platform blocked' @{ Facet = $true }), (& $col 'PersonalBlocked' 'Personal blocked' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'MinimumOS' 'Minimum OS'), (& $col 'MaximumOS' 'Maximum OS'), (& $col 'Limit' 'Device limit' $num)) }
    & $add @{ Id = 'm365-compliance'; Section = 'Intune'; Title = 'Compliance policies'; Noun = 'policies'; File = 'm365-compliance-policies'; Rows = @($a.CompliancePolicies)
        Columns = @((& $col 'Policy' 'Policy'), (& $col 'Platform' 'Platform' $facet), (& $col 'Assignments' 'Assignments' $num), (& $col 'Modified' 'Modified' @{ Type = 'date' })) }
    & $add @{ Id = 'm365-endpoint'; Section = 'Intune'; Title = 'Endpoint security policies'; Noun = 'policies'; File = 'm365-endpoint-security'; Rows = @($a.EndpointSecurity); GroupBy = @('Family', 'Assigned')
        Columns = @((& $col 'Policy' 'Policy'), (& $col 'Family' 'Family' $facet), (& $col 'Assigned' 'Assigned' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'Platforms' 'Platforms' $facet), (& $col 'Template' 'Template'), (& $col 'Source' 'Source' @{ Facet = $true; Hidden = $true }), (& $col 'Modified' 'Modified' @{ Type = 'date' })) }
    & $add @{ Id = 'm365-devices'; Section = 'Intune'; Title = 'Managed devices'; Note = 'Non-compliant first, then stale.'; Noun = 'devices'; File = 'm365-managed-devices'; Rows = @($a.ManagedDevices); GroupBy = @('OS', 'Compliance', 'Ownership', 'Stale')
        Columns = @((& $col 'Device' 'Device'), (& $col 'User' 'User'), (& $col 'OS' 'OS' $facet), (& $col 'OSVersion' 'Version'), (& $col 'Compliance' 'Compliance' @{ Type = 'badge'; Tones = @{ Compliant = 'good'; Noncompliant = 'bad'; 'In Grace Period' = 'warn' }; Facet = $true }), (& $col 'Ownership' 'Ownership' $facet), (& $col 'Encrypted' 'Encrypted' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'Jailbroken' 'Jailbroken' @{ Facet = $true; Hidden = $true }), (& $col 'LastSync' 'Last check-in' @{ Type = 'date' }), (& $col 'DaysSinceSync' 'Days' $num), (& $col 'Stale' 'Stale' @{ Type = 'badge'; Tones = $yesBad; Facet = $true }), (& $col 'Model' 'Model' @{ Hidden = $true }), (& $col 'Manufacturer' 'Manufacturer' @{ Facet = $true; Hidden = $true }), (& $col 'Enrolled' 'Enrolled' @{ Type = 'date'; Hidden = $true })) }
    & $add @{ Id = 'm365-entra-devices'; Section = 'Intune'; Title = 'Entra ID devices'; Note = 'Every device in Entra ID: unmanaged ones in use first.'; Noun = 'devices'; File = 'm365-entra-devices'; Rows = @($a.EntraDevices); GroupBy = @('OS', 'Join', 'Managed', 'Stale')
        Columns = @((& $col 'Device' 'Device'), (& $col 'OS' 'OS' $facet), (& $col 'OSVersion' 'Version'), (& $col 'Join' 'Join type' $facet), (& $col 'Managed' 'Managed' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'Compliant' 'Compliant' @{ Facet = $true }), (& $col 'Enabled' 'Enabled' @{ Facet = $true }), (& $col 'LastSignIn' 'Last sign-in' @{ Type = 'date' }), (& $col 'DaysSinceSignIn' 'Days' $num), (& $col 'Stale' 'Stale' @{ Type = 'badge'; Tones = $yesBad; Facet = $true }), (& $col 'Registered' 'Registered' @{ Type = 'date'; Hidden = $true }), (& $col 'DeviceId' 'Device ID' $mono)) }
    & $add @{ Id = 'm365-pim'; Section = 'Entra ID'; Title = 'PIM role settings'; Note = 'What activating each privileged role takes (Privileged Identity Management, Entra ID P2).'; Noun = 'roles'; File = 'm365-pim-role-settings'; Rows = @($a.PimRoleSettings)
        Columns = @((& $col 'Role' 'Role'), (& $col 'MfaOnActivation' 'MFA on activation' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'Approval' 'Approval' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'Justification' 'Justification' @{ Facet = $true }), (& $col 'MaxActivationHours' 'Max activation (h)' @{ Type = 'number'; Format = 'N1' }), (& $col 'PermanentEligible' 'Permanent eligible' @{ Facet = $true }), (& $col 'PermanentActive' 'Permanent active' @{ Type = 'badge'; Tones = @{ Allowed = 'warn'; No = 'good' }; Facet = $true }), (& $col 'GlobalAdministrator' 'Global admin' @{ Facet = $true; Hidden = $true })) }
    & $add @{ Id = 'm365-groups'; Section = 'Entra ID'; Title = 'Groups behind exclusions and roles'; Note = 'The groups excluded from Conditional Access and the groups holding admin roles, with who is in them (nested members included).'; Noun = 'groups'; File = 'm365-group-exposure'; Rows = @($a.GroupExposure)
        Columns = @((& $col 'Group' 'Group'), (& $col 'Why' 'Why it matters' $wide), (& $col 'Members' 'Members' $num), (& $col 'Users' 'Users' $num), (& $col 'Guests' 'Guests' @{ Type = 'number'; Tone = 'bad' }), (& $col 'Disabled' 'Disabled' $num), (& $col 'Dynamic' 'Dynamic' @{ Facet = $true }), (& $col 'RoleAssignable' 'Role-assignable' @{ Facet = $true }), (& $col 'MemberList' 'Members (first 15)' $wide), (& $col 'Id' 'ID' $mono)) }
    & $add @{ Id = 'm365-reviews'; Section = 'Entra ID'; Title = 'Access reviews'; Noun = 'reviews'; File = 'm365-access-reviews'; Rows = @($a.AccessReviews)
        Columns = @((& $col 'Review' 'Review'), (& $col 'Covers' 'Covers' $facet), (& $col 'Status' 'Status' $facet), (& $col 'Recurrence' 'Recurrence' $facet), (& $col 'Reviewers' 'Reviewer rules' $num), (& $col 'Created' 'Created' @{ Type = 'date' })) }
    & $add @{ Id = 'm365-risky-users'; Section = 'Threat protection'; Title = 'Risky users'; Note = 'At risk or confirmed compromised, and not yet remediated or dismissed (Identity Protection).'; Noun = 'users'; File = 'm365-risky-users'; Rows = @($a.RiskyUsers)
        Columns = @((& $col 'User' 'User'), (& $col 'UserPrincipalName' 'User principal name'), (& $col 'RiskLevel' 'Risk level' @{ Type = 'badge'; Tones = @{ High = 'bad'; Medium = 'warn'; Low = 'info' }; Facet = $true }), (& $col 'RiskState' 'State' @{ Type = 'badge'; Tones = @{ 'Confirmed Compromised' = 'bad'; 'At Risk' = 'warn' }; Facet = $true }), (& $col 'RiskDetail' 'Detail' $facet), (& $col 'Updated' 'Updated' @{ Type = 'datetime' })) }
    & $add @{ Id = 'm365-risk-detections'; Section = 'Threat protection'; Title = 'Risk detections'; Note = 'Identity Protection detections in the period, by kind.'; Noun = 'kinds'; File = 'm365-risk-detections'; Rows = @($a.RiskDetections)
        Columns = @((& $col 'Detection' 'Detection'), (& $col 'Detections' 'Detections' @{ Type = 'number'; Sum = $true }), (& $col 'High' 'High risk' @{ Type = 'number'; Tone = 'bad'; Sum = $true }), (& $col 'Users' 'Users' $num), (& $col 'TopUsers' 'Top users' $wide), (& $col 'Latest' 'Latest' @{ Type = 'datetime' })) }
    & $add @{ Id = 'm365-incidents'; Section = 'Threat protection'; Title = 'Microsoft Defender XDR incidents'; Note = 'The most recently updated incidents: active ones first.'; Noun = 'incidents'; File = 'm365-incidents'; Rows = @($a.Incidents); Filters = @{ Active = 'Yes' }
        Columns = @((& $col 'Incident' 'Incident' $wide), (& $col 'Severity' 'Severity' @{ Type = 'badge'; Tones = @{ High = 'bad'; Medium = 'warn'; Low = 'info'; Informational = 'neutral' }; Facet = $true }), (& $col 'Status' 'Status' $facet), (& $col 'Active' 'Active' @{ Facet = $true }), (& $col 'AssignedTo' 'Assigned to' $facet), (& $col 'AgeDays' 'Age (days)' $num), (& $col 'Updated' 'Updated' @{ Type = 'datetime' }), (& $col 'Classification' 'Classification' @{ Facet = $true; Hidden = $true }), (& $col 'Link' 'Defender' @{ Type = 'link'; Text = 'Open ↗' })) }
    & $add @{ Id = 'm365-inactive'; Section = 'Microsoft 365'; Title = 'Licensed users with no activity in 30 days'; Noun = 'users'; File = 'm365-inactive-users'; Rows = @($a.InactiveUsers)
        Columns = @((& $col 'User' 'User'), (& $col 'UserPrincipalName' 'User principal name'), (& $col 'Products' 'Licences' $wide), (& $col 'LastActivity' 'Last activity' @{ Type = 'date' })) }
    & $add @{ Id = 'm365-mam'; Section = 'Intune'; Title = 'App protection policies'; Note = 'Corporate data in apps on personal phones and tablets (MAM).'; Noun = 'policies'; File = 'm365-app-protection'; Rows = @($a.AppProtection)
        Columns = @((& $col 'Policy' 'Policy'), (& $col 'Platform' 'Platform' $facet), (& $col 'Assigned' 'Assigned' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'PinRequired' 'PIN' @{ Type = 'badge'; Tones = $yes; Facet = $true }), (& $col 'SendDataTo' 'Send data to' $facet), (& $col 'Clipboard' 'Clipboard' $facet), (& $col 'BackupBlocked' 'Backup blocked' @{ Facet = $true }), (& $col 'SaveAsBlocked' 'Save as blocked' @{ Facet = $true }), (& $col 'MinimumOS' 'Minimum OS')) }
    & $add @{ Id = 'm365-coverage'; Section = 'Coverage'; Title = 'What this report covers - and what it doesn''t'; Note = 'Every lens: assessed, partly, not assessed (and why), not in this run - and what Microsoft Graph can''t reach, with what would cover it.'; Noun = 'lenses'; File = 'm365-coverage'; Rows = @($a.Coverage); GroupBy = @('Status', 'Area', 'Source')
        Columns = @((& $col 'Lens' 'Lens' $wide), (& $col 'Area' 'Area' $facet), (& $col 'Status' 'Status' @{ Type = 'badge'; Tones = @{ Assessed = 'good'; 'Partly assessed' = 'warn'; 'Not assessed' = 'bad'; 'Not covered' = 'neutral'; 'Not in this run' = 'info' }; Facet = $true }), (& $col 'Reads' 'Graph reads'), (& $col 'Reason' 'Why not' $wide), (& $col 'ToCover' 'To cover it' $wide), (& $col 'Source' 'Source' @{ Facet = $true; Hidden = $true })) }
    & $add @{ Id = 'm365-permissions'; Section = 'Coverage'; Title = 'Microsoft Graph reads'; Note = 'Each Graph read, and the permission it needs. Grant the missing ones (admin consent) to the app you sign in with, then run again.'; Noun = 'reads'; File = 'm365-permissions'; Rows = @($a.Permissions); GroupBy = @('Area', 'Status', 'Permission')
        Columns = @((& $col 'Data' 'Data'), (& $col 'Area' 'Area' $facet), (& $col 'Status' 'Status' @{ Type = 'badge'; Tones = @{ Read = 'good'; 'Not read' = 'bad'; 'Not asked' = 'neutral' }; Facet = $true }), (& $col 'Permission' 'Permission' @{ Type = 'mono'; Facet = $true }), (& $col 'Reason' 'Why not' $wide), (& $col 'Note' 'Note')) }

    $tabs = @(
        @{ Name = 'Findings'; Badge = $(if ($stats.Critical + $stats.High) { [string]($stats.Critical + $stats.High) } elseif ($stats.Findings) { [string]$stats.Findings } else { '' }); Tone = $(if ($stats.Critical + $stats.High) { 'bad' } else { 'warn' }) }
        @{ Name = 'Entra ID'; Note = 'Tenant configuration, user and guest settings, Conditional Access, admin roles, MFA registration and authentication methods.' }
        @{ Name = 'Microsoft 365'; Note = 'Domains, Microsoft Secure Score and its controls. SharePoint, OneDrive and audit settings are in the Entra ID tab''s settings table (Area).' }
        @{ Name = 'Applications'; Badge = $(if ($stats.RiskyAppPermissions + $stats.RedirectUriIssues + $stats.ExpiringCredentials) { [string]($stats.RiskyAppPermissions + $stats.RedirectUriIssues + $stats.ExpiringCredentials) } else { '' }); Tone = 'warn'; Note = 'App registrations and enterprise apps: over-privileged permissions, expiring and long-lived secrets, dangling redirect URIs.' }
        @{ Name = 'Threat protection'; Badge = $(if ($stats.RiskyUsers + $stats.ActiveIncidents) { [string]($stats.RiskyUsers + $stats.ActiveIncidents) } else { '' }); Tone = 'bad'; Note = 'Identity Protection''s risky users and risk detections, and Microsoft Defender XDR incidents.' }
        @{ Name = 'Intune'; Note = 'Enrollment restrictions, compliance, endpoint security and app protection policies, managed devices and the Entra ID devices nobody manages.' }
        @{ Name = 'Coverage'; Badge = $(if ($stats.LensesNotAssessed + $stats.LensesPartly) { [string]($stats.LensesNotAssessed + $stats.LensesPartly) } else { '' }); Tone = 'warn'; Note = "$($stats.LensesAssessed) lenses assessed, $($stats.LensesPartly) partly, $($stats.LensesNotAssessed) not assessed; $($stats.LensesNotCovered) areas Microsoft Graph doesn't reach." }
    )
    $notices = @(foreach ($line in @($a.Notices)) { @{ Tone = 'warn'; Text = $line } })
    $notices += @{ Tone = 'info'; Text = 'Read-only, from the Microsoft Graph REST API with one token (no AzureAD, MSOnline or Microsoft.Graph modules). Click a tile to open its table, and any row for all its details.' }
    $subtitle = "Microsoft 365 tenant discovery and security posture$(if ($stats.Tenant) { ": $($stats.Tenant)" })"
    Write-AACHtmlReport -Path $Path -Title $Title -Subtitle $subtitle -Fact $Detail -Tile $tiles -Chart $charts -Table $tables.ToArray() -Notice $notices -Tab $tabs
}