Public/Export-AgentIdReport.ps1

function Export-AgentIdReport {
    <#
    .SYNOPSIS
    Writes a self-contained HTML report for a snapshot.

    .DESCRIPTION
    The report lists findings by severity, the checks that could not run and why, an inventory of blueprints,
    agent identities and agent users, and every agent's effective access. It is a single HTML file with no
    external resources, safe to email or archive.

    .EXAMPLE
    Get-AgentIdInventory | Export-AgentIdReport -Path .\agent-audit.html

    .EXAMPLE
    Import-AgentIdSnapshot .\contoso.json | Export-AgentIdReport -Path .\contoso.html -StaleAfterDays 60
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)][PSTypeName('AgentIdAudit.Snapshot')]$Snapshot,
        [Parameter(Mandatory)][string]$Path,
        [datetimeoffset]$AsOf,
        [ValidateRange(1, 36500)][int]$MaxCredentialLifetimeDays = 180,
        [ValidateRange(1, 3650)][int]$StaleAfterDays = 90,
        [ValidateRange(1, 365)][int]$ExpiringWithinDays = 30,
        [ValidatePattern('^\w+\.\w+$', ErrorMessage = 'Use the form AttributeSet.AttributeName, for example Engineering.CostCenter.')][string[]]$RequiredSecurityAttribute,
        [ValidatePattern('^\w+\.\w+$', ErrorMessage = 'Use the form AttributeSet.AttributeName, for example Engineering.CostCenter.')][string[]]$OptionalSecurityAttribute
    )
    process {
        $options = New-AgentIdOptions -MaxCredentialLifetimeDays $MaxCredentialLifetimeDays -StaleAfterDays $StaleAfterDays -ExpiringWithinDays $ExpiringWithinDays `
            -RequiredSecurityAttribute $RequiredSecurityAttribute -OptionalSecurityAttribute $OptionalSecurityAttribute
        $params = @{ Snapshot = $Snapshot; Options = $options }
        if ($PSBoundParameters.ContainsKey('AsOf')) { $params.AsOf = $AsOf }
        $evaluation = Invoke-AgentIdRuleEvaluation @params
        $html = ConvertTo-AgentIdReportHtml -Snapshot $Snapshot -Evaluation $evaluation -Branding $script:AgentIdBranding

        $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
        if ($PSCmdlet.ShouldProcess($full, 'Write agent identity audit report')) {
            Set-Content -LiteralPath $full -Value $html -Encoding utf8
            Get-Item -LiteralPath $full
        }
    }
}

function Invoke-AgentIdAudit {
    <#
    .SYNOPSIS
    Runs a complete audit: collects the inventory, evaluates it, and writes a snapshot, findings and a report.

    .DESCRIPTION
    Connects first if there is no Microsoft Graph connection. Writes three files to -OutputPath:
      agentid-snapshot-<date>.json the raw snapshot, for later analysis
      agentid-findings-<date>.csv one row per finding
      agentid-report-<date>.html the human-readable report
    and returns a summary.

    .PARAMETER RequiredSecurityAttribute
    Custom security attributes (AttributeSet.AttributeName) every agent identity must have a value for. Naming
    any implies -IncludeSecurityAttributes.

    .PARAMETER OptionalSecurityAttribute
    Custom security attributes an agent identity should have a value for if they apply (reported as Info).

    .PARAMETER IncludeSecurityAttributes
    Read agents' custom security attributes into the snapshot even when no attribute names are given, so they can
    be checked later. Needs CustomSecAttributeAssignment.Read.All and the Attribute Assignment Reader role.

    .EXAMPLE
    Invoke-AgentIdAudit -TenantId contoso.onmicrosoft.com -OutputPath .\audit

    .EXAMPLE
    Invoke-AgentIdAudit -RequiredSecurityAttribute Engineering.CostCenter, Engineering.Owner -OptionalSecurityAttribute Engineering.DataClass
    #>

    [CmdletBinding()]
    param(
        [string]$OutputPath = '.',
        [string]$TenantId,
        [switch]$UseDeviceCode,
        [switch]$SkipSignInActivity,
        [switch]$SkipRisk,
        [switch]$IncludeSecurityAttributes,
        [ValidateRange(1, 36500)][int]$MaxCredentialLifetimeDays = 180,
        [ValidateRange(1, 3650)][int]$StaleAfterDays = 90,
        [ValidateRange(1, 365)][int]$ExpiringWithinDays = 30,
        [ValidatePattern('^\w+\.\w+$', ErrorMessage = 'Use the form AttributeSet.AttributeName, for example Engineering.CostCenter.')][string[]]$RequiredSecurityAttribute,
        [ValidatePattern('^\w+\.\w+$', ErrorMessage = 'Use the form AttributeSet.AttributeName, for example Engineering.CostCenter.')][string[]]$OptionalSecurityAttribute
    )

    $collectAttributes = $IncludeSecurityAttributes -or $RequiredSecurityAttribute -or $OptionalSecurityAttribute
    if (-not $script:AgentIdGraphHandler) {
        $connected = (Get-Command Get-MgContext -ErrorAction SilentlyContinue) -and (Get-MgContext)
        if (-not $connected) {
            $connect = @{ SkipOptionalScopes = ($SkipSignInActivity -and $SkipRisk); IncludeSecurityAttributes = [bool]$collectAttributes }
            if ($TenantId) { $connect.TenantId = $TenantId }
            if ($UseDeviceCode) { $connect.UseDeviceCode = $true }
            Connect-AgentIdAudit @connect | Out-Null
        }
    }

    $dir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
    New-Item -ItemType Directory -Force -Path $dir | Out-Null
    $stamp = [datetime]::UtcNow.ToString('yyyyMMdd-HHmm')

    $snapshot = Get-AgentIdInventory -SkipSignInActivity:$SkipSignInActivity -SkipRisk:$SkipRisk -IncludeSecurityAttributes:$collectAttributes
    $snapshotFile = Export-AgentIdSnapshot -Snapshot $snapshot -Path (Join-Path $dir "agentid-snapshot-$stamp.json")

    $options = New-AgentIdOptions -MaxCredentialLifetimeDays $MaxCredentialLifetimeDays -StaleAfterDays $StaleAfterDays -ExpiringWithinDays $ExpiringWithinDays `
        -RequiredSecurityAttribute $RequiredSecurityAttribute -OptionalSecurityAttribute $OptionalSecurityAttribute
    $evaluation = Invoke-AgentIdRuleEvaluation -Snapshot $snapshot -Options $options
    $findingsFile = Join-Path $dir "agentid-findings-$stamp.csv"
    $evaluation.Findings | Select-Object Severity, RuleId, Title, ObjectType, ObjectName, ObjectId, Detail, Remediation, Reference |
        Export-Csv -LiteralPath $findingsFile -NoTypeInformation -Encoding utf8
    $reportFile = Join-Path $dir "agentid-report-$stamp.html"
    Set-Content -LiteralPath $reportFile -Value (ConvertTo-AgentIdReportHtml -Snapshot $snapshot -Evaluation $evaluation -Branding $script:AgentIdBranding) -Encoding utf8

    foreach ($n in $evaluation.NotEvaluated) {
        Write-Warning "$($n.RuleId) '$($n.Title)' not evaluated: $($n.Reason)"
    }
    $bySeverity = { param($s) @($evaluation.Findings | Where-Object Severity -EQ $s).Count }
    [pscustomobject]@{
        PSTypeName      = 'AgentIdAudit.Summary'
        Tenant          = if ($snapshot.TenantName) { $snapshot.TenantName } else { $snapshot.TenantId }
        Blueprints      = @($snapshot.Blueprints).Count
        AgentIdentities = @($snapshot.AgentIdentities).Count
        AgentUsers      = @($snapshot.AgentUsers).Count
        Critical        = & $bySeverity 'Critical'
        High            = & $bySeverity 'High'
        Medium          = & $bySeverity 'Medium'
        Low             = & $bySeverity 'Low'
        Info            = & $bySeverity 'Info'
        NotEvaluated    = $evaluation.NotEvaluated.Count
        Report          = $reportFile
        Findings        = $findingsFile
        Snapshot        = $snapshotFile.FullName
    }
}