Practical engineering guidance
A repeatable Microsoft 365 evidence collection script
Audits, incidents and reviews all ask the same questions about a tenant. Collecting the answers the same way every time turns an ad-hoc scramble into a comparable record.
Series: PowerShell toolkit
An auditor asks who holds privileged roles. An incident needs to know which applications have tenant-wide permissions. A quarterly review wants the guest population. A new client engagement wants all of it at once.
These are the same questions, asked repeatedly, and answering them by clicking through a portal produces an answer that is slow, incomplete and impossible to compare with last quarter. A script produces a dated, comparable record.
The problem: evidence has requirements that a screenshot does not meet
Useful evidence is dated, reproducible, complete and comparable. A portal screenshot is none of those. It shows one view at one moment, filtered in a way nobody recorded, and it cannot be diffed against anything.
The design goal here is therefore not a report. It is a set of files that can be regenerated identically in three months and compared.
The data model: what to collect
Seven categories, chosen because they are what actually gets asked about.
| Category | Why it is asked for |
|---|---|
| Privileged role assignments | Who can do anything; the first question in every review |
| Application permissions | Tenant-wide access that bypasses user permissions |
| Conditional Access policies | The enforcement position, and its exclusions |
| Guest accounts | External access, usually stale |
| Authentication methods | Registration coverage for strong authentication |
| Licence assignment | Cost, and entitlement to the controls you claim |
| Tenant settings | User consent, sharing defaults, security defaults |
The script
<#
.SYNOPSIS
Collects Microsoft 365 tenant configuration evidence to dated CSV files.
.DESCRIPTION
Read-only. Produces a comparable evidence set for audit, review or incident use.
Makes no changes to the tenant.
.EXAMPLE
.\Get-M365Evidence.ps1 -OutputFolder 'C:\Evidence'
.NOTES
Output contains personal data and tenant configuration. Treat as sensitive.
Requires Security Reader or equivalent in addition to the Graph scopes.
#>
#requires -Modules Microsoft.Graph.Authentication
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -Path $_ -PathType Container })]
[string]$OutputFolder
)
$ErrorActionPreference = 'Stop'
$scopes = @(
'Directory.Read.All'
'RoleManagement.Read.Directory'
'Application.Read.All'
'Policy.Read.All'
'User.Read.All'
'UserAuthenticationMethod.Read.All'
'Organization.Read.All'
)
Connect-MgGraph -Scopes $scopes -NoWelcome
$stamp = Get-Date -Format 'yyyyMMdd'
$save = {
param($Data, $Name)
$path = Join-Path $OutputFolder "$stamp-$Name.csv"
$Data | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8
Write-Verbose "Wrote $path"
}
try {
# --- 1. Privileged role assignments --------------------------------------
$roles = Get-MgDirectoryRole -All
$roleAssignments = foreach ($role in $roles) {
Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All |
ForEach-Object {
[pscustomobject]@{
Role = $role.DisplayName
MemberId = $_.Id
MemberType = $_.AdditionalProperties['@odata.type']
DisplayName = $_.AdditionalProperties['displayName']
UserPrincipalName = $_.AdditionalProperties['userPrincipalName']
}
}
}
& $save $roleAssignments 'privileged-roles'
# --- 2. Application permissions ------------------------------------------
$servicePrincipals = Get-MgServicePrincipal -All `
-Property 'id', 'displayName', 'appId', 'servicePrincipalType',
'accountEnabled', 'publisherName'
$appPermissions = foreach ($sp in $servicePrincipals) {
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -All `
-ErrorAction SilentlyContinue |
ForEach-Object {
[pscustomobject]@{
Application = $sp.DisplayName
AppId = $sp.AppId
Publisher = $sp.PublisherName
Enabled = $sp.AccountEnabled
ResourceName = $_.ResourceDisplayName
PermissionId = $_.AppRoleId
GrantedOn = $_.CreatedDateTime
}
}
}
& $save $appPermissions 'application-permissions'
# --- 3. Conditional Access policies --------------------------------------
$caPolicies = Get-MgIdentityConditionalAccessPolicy -All |
ForEach-Object {
[pscustomobject]@{
DisplayName = $_.DisplayName
State = $_.State
CreatedDateTime = $_.CreatedDateTime
ModifiedDateTime = $_.ModifiedDateTime
IncludeUsers = ($_.Conditions.Users.IncludeUsers) -join '; '
ExcludeUsers = ($_.Conditions.Users.ExcludeUsers) -join '; '
ExcludeGroups = ($_.Conditions.Users.ExcludeGroups) -join '; '
IncludeApplications = ($_.Conditions.Applications.IncludeApplications) -join '; '
BuiltInControls = ($_.GrantControls.BuiltInControls) -join '; '
}
}
& $save $caPolicies 'conditional-access'
# --- 4. Guest accounts ---------------------------------------------------
$guests = Get-MgUser -All -Filter "userType eq 'Guest'" `
-Property 'id', 'displayName', 'userPrincipalName', 'mail',
'createdDateTime', 'accountEnabled', 'externalUserState' |
Select-Object DisplayName, UserPrincipalName, Mail, CreatedDateTime,
AccountEnabled, ExternalUserState
& $save $guests 'guest-accounts'
# --- 5. Tenant settings --------------------------------------------------
$organisation = Get-MgOrganization |
Select-Object DisplayName, Id, CreatedDateTime,
@{ Name = 'VerifiedDomains'; Expression = { ($_.VerifiedDomains.Name) -join '; ' } }
& $save @($organisation) 'tenant'
Write-Verbose 'Collection complete.'
}
finally {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
The authentication method registration and licence categories are deliberately left out of the
listing above to keep it readable — the pattern is identical, and
Get-MgReportAuthenticationMethodUserRegistrationDetail and Get-MgSubscribedSku are the
starting points.
Interpreting the output
privileged-roles. Count Global Administrator holders first. Microsoft’s general guidance
is to keep this number small, and most tenants exceed what their own policy would allow. Look
for service principals holding directory roles, which is a category people forget exists.
Note that this collects active role assignments. Where Privileged Identity Management is in use, eligible assignments are a separate query — and a tenant with few active assignments and many eligible ones is in a better position, not a worse one, per Privileged Identity Management.
application-permissions. The highest-value file. These are tenant-wide grants that
operate without a user. Anything with write access to directory objects, mail or files is
effectively an administrator. Every row needs a named owner, and rows whose owner cannot be
identified are the finding. The reasoning is in
application consent.
conditional-access. Read the exclusion columns before the inclusion ones. The exclusions
are where a policy’s real coverage is decided, and a growing exclusion group is how a baseline
quietly stops applying. Check that policies are in enabled state rather than
enabledForReportingButNotEnforced — a tenant whose baseline is entirely report-only is not
enforcing anything.
guest-accounts. Sort by creation date. Guests from years ago with
ExternalUserState of PendingAcceptance never accepted and can go. The rest need an owner
confirming they are still needed.
tenant. Verified domains, which matters for the email authentication work in
SPF, DKIM and DMARC — every verified domain
is a domain someone could send as.
Extension: the comparison is the point
A single collection is a snapshot. The value comes from running it quarterly and comparing.
$previous = Import-Csv 'C:\Evidence\20260620-privileged-roles.csv'
$current = Import-Csv 'C:\Evidence\20260920-privileged-roles.csv'
Compare-Object -ReferenceObject $previous -DifferenceObject $current `
-Property Role, UserPrincipalName |
Where-Object { $_.SideIndicator -eq '=>' } |
Select-Object Role, UserPrincipalName
That returns role assignments that exist now and did not last quarter. It is a short list, and every entry has a specific answer — which is exactly the kind of question a review should be asking.
Run the same comparison against the application permissions file. A new tenant-wide grant appearing between two collections is the single highest-value finding this whole exercise produces.
Operational cautions
Keep it read-only. The scopes above are all .Read scopes deliberately. A collection tool
that can change things cannot be run freely, and being runnable freely is the point.
The output is sensitive. It contains user principal names, guest email addresses, your enforcement posture and your privileged population — a useful reconnaissance package. Restrict access to the folder, agree retention, and delete when the engagement ends. Never attach these files to a ticket or upload them to a third-party portal.
Record what you could not collect. A permission you did not have, a cmdlet that failed — the gaps belong in the evidence. Evidence that silently omits a category is worse than evidence that names what is missing.
Verification and limits
The Graph PowerShell cmdlets used, the delegated scopes required, and the property names referenced were checked against current Microsoft documentation on 20 September 2026. Reading directory and policy data also requires an appropriate directory role, such as Security Reader or Global Reader, in addition to the consented scopes.
The script was not executed against a tenant for this article and should be run against a test tenant first. All operations are read-only, but consenting to these scopes grants durable permissions — review them against Microsoft Graph PowerShell: authentication and scopes before consenting. Cmdlet coverage and available properties change between SDK versions, so expect to adjust it; that is a normal cost of a tool like this, not a defect.
References
Reader feedback
Was this article useful?
No ratings yet. Be the first to rate this article.
