Public/Invoke-M365HygieneAudit.ps1

<#
.SYNOPSIS
    Runs the hygiene checks against the connected tenant and returns the result.

.DESCRIPTION
    Executes each selected check, collects the findings, and returns one audit object
    that Export-M365HygieneReport turns into HTML and CSV.

    The audit object separates what was found from what was looked at. A check that
    could not run -- missing permission, missing licence, mailbox access denied -- is
    recorded as Skipped with the reason attached, never folded in with the checks that
    ran and found nothing. That distinction is the difference between "your tenant is
    clean" and "this tool did not look", and a report that blurs it is worse than no
    report at all.

    Nothing here writes to the tenant. All Graph traffic goes through a gateway with
    the HTTP verb hardcoded to GET.

.PARAMETER CheckId
    Run only these checks. Omit to run all of them. Use Get-M365HygieneCheck to list them.

.PARAMETER InactiveDays
    How many days without an interactive sign-in makes an enabled account inactive.

.PARAMETER GlobalAdminThreshold
    Number of permanent Global Administrators above which a finding is raised.
    Microsoft's guidance is a small number of permanent holders; the default is 4.

.PARAMETER MaxMailbox
    Cap on how many mailboxes the forwarding check examines. Any mailbox skipped by
    this cap is reported in the check's Reason, never silently dropped.

.PARAMETER MaxPages
    Cap on Graph result pages per request, as a safety valve on very large tenants.
    Truncation is reported, not hidden.

.EXAMPLE
    $audit = Invoke-M365HygieneAudit
    $audit.Summary

.EXAMPLE
    $audit = Invoke-M365HygieneAudit -CheckId MfaRegistration, PrivilegedRole -InactiveDays 60
    $audit.Findings | Where-Object Severity -eq 'Critical'

.EXAMPLE
    Invoke-M365HygieneAudit | Export-M365HygieneReport -Path ./reports

.OUTPUTS
    M365TenantHygiene.AuditResult
#>

