Invoke-M365TenantAudit.ps1
|
<#PSScriptInfo .VERSION 1.0.3 .GUID 7d3f2a91-5c4e-4b8a-9f61-2e0c8d4b7a13 .AUTHOR Admin of One .COMPANYNAME Admin of One .COPYRIGHT (c) 2026 Admin of One. MIT License. .TAGS M365 Microsoft365 Security Audit EntraID ConditionalAccess Intune MFA .LICENSEURI https://github.com/adminofone/m365-tenant-audit/blob/main/LICENSE .PROJECTURI https://github.com/adminofone/m365-tenant-audit .ICONURI .EXTERNALMODULEDEPENDENCIES Microsoft.Graph.Authentication .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES 1.0.3 - Admin MFA check now honours CA exclusions (excluded roles, users and groups) and only passes 'strong MFA' for a phishing-resistant authentication strength. The script no longer installs modules on its own; it tells you what to install and exits. 1.0.2 - Report footer links to the Tenant Lockdown Kit. 1.0.1 - Real-tenant fixes. #> <# .SYNOPSIS M365 Tenant Security Audit (Free Edition) - READ-ONLY. .DESCRIPTION Connects to Microsoft Graph with read-only permissions, checks common Microsoft 365 / Entra ID security baseline items and writes a self-contained HTML report. THIS SCRIPT MAKES NO CHANGES TO YOUR TENANT. Checks: - Licensing (Entra ID P1/P2 detection) - Security Defaults / Conditional Access (MFA for all, legacy auth block, admin MFA, break-glass exclusions, report-only policies) - Global Administrator count - MFA registration (all users + admins) - Stale / never-used accounts and guests - User consent to apps, guest invite settings, app creation - App registration secrets/certificates (expired / expiring) - Microsoft Secure Score - Intune compliance policies - Email DNS: SPF, DMARC, DKIM for each custom domain - Password expiration policy .PARAMETER OutputPath Path of the HTML report. Default: ./M365-Audit-<date>.html .PARAMETER StaleDays Accounts with no sign-in for this many days are flagged. Default: 90 .PARAMETER UseDeviceCode Use device-code sign-in (useful on servers / remote shells). .PARAMETER CsvPath Optional: also export raw findings to CSV. .EXAMPLE ./Invoke-M365TenantAudit.ps1 .EXAMPLE ./Invoke-M365TenantAudit.ps1 -StaleDays 60 -CsvPath ./findings.csv .NOTES Requirements : PowerShell 7+, module Microsoft.Graph.Authentication Sign-in role : Global Reader (recommended) or Global Administrator License : Provided AS-IS without warranty. MIT License. #> #Requires -Version 7.0 [CmdletBinding()] param( [string]$OutputPath = (Join-Path -Path (Get-Location) -ChildPath ("M365-Audit-{0}.html" -f (Get-Date -Format 'yyyyMMdd-HHmm'))), [ValidateRange(7, 3650)] [int]$StaleDays = 90, [switch]$UseDeviceCode, [string]$CsvPath ) $ErrorActionPreference = 'Stop' $ScriptVersion = '1.0.3' # --------------------------------------------------------------------------- # 0. Prerequisites # --------------------------------------------------------------------------- if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { Write-Host "Microsoft.Graph.Authentication module not found." -ForegroundColor Yellow Write-Host "This script does not install modules on its own. Install it from the PowerShell Gallery, then run the script again:" -ForegroundColor Yellow Write-Host " Install-Module Microsoft.Graph.Authentication -Scope CurrentUser" -ForegroundColor White exit 1 } Import-Module Microsoft.Graph.Authentication $Scopes = @( 'Organization.Read.All', 'Directory.Read.All', 'Policy.Read.All', 'AuditLog.Read.All', 'Reports.Read.All', 'RoleManagement.Read.Directory', 'Application.Read.All', 'SecurityEvents.Read.All', 'DeviceManagementConfiguration.Read.All' ) Write-Host "`n=== M365 Tenant Security Audit v$ScriptVersion (read-only) ===`n" -ForegroundColor Cyan Write-Host "A browser window will open. Sign in with a Global Reader or Global Admin account." -ForegroundColor Gray $connectParams = @{ Scopes = $Scopes; NoWelcome = $true; ContextScope = 'Process' } if ($UseDeviceCode) { $connectParams.UseDeviceCode = $true } Connect-MgGraph @connectParams # --------------------------------------------------------------------------- # 1. Helpers # --------------------------------------------------------------------------- $Results = [System.Collections.Generic.List[object]]::new() function Add-Result { param( [Parameter(Mandatory)][string]$Category, [Parameter(Mandatory)][string]$Check, [Parameter(Mandatory)][ValidateSet('Pass', 'Warn', 'Fail', 'Info', 'Error')][string]$Status, [string]$Detail = '', [string]$Recommendation = '', [ValidateRange(1, 5)][int]$Weight = 1 ) $Results.Add([pscustomobject]@{ Category = $Category Check = $Check Status = $Status Detail = $Detail Recommendation = $Recommendation Weight = $Weight }) $color = switch ($Status) { 'Pass' { 'Green' } 'Warn' { 'Yellow' } 'Fail' { 'Red' } 'Error' { 'DarkGray' } default { 'Gray' } } Write-Host (" [{0,-5}] {1} - {2}" -f $Status.ToUpper(), $Category, $Check) -ForegroundColor $color } function Get-GraphObject { param([Parameter(Mandatory)][string]$Uri) Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject } function Get-GraphCollection { param([Parameter(Mandatory)][string]$Uri) $items = [System.Collections.Generic.List[object]]::new() $next = $Uri while ($next) { $resp = Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject foreach ($v in @($resp.value)) { if ($null -ne $v) { $items.Add($v) } } $next = $resp.'@odata.nextLink' } return , $items.ToArray() } function Get-ShortError { param($ErrorRecord) $msg = $ErrorRecord.Exception.Message if ($msg -match 'Forbidden|403|Authorization_RequestDenied') { return "Access denied - check sign-in role or license (Entra ID P1 may be required)." } if ($msg -match 'NotFound|404') { return "Not available in this tenant (feature not licensed or not provisioned)." } if ($msg.Length -gt 200) { $msg = $msg.Substring(0, 200) + '...' } return $msg } function Resolve-DohRecord { # DNS-over-HTTPS lookup (works on macOS, Linux and Windows) param([Parameter(Mandatory)][string]$Name, [ValidateSet('TXT', 'CNAME')][string]$Type = 'TXT') $q = "name=$([uri]::EscapeDataString($Name))&type=$Type" $r = $null foreach ($endpoint in @("https://cloudflare-dns.com/dns-query?$q", "https://dns.google/resolve?$q")) { try { $r = Invoke-RestMethod -Uri $endpoint -Headers @{ accept = 'application/dns-json' } -TimeoutSec 15; break } catch { $r = $null } } if ($null -eq $r) { throw "DNS lookup failed for $Name (could not reach DNS-over-HTTPS resolvers - check internet/proxy)." } if (-not $r.Answer) { return @() } $typeCode = if ($Type -eq 'TXT') { 16 } else { 5 } return @($r.Answer | Where-Object { $_.type -eq $typeCode } | ForEach-Object { ($_.data -replace '"\s*"', '').Trim('"') }) } function Test-RequiresMfa { param($Policy) $g = $Policy.grantControls if (-not $g) { return $false } return (($g.builtInControls -contains 'mfa') -or ($null -ne $g.authenticationStrength)) } $PhishingResistantMethods = @('fido2', 'windowsHelloForBusiness', 'x509CertificateMultiFactor') function Test-PhishingResistantStrength { param($Policy) $st = $Policy.grantControls.authenticationStrength if (-not $st) { return $false } if ($st.id -eq '00000000-0000-0000-0000-000000000004') { return $true } # built-in "Phishing-resistant MFA" $combos = @($st.allowedCombinations | Where-Object { $_ }) if ($combos.Count -eq 0) { return $false } foreach ($c in $combos) { $parts = @($c -split ',' | ForEach-Object { $_.Trim() }) if (@($parts | Where-Object { $_ -notin $PhishingResistantMethods }).Count -gt 0) { return $false } } return $true } $GroupMemberCache = @{} function Get-GroupMemberIds { param([Parameter(Mandatory)][string]$GroupId) if (-not $GroupMemberCache.ContainsKey($GroupId)) { $ids = @() try { $ids = @(Get-GraphCollection -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/transitiveMembers?`$select=id" | ForEach-Object { $_.id }) } catch { $ids = $null } $GroupMemberCache[$GroupId] = $ids } return $GroupMemberCache[$GroupId] } function Get-AdminCoverage { # Returns $null if the policy does not cover Global Admins, otherwise an object with the admins it leaves out. param($Policy, [string[]]$GaIds) $u = $Policy.conditions.users if (@($u.excludeRoles) -contains $GlobalAdminRoleId) { return $null } if (-not ((@($u.includeRoles) -contains $GlobalAdminRoleId) -or (@($u.includeUsers) -contains 'All'))) { return $null } $excluded = [System.Collections.Generic.HashSet[string]]::new() foreach ($id in @($u.excludeUsers | Where-Object { $_ })) { [void]$excluded.Add($id) } $unresolved = 0 foreach ($g in @($u.excludeGroups | Where-Object { $_ })) { $members = Get-GroupMemberIds -GroupId $g if ($null -eq $members) { $unresolved++ } else { foreach ($m in $members) { [void]$excluded.Add($m) } } } $gaExcluded = @($GaIds | Where-Object { $excluded.Contains($_) }) if ($GaIds.Count -gt 0 -and $gaExcluded.Count -ge $GaIds.Count) { return $null } return [pscustomobject]@{ Policy = $Policy; ExcludedAdmins = $gaExcluded.Count; UnresolvedGroups = $unresolved } } function ConvertTo-Html-Safe { param([string]$Text) [System.Net.WebUtility]::HtmlEncode($Text) } $GlobalAdminRoleId = '62e90394-69f5-4237-9190-012177145e10' $Now = Get-Date $HasP1 = $false $TenantName = 'Unknown tenant' $TenantId = '' $CustomDomains = @() # --------------------------------------------------------------------------- # 2. Tenant & licensing # --------------------------------------------------------------------------- Write-Host "`n[1/9] Tenant & licensing" -ForegroundColor Cyan try { $org = @(Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/organization')[0] $TenantName = $org.displayName $TenantId = $org.id Add-Result -Category 'Tenant' -Check 'Tenant information' -Status 'Info' -Detail "$TenantName ($TenantId)" } catch { Add-Result -Category 'Tenant' -Check 'Tenant information' -Status 'Error' -Detail (Get-ShortError $_) } try { $skus = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus' $planNames = @($skus | ForEach-Object { $_.servicePlans.servicePlanName }) $HasP1 = ($planNames -contains 'AAD_PREMIUM') -or ($planNames -contains 'AAD_PREMIUM_P2') $skuList = ($skus | Where-Object { $_.capabilityStatus -eq 'Enabled' } | ForEach-Object { "$($_.skuPartNumber) ($($_.consumedUnits)/$($_.prepaidUnits.enabled))" }) -join ', ' Add-Result -Category 'Tenant' -Check 'Active licenses' -Status 'Info' -Detail $skuList if ($HasP1) { Add-Result -Category 'Tenant' -Check 'Entra ID P1/P2 available' -Status 'Pass' -Detail 'Conditional Access and sign-in reporting are available.' } else { Add-Result -Category 'Tenant' -Check 'Entra ID P1/P2 available' -Status 'Warn' -Weight 2 ` -Detail 'No Entra ID P1/P2 found. Conditional Access is not available; rely on Security Defaults.' ` -Recommendation 'Consider Microsoft 365 Business Premium (includes Entra ID P1 and Intune).' } } catch { Add-Result -Category 'Tenant' -Check 'Active licenses' -Status 'Error' -Detail (Get-ShortError $_) } # --------------------------------------------------------------------------- # 3. Security Defaults & Conditional Access # --------------------------------------------------------------------------- Write-Host "`n[2/9] MFA enforcement (Security Defaults / Conditional Access)" -ForegroundColor Cyan $SecurityDefaultsOn = $false $CaPolicies = @() try { $sd = Get-GraphObject -Uri 'https://graph.microsoft.com/v1.0/policies/identitySecurityDefaultsEnforcementPolicy' $SecurityDefaultsOn = [bool]$sd.isEnabled } catch { Add-Result -Category 'Identity' -Check 'Security Defaults' -Status 'Error' -Detail (Get-ShortError $_) } try { $CaPolicies = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies' } catch { if ($HasP1) { Add-Result -Category 'Identity' -Check 'Conditional Access policies' -Status 'Error' -Detail (Get-ShortError $_) } } $enabledCa = @($CaPolicies | Where-Object { $_.state -eq 'enabled' }) $reportOnlyCa = @($CaPolicies | Where-Object { $_.state -eq 'enabledForReportingButNotEnforced' }) if ($SecurityDefaultsOn) { Add-Result -Category 'Identity' -Check 'MFA enforcement' -Status 'Pass' -Weight 5 ` -Detail 'Security Defaults are ON (MFA required, legacy authentication blocked).' ` -Recommendation $(if ($HasP1) { 'You have Entra ID P1: consider replacing Security Defaults with Conditional Access for finer control.' } else { '' }) } elseif ($enabledCa.Count -eq 0) { Add-Result -Category 'Identity' -Check 'MFA enforcement' -Status 'Fail' -Weight 5 ` -Detail 'Security Defaults are OFF and no Conditional Access policy is enabled. MFA is NOT enforced.' ` -Recommendation 'Immediately enable Security Defaults, or deploy Conditional Access policies requiring MFA for all users.' } else { # MFA for all users $mfaAll = @($enabledCa | Where-Object { ($_.conditions.users.includeUsers -contains 'All') -and ($_.conditions.applications.includeApplications -contains 'All') -and (Test-RequiresMfa $_) }) if ($mfaAll.Count -gt 0) { Add-Result -Category 'Identity' -Check 'MFA for all users (CA)' -Status 'Pass' -Weight 5 -Detail ("Policy: " + (($mfaAll.displayName) -join ', ')) } else { Add-Result -Category 'Identity' -Check 'MFA for all users (CA)' -Status 'Fail' -Weight 5 ` -Detail 'No enabled CA policy requires MFA for All users on All cloud apps.' ` -Recommendation 'Create a CA policy: Users = All (exclude break-glass), Apps = All, Grant = Require MFA.' } # Legacy authentication block $legacy = @($enabledCa | Where-Object { ($_.conditions.clientAppTypes -contains 'exchangeActiveSync') -and ($_.conditions.clientAppTypes -contains 'other') -and ($_.grantControls.builtInControls -contains 'block') }) if ($legacy.Count -gt 0) { Add-Result -Category 'Identity' -Check 'Legacy authentication blocked' -Status 'Pass' -Weight 4 -Detail ("Policy: " + (($legacy.displayName) -join ', ')) } else { Add-Result -Category 'Identity' -Check 'Legacy authentication blocked' -Status 'Fail' -Weight 4 ` -Detail 'No enabled CA policy blocks legacy authentication (Exchange ActiveSync + Other clients).' ` -Recommendation 'Create a CA policy: Users = All, Client apps = Exchange ActiveSync + Other clients, Grant = Block.' } # Admin MFA (honours CA exclusions: excluded roles, users and groups) $gaIds = @() try { $gaIds = @(Get-GraphCollection -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$GlobalAdminRoleId'&`$select=principalId" | ForEach-Object { $_.principalId }) } catch { $gaIds = @() } $adminCoverage = @($enabledCa | Where-Object { Test-RequiresMfa $_ } | ForEach-Object { Get-AdminCoverage -Policy $_ -GaIds $gaIds } | Where-Object { $_ }) $adminMfa = @($adminCoverage | ForEach-Object { $_.Policy }) $adminStrength = @($adminCoverage | Where-Object { Test-PhishingResistantStrength $_.Policy }) $exclNote = '' $maxExcl = ($adminCoverage | Measure-Object -Property ExcludedAdmins -Maximum).Maximum if ($maxExcl -gt 0) { $exclNote += " Note: $maxExcl Global Admin(s) are excluded from a covering policy (fine if that is your break-glass account)." } if (@($adminCoverage | Where-Object { $_.UnresolvedGroups -gt 0 }).Count -gt 0) { $exclNote += ' Some excluded groups could not be read, so admin exclusions may be incomplete.' } if ($adminStrength.Count -gt 0) { Add-Result -Category 'Identity' -Check 'Admins require strong MFA' -Status 'Pass' -Weight 4 -Detail ("Phishing-resistant authentication strength enforced by: " + (($adminStrength.Policy.displayName) -join ', ') + $exclNote) } elseif ($adminMfa.Count -gt 0) { Add-Result -Category 'Identity' -Check 'Admins require strong MFA' -Status 'Warn' -Weight 4 ` -Detail ('Admins require MFA, but not a phishing-resistant authentication strength. Policies: ' + (($adminMfa.displayName) -join ', ') + $exclNote) ` -Recommendation 'Create a CA policy for admin roles with Grant = Require authentication strength "Phishing-resistant MFA".' } else { Add-Result -Category 'Identity' -Check 'Admins require strong MFA' -Status 'Fail' -Weight 4 ` -Detail 'No enabled CA policy requires MFA for Global Administrators once exclusions (roles, users, groups) are taken into account.' ` -Recommendation 'Create a CA policy targeting admin roles requiring phishing-resistant MFA.' } # Break-glass exclusion $allUserPolicies = @($enabledCa | Where-Object { $_.conditions.users.includeUsers -contains 'All' }) $noExclusion = @($allUserPolicies | Where-Object { @($_.conditions.users.excludeUsers | Where-Object { $_ }).Count -eq 0 -and @($_.conditions.users.excludeGroups | Where-Object { $_ }).Count -eq 0 }) if ($allUserPolicies.Count -gt 0 -and $noExclusion.Count -gt 0) { Add-Result -Category 'Identity' -Check 'Break-glass account excluded' -Status 'Warn' -Weight 2 ` -Detail ("Policies targeting All users with no exclusion: " + (($noExclusion.displayName) -join ', ')) ` -Recommendation 'Exclude 1-2 emergency access (break-glass) accounts from CA policies to avoid tenant lockout.' } elseif ($allUserPolicies.Count -gt 0) { Add-Result -Category 'Identity' -Check 'Break-glass account excluded' -Status 'Pass' -Weight 2 -Detail 'All-user policies have exclusions configured.' } } if ($reportOnlyCa.Count -gt 0) { Add-Result -Category 'Identity' -Check 'Report-only CA policies' -Status 'Info' ` -Detail ("$($reportOnlyCa.Count) policy(ies) in report-only mode: " + (($reportOnlyCa.displayName) -join ', ')) ` -Recommendation 'Review sign-in logs for impact, then switch to On.' } # --------------------------------------------------------------------------- # 4. Privileged roles # --------------------------------------------------------------------------- Write-Host "`n[3/9] Privileged roles" -ForegroundColor Cyan try { $gaAssign = Get-GraphCollection -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$GlobalAdminRoleId'&`$expand=principal" $gaNames = @($gaAssign | ForEach-Object { if ($_.principal.userPrincipalName) { $_.principal.userPrincipalName } else { $_.principal.displayName } }) $gaCount = $gaNames.Count $detail = "$gaCount permanent Global Admin(s): " + ($gaNames -join ', ') if ($gaCount -lt 2) { Add-Result -Category 'Privileged access' -Check 'Global Administrator count' -Status 'Warn' -Weight 3 -Detail $detail ` -Recommendation 'Keep at least 2 Global Admins (including one break-glass account) to avoid lockout.' } elseif ($gaCount -gt 4) { Add-Result -Category 'Privileged access' -Check 'Global Administrator count' -Status 'Fail' -Weight 3 -Detail $detail ` -Recommendation 'Reduce Global Admins to 2-4. Use least-privilege roles (User/Exchange/Intune Administrator) instead.' } else { Add-Result -Category 'Privileged access' -Check 'Global Administrator count' -Status 'Pass' -Weight 3 -Detail $detail } } catch { Add-Result -Category 'Privileged access' -Check 'Global Administrator count' -Status 'Error' -Detail (Get-ShortError $_) } # --------------------------------------------------------------------------- # 5. MFA registration # --------------------------------------------------------------------------- Write-Host "`n[4/9] MFA registration" -ForegroundColor Cyan try { $reg = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails?$top=999' if ($reg.Count -eq 0) { throw 'REPORT_EMPTY' } $members = @($reg | Where-Object { $_.userType -eq 'member' }) $notReg = @($members | Where-Object { -not $_.isMfaRegistered }) $adminsNotReg = @($reg | Where-Object { $_.isAdmin -and -not $_.isMfaRegistered }) if ($members.Count -gt 0) { $pct = [math]::Round((($members.Count - $notReg.Count) / $members.Count) * 100, 1) $sample = ($notReg | Select-Object -First 10 | ForEach-Object { $_.userPrincipalName }) -join ', ' $status = if ($pct -ge 95) { 'Pass' } elseif ($pct -ge 80) { 'Warn' } else { 'Fail' } Add-Result -Category 'Identity' -Check 'Users registered for MFA' -Status $status -Weight 4 ` -Detail ("$pct% of $($members.Count) member users registered. Not registered ($($notReg.Count)): $sample" + $(if ($notReg.Count -gt 10) { ' ...' } else { '' })) ` -Recommendation $(if ($status -ne 'Pass') { 'Run an MFA registration campaign (Entra > Protection > Authentication methods > Registration campaign).' } else { '' }) } if ($adminsNotReg.Count -gt 0) { Add-Result -Category 'Privileged access' -Check 'Admins registered for MFA' -Status 'Fail' -Weight 5 ` -Detail ("Admins without MFA: " + (($adminsNotReg.userPrincipalName) -join ', ')) ` -Recommendation 'Require these admins to register MFA immediately.' } else { Add-Result -Category 'Privileged access' -Check 'Admins registered for MFA' -Status 'Pass' -Weight 5 -Detail 'All admin accounts have MFA registered.' } } catch { if ($_.Exception.Message -match 'REPORT_EMPTY') { Add-Result -Category 'Identity' -Check 'MFA registration report' -Status 'Info' -Detail 'Registration report is empty (new tenants can take up to 48 hours to populate). Re-run later.' } else { Add-Result -Category 'Identity' -Check 'MFA registration report' -Status 'Error' -Detail (Get-ShortError $_) } } # --------------------------------------------------------------------------- # 6. Stale accounts & guests # --------------------------------------------------------------------------- Write-Host "`n[5/9] Stale accounts & guests" -ForegroundColor Cyan $users = @() $hasSignIn = $true try { $users = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName,accountEnabled,userType,createdDateTime,signInActivity&$top=120' } catch { $hasSignIn = $false try { $users = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName,accountEnabled,userType,createdDateTime&$top=999' } catch { Add-Result -Category 'Accounts' -Check 'User inventory' -Status 'Error' -Detail (Get-ShortError $_) } } if ($users.Count -gt 0) { $enabled = @($users | Where-Object { $_.accountEnabled }) $guests = @($enabled | Where-Object { $_.userType -eq 'Guest' }) Add-Result -Category 'Accounts' -Check 'User inventory' -Status 'Info' ` -Detail "$($users.Count) total users, $($enabled.Count) enabled, $($guests.Count) enabled guests." if ($hasSignIn) { $cutoff = $Now.AddDays(-$StaleDays) $stale = @($enabled | Where-Object { $last = $_.signInActivity.lastSignInDateTime $created = if ($_.createdDateTime) { [datetime]$_.createdDateTime } else { $Now } if ($last) { [datetime]$last -lt $cutoff } else { $created -lt $cutoff } }) $staleMembers = @($stale | Where-Object { $_.userType -ne 'Guest' }) $staleGuests = @($stale | Where-Object { $_.userType -eq 'Guest' }) foreach ($set in @(@{ Name = 'Stale member accounts'; Items = $staleMembers; W = 2 }, @{ Name = 'Stale guest accounts'; Items = $staleGuests; W = 2 })) { $items = @($set.Items) if ($items.Count -eq 0) { Add-Result -Category 'Accounts' -Check $set.Name -Status 'Pass' -Weight $set.W -Detail "No enabled accounts without sign-in for $StaleDays+ days." } else { $sample = ($items | Select-Object -First 10 | ForEach-Object { $_.userPrincipalName }) -join ', ' Add-Result -Category 'Accounts' -Check $set.Name -Status 'Warn' -Weight $set.W ` -Detail ("$($items.Count) enabled account(s) with no sign-in for $StaleDays+ days: $sample" + $(if ($items.Count -gt 10) { ' ...' } else { '' })) ` -Recommendation 'Review and disable/remove unused accounts. Note: shared mailboxes and service accounts may appear here.' } } } else { Add-Result -Category 'Accounts' -Check 'Stale accounts' -Status 'Info' -Detail 'Sign-in activity not available (requires Entra ID P1 and AuditLog.Read.All).' } } # --------------------------------------------------------------------------- # 7. Authorization policy (consent, guests, app creation) # --------------------------------------------------------------------------- Write-Host "`n[6/9] User consent & collaboration settings" -ForegroundColor Cyan try { $authz = Get-GraphObject -Uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' if ($authz.value) { $authz = @($authz.value)[0] } $grant = @($authz.defaultUserRolePermissions.permissionGrantPoliciesAssigned) if ($grant | Where-Object { $_ -like '*microsoft-user-default-legacy*' }) { Add-Result -Category 'Applications' -Check 'User consent to apps' -Status 'Fail' -Weight 4 ` -Detail 'Users can consent to ANY third-party app accessing company data (legacy setting).' ` -Recommendation 'Entra > Enterprise apps > Consent and permissions: allow consent only for verified publishers (low-risk) or disable and enable admin consent workflow.' } elseif ($grant | Where-Object { $_ -like '*microsoft-user-default-low*' }) { Add-Result -Category 'Applications' -Check 'User consent to apps' -Status 'Pass' -Weight 4 -Detail 'Users can only consent to low-risk permissions from verified publishers.' } elseif ($grant | Where-Object { $_ -like '*microsoft-user-default-recommended*' }) { Add-Result -Category 'Applications' -Check 'User consent to apps' -Status 'Pass' -Weight 4 -Detail 'Microsoft-managed (recommended) user consent policy.' } elseif (@($grant | Where-Object { $_ -like 'ManagePermissionGrantsForSelf.*' }).Count -eq 0) { Add-Result -Category 'Applications' -Check 'User consent to apps' -Status 'Pass' -Weight 4 -Detail 'User consent is disabled (admin approval required).' } else { Add-Result -Category 'Applications' -Check 'User consent to apps' -Status 'Warn' -Weight 4 ` -Detail ('Custom consent policy assigned: ' + (($grant | Where-Object { $_ -like 'ManagePermissionGrantsForSelf.*' }) -join ', ')) ` -Recommendation 'Review the custom permission grant policy to confirm it only allows low-risk permissions.' } if ($authz.defaultUserRolePermissions.allowedToCreateApps) { Add-Result -Category 'Applications' -Check 'Users can register apps' -Status 'Warn' -Weight 2 ` -Detail 'All users can create app registrations.' ` -Recommendation 'Entra > Users > User settings: set "Users can register applications" to No.' } else { Add-Result -Category 'Applications' -Check 'Users can register apps' -Status 'Pass' -Weight 2 -Detail 'Only admins can register applications.' } $invite = $authz.allowInvitesFrom if ($invite -eq 'everyone') { Add-Result -Category 'Accounts' -Check 'Guest invitation settings' -Status 'Warn' -Weight 2 ` -Detail 'Anyone in the organization, including guests, can invite guests.' ` -Recommendation 'Entra > External Identities > External collaboration settings: restrict to admins or members with Guest Inviter role.' } else { Add-Result -Category 'Accounts' -Check 'Guest invitation settings' -Status 'Pass' -Weight 2 -Detail "Guest invitations allowed from: $invite" } } catch { Add-Result -Category 'Applications' -Check 'Authorization policy' -Status 'Error' -Detail (Get-ShortError $_) } # --------------------------------------------------------------------------- # 8. App registration credentials # --------------------------------------------------------------------------- Write-Host "`n[7/9] App registration credentials" -ForegroundColor Cyan try { $apps = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/applications?$select=displayName,appId,passwordCredentials,keyCredentials&$top=999' $expired = [System.Collections.Generic.List[string]]::new() $expiring = [System.Collections.Generic.List[string]]::new() foreach ($a in $apps) { foreach ($c in @($a.passwordCredentials) + @($a.keyCredentials)) { if (-not $c -or -not $c.endDateTime) { continue } $end = [datetime]$c.endDateTime if ($end -lt $Now) { $expired.Add("$($a.displayName) (expired $($end.ToString('yyyy-MM-dd')))") } elseif ($end -lt $Now.AddDays(30)) { $expiring.Add("$($a.displayName) (expires $($end.ToString('yyyy-MM-dd')))") } } } if ($expiring.Count -gt 0) { Add-Result -Category 'Applications' -Check 'Secrets/certificates expiring in 30 days' -Status 'Warn' -Weight 2 ` -Detail ($expiring -join '; ') -Recommendation 'Rotate these credentials before expiry to avoid outages.' } else { Add-Result -Category 'Applications' -Check 'Secrets/certificates expiring in 30 days' -Status 'Pass' -Weight 2 -Detail "$($apps.Count) app registration(s) checked." } if ($expired.Count -gt 0) { Add-Result -Category 'Applications' -Check 'Expired secrets/certificates' -Status 'Info' ` -Detail ($expired -join '; ') -Recommendation 'Remove expired credentials to keep app registrations clean.' } } catch { Add-Result -Category 'Applications' -Check 'App registration credentials' -Status 'Error' -Detail (Get-ShortError $_) } # --------------------------------------------------------------------------- # 9. Secure Score & Intune # --------------------------------------------------------------------------- Write-Host "`n[8/9] Secure Score & device compliance" -ForegroundColor Cyan try { $ss = @(Get-GraphObject -Uri 'https://graph.microsoft.com/v1.0/security/secureScores?$top=1').value if ($ss.Count -gt 0 -and $ss[0].maxScore -gt 0) { $s = $ss[0] $pct = [math]::Round(($s.currentScore / $s.maxScore) * 100, 1) $status = if ($pct -ge 70) { 'Pass' } elseif ($pct -ge 50) { 'Warn' } else { 'Fail' } Add-Result -Category 'Posture' -Check 'Microsoft Secure Score' -Status $status -Weight 3 ` -Detail "$([math]::Round($s.currentScore,1)) / $([math]::Round($s.maxScore,1)) ($pct%)" ` -Recommendation $(if ($status -ne 'Pass') { 'Review improvement actions at security.microsoft.com > Exposure management > Secure Score.' } else { '' }) } else { Add-Result -Category 'Posture' -Check 'Microsoft Secure Score' -Status 'Info' -Detail 'No Secure Score data yet (new tenants can take 24-48 hours).' } } catch { Add-Result -Category 'Posture' -Check 'Microsoft Secure Score' -Status 'Error' -Detail (Get-ShortError $_) } try { $cp = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/deviceManagement/deviceCompliancePolicies' if ($cp.Count -eq 0) { Add-Result -Category 'Devices' -Check 'Intune compliance policies' -Status 'Warn' -Weight 3 ` -Detail 'No device compliance policies found.' ` -Recommendation 'Create compliance policies (BitLocker/FileVault, OS version, Defender) and require compliant devices in CA.' } else { Add-Result -Category 'Devices' -Check 'Intune compliance policies' -Status 'Pass' -Weight 3 -Detail ("$($cp.Count) policy(ies): " + (($cp.displayName) -join ', ')) } } catch { Add-Result -Category 'Devices' -Check 'Intune compliance policies' -Status 'Info' -Detail ('Could not read Intune: ' + (Get-ShortError $_)) } # --------------------------------------------------------------------------- # 10. Domains: email DNS & password policy # --------------------------------------------------------------------------- Write-Host "`n[9/9] Email DNS (SPF / DMARC / DKIM) & password policy" -ForegroundColor Cyan try { $domains = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/domains' $CustomDomains = @($domains | Where-Object { $_.isVerified -and $_.id -notlike '*.onmicrosoft.com' }) $expiringPw = @($domains | Where-Object { $_.isVerified -and $_.passwordValidityPeriodInDays -and $_.passwordValidityPeriodInDays -lt 2147483647 }) if ($expiringPw.Count -gt 0) { Add-Result -Category 'Identity' -Check 'Password expiration' -Status 'Info' ` -Detail ("Passwords expire on: " + (($expiringPw | ForEach-Object { "$($_.id) ($($_.passwordValidityPeriodInDays) days)" }) -join ', ')) ` -Recommendation 'With MFA enforced, Microsoft and NIST recommend passwords that never expire.' } else { Add-Result -Category 'Identity' -Check 'Password expiration' -Status 'Pass' -Detail 'Passwords set to never expire (recommended with MFA).' } } catch { Add-Result -Category 'Email' -Check 'Domain list' -Status 'Error' -Detail (Get-ShortError $_) } if ($CustomDomains.Count -eq 0) { Add-Result -Category 'Email' -Check 'Custom domains' -Status 'Info' -Detail 'No verified custom domains - email DNS checks skipped.' } foreach ($d in $CustomDomains) { $name = $d.id try { # SPF $spf = @(Resolve-DohRecord -Name $name -Type TXT | Where-Object { $_ -like 'v=spf1*' }) if ($spf.Count -eq 0) { Add-Result -Category 'Email' -Check "SPF - $name" -Status 'Fail' -Weight 3 -Detail 'No SPF record.' ` -Recommendation "Add TXT record: v=spf1 include:spf.protection.outlook.com -all" } elseif ($spf.Count -gt 1) { Add-Result -Category 'Email' -Check "SPF - $name" -Status 'Fail' -Weight 3 -Detail "Multiple SPF records found (invalid): $($spf -join ' | ')" ` -Recommendation 'Merge into a single SPF TXT record.' } elseif ($spf[0] -match '[-~]all\s*$') { Add-Result -Category 'Email' -Check "SPF - $name" -Status 'Pass' -Weight 3 -Detail $spf[0] } else { Add-Result -Category 'Email' -Check "SPF - $name" -Status 'Warn' -Weight 3 -Detail $spf[0] ` -Recommendation 'End SPF with -all (or ~all), not ?all / +all.' } # DMARC $dmarc = @(Resolve-DohRecord -Name "_dmarc.$name" -Type TXT | Where-Object { $_ -like 'v=DMARC1*' }) if ($dmarc.Count -eq 0) { Add-Result -Category 'Email' -Check "DMARC - $name" -Status 'Fail' -Weight 3 -Detail 'No DMARC record.' ` -Recommendation "Add TXT record _dmarc.$name : v=DMARC1; p=none; rua=mailto:dmarc@$name (then move to quarantine/reject)" } elseif ($dmarc[0] -match 'p\s*=\s*(quarantine|reject)') { Add-Result -Category 'Email' -Check "DMARC - $name" -Status 'Pass' -Weight 3 -Detail $dmarc[0] } else { Add-Result -Category 'Email' -Check "DMARC - $name" -Status 'Warn' -Weight 3 -Detail $dmarc[0] ` -Recommendation 'Policy is p=none (monitoring only). After reviewing reports, move to p=quarantine, then p=reject.' } # DKIM (Microsoft 365 selectors) $dkim1 = @(Resolve-DohRecord -Name "selector1._domainkey.$name" -Type CNAME) $dkim2 = @(Resolve-DohRecord -Name "selector2._domainkey.$name" -Type CNAME) if ($dkim1.Count -gt 0 -and $dkim2.Count -gt 0) { Add-Result -Category 'Email' -Check "DKIM - $name" -Status 'Pass' -Weight 2 -Detail 'selector1 and selector2 CNAME records found.' } else { Add-Result -Category 'Email' -Check "DKIM - $name" -Status 'Warn' -Weight 2 -Detail 'Microsoft 365 DKIM CNAME records (selector1/selector2) not found.' ` -Recommendation 'Defender portal > Email & collaboration > Policies > Email authentication settings > DKIM: publish CNAMEs and enable signing. (Ignore if mail is sent via another provider.)' } } catch { Add-Result -Category 'Email' -Check "DNS - $name" -Status 'Error' -Detail $_.Exception.Message } } # --------------------------------------------------------------------------- # 11. Score & HTML report # --------------------------------------------------------------------------- $scored = @($Results | Where-Object { $_.Status -in 'Pass', 'Warn', 'Fail' }) $max = ($scored | Measure-Object -Property Weight -Sum).Sum $got = 0 foreach ($r in $scored) { if ($r.Status -eq 'Pass') { $got += $r.Weight } elseif ($r.Status -eq 'Warn') { $got += $r.Weight / 2 } } $score = if ($max -gt 0) { [math]::Round(($got / $max) * 100) } else { 0 } $counts = @{} foreach ($s in 'Pass', 'Warn', 'Fail', 'Info', 'Error') { $counts[$s] = @($Results | Where-Object Status -eq $s).Count } $grade = if ($score -ge 85) { 'Good' } elseif ($score -ge 60) { 'Needs attention' } else { 'At risk' } $gradeColor = if ($score -ge 85) { '#1a7f37' } elseif ($score -ge 60) { '#b35900' } else { '#c62828' } $KitUrl = 'https://adminofone.gumroad.com/l/tenant-lockdown-kit?utm_source=audit-report&utm_medium=script' $issues = $counts.Fail + $counts.Warn $ctaHeadline = "$issues open finding(s). Most of them can be fixed in one afternoon." $ctaHtml = if ($issues -eq 0) { '' } else { @" <div class="cta"> <div class="cta-t">$ctaHeadline</div> <div class="cta-d">Tenant Lockdown Kit deploys the fixes for the Conditional Access, Exchange Online and Intune findings above: 8 CA policies in report-only mode with a break-glass account, 10 Exchange hardening settings, and Windows/macOS compliance baselines. Every script supports -WhatIf and has a rollback.</div> <a class="cta-b" href="$KitUrl" target="_blank" rel="noopener">See the kit →</a> </div> "@ } $order = @{ 'Fail' = 0; 'Warn' = 1; 'Error' = 2; 'Pass' = 3; 'Info' = 4 } $rows = foreach ($r in ($Results | Sort-Object @{ Expression = { $order[$_.Status] } }, Category, Check)) { $cls = $r.Status.ToLower() "<tr><td><span class='badge $cls'>$($r.Status)</span></td><td>$(ConvertTo-Html-Safe $r.Category)</td><td><strong>$(ConvertTo-Html-Safe $r.Check)</strong><div class='detail'>$(ConvertTo-Html-Safe $r.Detail)</div></td><td>$(ConvertTo-Html-Safe $r.Recommendation)</td></tr>" } $html = @" <!doctype html> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <title>M365 Security Audit - $(ConvertTo-Html-Safe $TenantName)</title> <style> :root{--bg:#f6f7f9;--card:#fff;--text:#1f2328;--muted:#656d76;--border:#d0d7de} @media (prefers-color-scheme:dark){:root{--bg:#0d1117;--card:#161b22;--text:#e6edf3;--muted:#8d96a0;--border:#30363d}} *{box-sizing:border-box}body{margin:0;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);line-height:1.5} .wrap{max-width:1100px;margin:0 auto;padding:24px 16px} h1{margin:0 0 4px;font-size:24px}.sub{color:var(--muted);font-size:14px;margin-bottom:24px} .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px} .card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px} .card .n{font-size:28px;font-weight:700}.card .l{color:var(--muted);font-size:13px} .score .n{color:$gradeColor;font-size:40px} table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--border);border-radius:10px;overflow:hidden} th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:top;font-size:14px} th{font-size:12px;text-transform:uppercase;color:var(--muted)} .detail{color:var(--muted);font-size:13px;margin-top:2px;word-break:break-word} .badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;color:#fff} .pass{background:#1a7f37}.warn{background:#b35900}.fail{background:#c62828}.info{background:#57606a}.error{background:#8250df} .foot{color:var(--muted);font-size:12px;margin-top:24px} .cta{margin-top:24px;background:var(--card);border:1px solid var(--border);border-left:4px solid #0969da;border-radius:10px;padding:16px 20px} .cta-t{font-size:17px;font-weight:700;margin-bottom:4px}.cta-d{color:var(--muted);font-size:14px;margin-bottom:12px} .cta-b{display:inline-block;background:#0969da;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:8px 16px;border-radius:6px} @media (max-width:700px){th:nth-child(2),td:nth-child(2){display:none}} </style></head><body><div class="wrap"> <h1>Microsoft 365 Security Audit</h1> <div class="sub">$(ConvertTo-Html-Safe $TenantName) · $TenantId · $($Now.ToString('yyyy-MM-dd HH:mm')) · v$ScriptVersion (read-only)</div> <div class="cards"> <div class="card score"><div class="n">$score</div><div class="l">Baseline score / 100 · $grade</div></div> <div class="card"><div class="n" style="color:#c62828">$($counts.Fail)</div><div class="l">Fail</div></div> <div class="card"><div class="n" style="color:#b35900">$($counts.Warn)</div><div class="l">Warning</div></div> <div class="card"><div class="n" style="color:#1a7f37">$($counts.Pass)</div><div class="l">Pass</div></div> <div class="card"><div class="n">$($counts.Info + $counts.Error)</div><div class="l">Info / Not checked</div></div> </div> <table><thead><tr><th>Status</th><th>Area</th><th>Check</th><th>Recommendation</th></tr></thead><tbody> $($rows -join "`n") </tbody></table> $ctaHtml <div class="foot">Generated by the free Tenant Lockdown Kit audit (Admin of One). Read-only: no changes were made to the tenant. Findings are guidance, not a guarantee of security. Provided AS-IS without warranty.</div> </div></body></html> "@ $html | Out-File -FilePath $OutputPath -Encoding utf8 if ($CsvPath) { $Results | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding utf8 } Disconnect-MgGraph | Out-Null Write-Host "`nScore: $score/100 ($grade) | Fail: $($counts.Fail) Warn: $($counts.Warn) Pass: $($counts.Pass)" -ForegroundColor Cyan Write-Host "Report saved: $OutputPath`n" -ForegroundColor Green if ($issues -gt 0) { Write-Host "Want to fix these automatically? Tenant Lockdown Kit: https://adminofone.gumroad.com/l/tenant-lockdown-kit`n" -ForegroundColor Yellow } try { if ($IsMacOS) { & open $OutputPath } elseif ($IsWindows) { Invoke-Item $OutputPath } } catch { } |