Private/Report.ps1
|
function ConvertTo-AgentIdHtmlText { # Everything placed in the report goes through here: display names are set by whoever creates an agent, # so they must never be interpreted as markup. param($Value) if ($null -eq $Value) { return '' } return [System.Net.WebUtility]::HtmlEncode("$Value") } function ConvertTo-AgentIdHtmlLink { param([string]$Url, [string]$Text) if ($Url -notmatch '^https://') { return (ConvertTo-AgentIdHtmlText $Text) } return '<a href="{0}" rel="noopener noreferrer">{1}</a>' -f (ConvertTo-AgentIdHtmlText $Url), (ConvertTo-AgentIdHtmlText $Text) } function Get-AgentIdReportCss { return @' :root { --bg: #f6f6f3; --panel: #ffffff; --text: #1d1d1f; --muted: #5d6166; --line: #e2e2dd; --accent: #2b5d8a; --crit: #a1261d; --crit-bg: #fbe8e5; --high: #b54708; --high-bg: #fdf0e3; --med: #8a6100; --med-bg: #fbf3d6; --low: #2b5d8a; --low-bg: #e7eff7; --info: #5d6166; --info-bg: #ededea; --ok: #2e7d4f; --ok-bg: #e5f3ea; } @media (prefers-color-scheme: dark) { :root { --bg: #141517; --panel: #1d1e21; --text: #e9e9e6; --muted: #a3a5aa; --line: #34363a; --accent: #8cb8e0; --crit: #f2a097; --crit-bg: #3d1c19; --high: #f3b27a; --high-bg: #3b2615; --med: #e6c66e; --med-bg: #362d12; --low: #9cc1e4; --low-bg: #1b2a38; --info: #b7b9bd; --info-bg: #2a2b2e; --ok: #8fd1a8; --ok-bg: #1a3325; } } * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, "Segoe UI", system-ui, sans-serif; } .wrap { max-width: 1120px; margin: 0 auto; padding: 32px 20px 48px; } header h1 { font-size: 26px; margin: 0 0 4px; letter-spacing: -0.01em; } header p { margin: 0; color: var(--muted); } h2 { font-size: 19px; margin: 40px 0 12px; } h3 { font-size: 16px; margin: 24px 0 10px; } a { color: var(--accent); } .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin-top: 24px; } .tile { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; } .tile b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; } .tile span { color: var(--muted); font-size: 13px; } .tile.sev-Critical b { color: var(--crit); } .tile.sev-High b { color: var(--high); } .tile.sev-Medium b { color: var(--med); } .tile.sev-Low b { color: var(--low); } .tile.sev-Info b { color: var(--info); } .callout { background: var(--med-bg); border: 1px solid var(--line); border-left: 4px solid var(--med); border-radius: 8px; padding: 12px 16px; margin-top: 20px; } .callout p { margin: 0 0 6px; } .callout ul { margin: 0; padding-left: 20px; } .finding { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; } .finding .head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } .finding .title { font-weight: 600; } .finding .rule { color: var(--muted); font-size: 12px; font-family: ui-monospace, Consolas, monospace; } .finding .object { color: var(--muted); font-size: 13px; margin-top: 2px; overflow-wrap: anywhere; } .finding p { margin: 8px 0 0; overflow-wrap: anywhere; } .finding .fix { color: var(--muted); } .chip { display: inline-block; border-radius: 999px; padding: 1px 9px; font-size: 12px; font-weight: 600; white-space: nowrap; } .chip.Critical { color: var(--crit); background: var(--crit-bg); } .chip.High { color: var(--high); background: var(--high-bg); } .chip.Medium { color: var(--med); background: var(--med-bg); } .chip.Low { color: var(--low); background: var(--low-bg); } .chip.Info { color: var(--info); background: var(--info-bg); } .chip.Ok { color: var(--ok); background: var(--ok-bg); } .chip.Partial { color: var(--med); background: var(--med-bg); } .chip.Denied, .chip.Error, .chip.NotSupported, .chip.Skipped, .chip.NotCollected { color: var(--crit); background: var(--crit-bg); } .scroll { overflow-x: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; } table { border-collapse: collapse; width: 100%; font-size: 13.5px; } th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--line); vertical-align: top; } th { color: var(--muted); font-weight: 600; white-space: nowrap; } tr:last-child td { border-bottom: 0; } td.num { text-align: right; font-variant-numeric: tabular-nums; } td.date { white-space: nowrap; } .mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: var(--muted); } .unknown { color: var(--muted); font-style: italic; } .empty { color: var(--muted); padding: 12px 16px; margin: 0; } footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; } footer p { margin: 2px 0; } @media print { body { background: #fff; } .finding, .scroll, .tile { break-inside: avoid; } } '@ } function ConvertTo-AgentIdReportHtml { param( [Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)][hashtable]$Evaluation, [Parameter(Mandatory)][hashtable]$Branding ) $ctx = $Evaluation.Context $h = { param($v) ConvertTo-AgentIdHtmlText $v } $unknown = '<span class="unknown">unknown</span>' $count = { param($list) if ($null -eq $list) { $unknown } else { @($list).Count } } $sb = [System.Text.StringBuilder]::new() $add = { param([string]$s) [void]$sb.AppendLine($s) } $table = { param([string[]]$Headers, $Rows, [string]$Empty) if (-not @($Rows).Count) { & $add "<div class=""scroll""><p class=""empty"">$(& $h $Empty)</p></div>"; return } & $add '<div class="scroll"><table><thead><tr>' foreach ($col in $Headers) { & $add "<th>$(& $h $col)</th>" } & $add '</tr></thead><tbody>' foreach ($row in $Rows) { & $add "<tr>$($row -join '')</tr>" } & $add '</tbody></table></div>' } $td = { param($html, [string]$class) if ($class) { "<td class=""$class"">$html</td>" } else { "<td>$html</td>" } } $tenant = if ($Snapshot.TenantName) { $Snapshot.TenantName } else { 'Unknown tenant' } $collected = Format-AgentIdDate $Snapshot.CollectedAt $collectedTime = (ConvertTo-AgentIdDate $Snapshot.CollectedAt) $collectedText = if ($collectedTime) { $collectedTime.UtcDateTime.ToString('yyyy-MM-dd HH:mm') + ' UTC' } else { 'unknown' } & $add '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' & $add "<title>Agent identity audit - $(& $h $tenant) - $(& $h $collected)</title>" & $add "<style>$(Get-AgentIdReportCss)</style></head><body><div class=""wrap"">" # ---- Header and summary & $add '<header><h1>Agent identity audit</h1>' & $add "<p>$(& $h $tenant) · <span class=""mono"">$(& $h $Snapshot.TenantId)</span></p>" & $add "<p>Collected $(& $h $collectedText) · evaluated as of $(& $h $ctx.AsOf.UtcDateTime.ToString('yyyy-MM-dd')) · $(& $h $Snapshot.Tool) $(& $h $Snapshot.ToolVersion) · Graph $(& $h $Snapshot.GraphVersion)</p></header>" & $add '<div class="tiles">' & $add "<div class=""tile""><b>$(@($Snapshot.Blueprints).Count)</b><span>Blueprints</span></div>" & $add "<div class=""tile""><b>$(@($Snapshot.AgentIdentities).Count)</b><span>Agent identities</span></div>" & $add "<div class=""tile""><b>$(@($Snapshot.AgentUsers).Count)</b><span>Agent users</span></div>" foreach ($sev in 'Critical', 'High', 'Medium', 'Low', 'Info') { $n = @($Evaluation.Findings | Where-Object Severity -EQ $sev).Count & $add "<div class=""tile sev-$sev""><b>$n</b><span>$sev</span></div>" } & $add '</div>' if ($Evaluation.NotEvaluated.Count) { & $add "<div class=""callout""><p><strong>$($Evaluation.NotEvaluated.Count) check(s) could not run</strong> because the data they need wasn't available. Their absence below means <em>not checked</em>, not <em>clean</em>. See <a href=""#coverage"">collection coverage</a>.</p><ul>" foreach ($n in $Evaluation.NotEvaluated) { & $add "<li><span class=""mono"">$(& $h $n.RuleId)</span> $(& $h $n.Title) — $(& $h $n.Reason)</li>" } & $add '</ul></div>' } # ---- Findings & $add '<h2>Findings</h2>' if (-not $Evaluation.Findings.Count) { & $add '<div class="scroll"><p class="empty">No findings among the checks that ran.</p></div>' } foreach ($sev in 'Critical', 'High', 'Medium', 'Low', 'Info') { $group = @($Evaluation.Findings | Where-Object Severity -EQ $sev) if (-not $group) { continue } & $add "<h3><span class=""chip $sev"">$sev</span> $($group.Count) finding(s)</h3>" foreach ($f in $group) { & $add '<div class="finding"><div class="head">' & $add "<span class=""title"">$(& $h $f.Title)</span><span class=""rule"">$(& $h $f.RuleId)</span></div>" & $add "<div class=""object"">$(& $h $f.ObjectType): <strong>$(& $h $f.ObjectName)</strong> <span class=""mono"">$(& $h $f.ObjectId)</span></div>" & $add "<p>$(& $h $f.Detail)</p>" & $add "<p class=""fix"">$(& $h $f.Remediation) $(ConvertTo-AgentIdHtmlLink $f.Reference 'Reference')</p></div>" } } # ---- Blueprints & $add '<h2>Blueprints</h2>' $rows = foreach ($bp in @($Snapshot.Blueprints)) { if (-not $bp) { continue } $agentCount = if ($ctx.AgentsByBlueprintAppId.ContainsKey("$($bp.appId)")) { $ctx.AgentsByBlueprintAppId[$bp.appId].Count } else { 0 } $inherit = if ($null -eq $bp.inheritablePermissions) { $unknown } elseif (-not @($bp.inheritablePermissions).Count) { 'none' } else { (@($bp.inheritablePermissions) | ForEach-Object { $scopeText = if ($_.kind -eq 'allAllowed') { 'all scopes' } else { ($_.scopes -join ', ') } '{0}: {1}' -f (& $h (Get-AgentIdResourceName $ctx $_.resourceAppId)), (& $h $scopeText) }) -join '<br>' } , @( (& $td "<strong>$(& $h $bp.displayName)</strong><br><span class=""mono"">$(& $h $bp.appId)</span>") (& $td $agentCount 'num') (& $td (@(Get-AgentIdActiveCredential $ctx $bp.passwordCredentials).Count) 'num') (& $td (@(Get-AgentIdActiveCredential $ctx $bp.keyCredentials).Count) 'num') (& $td (& $count $bp.federatedIdentityCredentials) 'num') (& $td (& $count $bp.sponsors) 'num') (& $td $inherit) (& $td (& $h $bp.signInAudience)) ) } & $table @('Blueprint', 'Agents', 'Active secrets', 'Active certificates', 'Federated credentials', 'Sponsors', 'Inheritable scopes', 'Audience') $rows 'No blueprints found.' # ---- Agent identities & $add '<h2>Agent identities</h2>' $risk = @{} foreach ($r in @($Snapshot.RiskyAgents)) { if ($r) { $risk[$r.id] = $r } } $rows = foreach ($a in @($Snapshot.AgentIdentities)) { if (-not $a) { continue } $bpName = if ($ctx.BlueprintByAppId.ContainsKey("$($a.agentIdentityBlueprintId)")) { $ctx.BlueprintByAppId[$a.agentIdentityBlueprintId].displayName } elseif ($ctx.BlueprintPrincipalByAppId.ContainsKey("$($a.agentIdentityBlueprintId)")) { $ctx.BlueprintPrincipalByAppId[$a.agentIdentityBlueprintId].displayName } else { $a.agentIdentityBlueprintId } $mine = @($ctx.AccessRows | Where-Object PrincipalId -EQ $a.id) $direct = if ($null -eq $a.appRoleAssignments -and $null -eq $a.oauth2PermissionGrants) { $unknown } else { @($mine | Where-Object Source -EQ 'Direct').Count } $inherited = if ($null -eq $a.inheritedAppRoleAssignments -and $null -eq $a.inheritedOauth2PermissionGrants) { $unknown } else { @($mine | Where-Object Source -EQ 'Inherited').Count } $signIn = if (-not $ctx.SignInByAppId.ContainsKey("$($a.appId)")) { $unknown } elseif ($ctx.SignInByAppId[$a.appId].lastSignInDateTime) { Format-AgentIdDate $ctx.SignInByAppId[$a.appId].lastSignInDateTime } else { 'none recorded' } $riskText = if ($risk.ContainsKey("$($a.id)")) { & $h ("{0} / {1}" -f $risk[$a.id].riskLevel, $risk[$a.id].riskState) } elseif ((Test-AgentIdCoverageUsable $Snapshot 'RiskyAgents')) { 'none' } else { $unknown } , @( (& $td "<strong>$(& $h $a.displayName)</strong><br><span class=""mono"">$(& $h $a.id)</span>") (& $td (& $h $bpName)) (& $td (& $h $a.accountEnabled)) (& $td (& $h (Format-AgentIdDate $a.createdDateTime)) 'date') (& $td (& $count $a.sponsors) 'num') (& $td $direct 'num') (& $td $inherited 'num') (& $td $signIn 'date') (& $td $riskText) ) } & $table @('Agent identity', 'Blueprint', 'Enabled', 'Created', 'Sponsors', 'Direct permissions', 'Inherited permissions', 'Last sign-in', 'Risk') $rows 'No agent identities found.' # ---- Agent users & $add '<h2>Agent users</h2>' $rows = foreach ($u in @($Snapshot.AgentUsers)) { if (-not $u) { continue } $parent = if ($ctx.AgentById.ContainsKey("$($u.identityParentId)")) { & $h $ctx.AgentById[$u.identityParentId].displayName } elseif ($u.identityParentId) { "<span class=""mono"">$(& $h $u.identityParentId)</span>" } else { $unknown } , @( (& $td "<strong>$(& $h $u.displayName)</strong><br><span class=""mono"">$(& $h $u.userPrincipalName)</span>") (& $td (& $h $u.accountEnabled)) (& $td $parent) (& $td (& $count $u.sponsors) 'num') ) } & $table @('Agent user', 'Enabled', 'Parent agent identity', 'Sponsors') $rows 'No agent users found.' # ---- Effective access & $add '<h2>Effective access</h2>' $rows = foreach ($row in ($ctx.AccessRows | Sort-Object PrincipalName, PermissionType, ResourceName, Permission)) { $flag = if (Test-AgentIdBlockedPermission $ctx $row) { '<span class="chip Critical">Blocked for agents</span>' } elseif (Test-AgentIdSensitivePermission $ctx $row.PermissionType $row.ResourceAppId $row.Permission) { '<span class="chip High">Sensitive</span>' } else { '' } $consent = if ($row.PermissionType -eq 'Application') { 'n/a' } elseif ($row.ConsentType -eq 'AllPrincipals') { 'All users' } else { 'One user' } , @( (& $td "<strong>$(& $h $row.PrincipalName)</strong><br><span class=""mono"">$(& $h (Get-AgentIdPrincipalLabel $row.PrincipalKind))</span>") (& $td (& $h $row.PermissionType)) (& $td (& $h $row.ResourceName)) (& $td "$(& $h $row.Permission) $flag") (& $td (& $h $consent)) (& $td (& $h $row.Source)) ) } & $table @('Principal', 'Type', 'Resource', 'Permission', 'Consent', 'Source') $rows 'No permission grants found.' # ---- Coverage & $add '<h2 id="coverage">Collection coverage</h2>' $rows = foreach ($p in $Snapshot.Coverage.PSObject.Properties) { $c = $p.Value , @( (& $td (& $h $p.Name)) (& $td "<span class=""chip $(& $h $c.Status)"">$(& $h $c.Status)</span>") (& $td $c.Succeeded 'num') (& $td $c.Failed 'num') (& $td (& $h $c.Detail)) ) } & $table @('Data', 'Status', 'Calls succeeded', 'Calls failed', 'Detail') $rows 'No coverage information.' # ---- Footer & $add '<footer>' & $add "<p>Generated by $(ConvertTo-AgentIdHtmlLink $Branding.ProjectUrl "$($Branding.ProductName) $($Snapshot.ToolVersion)"). Collection is read-only; no secret values, certificate contents or tokens are collected.</p>" if ($Branding.ContactLine) { & $add "<p>$(& $h $Branding.ContactLine)</p>" } & $add '</footer></div></body></html>' return $sb.ToString() } |