Type to search 66 articles.

    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

    • Microsoft 365
    • PowerShell
    • Security assessment

    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