Public/Invoke-InforcerAssessment.ps1
|
<# .SYNOPSIS Runs an assessment against one or more tenants via the Inforcer API. Required API scope(s): Tenants.Read + Assessments.Read + Assessments.Run (API key); Tenants (read) + Tenant Assessment (run) permissions (API client, unconfirmed). .DESCRIPTION Triggers an assessment run and returns detailed results including per-check scores, passes, violations, warnings, and framework metadata. Each check's Status is Pass, Fail, InsufficientPermissions or Error. The last two mean the API could not assess the check; they are reported but left out of the compliance score. Supports single-tenant, multi-tenant (all tenants), and subset modes. Use -MultiTenant to run against all tenants, or pass multiple values to -TenantId. Works with both login types. With an API key (legacy API) tenants are identified by numeric Client Tenant ID, GUID or name; with a client ID and secret (v1 API) by Microsoft tenant ID (GUID) or name, and the TenantId on the output is the Microsoft tenant ID. v1 runs return when the assessment completes, exactly like the legacy runs. When -OutputPath is specified with multi-tenant, generates an HTML matrix report. With client credentials a throttled run (429) is retried after Retry-After and a 401 renews the token once; with an API key neither is retried. A run that answers with a redirect fails. .PARAMETER TenantId Tenant(s) to run the assessment against. Accepts numeric Client Tenant ID (API key only), Microsoft tenant GUID, or friendly name. Aliases: -ClientTenantId, -MsTenantId. Pass multiple values for subset multi-tenant mode. Optional when -MultiTenant is used (defaults to all tenants). Piped tenants are collected into one run; every check then carries TenantId. .PARAMETER AssessmentId The assessment to run. Accepts an assessment ID string or a friendly assessment name. .PARAMETER MultiTenant Switch. Runs the assessment against all tenants (or subset if -TenantId also specified). Requires -OutputPath with HTML extension for the matrix report. .PARAMETER OutputPath Optional. File path (not a directory) to export results; the extension picks the format, and any other extension fails with UnsupportedFormat. Single tenant: HTML (.html or .htm) report or CSV (.csv). Multi-tenant: HTML (.html) matrix report or CSV (.csv) with tenant column. CSV values that start with =, +, -, @, a tab or a carriage return get a leading ' so Excel shows them as text instead of running them as formulas. Returns System.IO.FileInfo when specified. .PARAMETER OutputType PowerShellObject (default) or JsonObject. JSON uses Depth 100. .EXAMPLE Invoke-InforcerAssessment -TenantId 'Contoso' -AssessmentId "Copilot Readiness" Runs the Copilot Readiness assessment against the tenant Contoso. .EXAMPLE Invoke-InforcerAssessment -TenantId "Contoso" -AssessmentId "Copilot Readiness" -OutputPath ./report.html Generates an interactive HTML assessment report. .EXAMPLE Invoke-InforcerAssessment -AssessmentId "Copilot Readiness" -MultiTenant -OutputPath ./matrix.html Runs the assessment against all tenants and generates a matrix comparison report. .EXAMPLE Invoke-InforcerAssessment -TenantId "Contoso","Fabrikam","Woodgrove" -AssessmentId "Copilot Readiness" -OutputPath ./matrix.html Runs the assessment against specific tenants and generates a matrix report. .EXAMPLE Invoke-InforcerAssessment -TenantId 'Contoso' -AssessmentId "Copilot Readiness" -OutputPath ./report.csv Exports assessment results to CSV. .EXAMPLE Invoke-InforcerAssessment -TenantId 'Contoso' -AssessmentId "Copilot Readiness" -OutputType JsonObject Returns the assessment results as a JSON string (depth 100) instead of objects. .OUTPUTS PSObject, String, or System.IO.FileInfo .LINK https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#invoke-inforcerassessment .LINK Connect-Inforcer .LINK Get-InforcerAssessment #> function Invoke-InforcerAssessment { [CmdletBinding()] [OutputType([PSObject], [string], [System.IO.FileInfo])] param( [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [Alias('ClientTenantId', 'MsTenantId')] [object[]]$TenantId, [Parameter(Mandatory = $true)] [string]$AssessmentId, [Parameter(Mandatory = $false)] [switch]$MultiTenant, [Parameter(Mandatory = $false)] [string]$OutputPath, [Parameter(Mandatory = $false)] [ValidateSet('PowerShellObject', 'JsonObject')] [string]$OutputType = 'PowerShellObject' ) begin { $pipedTenantIds = [System.Collections.Generic.List[object]]::new() } process { if ($null -ne $TenantId) { foreach ($t in @($TenantId)) { $pipedTenantIds.Add($t) } } } end { # Piped input that held no tenant (an empty filter result) must not fall through to an all-tenant run. if ($PSCmdlet.MyInvocation.ExpectingInput -and $pipedTenantIds.Count -eq 0) { Write-Verbose 'No tenants were piped in; nothing to assess.' return } if ($pipedTenantIds.Count -gt 0) { $TenantId = $pipedTenantIds.ToArray() } if (-not (Test-InforcerSession)) { Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' -ErrorId 'NotConnected' -Category ConnectionError return } # Validate: single-tenant requires -TenantId $isMultiTenant = $MultiTenant.IsPresent -or ($null -ne $TenantId -and @($TenantId).Count -gt 1) if (-not $isMultiTenant -and ($null -eq $TenantId -or @($TenantId).Count -eq 0)) { Write-Error -Message 'TenantId is required for single-tenant mode. Use -MultiTenant to run against all tenants.' -ErrorId 'MissingTenantId' -Category InvalidArgument return } # Fetch tenant list (needed for name resolution and multi-tenant) $isV1 = $script:InforcerSession.ApiVersion -eq 'v1' $tenantIdProperty = if ($isV1) { 'msTenantId' } else { 'clientTenantId' } $tenantData = @(Get-InforcerTenantList -Refresh) # Resolve assessment $assessmentData = $null try { $assessmentData = @(Invoke-InforcerApiRequest -Endpoint '/assessments' -Method GET -OutputType PowerShellObject -ObjectType Assessment) $resolvedAssessmentId = Resolve-InforcerAssessmentId -AssessmentId $AssessmentId -AssessmentData $assessmentData } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidAssessmentId' -Category InvalidArgument return } # Resolve assessment friendly name $assessmentDisplayName = $AssessmentId foreach ($a in $assessmentData) { if ($a -is [PSObject]) { $idProp = $a.PSObject.Properties['id'] if ($idProp -and $idProp.Value -eq $resolvedAssessmentId) { $np = $a.PSObject.Properties['name'] if ($np -and -not [string]::IsNullOrWhiteSpace($np.Value)) { $assessmentDisplayName = $np.Value } break } } } # Helper: resolve tenant friendly name from tenant data (id -> name, first match wins) $tenantNames = @{} foreach ($t in $tenantData) { if ($t -isnot [PSObject]) { continue } $cidProp = $t.PSObject.Properties[$tenantIdProperty] if (-not $cidProp -or $null -eq $cidProp.Value -or $tenantNames.ContainsKey("$($cidProp.Value)")) { continue } $fn = $t.PSObject.Properties['tenantFriendlyName'] $dn = $t.PSObject.Properties['tenantDnsName'] $tenantNames["$($cidProp.Value)"] = if ($fn -and -not [string]::IsNullOrWhiteSpace($fn.Value) -and $fn.Value -ne 'Tenant') { $fn.Value } elseif ($dn -and -not [string]::IsNullOrWhiteSpace($dn.Value)) { $dn.Value } else { "$($cidProp.Value)" } } $resolveTenantName = { param($cid) if ($tenantNames.ContainsKey("$cid")) { return $tenantNames["$cid"] } return "$cid" } # CSV cells that start with = + - @ tab or CR are formulas in Excel: prefix them with a quote. $csvSafe = { param($row) foreach ($prop in $row.PSObject.Properties) { $v = $prop.Value if ($v -is [string] -and $v.Length -gt 0 -and "=+-@`t`r".IndexOf($v[0]) -ge 0) { $prop.Value = "'" + $v } } $row } # Helper: process raw API results into enriched check objects $placeholderNames = @('[Multiple Objects Evaluated]', '[unknown id]', '[unknown name]') $processResults = { param([array]$results) $checks = [System.Collections.Generic.List[object]]::new() foreach ($r in $results) { if (-not ($r -is [PSObject])) { continue } $fp = $r.PSObject.Properties['findings'] $isCompliant = $false; $findingsMessage = ''; $apiStatus = $null if ($fp -and $fp.Value -is [PSObject]) { $cp = $fp.Value.PSObject.Properties['compliant'] if ($cp) { $isCompliant = $cp.Value -eq $true } $mp = $fp.Value.PSObject.Properties['message'] if ($mp) { $findingsMessage = $mp.Value } $sp = $fp.Value.PSObject.Properties['status'] if ($sp) { $apiStatus = $sp.Value } } # The API sends compliant=false for checks it could not assess; status tells them apart $statusText = switch ($apiStatus) { 'pass' { 'Pass' } 'fail' { 'Fail' } 'insufficientPermissions' { 'InsufficientPermissions' } 'error' { 'Error' } default { if ($isCompliant) { 'Pass' } else { 'Fail' } } } $allPasses = [System.Collections.Generic.List[string]]::new() $allViolations = [System.Collections.Generic.List[string]]::new() $allWarnings = [System.Collections.Generic.List[string]]::new() $objectsEvaluated = 0; $scoresArray = @() if ($fp -and $fp.Value -is [PSObject]) { $scoresProp = $fp.Value.PSObject.Properties['scores'] if ($scoresProp -and $scoresProp.Value -is [array]) { $objectsEvaluated = $scoresProp.Value.Count $scoresArray = $scoresProp.Value foreach ($s in $scoresProp.Value) { if (-not ($s -is [PSObject])) { continue } $objName = '' $onp = $s.PSObject.Properties['objectName'] if ($onp -and $onp.Value -and $onp.Value -notin $placeholderNames) { $objName = $onp.Value } $prefix = if ($objName) { "$objName — " } else { '' } $pp = $s.PSObject.Properties['passes'] if ($pp -and $pp.Value -is [array]) { foreach ($p in $pp.Value) { if ($p) { [void]$allPasses.Add("${prefix}$p") } } } $vp = $s.PSObject.Properties['violations'] if ($vp -and $vp.Value -is [array]) { foreach ($v in $vp.Value) { if ($v) { [void]$allViolations.Add("${prefix}$v") } } } $wp = $s.PSObject.Properties['warnings'] if ($wp -and $wp.Value -is [array]) { foreach ($w in $wp.Value) { if ($w) { [void]$allWarnings.Add("${prefix}$w") } } } } } } $r | Add-Member -NotePropertyName 'Status' -NotePropertyValue $statusText -Force $r | Add-Member -NotePropertyName 'ObjectsEvaluated' -NotePropertyValue $objectsEvaluated -Force $r | Add-Member -NotePropertyName 'FindingsMessage' -NotePropertyValue $findingsMessage -Force $r | Add-Member -NotePropertyName 'Scores' -NotePropertyValue $scoresArray -Force $r | Add-Member -NotePropertyName 'Violations' -NotePropertyValue @($allViolations) -Force $r | Add-Member -NotePropertyName 'Warnings' -NotePropertyValue @($allWarnings) -Force $r | Add-Member -NotePropertyName 'Passes' -NotePropertyValue @($allPasses) -Force $r.PSObject.TypeNames.Insert(0, 'InforcerCommunity.AssessmentCheck') [void]$checks.Add($r) } return ,$checks } # Helper: counts and score; checks that were not assessed stay out of the score. # v1 sends inforcer's own score (summary.score, the portal's number); beta has none, so it is computed. $summarize = { param($checks, $response) $passed = 0; $failed = 0 foreach ($c in $checks) { if ($c.Status -eq 'Pass') { $passed++ } elseif ($c.Status -eq 'Fail') { $failed++ } } $apiSummary = if ($response -is [PSObject]) { $response.PSObject.Properties['summary'].Value } $apiScore = if ($apiSummary -is [PSObject]) { $apiSummary.PSObject.Properties['score'].Value } # Only a real 0-100 number is trusted; it lands in HTML reports. Anything else falls back to the computed score. $useApi = $apiScore -is [ValueType] -and $apiScore -isnot [bool] -and [double]$apiScore -ge 0 -and [double]$apiScore -le 100 @{ Score = if ($useApi) { [double]$apiScore } elseif ($passed + $failed -gt 0) { [math]::Round(($passed / ($passed + $failed)) * 100, 1) } else { 0 } Passed = $passed Failed = $failed NotAssessed = $checks.Count - $passed - $failed TotalChecks = $checks.Count } } # ── MULTI-TENANT MODE ── if ($isMultiTenant) { # Build list of tenants to run against $tenantsToRun = [System.Collections.Generic.List[object]]::new() if ($null -ne $TenantId -and @($TenantId).Count -gt 0) { # Resolve each specified tenant foreach ($tid in @($TenantId)) { try { $cid = Resolve-InforcerTenantId -TenantId $tid -TenantData $tenantData $tname = & $resolveTenantName $cid [void]$tenantsToRun.Add(@{ Id = $cid; Name = $tname }) } catch { Write-Warning "Skipping tenant '$tid': $($_.Exception.Message)" } } } else { # All tenants foreach ($t in $tenantData) { if ($t -is [PSObject]) { $cidProp = $t.PSObject.Properties[$tenantIdProperty] if ($cidProp -and $null -ne $cidProp.Value) { $cid = if ($isV1) { "$($cidProp.Value)" } else { [int]$cidProp.Value } $tname = & $resolveTenantName $cid [void]$tenantsToRun.Add(@{ Id = $cid; Name = $tname }) } } } } if ($tenantsToRun.Count -eq 0) { Write-Error -Message 'No tenants found to run assessment against.' -ErrorId 'NoTenants' -Category ObjectNotFound return } # Format elapsed time as human-readable $fmtTime = { param([double]$totalSeconds) if ($totalSeconds -ge 60) { $mins = [math]::Floor($totalSeconds / 60) $secs = [math]::Round($totalSeconds % 60) return "${mins}m ${secs}s" } return "$([math]::Round($totalSeconds, 1))s" } Write-Host "" Write-Host "Multi-tenant assessment: '$assessmentDisplayName' across $($tenantsToRun.Count) tenant(s)" -ForegroundColor Cyan Write-Host "" # Run assessment for each tenant $totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew() $allTenantResults = [System.Collections.Generic.List[object]]::new() $tenantIndex = 0 foreach ($tenant in $tenantsToRun) { $tenantIndex++ Write-Host "[$tenantIndex/$($tenantsToRun.Count)] " -NoNewline $response = Invoke-InforcerAssessmentRun ` -TenantId $tenant.Id ` -ResolvedAssessmentId $resolvedAssessmentId ` -TenantDisplayName $tenant.Name ` -AssessmentDisplayName $assessmentDisplayName if ($null -eq $response) { Write-Warning " No results for $($tenant.Name) — skipping." continue } $resultsProp = $response.PSObject.Properties['results'] $results = if ($resultsProp -and $resultsProp.Value -is [array]) { $resultsProp.Value } else { @() } $checks = & $processResults $results [void]$allTenantResults.Add(@{ TenantId = $tenant.Id; TenantName = $tenant.Name; Checks = @($checks) } + (& $summarize $checks $response)) } $totalStopwatch.Stop() $totalTimeStr = & $fmtTime $totalStopwatch.Elapsed.TotalSeconds Write-Host "" Write-Host "All assessments complete. $($allTenantResults.Count) tenant(s) processed in $totalTimeStr." -ForegroundColor Green # Every tenant failed. Bail before the summary and every export path: the HTML and CSV # renderers take a mandatory -TenantResults, so falling through binds an empty collection # and throws a parameter-binding error that says nothing about why the runs failed. # One guard here covers the Json, Html and Csv paths below. if ($allTenantResults.Count -eq 0) { Write-Error -Message 'No assessment results were returned for any tenant, so there is nothing to report. See the errors above for the per-tenant failures.' -ErrorId 'NoAssessmentResults' -Category InvalidResult return } # Summary per tenant foreach ($tr in $allTenantResults) { $color = if ($tr.Score -ge 90) { 'Green' } elseif ($tr.Score -ge 70) { 'Yellow' } else { 'Red' } Write-Host " $($tr.TenantName) — $($tr.Score)% ($($tr.Passed)/$($tr.Passed + $tr.Failed)$(if ($tr.NotAssessed) { ", $($tr.NotAssessed) not assessed" }))" -ForegroundColor $color } Write-Host "" # JsonObject output for automation if ($OutputType -eq 'JsonObject') { $jsonOutput = [System.Collections.Generic.List[object]]::new() foreach ($tr in $allTenantResults) { [void]$jsonOutput.Add([PSCustomObject]@{ TenantName = $tr.TenantName TenantId = $tr.TenantId ComplianceScore = $tr.Score TotalChecks = $tr.TotalChecks Passed = $tr.Passed Failed = $tr.Failed NotAssessed = $tr.NotAssessed Checks = @($tr.Checks | ForEach-Object { [PSCustomObject]@{ Name = $_.name Category = $_.category SubCategory = $_.subCategory Importance = $_.importance Status = $_.Status ObjectsEvaluated = $_.ObjectsEvaluated FindingsMessage = $_.FindingsMessage Violations = $_.Violations Warnings = $_.Warnings Passes = $_.Passes } }) }) } ConvertTo-Json -InputObject @($jsonOutput) -Depth 100 return } # Export if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { $resolvedPath = [System.IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)) $ext = [System.IO.Path]::GetExtension($resolvedPath).ToLower() if ($ext -eq '.html' -or $ext -eq '.htm') { $html = ConvertTo-InforcerAssessmentMatrixHtml ` -AssessmentName $assessmentDisplayName ` -TenantResults @($allTenantResults) $html | Set-Content -Path $resolvedPath -Encoding UTF8 Write-Host "Matrix HTML report saved to: $resolvedPath" } elseif ($ext -eq '.csv') { $csvRows = [System.Collections.Generic.List[object]]::new() foreach ($tr in $allTenantResults) { foreach ($c in $tr.Checks) { [void]$csvRows.Add((& $csvSafe ([PSCustomObject]@{ Tenant = $tr.TenantName TenantId = $tr.TenantId Status = $c.Status Name = $c.name Category = $c.category SubCategory = $c.subCategory Importance = $c.importance ObjectsEvaluated = $c.ObjectsEvaluated FindingsMessage = $c.FindingsMessage Violations = ($c.Violations -join '; ') Warnings = ($c.Warnings -join '; ') Passes = ($c.Passes -join '; ') }))) } } $csvRows | Export-Csv -Path $resolvedPath -NoTypeInformation -Encoding utf8NoBOM Write-Host "CSV report saved to: $resolvedPath" } else { Write-Error -Message "Unsupported file extension '$ext'. Use .html or .csv." -ErrorId 'UnsupportedFormat' -Category InvalidArgument return } [System.IO.FileInfo]::new($resolvedPath) return } # Pipeline output: emit checks with TenantName added foreach ($tr in $allTenantResults) { foreach ($c in $tr.Checks) { $c | Add-Member -NotePropertyName 'TenantName' -NotePropertyValue $tr.TenantName -Force $c | Add-Member -NotePropertyName 'TenantId' -NotePropertyValue $tr.TenantId -Force $c } } return } # ── SINGLE-TENANT MODE ── $singleTenantId = @($TenantId)[0] try { $clientTenantId = Resolve-InforcerTenantId -TenantId $singleTenantId -TenantData $tenantData } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument return } $tenantDisplayName = & $resolveTenantName $clientTenantId $response = Invoke-InforcerAssessmentRun ` -TenantId $clientTenantId ` -ResolvedAssessmentId $resolvedAssessmentId ` -TenantDisplayName $tenantDisplayName ` -AssessmentDisplayName $assessmentDisplayName if ($null -eq $response) { return } # A piped tenant: results carry its id, as in multi-tenant mode. $pipedTenantId = if ($PSCmdlet.MyInvocation.ExpectingInput) { $clientTenantId } else { $null } if ($OutputType -eq 'JsonObject') { if ($null -ne $pipedTenantId -and $response -is [PSObject] -and -not $response.PSObject.Properties['TenantId']) { $response.PSObject.Properties.Add([System.Management.Automation.PSNoteProperty]::new('TenantId', $pipedTenantId)) } $response | ConvertTo-Json -Depth 100 return } # Compute summary $resultsProp = $response.PSObject.Properties['results'] $results = if ($resultsProp -and $resultsProp.Value -is [array]) { $resultsProp.Value } else { @() } $processedChecks = & $processResults $results $summary = & $summarize $processedChecks $response $score = $summary.Score Write-Host "" Write-Host " $assessmentDisplayName — ${score}% compliant ($($summary.Passed)/$($summary.Passed + $summary.Failed) checks passed$(if ($summary.NotAssessed) { ", $($summary.NotAssessed) not assessed" }))" -ForegroundColor $(if ($score -eq 100) { 'Green' } elseif ($score -ge 75) { 'Yellow' } else { 'Red' }) Write-Host "" # Export or emit if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { $resolvedPath = [System.IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)) $ext = [System.IO.Path]::GetExtension($resolvedPath).ToLower() if ($ext -eq '.html' -or $ext -eq '.htm') { $html = ConvertTo-InforcerAssessmentHtml ` -AssessmentName $assessmentDisplayName ` -TenantName $tenantDisplayName ` -Checks @($processedChecks) ` -Score $score ` -TotalChecks $summary.TotalChecks ` -Passed $summary.Passed ` -Failed $summary.Failed ` -NotAssessed $summary.NotAssessed $html | Set-Content -Path $resolvedPath -Encoding UTF8 Write-Host "HTML report saved to: $resolvedPath" } elseif ($ext -eq '.csv') { $csvRows = foreach ($c in $processedChecks) { & $csvSafe ([PSCustomObject]@{ Status = $c.Status Name = $c.name Category = $c.category SubCategory = $c.subCategory Importance = $c.importance ObjectsEvaluated = $c.ObjectsEvaluated FindingsMessage = $c.FindingsMessage Violations = ($c.Violations -join '; ') Warnings = ($c.Warnings -join '; ') Passes = ($c.Passes -join '; ') }) } $csvRows | Export-Csv -Path $resolvedPath -NoTypeInformation -Encoding utf8NoBOM Write-Host "CSV report saved to: $resolvedPath" } else { Write-Error -Message "Unsupported file extension '$ext'. Use .html or .csv." -ErrorId 'UnsupportedFormat' -Category InvalidArgument return } [System.IO.FileInfo]::new($resolvedPath) return } foreach ($c in $processedChecks) { if ($null -ne $pipedTenantId -and -not $c.PSObject.Properties['TenantId']) { $c | Add-Member -NotePropertyName 'TenantId' -NotePropertyValue $pipedTenantId } $c } } } |