Toolbox

Small scripts I use again and again.

Read-only PowerShell and Microsoft Graph snippets for everyday Microsoft 365 checks, with the modules and rights they need. Copy them, adapt them, run them in a test tenant first. No secrets, no tenant IDs; MIT licence.

Before you run them

  • PowerShell 7 with the current Microsoft.Graph and ExchangeOnlineManagement modules: Install-Module Microsoft.Graph, ExchangeOnlineManagement -Scope CurrentUser
  • Every snippet only reads. Sign in with the smallest role that works; the rights are listed with each snippet.
  • For scheduled runs, use an app registration with a certificate and application permissions instead of an interactive sign-in. See App registrations with least privilege.

SPF, DKIM and DMARC for every accepted domain

  • ExchangeOnlineManagement
  • Windows (Resolve-DnsName)
  • Exchange: View-Only Configuration

Lists every accepted domain with its SPF and DMARC record and whether Exchange Online signs it with DKIM. Domains without a DMARC record or still on p=none are your work list.

Connect-ExchangeOnline
Get-AcceptedDomain | Where-Object { $_.DomainName -notlike "*.onmicrosoft.com" } | ForEach-Object {
  $d = $_.DomainName
  $spf   = Resolve-DnsName $d -Type TXT -ErrorAction SilentlyContinue | Where-Object { ($_.Strings -join "") -like "v=spf1*" }
  $dmarc = Resolve-DnsName "_dmarc.$d" -Type TXT -ErrorAction SilentlyContinue | Where-Object { ($_.Strings -join "") -like "v=DMARC1*" }
  [pscustomobject]@{
    Domain = $d
    Type   = $_.DomainType
    SPF    = ($spf   | ForEach-Object { $_.Strings -join "" }) -join " | "
    DMARC  = ($dmarc | ForEach-Object { $_.Strings -join "" }) -join " | "
    DKIM   = (Get-DkimSigningConfig -Identity $d -ErrorAction SilentlyContinue).Enabled
  }
} | Format-Table -Wrap

Two SPF records for one domain is an error in itself (SPF then fails). For a quick look at one domain without PowerShell, use the domain check in DMARC Geeks.

Licensed accounts without a sign-in for 90 days

  • Microsoft.Graph
  • Entra ID P1 or P2 for sign-in activity
  • Graph: User.Read.All, AuditLog.Read.All

Finds accounts that hold licences but have not signed in successfully for 90 days, or never, and were created before that. Typical results: leavers nobody offboarded, shared mailboxes that were given a licence, test accounts.

Connect-MgGraph -Scopes "User.Read.All","AuditLog.Read.All"
$cut  = (Get-Date).AddDays(-90)
$skus = @{}; Get-MgSubscribedSku -All | ForEach-Object { $skus[$_.SkuId] = $_.SkuPartNumber }
Get-MgUser -All -Property DisplayName, UserPrincipalName, AccountEnabled, AssignedLicenses, SignInActivity, CreatedDateTime |
  Where-Object { $_.AssignedLicenses.Count -gt 0 -and $_.CreatedDateTime -lt $cut } |
  ForEach-Object {
    $last = $_.SignInActivity.LastSuccessfulSignInDateTime
    if (-not $last -or $last -lt $cut) {
      [pscustomobject]@{
        User       = $_.UserPrincipalName
        Enabled    = $_.AccountEnabled
        LastSignIn = $last
        Licences   = ($_.AssignedLicenses | ForEach-Object { $skus[$_.SkuId] }) -join ", "
      }
    }
  } | Sort-Object LastSignIn | Format-Table -AutoSize

Sign-in activity is updated with some delay and does not cover every protocol in the same way. Treat the list as candidates to check, not as accounts to delete.

App registrations with expiring credentials, and every client secret

  • Microsoft.Graph
  • Graph: Application.Read.All

Lists credentials that expire within 30 days, plus every client secret regardless of expiry, because secrets are the credentials you want to replace with certificates.

Connect-MgGraph -Scopes "Application.Read.All"
$limit = (Get-Date).AddDays(30)
Get-MgApplication -All -Property DisplayName, AppId, PasswordCredentials, KeyCredentials | ForEach-Object {
  $app = $_
  @($app.PasswordCredentials | ForEach-Object { [pscustomobject]@{ App = $app.DisplayName; AppId = $app.AppId; Type = "Secret";      Name = $_.DisplayName; Expires = $_.EndDateTime } }) +
  @($app.KeyCredentials      | ForEach-Object { [pscustomobject]@{ App = $app.DisplayName; AppId = $app.AppId; Type = "Certificate"; Name = $_.DisplayName; Expires = $_.EndDateTime } })
} | Where-Object { $_.Type -eq "Secret" -or $_.Expires -lt $limit } |
  Sort-Object Expires | Format-Table -AutoSize