function Invoke-M365HygieneAudit {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [string[]] $CheckId,

        [ValidateRange(1, 3650)]
        [int] $InactiveDays = 90,

        [ValidateRange(1, 100)]
        [int] $GlobalAdminThreshold = 4,

        [ValidateRange(0, 100000)]
        [int] $MaxMailbox = 0,

        [ValidateRange(1, 10000)]
        [int] $MaxPages = 200
    )

    $graphContext = Get-MgContext
    if (-not $graphContext) {
        throw 'Not connected to Microsoft Graph. Run Connect-M365Hygiene first.'
    }

    $registry = Get-HygieneCheckRegistry
    $selected = if ($CheckId) { $CheckId } else { @($registry.Keys) }

    foreach ($id in $selected) {
        if (-not $registry.Contains($id)) {
            throw "Unknown check '$id'. Run Get-M365HygieneCheck to list the available checks."
        }
    }

    # RunOrder is not cosmetic: PrivilegedRole populates the cache that lets the
    # inactive account check raise the severity of a dormant administrator.
    $ordered = @($selected | Sort-Object { $registry[$_].RunOrder })

    $context = New-HygieneAuditContext `
        -InactiveDayThreshold $InactiveDays `
        -GlobalAdminWarnThreshold $GlobalAdminThreshold `
        -MaxPages $MaxPages
    $context['MailboxLimit'] = $MaxMailbox

    $startedAt = [datetime]::UtcNow
    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

    Initialize-HygieneTenantInfo -Context $context

    $results = [System.Collections.Generic.List[object]]::new()

    foreach ($id in $ordered) {

        $entry = $registry[$id]
        Write-HygieneLog -Level Information -Message "Running check '$($entry.Name)'."

        $checkTimer = [System.Diagnostics.Stopwatch]::StartNew()
        $result = $null

        try {
            $result = & $entry.Function -Context $context
        }
        catch {
            $kind = $_.Exception.Data['Kind']

            if ($kind -in @('Permission', 'Authentication')) {
                # A missing permission is a gap in coverage, not a clean result.
                $result = New-HygieneCheckResult `
                    -CheckId $entry.Id -CheckName $entry.Name -Category $entry.Category `
                    -Status 'Skipped' `
                    -RequiredScopes $entry.RequiredScopes `
                    -Reason ("Microsoft Graph denied access ({0}). Required delegated scope(s): {1}. The signed-in account also needs a directory role that can read this data. Nothing was evaluated for this check." -f $_.Exception.Message, ($entry.RequiredScopes -join ', '))
                Write-HygieneLog -Level Warning -Message "Check '$($entry.Name)' skipped: access denied."
            }
            else {
                $result = New-HygieneCheckResult `
                    -CheckId $entry.Id -CheckName $entry.Name -Category $entry.Category `
                    -Status 'Failed' `
                    -RequiredScopes $entry.RequiredScopes `
                    -ErrorMessage $_.Exception.Message
                Write-HygieneLog -Level Warning -Message "Check '$($entry.Name)' failed: $($_.Exception.Message)"
            }
        }
        finally {
            $checkTimer.Stop()
        }

        if ($result) {
            $result.Duration = $checkTimer.Elapsed
            $results.Add($result)
        }
    }

    $stopwatch.Stop()

    $findings = @(
        $results |
            Where-Object { $_.Status -eq 'Completed' } |
            ForEach-Object { $_.Findings } |
            Sort-Object SeverityRank, Category, CheckName, ObjectName
    )

    $moduleVersion = try { (Get-Module M365TenantHygiene).Version.ToString() } catch { 'unknown' }

    [pscustomobject]@{
        PSTypeName    = 'M365TenantHygiene.AuditResult'
        Tenant        = $context.Tenant
        VerifiedDomains = $context.VerifiedDomains
        Account       = $graphContext.Account
        Environment   = $graphContext.Environment
        StartedAt     = $startedAt
        CompletedAt   = [datetime]::UtcNow
        Duration      = $stopwatch.Elapsed
        ModuleVersion = $moduleVersion
        Parameters    = [ordered]@{
            InactiveDays         = $InactiveDays
            GlobalAdminThreshold = $GlobalAdminThreshold
            MaxMailbox           = $MaxMailbox
            MaxPages             = $MaxPages
        }
        Checks        = @($results)
        Findings      = $findings
        Summary       = (New-HygieneSummary -CheckResult @($results) -Finding $findings)
    }
}

<#
.SYNOPSIS
    Aggregates an audit into the counters the report header shows.
.DESCRIPTION
    Counts skipped and failed checks alongside the findings, so the headline can never
    say "0 issues" without also saying how much of the tenant was never examined.
#>

function New-HygieneSummary {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [object[]] $CheckResult = @(),
        [object[]] $Finding = @()
    )

    $bySeverity = [ordered]@{}
    foreach ($severity in 'Critical', 'High', 'Medium', 'Low', 'Informational') {
        $bySeverity[$severity] = @($Finding | Where-Object { $_.Severity -eq $severity }).Count
    }

    $actionable = $bySeverity['Critical'] + $bySeverity['High'] + $bySeverity['Medium'] + $bySeverity['Low']

    [pscustomobject]@{
        PSTypeName        = 'M365TenantHygiene.Summary'
        TotalFindings     = @($Finding).Count
        ActionableFindings = $actionable
        BySeverity        = $bySeverity
        ChecksRun         = @($CheckResult).Count
        ChecksCompleted   = @($CheckResult | Where-Object { $_.Status -eq 'Completed' }).Count
        ChecksSkipped     = @($CheckResult | Where-Object { $_.Status -eq 'Skipped' }).Count
        ChecksFailed      = @($CheckResult | Where-Object { $_.Status -eq 'Failed' }).Count
        ObjectsEvaluated  = (@($CheckResult | Where-Object { $_.Status -eq 'Completed' }) |
                             Measure-Object -Property ObjectsEvaluated -Sum).Sum
    }
}