<# .SYNOPSIS Read-only Microsoft Entra ID health check for azureblog.co.uk/tools/health/ .DESCRIPTION Collects security settings and counts from your tenant using Microsoft Graph, then saves them to a JSON file. Load that file at https://azureblog.co.uk/tools/health/ to see a scored report. The report is generated in your browser; the file is never uploaded. The script only reads. It makes no changes to your tenant. The output contains counts, settings and Conditional Access policy names. It does not contain user names, email addresses or object IDs. .NOTES Requires: Microsoft Graph PowerShell SDK (Install-Module Microsoft.Graph -Scope CurrentUser) Sign in as: Global Reader (recommended) or Security Reader Some checks need Entra ID P1/P2 (sign-in activity, registration details, PIM). They are skipped if unavailable. .EXAMPLE .\Get-AzureBlogHealthCheck.ps1 .\Get-AzureBlogHealthCheck.ps1 -OutputPath C:\Temp #> [CmdletBinding()] param( [string]$OutputPath = (Get-Location).Path, [int]$StaleDays = 90 ) $ErrorActionPreference = 'Stop' $scopes = @( 'Directory.Read.All', 'Policy.Read.All', 'RoleManagement.Read.Directory', 'AuditLog.Read.All', 'Application.Read.All', 'UserAuthenticationMethod.Read.All' ) if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { Write-Host 'Microsoft Graph PowerShell is not installed. Run: Install-Module Microsoft.Graph -Scope CurrentUser' -ForegroundColor Yellow return } Write-Host 'Connecting to Microsoft Graph (read-only scopes)...' -ForegroundColor Cyan Connect-MgGraph -Scopes $scopes -NoWelcome $result = [ordered]@{ schema = 'azureblog-health/1' generated = (Get-Date).ToUniversalTime().ToString('o') staleDays = $StaleDays tenant = $null checks = [ordered]@{} errors = [ordered]@{} } $now = Get-Date function Invoke-Check { param([string]$Name, [scriptblock]$Script) Write-Host (" - {0}" -f $Name) try { $result.checks[$Name] = & $Script } catch { $result.errors[$Name] = $_.Exception.Message; Write-Host (" skipped: {0}" -f $_.Exception.Message) -ForegroundColor DarkYellow } } Write-Host 'Running checks...' -ForegroundColor Cyan Invoke-Check 'tenant' { $org = Get-MgOrganization | Select-Object -First 1 [ordered]@{ name = $org.DisplayName; createdDateTime = $org.CreatedDateTime } } Invoke-Check 'securityDefaults' { [ordered]@{ enabled = [bool](Get-MgPolicyIdentitySecurityDefaultEnforcementPolicy).IsEnabled } } Invoke-Check 'conditionalAccess' { $policies = @(Get-MgIdentityConditionalAccessPolicy -All) [ordered]@{ total = $policies.Count policies = @($policies | ForEach-Object { $c = $_.Conditions; $g = $_.GrantControls [ordered]@{ name = $_.DisplayName state = $_.State includeUsers = @($c.Users.IncludeUsers) includeRolesCount = @($c.Users.IncludeRoles).Count excludeUsersCount = @($c.Users.ExcludeUsers).Count excludeGroupsCount = @($c.Users.ExcludeGroups).Count includeApps = @($c.Applications.IncludeApplications) clientAppTypes = @($c.ClientAppTypes) userRiskLevels = @($c.UserRiskLevels) signInRiskLevels = @($c.SignInRiskLevels) builtInControls = @($g.BuiltInControls) authenticationStrength = $(if ($g.AuthenticationStrength) { $g.AuthenticationStrength.DisplayName } else { $null }) } }) } } Invoke-Check 'authorizationPolicy' { $p = Get-MgPolicyAuthorizationPolicy | Select-Object -First 1 [ordered]@{ allowInvitesFrom = $p.AllowInvitesFrom guestUserRoleId = $p.GuestUserRoleId usersCanRegisterApps = [bool]$p.DefaultUserRolePermissions.AllowedToCreateApps usersCanCreateTenants = [bool]$p.DefaultUserRolePermissions.AllowedToCreateTenants usersCanCreateSecurityGroups = [bool]$p.DefaultUserRolePermissions.AllowedToCreateSecurityGroups userConsentPolicies = @($p.DefaultUserRolePermissions.PermissionGrantPoliciesAssigned) } } Invoke-Check 'authenticationMethods' { $p = Get-MgPolicyAuthenticationMethodPolicy $methods = [ordered]@{} foreach ($m in $p.AuthenticationMethodConfigurations) { $methods[$m.Id] = $m.State } [ordered]@{ methods = $methods } } $gaRoleId = '62e90394-69f5-4237-9190-012177145e10' Invoke-Check 'globalAdmins' { $active = @(Get-MgRoleManagementDirectoryRoleAssignment -All -Filter "roleDefinitionId eq '$gaRoleId'") $eligible = $null try { $eligible = @(Get-MgRoleManagementDirectoryRoleEligibilitySchedule -All -Filter "roleDefinitionId eq '$gaRoleId'").Count } catch { } [ordered]@{ active = $active.Count; eligible = $eligible } } Invoke-Check 'privilegedRoles' { $ids = @{ 'Privileged Role Administrator' = 'e8611ab8-c189-46e8-94e1-60213ab1f814' 'Privileged Authentication Administrator' = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' 'Security Administrator' = '194ae4cb-b126-40b2-bd5b-6091b380977d' 'Conditional Access Administrator' = 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9' 'Exchange Administrator' = '29232cdf-9323-42fd-ade2-1d097af3e4de' 'SharePoint Administrator' = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' 'User Administrator' = 'fe930be7-5e62-47db-91af-98c3a49a38b1' 'Application Administrator' = '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3' 'Cloud Application Administrator' = '158c047a-c907-4556-b7ef-446551a6b5f7' 'Intune Administrator' = '3a2c62db-5318-420d-8d74-23affee5d9d5' } $out = [ordered]@{} foreach ($k in $ids.Keys) { $out[$k] = @(Get-MgRoleManagementDirectoryRoleAssignment -All -Filter "roleDefinitionId eq '$($ids[$k])'").Count } $out } Invoke-Check 'mfaRegistration' { $rows = @(Get-MgReportAuthenticationMethodUserRegistrationDetail -All) $members = @($rows | Where-Object { $_.UserType -ne 'guest' }) $admins = @($members | Where-Object { $_.IsAdmin }) [ordered]@{ users = $members.Count mfaRegistered = @($members | Where-Object { $_.IsMfaRegistered }).Count passwordlessCapable = @($members | Where-Object { $_.IsPasswordlessCapable }).Count admins = $admins.Count adminsMfaRegistered = @($admins | Where-Object { $_.IsMfaRegistered }).Count smsOrVoiceOnly = @($members | Where-Object { $m = @($_.MethodsRegistered) ($m -contains 'mobilePhone' -or $m -contains 'alternateMobilePhone' -or $m -contains 'officePhone') -and -not ($m | Where-Object { $_ -notin @('mobilePhone', 'alternateMobilePhone', 'officePhone', 'email', 'securityQuestion') }) }).Count } } Invoke-Check 'guests' { $guests = $null $hasActivity = $true try { $guests = @(Get-MgUser -All -Filter "userType eq 'Guest'" -Property 'Id,CreatedDateTime,SignInActivity,ExternalUserState') } catch { $hasActivity = $false; $guests = @(Get-MgUser -All -Filter "userType eq 'Guest'" -Property 'Id,CreatedDateTime,ExternalUserState') } $cut = $now.AddDays(-$StaleDays) $stale = $null; $never = $null if ($hasActivity) { $stale = @($guests | Where-Object { $last = $_.SignInActivity.LastSignInDateTime ($last -and $last -lt $cut) -or (-not $last -and $_.CreatedDateTime -lt $cut) }).Count $never = @($guests | Where-Object { -not $_.SignInActivity.LastSignInDateTime }).Count } [ordered]@{ total = $guests.Count pendingAcceptance = @($guests | Where-Object { $_.ExternalUserState -eq 'PendingAcceptance' }).Count stale = $stale neverSignedIn = $never } } Invoke-Check 'appCredentials' { $apps = @(Get-MgApplication -All -Property 'Id,PasswordCredentials,KeyCredentials') $expired = 0; $soon = 0; $secrets = 0; $certs = 0; $appsWithSecrets = 0 foreach ($a in $apps) { $pw = @($a.PasswordCredentials | Where-Object { $_ }) $kc = @($a.KeyCredentials | Where-Object { $_ }) if ($pw.Count) { $appsWithSecrets++ } $secrets += $pw.Count $certs += $kc.Count foreach ($c in ($pw + $kc)) { if (-not $c.EndDateTime) { continue } if ($c.EndDateTime -lt $now) { $expired++ } elseif ($c.EndDateTime -lt $now.AddDays(30)) { $soon++ } } } [ordered]@{ apps = $apps.Count; appsWithSecrets = $appsWithSecrets; secrets = $secrets; certificates = $certs; expired = $expired; expiringIn30Days = $soon } } Invoke-Check 'devices' { $devices = @(Get-MgDevice -All -Property 'Id,ApproximateLastSignInDateTime,AccountEnabled') $cut = $now.AddDays(-180) [ordered]@{ total = $devices.Count stale180 = @($devices | Where-Object { $_.ApproximateLastSignInDateTime -and $_.ApproximateLastSignInDateTime -lt $cut }).Count disabled = @($devices | Where-Object { -not $_.AccountEnabled }).Count } } $result.tenant = $result.checks['tenant'] $file = Join-Path $OutputPath ("azureblog-health-{0}.json" -f (Get-Date -Format 'yyyy-MM-dd')) $result | ConvertTo-Json -Depth 8 | Set-Content -Path $file -Encoding UTF8 Write-Host '' Write-Host ("Saved: {0}" -f $file) -ForegroundColor Green Write-Host 'Open https://azureblog.co.uk/tools/health/ and load this file to see your report.' -ForegroundColor Green if ($result.errors.Count) { Write-Host ("{0} check(s) were skipped. The report shows which and why." -f $result.errors.Count) -ForegroundColor DarkYellow }