Invoke-M365QuickCheck.ps1
|
<#PSScriptInfo .VERSION 1.0.1 .GUID f07b2571-bfa1-4ce1-b021-8bd470192324 .AUTHOR thecyberape .COMPANYNAME .COPYRIGHT (c) 2026 thecyberape. MIT License. .TAGS Microsoft365 M365 Office365 EntraID AzureAD Security Audit SecurityDefaults ConditionalAccess MFA Graph SMB .LICENSEURI https://github.com/thecyberape/m365-quick-check/blob/main/LICENSE .PROJECTURI https://github.com/thecyberape/m365-quick-check .ICONURI .EXTERNALMODULEDEPENDENCIES Microsoft.Graph.Authentication .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES 1.0.1 - Added PowerShell Gallery metadata and AI-assistance disclosure. No functional changes. 1.0.0 - Initial release: 8 read-only checks with an HTML report. .PRIVATEDATA #> <# .SYNOPSIS Free, read-only Microsoft 365 security quick check for small businesses. .DESCRIPTION Eight fast checks against the settings attackers abuse most often in small-business Microsoft 365 tenants. It writes an HTML report with PASS / WARN / FAIL results and what to do about each one. It changes NOTHING. It asks for read-only Microsoft Graph permissions and only reads settings. Checks: MFA enforcement (Security Defaults / Conditional Access), legacy authentication, Global Admin count, user consent to apps, app registration by users, guest invitations, password expiration, and "Anyone" sharing links in SharePoint/OneDrive. The full version (about two dozen checks, including MFA registration, admin hygiene, risky third-party apps, inbox rules, mailbox forwarding, audit logging, DKIM and SMTP AUTH, plus an incident-response evidence script) comes with the book: https://radio1one.gumroad.com/l/qvoxbr .PARAMETER OutputPath Where to write the HTML report. Defaults to the current folder. .EXAMPLE .\Invoke-M365QuickCheck.ps1 .NOTES Author: thecyberape License: MIT Written with AI assistance; reviewed and tested by the author against a live Microsoft 365 tenant. Requires: Microsoft.Graph.Authentication module Install-Module Microsoft.Graph.Authentication -Scope CurrentUser Sign in as a Global Reader (recommended) or Global Administrator. #> [CmdletBinding()] param( [string]$OutputPath = (Get-Location).Path ) $ErrorActionPreference = 'Stop' $FullVersionUrl = 'https://radio1one.gumroad.com/l/qvoxbr' $results = New-Object System.Collections.ArrayList function Add-Result { param( [string]$Area, [string]$Check, [ValidateSet('PASS','WARN','FAIL','INFO')][string]$Status, [string]$Detail, [string]$Fix = '' ) [void]$results.Add([pscustomobject]@{ Area = $Area; Check = $Check; Status = $Status; Detail = $Detail; Fix = $Fix }) $color = @{ PASS='Green'; WARN='Yellow'; FAIL='Red'; INFO='Cyan' }[$Status] Write-Host ("[{0}] {1} - {2}" -f $Status, $Check, $Detail) -ForegroundColor $color } function Get-GraphAll { param([string]$Uri) $items = @() $next = $Uri while ($next) { $resp = Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject if ($resp.value) { $items += $resp.value } $next = $resp.'@odata.nextLink' } return ,$items } # ---------------------------------------------------------------- connect if (-not (Get-Module -ListAvailable Microsoft.Graph.Authentication)) { throw "Microsoft.Graph.Authentication is not installed. Run: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser" } Import-Module Microsoft.Graph.Authentication Write-Host "Connecting to Microsoft Graph (read-only scopes)..." -ForegroundColor Cyan Connect-MgGraph -NoWelcome -Scopes @( 'Policy.Read.All', 'Directory.Read.All', 'RoleManagement.Read.Directory', 'Organization.Read.All', 'SharePointTenantSettings.Read.All' ) $org = (Invoke-MgGraphRequest -Method GET -Uri 'v1.0/organization' -OutputType PSObject).value | Select-Object -First 1 $tenantName = $org.displayName # 1-2. MFA baseline and legacy authentication $caPolicies = @() try { $caPolicies = Get-GraphAll 'v1.0/identity/conditionalAccess/policies' } catch { } $enabledCa = @($caPolicies | Where-Object { $_.state -eq 'enabled' }) $secDefaults = Invoke-MgGraphRequest -Method GET -Uri 'v1.0/policies/identitySecurityDefaultsEnforcementPolicy' -OutputType PSObject if ($secDefaults.isEnabled) { Add-Result 'Identity' 'MFA enforced' 'PASS' 'Security Defaults are ON (MFA for everyone).' Add-Result 'Identity' 'Legacy authentication blocked' 'PASS' 'Security Defaults block legacy authentication.' } else { $mfaAll = $enabledCa | Where-Object { ($_.conditions.users.includeUsers -contains 'All') -and ($_.grantControls.builtInControls -contains 'mfa' -or $_.grantControls.authenticationStrength) } if ($mfaAll) { Add-Result 'Identity' 'MFA enforced' 'PASS' "Conditional Access requires MFA for all users: $(($mfaAll | Select-Object -ExpandProperty displayName) -join '; ')" } else { Add-Result 'Identity' 'MFA enforced' 'FAIL' 'Security Defaults are off and no enabled Conditional Access policy requires MFA for all users.' 'Turn on Security Defaults today, or create a CA policy: All users, All cloud apps, require MFA (exclude a break-glass account).' } $legacy = $enabledCa | Where-Object { ($_.conditions.clientAppTypes -contains 'exchangeActiveSync' -or $_.conditions.clientAppTypes -contains 'other') -and ($_.grantControls.builtInControls -contains 'block') } if ($legacy) { Add-Result 'Identity' 'Legacy authentication blocked' 'PASS' "Blocked by: $(($legacy | Select-Object -ExpandProperty displayName) -join '; ')" } else { Add-Result 'Identity' 'Legacy authentication blocked' 'FAIL' 'Nothing blocks legacy authentication, which skips MFA entirely.' 'Find what still uses it (old scanners, mail clients), then add a CA policy blocking Exchange ActiveSync + Other clients.' } } # 3. Global admin count $gaRole = (Invoke-MgGraphRequest -Method GET -Uri "v1.0/directoryRoles?`$filter=roleTemplateId eq '62e90394-69f5-4237-9190-012177145e10'" -OutputType PSObject).value | Select-Object -First 1 $gaMembers = @() if ($gaRole) { $gaMembers = Get-GraphAll "v1.0/directoryRoles/$($gaRole.id)/members" } $gaCount = $gaMembers.Count if ($gaCount -lt 2) { Add-Result 'Admin' 'Global Administrator count' 'WARN' "$gaCount Global Admin(s)." 'Keep 2-4, including one break-glass account so you cannot lock yourself out.' } elseif ($gaCount -gt 4) { Add-Result 'Admin' 'Global Administrator count' 'FAIL' "$gaCount Global Admins." 'Reduce to 2-4. Move day-to-day admins to narrower roles.' } else { Add-Result 'Admin' 'Global Administrator count' 'PASS' "$gaCount Global Admins." } # 4-6. Authorization policy $auth = Invoke-MgGraphRequest -Method GET -Uri 'v1.0/policies/authorizationPolicy' -OutputType PSObject $grant = @($auth.defaultUserRolePermissions.permissionGrantPoliciesAssigned) if ($grant | Where-Object { $_ -match 'microsoft-user-default-legacy' }) { Add-Result 'Apps' 'User consent to apps' 'FAIL' 'Any user can let any app read company data. This is how consent phishing works.' 'Entra > Enterprise apps > Consent and permissions: verified publishers only, or no user consent.' } else { Add-Result 'Apps' 'User consent to apps' 'PASS' 'User consent is restricted.' } if ($auth.defaultUserRolePermissions.allowedToCreateApps) { Add-Result 'Apps' 'Users can register apps' 'WARN' 'Any user can register applications.' 'Entra > User settings: "Users can register applications" = No.' } else { Add-Result 'Apps' 'Users can register apps' 'PASS' 'App registration restricted to admins.' } if ($auth.allowInvitesFrom -eq 'everyone') { Add-Result 'Guests' 'Who can invite guests' 'WARN' 'Anyone, including guests, can invite more guests.' 'External collaboration settings: limit invites to members or admins.' } else { Add-Result 'Guests' 'Who can invite guests' 'PASS' "Guest invites limited to: $($auth.allowInvitesFrom)" } # 7. Password expiration $domains = (Invoke-MgGraphRequest -Method GET -Uri 'v1.0/domains' -OutputType PSObject).value | Where-Object { $_.isVerified } $expiring = @($domains | Where-Object { $_.passwordValidityPeriodInDays -and $_.passwordValidityPeriodInDays -lt 2147483647 }) if ($expiring.Count -gt 0) { Add-Result 'Identity' 'Password expiration' 'WARN' "Passwords expire on: $(($expiring | ForEach-Object { "$($_.id) ($($_.passwordValidityPeriodInDays) days)" }) -join ', ')" 'With MFA enforced, set passwords to never expire (current Microsoft/NIST guidance).' } else { Add-Result 'Identity' 'Password expiration' 'PASS' 'Passwords never expire (correct when MFA is enforced).' } # 8. SharePoint / OneDrive "Anyone" links try { $spo = Invoke-MgGraphRequest -Method GET -Uri 'v1.0/admin/sharepoint/settings' -OutputType PSObject if ($spo.sharingCapability -eq 'externalUserAndGuestSharing') { Add-Result 'Data' '"Anyone" sharing links' 'WARN' 'Tenant allows "Anyone" links that need no sign-in.' 'SharePoint admin > Policies > Sharing: "New and existing guests", or at least set expiry and View-only defaults.' } else { Add-Result 'Data' '"Anyone" sharing links' 'PASS' "Sharing level: $($spo.sharingCapability)" } } catch { Add-Result 'Data' '"Anyone" sharing links' 'INFO' 'Could not read SharePoint sharing settings.' } Disconnect-MgGraph | Out-Null # ---------------------------------------------------------------- report function ConvertTo-SafeHtml([string]$s) { [System.Net.WebUtility]::HtmlEncode("$s") } $counts = @{} foreach ($s in 'FAIL','WARN','PASS','INFO') { $counts[$s] = @($results | Where-Object Status -eq $s).Count } $order = @{ FAIL = 0; WARN = 1; PASS = 2; INFO = 3 } $rows = foreach ($r in ($results | Sort-Object @{ e = { $order[$_.Status] } }, Area)) { "<tr class='$($r.Status.ToLower())'><td><span class='tag'>$($r.Status)</span></td><td>$(ConvertTo-SafeHtml $r.Area)</td><td><b>$(ConvertTo-SafeHtml $r.Check)</b><div class='d'>$(ConvertTo-SafeHtml $r.Detail)</div></td><td>$(ConvertTo-SafeHtml $r.Fix)</td></tr>" } $notChecked = @( 'Users and admins without MFA registered, and SMS-only MFA', 'Admins using their admin account for everyday email', 'Third-party apps holding mail or file access, and who approved them', 'Inbox rules that forward, delete, or hide mail (a classic sign of a hijacked mailbox)', 'Mailboxes forwarding to outside addresses, and external auto-forwarding', 'Unified audit log and mailbox auditing', 'DKIM signing and tenant-wide SMTP AUTH', 'Licensed accounts nobody has signed in to for 90+ days', 'Old guest accounts', 'Conditional Access policies stuck in report-only' ) | ForEach-Object { "<li>$_</li>" } $stamp = Get-Date -Format 'yyyy-MM-dd HH:mm' $html = @" <!doctype html><html><head><meta charset='utf-8'><title>M365 Quick Check - $(ConvertTo-SafeHtml $tenantName)</title> <style> body{font-family:Segoe UI,Arial,sans-serif;margin:32px;color:#12181F;background:#fff;max-width:1100px} h1{margin:0 0 4px}.sub{color:#5a6577;margin-bottom:20px} .cards{display:flex;gap:12px;margin:16px 0 24px}.card{padding:12px 18px;border-radius:8px;min-width:90px} .card b{display:block;font-size:26px}.c-fail{background:#fde8e8}.c-warn{background:#fff4d6}.c-pass{background:#e4f6ea} table{border-collapse:collapse;width:100%}th,td{text-align:left;vertical-align:top;padding:10px;border-bottom:1px solid #e3e7ee;font-size:14px} th{background:#12181F;color:#fff}.d{color:#5a6577;margin-top:4px} .tag{font-weight:700;font-size:12px;padding:3px 8px;border-radius:4px} .fail .tag{background:#c62828;color:#fff}.warn .tag{background:#E8A33D;color:#12181F}.pass .tag{background:#2e7d32;color:#fff}.info .tag{background:#1565c0;color:#fff} .more{margin-top:28px;padding:18px 22px;border:2px solid #E8A33D;border-radius:10px;background:#fbf6ec} .more h2{margin:0 0 8px;font-size:18px}.more li{margin:4px 0}.more a{color:#B97323;font-weight:700} footer{margin-top:24px;color:#8a93a3;font-size:12px} </style></head><body> <h1>Microsoft 365 Quick Check</h1> <div class='sub'>$(ConvertTo-SafeHtml $tenantName) · $stamp · free version, 8 read-only checks</div> <div class='cards'> <div class='card c-fail'><b>$($counts.FAIL)</b>Fail</div> <div class='card c-warn'><b>$($counts.WARN)</b>Warn</div> <div class='card c-pass'><b>$($counts.PASS)</b>Pass</div> </div> <table><tr><th>Result</th><th>Area</th><th>Check</th><th>What to do</th></tr> $($rows -join "`n") </table> <div class='more'> <h2>Not checked by the free version</h2> <ul>$($notChecked -join '')</ul> <p>The full Quick Audit checks all of these. It comes with an incident-response evidence script, an Excel security workbook, and the book <i>Microsoft 365 Security: A Practical Small-Business Hardening Guide</i>. <a href='$FullVersionUrl'>Get the full toolkit</a></p> </div> <footer>Invoke-M365QuickCheck.ps1 by thecyberape · MIT license · read-only, no settings were changed.</footer> </body></html> "@ $file = Join-Path $OutputPath ("M365-QuickCheck-{0}.html" -f (Get-Date -Format 'yyyyMMdd-HHmm')) $html | Out-File -FilePath $file -Encoding utf8 Write-Host "" Write-Host "Report written to $file" -ForegroundColor Green Write-Host ("FAIL: {0} WARN: {1} PASS: {2}" -f $counts.FAIL, $counts.WARN, $counts.PASS) Write-Host "Want the other checks (MFA registration, inbox rules, forwarding, risky apps, audit log)? $FullVersionUrl" -ForegroundColor Yellow |