Mailboxes and inbox rules that forward to external addresses

  • ExchangeOnlineManagement
  • Exchange: View-Only Recipients

Forwarding to private addresses is a data leak waiting to happen, and a classic sign of a compromised mailbox. This checks both mailbox forwarding and inbox rules, and marks targets outside your accepted domains.

Connect-ExchangeOnline
$accepted = (Get-AcceptedDomain).DomainName
function Test-External([string]$address) { $accepted -notcontains ($address.Split("@")[-1]) }
# Mailbox-level forwarding
Get-EXOMailbox -ResultSize Unlimited -Properties ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward |
  Where-Object { $_.ForwardingSmtpAddress } |
  ForEach-Object {
    $to = $_.ForwardingSmtpAddress -replace "^smtp:", ""
    [pscustomobject]@{ Mailbox = $_.UserPrincipalName; Source = "Mailbox"; Target = $to; External = Test-External $to; KeepCopy = $_.DeliverToMailboxAndForward }
  }
# Inbox rules (slow on large tenants: run it as a scheduled job)
Get-EXOMailbox -ResultSize Unlimited | ForEach-Object {
  $mbx = $_.UserPrincipalName
  Get-InboxRule -Mailbox $mbx -ErrorAction SilentlyContinue |
    Where-Object { $_.Enabled -and ($_.ForwardTo -or $_.ForwardAsAttachmentTo -or $_.RedirectTo) } |
    ForEach-Object {
      $rule = $_
      @($rule.ForwardTo) + @($rule.ForwardAsAttachmentTo) + @($rule.RedirectTo) |
        ForEach-Object { [regex]::Matches([string]$_, "\[SMTP:([^\]]+)\]") } |
        ForEach-Object { [pscustomobject]@{ Mailbox = $mbx; Source = "Rule: $($rule.Name)"; Target = $_.Groups[1].Value; External = Test-External $_.Groups[1].Value } }
    }
} | Where-Object External

Mailbox forwarding set with ForwardingAddress points to a recipient object in your directory, usually a mail contact; check those separately. Whether external forwarding is allowed at all is controlled by the outbound spam policy (AutoForwardingMode).

Export all Conditional Access policies as JSON

  • Microsoft.Graph
  • Graph: Policy.Read.All

A dated snapshot of every policy before a change, for documentation and as a reference for rollback. One file per policy, named by ID, plus an index with display names and states.

Connect-MgGraph -Scopes "Policy.Read.All"
$dir = New-Item -ItemType Directory -Force -Path ".\ca-export-$(Get-Date -Format yyyyMMdd-HHmm)"
$policies = Get-MgIdentityConditionalAccessPolicy -All
foreach ($p in $policies) {
  $p | ConvertTo-Json -Depth 20 | Out-File (Join-Path $dir "$($p.Id).json") -Encoding utf8
}
$policies | Select-Object Id, DisplayName, State, ModifiedDateTime |
  Export-Csv (Join-Path $dir "index.csv") -NoTypeInformation -Encoding utf8

To put a policy back into report-only mode after a problem: Update-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId <id> -State enabledForReportingButNotEnforced, signed in with Policy.ReadWrite.ConditionalAccess.

Who is registered for MFA, and who for phishing-resistant methods

  • Microsoft.Graph
  • Graph: AuditLog.Read.All

Before you require MFA or an authentication strength, know who would be blocked. This summarises the registration report and lists administrators without a passwordless-capable method.

Connect-MgGraph -Scopes "AuditLog.Read.All"
$reg = Get-MgReportAuthenticationMethodUserRegistrationDetail -All
$reg | Group-Object IsMfaRegistered | Select-Object @{ n = "MFA registered"; e = { $_.Name } }, Count
# Administrators who could not use a phishing-resistant or passwordless method today
$reg | Where-Object { $_.IsAdmin -and -not $_.IsPasswordlessCapable } |
  Select-Object UserPrincipalName, IsMfaRegistered, @{ n = "Methods"; e = { $_.MethodsRegistered -join ", " } } |
  Format-Table -AutoSize

Need more than a snippet?

These are the small pieces. If you need the whole automation, with logging, backup and a way back, let us talk.