src/report/renderers/Export-React.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Render the self-contained React single-page report from scored findings. .DESCRIPTION Same renderer contract as every other Export-* renderer: consumes the scored Findings object produced by Get-Score (GeneratedOn/Frameworks/Areas/Gaps/Manual/Errors/Findings) plus the raw Collect object. AB#6922 made this the product's deliverable -- everything the approved design (D:\tmp\azure-scout-react-mockups\unified-hcs.html) needs is built HERE so the front end (report-react.html.template) never re-derives anything from a thinner shape. The full payload contract (window.__SCOUT_DATA__) is: identity, meta, ran, inventory, subscriptions, assessments, resourceIndex, drift See each builder function below for the exact shape it produces. Everything is inlined into one report-react.html file -- CSS/JS/data all embedded -- so the report opens and works fully offline, same as every other renderer. .PARAMETER Findings The scored Findings object from Get-Score (merged run, or a single assessment's own score when this is called for a per-assessment folder -- see Invoke-ScoutAssessmentCore). .PARAMETER Collect The raw Collect object. .PARAMETER OutputPath Folder to write report-react.html into. Created if missing. Also used to derive `meta.runId` (its own leaf folder name is the run's timestamp id -- see Invoke-ScoutAssessmentCore). .PARAMETER Drift Optional. The drift object from Get-ScoutDrift. Embedded as-is; $null when omitted. .PARAMETER ReportIdentity Optional hashtable of report identity fields (AB#6930) -- clientName, engagementName, reportTitle, classification, etc. Unset fields fall back to neutral defaults (never a vendor name or URL) via Get-ScoutReportIdentityDefault. .OUTPUTS [string] the full path to the written report-react.html file. .NOTES Tracks ADO Story AB#5053 and Feature AB#6928 (AB#6929 payload / AB#6930 identity). #> function Export-React { param( $Findings, $Collect, [string] $OutputPath, $Drift = $null, [hashtable] $ReportIdentity = @{}, # AB#6928 follow-up (single-master-file decision) -- which of the front end's three view # lenses opens first when there is nothing persisted in the browser's localStorage yet # (the front end's own loadPersisted() always prefers a returning visitor's stored # choice; this only sets the FIRST-OPEN default). NOT a build-time filter -- all three # modes always ship in the one file; a `-ReportModes` switch that emitted only some of # them was discussed and explicitly deferred as speculative. [ValidateSet('Executive', 'Consultant', 'Data')] [string] $DefaultReportMode = 'Consultant' ) if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } # ---- shared safe-access helpers ----------------------------------------------------------- # Walks a dotted path one segment at a time, returning $null the moment any segment is # absent instead of throwing -- $Collect/$Findings may be a plain deserialized # PSCustomObject missing keys this renderer doesn't require, and Set-StrictMode -Version # Latest throws on a missing-property dot-access. function Get-ReactSafeProp { param($Object, [string[]] $Path) $cur = $Object foreach ($seg in $Path) { if ($null -eq $cur) { return $null } $prop = $cur.PSObject.Properties[$seg] if (-not $prop) { return $null } $cur = $prop.Value } return $cur } # Reads a property off a PSCustomObject/Hashtable evidence row without assuming its shape -- # every evidence row below is read this way rather than by dot-access, because Evidence rows # come from a dozen different collectors and no two of them share a field-name convention. function Get-ReactRowProp { param($Row, [string] $Name) if ($null -eq $Row) { return $null } if ($Row -is [System.Collections.IDictionary]) { if ($Row.Contains($Name)) { return $Row[$Name] } return $null } # A handful of Newtonsoft JToken shapes (observed on real rule output: a JSONPath query # that resolves to an object's own child PROPERTIES rather than array elements/objects) # slip past ConvertFrom-ReactJToken's normalisation with a .PSObject that PowerShell's # adapter cannot build a `Properties` collection for. Evidence identity is a best-effort # read, never a hard requirement -- degrading a genuinely unreadable row to "no value for # this field" is the right failure mode, not aborting the whole report. try { $p = $Row.PSObject.Properties[$Name] if ($p) { return $p.Value } } catch { return $null } return $null } # AB#6929 -- subscription id is embedded in almost every ARM resource id # ("/subscriptions/<guid>/..."), so a row carrying only an `id`/`ResourceId`/`keyVaultId`/ # `targetResourceId` field can still be attributed without a direct subscriptionId column. $armSubRegex = [regex]'(?i)/subscriptions/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})' $armRgRegex = [regex]'(?i)/resource[gG]roups/([^/]+)' function Get-ReactArmSubscriptionId { param([string] $ArmId) if ([string]::IsNullOrWhiteSpace($ArmId)) { return $null } $m = $armSubRegex.Match($ArmId) if ($m.Success) { return $m.Groups[1].Value } return $null } function Get-ReactArmResourceGroup { param([string] $ArmId) if ([string]::IsNullOrWhiteSpace($ArmId)) { return $null } $m = $armRgRegex.Match($ArmId) if ($m.Success) { return $m.Groups[1].Value } return $null } # ---- meta / ran ---------------------------------------------------------------------------- $metaSrc = Get-ReactSafeProp $Collect @('_meta') $subscriptions = @(Get-ReactSafeProp $Collect @('subscriptions')) # `ran` drives the adaptive nav -- which top-level sections the shell even offers -- AND the # derived reportTitle default just below, so it is computed here, ahead of the identity block. # `entra`: the Collect layer (Invoke-Collect) is ARM/Resource Graph only -- there is no # Entra/Graph collection path in this platform (see Invoke-ScoutAssessmentCore's own NOTES), # so this is always false for a Collect built by this pipeline. Left as an explicit field # (not just omitted) so a future Entra-aware Collect only has to flip this, not add a key. # # `@(Get-ReactSafeProp ...)` alone is NOT a safe truthiness check here: `@($null)` is an array # of ONE null element (`.Count` = 1), so a Collect that never carried the property at all -- # e.g. a bare `_meta`-only object from a CollectOnly run with no ARM access -- would still # read as "has inventory data". Test-ReactHasRow treats absent/null as zero explicitly. function Test-ReactHasRow { param($Value) return ($null -ne $Value -and @($Value).Count -gt 0) } $inventoryHasData = [bool]((Test-ReactHasRow (Get-ReactSafeProp $Collect @('subscriptions'))) -or (Test-ReactHasRow (Get-ReactSafeProp $Collect @('networking', 'virtualNetworks'))) -or (Test-ReactHasRow (Get-ReactSafeProp $Collect @('compute', 'virtualMachines')))) $allFindingsRows = @(Get-ReactSafeProp $Findings @('Findings')) $ran = [ordered]@{ inventory = $inventoryHasData entra = $false assessments = [bool]($allFindingsRows.Count -gt 0) } # AB#6930 default identity -- kept in its own function so tests can assert the neutral # fallback set without invoking the whole renderer. # # reportTitle (owner directive, AB#6928 follow-up 2026-08-04): the document is now ONE master # file carrying inventory plus whichever assessments were selected -- naming it after a single # assessment ("Azure Landing Zone Assessment") is a leftover from the per-assessment era and # is wrong the moment a run scopes to inventory only, or to a different assessment, or both. # Derived from the SAME `ran` flags that drive the adaptive nav, so the title always matches # what the reader is actually looking at. `-Ran` is passed in rather than read from a closure # variable so Get-ScoutReportIdentityDefault stays callable standalone in tests. function Get-ScoutReportIdentityDefault { param($Ran) $derivedTitle = if ($Ran -and $Ran.inventory -and $Ran.assessments) { 'Azure Inventory and Assessment' } elseif ($Ran -and $Ran.assessments) { 'Azure Assessment' } else { 'Azure Inventory' } [pscustomobject]@{ preparingOrganisation = '' clientName = 'Client Organization' engagementName = 'Cloud Governance & Well-Architected Assessment' reportTitle = $derivedTitle reportSubtitle = 'Cloud Governance & Well-Architected Review' classification = 'Confidential — Client Use Only' preparedBy = '' preparedFor = '' author = '' reviewer = '' reportDate = (Get-Date).ToString('yyyy-MM-dd') assessmentPeriod = '' scopeStatement = 'All Azure subscriptions and management groups in tenant scope at the time of collection.' documentReference = '' version = '1.0 (Draft)' handlingFooter = '' logoDataUri = '' } } $identityDefault = Get-ScoutReportIdentityDefault -Ran $ran $identity = [ordered]@{} foreach ($p in $identityDefault.PSObject.Properties) { # An explicitly supplied -ReportIdentity value ALWAYS wins over the derived default -- # including reportTitle, so a caller who wants "Azure Landing Zone Assessment" (or # anything else) back is never overruled by the ran-derived guess. $override = if ($ReportIdentity -and $ReportIdentity.ContainsKey($p.Name) -and $ReportIdentity[$p.Name]) { $ReportIdentity[$p.Name] } else { $null } $identity[$p.Name] = if ($override) { $override } else { $p.Value } } # "Confidential — do not distribute outside Acme Corp", falling back to the classification # alone when clientName is unset (never a dangling "outside —"). Mirrors the mockup's # fallbackHandlingFooter() in D:\tmp\azure-scout-react-mockups\_build\identity.cjs. if (-not $identity.handlingFooter) { $identity.handlingFooter = if ($ReportIdentity -and $ReportIdentity.ContainsKey('clientName') -and $ReportIdentity['clientName']) { "$($identity.classification) — do not distribute outside $($identity.clientName)" } else { $identity.classification } } # Root management group's displayName (parent == '') stands in for "tenant display name" -- # Collect carries no dedicated tenant-name field. Falls back to the first subscription's # name, then blank; never throws when governance data wasn't collected. $mgRows = @(Get-ReactSafeProp $Collect @('governance', 'managementGroups')) $rootMg = $mgRows | Where-Object { $null -ne (Get-ReactRowProp $_ 'parent') -and [string](Get-ReactRowProp $_ 'parent') -eq '' } | Select-Object -First 1 $tenantDisplayName = if ($rootMg) { Get-ReactRowProp $rootMg 'displayName' } elseif ($subscriptions.Count -gt 0) { Get-ReactRowProp $subscriptions[0] 'name' } else { '' } # productName/Version/Url read from the module's own manifest rather than hardcoded, so a # version bump or the pending org move (AB#-tracked in the mockup's identity.cjs) is a # one-line edit there, not two. Falls back to the mockup's known-good literals if the # manifest can't be read standalone (e.g. under Pester without the module imported). $moduleInfo = Get-Module -Name AzureScout -ErrorAction SilentlyContinue | Select-Object -First 1 $productVersion = if ($moduleInfo) { $moduleInfo.Version.ToString() } else { try { (Import-PowerShellDataFile "$PSScriptRoot/../../../AzureScout.psd1").ModuleVersion } catch { '3.3.4' } } $productUrl = 'https://thisismydemo.cloud/azure-scout/' $runId = if ($OutputPath) { Split-Path $OutputPath -Leaf } else { '' } $meta = [ordered]@{ scope = Get-ReactSafeProp $metaSrc @('scope') managementGroupId = Get-ReactSafeProp $metaSrc @('managementGroupId') generatedOn = (Get-ReactSafeProp $Findings @('GeneratedOn')) runId = $runId tenantDisplayName = $tenantDisplayName productName = 'Azure Scout' productVersion = $productVersion productUrl = $productUrl # Lower-cased to match the front end's own state.mode / data-mode vocabulary # ('executive'/'consultant'/'data') verbatim -- see report-react.html.template's # loadPersisted(), which should prefer this over its current hardcoded 'executive' # whenever localStorage has nothing stored yet. defaultMode = $DefaultReportMode.ToLowerInvariant() } # ---- resourceGroup -> subscriptionId backfill map ------------------------------------------- # AB#6929 -- built from every ARM-id-bearing evidence row across the WHOLE findings set before # any per-finding normalisation runs, so a sibling row that carries a full ARM id can donate # its subscription to another row in the SAME resource group that only carries a bare # `resourceGroup` name (the majority of the cost-cleanup / NSG-rule / disk shapes -- see the # evidence shape catalogue below). $rgToSub = @{} function Register-ReactRgMapping { param([string] $ResourceGroup, [string] $SubscriptionId) if ([string]::IsNullOrWhiteSpace($ResourceGroup) -or [string]::IsNullOrWhiteSpace($SubscriptionId)) { return } $key = $ResourceGroup.ToLowerInvariant() if (-not $rgToSub.ContainsKey($key)) { $rgToSub[$key] = $SubscriptionId } } # subscription NAME -> id, for evidence shapes that only carry a friendly name (Advisor rows: # `Subscription` = "This Is My Demo - MVP Subscription", never a guid). $subNameToId = @{} foreach ($s in $subscriptions) { $sName = Get-ReactRowProp $s 'name'; $sId = Get-ReactRowProp $s 'id' if ($sName -and $sId) { $subNameToId[$sName] = $sId } } # AB#6929 -- a resource NAME -> {resourceId,subscriptionId,resourceGroup} index built once # from the WHOLE raw Collect object (not just what happens to appear as evidence). A finding's # Evidence is a filtered/failed slice of the estate (e.g. a subnet-utilisation row carries no # subscriptionId or resourceGroup at all -- {vnet,subnet,prefix,total,used,ipUtilizationPct}), # but the SAME vnet almost always exists elsewhere in Collect (networking.virtualNetworks) WITH # both. This closes the gap the evidence-only rg->sub map above cannot: no sibling evidence row # ever donates a subscription for a resource group that is never itself named as evidence. $collectNameIndex = @{} function Add-ReactCollectNameIndexRow { param($Row) if ($null -eq $Row -or $Row -isnot [pscustomobject]) { return } $n = Get-ReactRowProp $Row 'name' if (-not $n) { $n = Get-ReactRowProp $Row 'displayName' } if (-not $n) { return } $rid = Get-ReactRowProp $Row 'id' if (-not $rid) { $rid = Get-ReactRowProp $Row 'ResourceId' } $sub = Get-ReactRowProp $Row 'subscriptionId' if (-not $sub) { $sub = Get-ReactRowProp $Row 'SubscriptionId' } if (-not $sub -and $rid) { $sub = Get-ReactArmSubscriptionId $rid } $rg = Get-ReactRowProp $Row 'resourceGroup' if (-not $rg) { $rg = Get-ReactRowProp $Row 'ResourceGroup' } if (-not $rg -and $rid) { $rg = Get-ReactArmResourceGroup $rid } if (-not $rid -and -not $sub -and -not $rg) { return } # nothing worth indexing $key = $n.ToLowerInvariant() if (-not $collectNameIndex.ContainsKey($key)) { $collectNameIndex[$key] = [pscustomobject]@{ ResourceId = $rid; SubscriptionId = $sub; ResourceGroup = $rg } } Register-ReactRgMapping -ResourceGroup $rg -SubscriptionId $sub } function Add-ReactCollectNameIndexNode { param($Node) if ($null -eq $Node) { return } if ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string] -and $Node -isnot [System.Collections.IDictionary]) { foreach ($row in @($Node)) { Add-ReactCollectNameIndexRow -Row $row } return } if ($Node -is [System.Collections.IDictionary]) { foreach ($k in $Node.Keys) { Add-ReactCollectNameIndexNode -Node $Node[$k] } return } if ($Node -is [pscustomobject]) { foreach ($p in ($Node.PSObject.Properties | Where-Object { $_.Name -ne '_meta' })) { Add-ReactCollectNameIndexNode -Node $p.Value } } } if ($Collect) { foreach ($p in ($Collect.PSObject.Properties | Where-Object { $_.Name -ne '_meta' })) { Add-ReactCollectNameIndexNode -Node $p.Value } } # A rule-set-generated diagnostic/policy/RBAC COVERAGE row -- exactly {type,total,withDiag, # coveragePct} -- names a resource TYPE's coverage, never one resource instance. Excluded from # resourceIndex (there is no single resource to click through to) even though its `type` # string is still shown as the evidence row's resourceName inside the finding itself. function Test-ReactCoverageRow { param($Row) if ($null -eq $Row -or $Row -isnot [pscustomobject]) { return $false } $names = @($Row.PSObject.Properties.Name | Sort-Object) return (($names -join ',') -eq 'coveragePct,total,type,withDiag') } # Flattens one raw evidence row to its identity fields WITHOUT yet resolving the # resourceGroup->subscription backfill (that needs the map fully built first, so this is # split into a "harvest" pass and a "resolve" pass below). function Get-ReactEvidenceIdentity { param($Row) # Defensive re-normalisation: the caller (ConvertTo-ReactEvidenceList) already converts # $Row before calling this, but a JOIN row's OWN Left/Right members are read via # Get-ReactRowProp below and must be normalised too -- ConvertFrom-ReactJToken is # idempotent (a no-op on an already-native value), so calling it again here costs nothing # on the common path and closes the gap on every path that reaches this function directly. $Row = ConvertFrom-ReactJToken -Value $Row # Cross-resource join rows (Resolve-RuleJoin output): {JoinMode,Key,Left,Right,...}. # Prefer Left (the primary side of the join); fall back to Right when Left is $null # (a rightOnly join match). $joinLeft = ConvertFrom-ReactJToken -Value (Get-ReactRowProp $Row 'Left') $joinRight = ConvertFrom-ReactJToken -Value (Get-ReactRowProp $Row 'Right') $isJoinRow = $null -ne (Get-ReactRowProp $Row 'JoinMode') $identitySource = if ($isJoinRow) { if ($joinLeft) { $joinLeft } else { $joinRight } } else { $Row } $name = $null foreach ($n in @('name', 'keyVaultName', 'nsg', 'displayName')) { $v = Get-ReactRowProp $identitySource $n if ($v) { $name = $v; break } } # Subnet rows carry no `name` at all -- {vnet,subnet,prefix,total,used,ipUtilizationPct}. # Checked BEFORE the bare `vnet` fallback below: a row with both `vnet` and `subnet` must # resolve to "vnet/subnet", not just "vnet" (which would collide with the vnet's OWN # resourceIndex entry and silently merge two different resources). if (-not $name) { $vnet = Get-ReactRowProp $identitySource 'vnet'; $subnet = Get-ReactRowProp $identitySource 'subnet' if ($vnet -and $subnet) { $name = "$vnet/$subnet" } elseif ($vnet) { $name = $vnet } } # Advisor rows: {..., ImpactedValue, ...} names the flagged resource. if (-not $name) { $name = Get-ReactRowProp $identitySource 'ImpactedValue' } # Diagnostic-coverage summary rows carry no resource identity at all -- {type,total, # withDiag,coveragePct} describes a resource TYPE's coverage, not one resource. if (-not $name) { $name = Get-ReactRowProp $identitySource 'type' } if (-not $name -and $isJoinRow) { # Both sides null is not reachable in practice, but degrade to the join Key (an ARM # id) rather than an empty string. $name = Get-ReactRowProp $Row 'Key' } $resourceId = $null foreach ($n in @('id', 'ResourceId', 'keyVaultId', 'targetResourceId', 'sourceResourceId')) { $v = Get-ReactRowProp $identitySource $n if ($v) { $resourceId = $v; break } } if (-not $resourceId -and $isJoinRow) { $resourceId = Get-ReactRowProp $Row 'Key' } $subscriptionId = $null foreach ($n in @('subscriptionId', 'SubscriptionId')) { $v = Get-ReactRowProp $identitySource $n if ($v) { $subscriptionId = $v; break } } if (-not $subscriptionId -and $resourceId) { $subscriptionId = Get-ReactArmSubscriptionId $resourceId } if (-not $subscriptionId) { $subName = Get-ReactRowProp $identitySource 'Subscription' if ($subName -and $subNameToId.ContainsKey($subName)) { $subscriptionId = $subNameToId[$subName] } } $resourceGroup = $null foreach ($n in @('resourceGroup', 'ResourceGroup')) { $v = Get-ReactRowProp $identitySource $n if ($v) { $resourceGroup = $v; break } } if (-not $resourceGroup -and $resourceId) { $resourceGroup = Get-ReactArmResourceGroup $resourceId } # A subnet row's only identity is its parent vnet's NAME (the row itself carries no # subscriptionId/resourceGroup at all) -- consult the whole-Collect name index built above # so "vnet-gh-runners-eus2" resolves to the same subscription/resource group the vnet's # own inventory row carries, even when no OTHER evidence row for that vnet happens to # appear in this findings set. Looked up by the VNET name specifically (not the combined # "vnet/subnet" resourceName) -- the index is built from Collect's virtualNetworks list, # which is keyed by vnet name alone. $lookupName = if ($isJoinRow) { $null } else { Get-ReactRowProp $identitySource 'vnet' } if (-not $lookupName) { $lookupName = $name } if ((-not $subscriptionId -or -not $resourceGroup -or -not $resourceId) -and $lookupName) { $hit = $collectNameIndex[$lookupName.ToLowerInvariant()] if ($hit) { if (-not $subscriptionId -and $hit.SubscriptionId) { $subscriptionId = $hit.SubscriptionId } if (-not $resourceGroup -and $hit.ResourceGroup) { $resourceGroup = $hit.ResourceGroup } if (-not $resourceId -and $lookupName -eq $name -and $hit.ResourceId) { $resourceId = $hit.ResourceId } } } [pscustomobject]@{ ResourceName = $name ResourceId = $resourceId SubscriptionId = $subscriptionId ResourceGroup = $resourceGroup Row = $Row IsJoinRow = $isJoinRow JoinLeft = $joinLeft JoinRight = $joinRight IsCoverageRow = (Test-ReactCoverageRow -Row $identitySource) } } # Builds the human-readable `detail` string from whatever's left on the row after the # identity fields are pulled out. Only scalar-typed properties are surfaced -- nested # `properties` bags and array-typed fields (e.g. PolicyDefinitionGroupName) are skipped so # `detail` stays a short, printable line, not a second copy of the ARM payload. $identityFieldNames = @( 'name', 'id', 'ResourceId', 'resourceId', 'keyVaultId', 'keyVaultName', 'nsg', 'displayName', 'vnet', 'subnet', 'ImpactedValue', 'type', 'subscriptionId', 'SubscriptionId', 'Subscription', 'resourceGroup', 'ResourceGroup', 'properties', 'targetResourceId', 'sourceResourceId', 'Left', 'Right', 'JoinMode', 'Key', 'LeftLabel', 'RightLabel' ) function Get-ReactEvidenceDetail { param($Identity) if ($Identity.IsJoinRow) { $mode = Get-ReactRowProp $Identity.Row 'JoinMode' $leftLabel = Get-ReactRowProp $Identity.Row 'LeftLabel' $rightLabel = Get-ReactRowProp $Identity.Row 'RightLabel' $joinDetail = switch ($mode) { 'leftOnly' { "$leftLabel present, no matching $rightLabel" } 'rightOnly' { "$rightLabel present, no matching $leftLabel" } default { "$leftLabel / $rightLabel ($mode)" } } return $joinDetail } $row = $Identity.Row $props = if ($row -is [System.Collections.IDictionary]) { $row.Keys } else { $row.PSObject.Properties.Name } $parts = [System.Collections.Generic.List[string]]::new() foreach ($n in $props) { if ($identityFieldNames -contains $n) { continue } $v = Get-ReactRowProp $row $n if ($null -eq $v) { continue } if ($v -is [System.Collections.IEnumerable] -and $v -isnot [string]) { continue } if ($v -is [System.Collections.IDictionary] -or ($v.PSObject -and $v.PSObject.TypeNames -contains 'System.Management.Automation.PSCustomObject' -and $v -isnot [string])) { continue } [void]$parts.Add("$n=$v") if ($parts.Count -ge 6) { break } } return ($parts -join '; ') } # A LIVE run's Evidence rows come straight out of Resolve-JsonPath, which is built on the # Newtonsoft JSONPath engine and returns Newtonsoft.Json.Linq.JObject/JArray/JValue tokens -- # NOT deserialized PSCustomObjects. (A run re-read from a written findings.json, as every # test/corpus fixture in this repo does, has already round-tripped through ConvertFrom-Json # and never hits this path.) Get-ReactRowProp's PSObject.Properties[...] lookup throws on a # JObject, so every row is normalised to a plain PSCustomObject/array up front via one JSON # round-trip -- cheap relative to the rest of this renderer, and it means every other helper # in this file only ever has to reason about one shape. function ConvertFrom-ReactJToken { param($Value) if ($null -eq $Value) { return $null } $typeName = $Value.GetType().FullName if ($typeName -notlike 'Newtonsoft.Json.Linq.*') { return $Value } # AB#6928 (94%-junk-evidence root cause). Branch on .NET's OWN GetType().FullName -- # 'Newtonsoft.Json.Linq.JObject' / 'JArray' / 'JValue' / 'JProperty' -- NOT Newtonsoft's own # `.Type` (JTokenType) property. `$Value.Type` reads as an EMPTY STRING through PowerShell's # member adapter for a real JObject (confirmed with a live reproducer: `.Type=` printed # blank for a genuine 7-property VM object) -- every JObject/JArray then silently fell into # the "not Object/Array" scalar branch below and got sent through `.ToObject([object])`, # which for a JObject returns a `System.Object[]`. That `System.Object[]` is exactly the # ".NET array metadata" evidence (Length/LongLength/Rank/IsReadOnly/...) the owner's review # measured at 94% of all evidence rows on tppoc -- this was never a rare edge case, it was # the DEFAULT path for every ordinary evidence row a rule's JSONPath returns. GetType() has # been the reliable signal throughout every reproducer used to find this; PowerShell's own # type name is unambiguous where Newtonsoft's own enum property is not. $typeShortName = $typeName.Substring($typeName.LastIndexOf('.') + 1) # A rule whose JSONPath query resolves to a wildcard over an object's OWN properties # (rather than array elements) hands Resolve-JsonPath back JProperty tokens -- a bare # "name":"pa3" pair, not the whole { name, properties } resource. Wrapping it into a # single-key object keeps the rest of the identity-resolution pipeline working (it can # still read that one key) instead of throwing on a shape none of it expects. if ($typeShortName -eq 'JProperty') { $wrapped = [pscustomobject]@{} $wrapped | Add-Member -NotePropertyName $Value.Name -NotePropertyValue (ConvertFrom-ReactJToken -Value $Value.Value) return $wrapped } # A scalar leaf (JValue: String/Integer/Boolean/...) is NOT a JSON container -- ToString() # on it renders the raw .NET value (e.g. an UNQUOTED string), which is not valid JSON on # its own and would make ConvertFrom-Json throw below, silently falling through to the # unconverted JToken (the exact bug this function exists to prevent). `.Value` returns the # boxed native scalar directly. if ($typeShortName -eq 'JValue') { return $Value.Value } if ($typeShortName -notin @('JObject', 'JArray')) { return $Value } # JObject/JArray: a full JSON round-trip is the one Newtonsoft operation confirmed to # produce correct, native PowerShell PSCustomObject/array shapes for these two container # types (ToString(Formatting.None) always emits well-formed JSON for a container, unlike # a JValue leaf). try { return ($Value.ToString([Newtonsoft.Json.Formatting]::None) | ConvertFrom-Json -Depth 100) } catch { return $Value } } # ---- resolve one finding's Evidence into the normalised {resourceName,resourceId, # subscriptionId,detail} shape the contract requires, harvesting rg->sub mappings as it goes. function ConvertTo-ReactEvidenceList { param($RawEvidence) $RawEvidence = ConvertFrom-ReactJToken -Value $RawEvidence # Evidence is sometimes a bare object instead of an array (a rule that resolved to # exactly one match via Resolve-JsonPath's -NoEnumerate contract, or a hand-built # fixture) -- wrap it so every caller can iterate uniformly. $rows = if ($null -eq $RawEvidence) { @() } elseif ($RawEvidence -is [string]) { @() } # a bare scalar carries no resource identity elseif ($RawEvidence -is [System.Collections.IEnumerable]) { @($RawEvidence) } else { @($RawEvidence) } $out = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($row in $rows) { $row = ConvertFrom-ReactJToken -Value $row if ($null -eq $row) { continue } $ident = Get-ReactEvidenceIdentity -Row $row Register-ReactRgMapping -ResourceGroup $ident.ResourceGroup -SubscriptionId $ident.SubscriptionId $out.Add($ident) } return $out } # ---- walk every finding once: normalise evidence, harvest rg->sub map ----------------------- # Two passes are required because the rg->sub backfill (pass 2) needs the WHOLE map built # first -- a row late in the findings list may be the only one that donates a subscription # id for a resource group whose other rows appear earlier. $normalisedByFindingIndex = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($f in $allFindingsRows) { $evList = ConvertTo-ReactEvidenceList -RawEvidence (Get-ReactSafeProp $f @('Evidence')) $normalisedByFindingIndex.Add([pscustomobject]@{ Finding = $f; Evidence = $evList }) } foreach ($entry in $normalisedByFindingIndex) { foreach ($ident in $entry.Evidence) { if (-not $ident.SubscriptionId -and $ident.ResourceGroup) { $key = $ident.ResourceGroup.ToLowerInvariant() if ($rgToSub.ContainsKey($key)) { $ident.SubscriptionId = $rgToSub[$key] } } } } # ---- assessments[] + resourceIndex ---------------------------------------------------------- $manifest = $null try { $manifest = Import-PowerShellDataFile "$PSScriptRoot/../../../manifests/assessments.psd1" } catch { $manifest = $null } # ---- learnUrl resolver ------------------------------------------------------------------------ # AB#6928 follow-up -- ported (not copied verbatim; same precedence, PowerShell-native) from # the approved mockup's resolver, D:\tmp\azure-scout-react-mockups\_build\generate.js # (CAF_DESIGN_AREAS / WAF_PILLARS / DOMAIN_LINKS / SPECIALTY_LINKS / FINDING_LINK_RULES / # classify() / findingLearnLink()). Precedence, most specific first: # 1. finding id/title regex override (topic-specific: backup, NSG, private endpoint, ...) -- # checked FIRST so a cross-cutting rule (e.g. an XR-* join, or a backup/NSG/PIM finding # that surfaces inside several different assessments) always resolves to the page for # what it's actually ABOUT, not the page for whichever assessment happened to run it. # 2. the assessment's own slug -- CAF design area / WAF pillar / domain / specialty map. # 3. a framework-level root (CAF or WAF design-areas index) -- NEVER an unrelated topic. # Every URL below is either one `scripts/Test-ScoutGuidanceLinks.ps1` already audits live # (AB#6913 — 26 URLs verified), or a well-known top-level Learn product/service root. Rule #1 # learned from that same audit: a rule file's OWN cited URL can 404 even after being marked # "verified" (Microsoft renames pages) -- this curated map is the source of truth, not a blind # pass-through of whatever a rule file cites. $cafDesignAreaLinks = @{ 'caf-azure-billing-and-microsoft-entra-tenant' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-areas' 'caf-identity-and-access-management' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-areas' 'caf-resource-organization' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-areas' 'caf-network-topology-and-connectivity' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-areas' 'caf-governance' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-area/governance' 'caf-security' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-area/security' 'caf-management' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-areas' 'caf-platform-automation-and-devops' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-areas' } $wafPillarLinks = @{ 'waf-reliability' = 'https://learn.microsoft.com/azure/well-architected/reliability/' 'waf-security' = 'https://learn.microsoft.com/azure/well-architected/security/' 'waf-cost-optimization' = 'https://learn.microsoft.com/azure/well-architected/cost-optimization/' 'waf-operational-excellence' = 'https://learn.microsoft.com/azure/well-architected/operational-excellence/' 'waf-performance-efficiency' = 'https://learn.microsoft.com/azure/well-architected/performance-efficiency/' 'waf-maturity-model' = 'https://learn.microsoft.com/azure/well-architected/' } $domainLinks = @{ 'assess-ai' = 'https://learn.microsoft.com/azure/ai-services/' 'assess-ai-workload' = 'https://learn.microsoft.com/azure/well-architected/ai/' 'assess-analytics' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/scenarios/data-management/' 'assess-avd-workload' = 'https://learn.microsoft.com/azure/architecture/landing-zones/azure-virtual-desktop/design-guide' 'assess-cloud-governance' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/govern/' 'assess-compliance' = 'https://learn.microsoft.com/azure/governance/policy/overview' 'assess-compute' = 'https://learn.microsoft.com/azure/architecture/framework/services/compute/' 'assess-containers' = 'https://learn.microsoft.com/azure/aks/best-practices' 'assess-databases' = 'https://learn.microsoft.com/azure/architecture/framework/services/data/' 'assess-hybrid' = 'https://learn.microsoft.com/azure/azure-arc/overview' 'assess-identity' = 'https://learn.microsoft.com/entra/fundamentals/' 'assess-integration' = 'https://learn.microsoft.com/azure/architecture/framework/services/messaging/' 'assess-iot' = 'https://learn.microsoft.com/azure/iot/iot-introduction' 'assess-management' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/manage/' 'assess-monitor' = 'https://learn.microsoft.com/azure/azure-monitor/overview' 'assess-networking' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-areas' 'assess-security' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-area/security' 'assess-storage' = 'https://learn.microsoft.com/azure/storage/common/storage-introduction' 'assess-web' = 'https://learn.microsoft.com/azure/app-service/overview' } $specialtyLinks = @{ 'cost' = 'https://learn.microsoft.com/azure/well-architected/cost-optimization/' 'crossresource' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-areas' 'devops-capability-assessment' = 'https://learn.microsoft.com/azure/devops/' 'finops-review' = 'https://learn.microsoft.com/azure/cost-management-billing/finops/overview-finops' 'governance' = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-area/governance' 'monitoring' = 'https://learn.microsoft.com/azure/azure-monitor/overview' 'smart' = 'https://learn.microsoft.com/azure/migrate/migrate-services-overview' 'updatemanager' = 'https://learn.microsoft.com/azure/update-manager/overview' } # Finding id/title overrides -- ordered list, first match wins. Checked BEFORE the # assessment-level map (see precedence note above). $findingLinkRules = [System.Collections.Generic.List[pscustomobject]]::new() $findingLinkRuleDefs = @( @{ p = '^XR-BKP'; u = 'https://learn.microsoft.com/azure/backup/backup-overview' } @{ p = '^XR-STO'; u = 'https://learn.microsoft.com/azure/storage/common/storage-private-endpoints' } @{ p = '^XR-KV'; u = 'https://learn.microsoft.com/azure/key-vault/general/private-link-service' } @{ p = '^/providers/microsoft\.authorization/policydefinitions/'; u = 'https://learn.microsoft.com/azure/governance/policy/overview' } @{ p = '^CAF-STO|^WAF-STO'; u = 'https://learn.microsoft.com/azure/storage/common/storage-introduction' } @{ p = '^CAF-DB'; u = 'https://learn.microsoft.com/azure/architecture/framework/services/data/' } @{ p = '^CAF-CON'; u = 'https://learn.microsoft.com/azure/aks/best-practices' } @{ p = '^CAF-HYB'; u = 'https://learn.microsoft.com/azure/azure-arc/overview' } @{ p = '^CAF-ANL'; u = 'https://learn.microsoft.com/azure/cloud-adoption-framework/scenarios/data-management/' } @{ p = '^CAF-AI'; u = 'https://learn.microsoft.com/azure/ai-services/' } @{ p = '^WAF-AI'; u = 'https://learn.microsoft.com/azure/well-architected/ai/' } @{ p = '^WAF-AVD'; u = 'https://learn.microsoft.com/azure/architecture/landing-zones/azure-virtual-desktop/design-guide' } @{ p = '^WAF-AZLOCAL'; u = 'https://learn.microsoft.com/azure/azure-local/' } @{ p = '^DEVOPS-'; u = 'https://learn.microsoft.com/azure/devops/' } @{ p = '^FINOPS-'; u = 'https://learn.microsoft.com/azure/cost-management-billing/finops/overview-finops' } @{ p = '^CGOV-'; u = 'https://learn.microsoft.com/azure/cloud-adoption-framework/govern/' } @{ p = '^SMART-'; u = 'https://learn.microsoft.com/azure/migrate/migrate-services-overview' } @{ p = '^CASA-'; u = 'https://learn.microsoft.com/security/' } @{ p = 'nsg rule|unrestricted.*inbound|inbound exposure'; u = 'https://learn.microsoft.com/azure/virtual-network/network-security-groups-overview' } @{ p = 'private endpoint'; u = 'https://learn.microsoft.com/azure/private-link/private-endpoint-overview' } @{ p = 'private dns zone'; u = 'https://learn.microsoft.com/azure/dns/private-dns-privatednszone' } @{ p = 'backup'; u = 'https://learn.microsoft.com/azure/backup/backup-overview' } @{ p = 'diagnostic setting'; u = 'https://learn.microsoft.com/azure/azure-monitor/essentials/diagnostic-settings' } @{ p = 'log analytics'; u = 'https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-workspace-overview' } @{ p = 'purview'; u = 'https://learn.microsoft.com/purview/purview' } @{ p = 'pim|privileged identity'; u = 'https://learn.microsoft.com/entra/id-governance/privileged-identity-management/pim-configure' } ) foreach ($def in $findingLinkRuleDefs) { [void]$findingLinkRules.Add([pscustomobject]@{ Pattern = [regex]::new($def.p, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase); Url = $def.u }) } # Per-assessment fallback link -- mirrors generate.js's classify(). Never a topic-unrelated # default: an unrecognised slug lands on the CAF design-areas INDEX (a real, on-topic landing # zone page), not a cost-management or other specialty page (the exact wrong-fallback class # the owner's team already hit once and corrected in the mockup). function Get-ReactAssessmentLearnUrl { param([string] $Slug) if ($Slug -eq 'landingzone') { return 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/' } if ($cafDesignAreaLinks.ContainsKey($Slug)) { return $cafDesignAreaLinks[$Slug] } if ($wafPillarLinks.ContainsKey($Slug)) { return $wafPillarLinks[$Slug] } if ($domainLinks.ContainsKey($Slug)) { return $domainLinks[$Slug] } if ($Slug -like 'avs-*') { return 'https://learn.microsoft.com/azure/azure-vmware/' } if ($Slug -eq 'casa') { return 'https://learn.microsoft.com/security/' } if ($specialtyLinks.ContainsKey($Slug)) { return $specialtyLinks[$Slug] } return 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/design-areas' } function Get-ReactFindingLearnUrl { param([string] $Id, [string] $Title, [string] $AssessmentSlug) $haystack = (@($Id, $Title) | Where-Object { $_ }) -join ' ' foreach ($rule in $findingLinkRules) { if ($rule.Pattern.IsMatch($haystack)) { return $rule.Url } } return Get-ReactAssessmentLearnUrl -Slug $AssessmentSlug } $sevRank = @{ high = 0; medium = 1; low = 2 } $resourceIndex = [ordered]@{} function Add-ReactResourceIndexEntry { param([string] $ResourceName, $Identity, [string] $Category, [string] $FindingId) if ([string]::IsNullOrWhiteSpace($ResourceName)) { return } # Coverage-by-type aggregate rows are not a resource -- see Test-ReactCoverageRow. They # still render inside the finding's own evidence list (resourceName = the type string); # they just never become a clickable entry in the global index. if ($Identity.IsCoverageRow) { return } if (-not $resourceIndex.Contains($ResourceName)) { $resourceIndex[$ResourceName] = [ordered]@{ resourceId = $Identity.ResourceId subscriptionId = $Identity.SubscriptionId resourceGroup = $Identity.ResourceGroup category = $Category findingIds = [System.Collections.Generic.List[string]]::new() } } $entry = $resourceIndex[$ResourceName] # A resource named identically in two evidence rows keeps whichever identity fields it # already has; only fill gaps, never overwrite a known id with a blank one. if (-not $entry.resourceId -and $Identity.ResourceId) { $entry.resourceId = $Identity.ResourceId } if (-not $entry.subscriptionId -and $Identity.SubscriptionId) { $entry.subscriptionId = $Identity.SubscriptionId } if (-not $entry.resourceGroup -and $Identity.ResourceGroup) { $entry.resourceGroup = $Identity.ResourceGroup } if (-not $entry.findingIds.Contains($FindingId)) { [void]$entry.findingIds.Add($FindingId) } } # Group by the `Assessment` property Invoke-Assessment stamps onto every finding. A merged # (run-root) findings set carries several distinct Assessment values; a per-assessment folder # render carries exactly one -- the same grouping logic handles both without a branch. $findingsByAssessment = [ordered]@{} for ($i = 0; $i -lt $allFindingsRows.Count; $i++) { $f = $allFindingsRows[$i] # Every finding-producing path (Invoke-Assessment's rule-set loop, its benchmark-compare # branch, Invoke-ScoutComplianceAssessment) stamps Assessment today -- see Invoke- # Assessment.ps1's own fix for the benchmark branch, which used to leave ALZ findings # unstamped and rendered as a nameless 0%-scoring "(unassigned)" card next to the real # assessments (found visually on the executive view, AB#6929 follow-up). This fallback is # a defensive backstop only, not an expected bucket -- named honestly rather than blank so # a future finding-building path that forgets to stamp Assessment still reads as "some # checks ran outside a named assessment", never as a mystery zero. $assessmentName = Get-ReactSafeProp $f @('Assessment') if (-not $assessmentName) { $assessmentName = 'Unassigned checks' } if (-not $findingsByAssessment.Contains($assessmentName)) { $findingsByAssessment[$assessmentName] = [System.Collections.Generic.List[int]]::new() } [void]$findingsByAssessment[$assessmentName].Add($i) } $assessments = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($assessmentName in $findingsByAssessment.Keys) { $idxList = $findingsByAssessment[$assessmentName] $assessmentFindingRows = @($idxList | ForEach-Object { $allFindingsRows[$_] }) $slug = ($assessmentName.ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-') $spec = if ($manifest -and $manifest.ContainsKey($assessmentName)) { $manifest[$assessmentName] } else { $null } $frameworkLabel = if ($spec -and $spec.ContainsKey('Frameworks') -and $spec.Frameworks) { ($spec.Frameworks -join ' + ') } else { (($assessmentFindingRows | ForEach-Object { $_.Framework } | Where-Object { $_ } | Select-Object -Unique) -join ' + ') } $category = if ($spec -and $spec.ContainsKey('Category')) { $spec.Category } else { $null } # Per-assessment area buckets, tallied INLINE from this assessment's slice of the # already-scored findings (each row carries Framework/Area/Status/Weight). The run-wide # $Findings.Areas would be wrong the moment two assessments render together in one # merged run -- and re-invoking the scoring engine here is exactly the renderer drift # conformance clause R-04 bans, so the grouping is a plain count over scored rows: no # finding is re-derived, no status is re-decided, and the percent arithmetic below is # the same visible formula the rest of this payload already uses. $areaBuckets = [ordered]@{} foreach ($row in $assessmentFindingRows) { $bKey = '{0}|{1}' -f (Get-ReactSafeProp $row @('Framework')), (Get-ReactSafeProp $row @('Area')) if (-not $areaBuckets.Contains($bKey)) { $areaBuckets[$bKey] = [pscustomobject]@{ Area = (Get-ReactSafeProp $row @('Area')) Pass = 0; Partial = 0; Fail = 0; Manual = 0; Unknown = 0; Error = 0; NotAssessed = 0 Score = $null; Weight = 0.0 } } $b = $areaBuckets[$bKey] switch ([string](Get-ReactSafeProp $row @('Status'))) { 'Pass' { $b.Pass++ } 'Partial' { $b.Partial++ } 'Fail' { $b.Fail++ } 'Manual' { $b.Manual++ } 'Error' { $b.Error++ } 'NotAssessed' { $b.NotAssessed++ } default { $b.Unknown++ } } $w = Get-ReactSafeProp $row @('Weight') if ($null -ne $w) { $b.Weight = [math]::Round($b.Weight + [double]$w, 4) } } foreach ($b in $areaBuckets.Values) { $bDen = $b.Pass + $b.Partial + $b.Fail if ($bDen -gt 0) { $b.Score = [math]::Round(($b.Pass + (0.5 * $b.Partial)) / $bDen * 100, 0, [System.MidpointRounding]::AwayFromZero) } } $areas = [System.Collections.Generic.List[pscustomobject]]::new() $totalPass = 0; $totalPartial = 0; $totalFail = 0; $totalManual = 0; $totalUnknown = 0; $totalError = 0; $totalNotAssessed = 0; $totalWeight = 0.0 foreach ($a in @($areaBuckets.Values)) { $den = $a.Pass + $a.Partial + $a.Fail $num = $a.Pass + (0.5 * $a.Partial) $excluded = $a.Manual + $a.Unknown + $a.Error + $a.NotAssessed $formula = if ($den -gt 0) { "($($a.Pass) pass + 0.5×$($a.Partial) partial) / $den automated = $($a.Score)%; $excluded excluded (manual/not-assessed)" } else { "No automated checks scored in this area; $excluded manual/not-assessed." } $areas.Add([pscustomobject]@{ name = $a.Area percent = $a.Score numerator = $num denominator = $den weight = $a.Weight excludedCount = $excluded formula = $formula }) $totalPass += $a.Pass; $totalPartial += $a.Partial; $totalFail += $a.Fail $totalManual += $a.Manual; $totalUnknown += $a.Unknown; $totalError += $a.Error; $totalNotAssessed += $a.NotAssessed $totalWeight += [double]$a.Weight } $scoreDen = $totalPass + $totalPartial + $totalFail $scoreNum = $totalPass + (0.5 * $totalPartial) $scoreExcluded = $totalManual + $totalUnknown + $totalError + $totalNotAssessed $scorePercent = if ($scoreDen -gt 0) { [math]::Round($scoreNum / $scoreDen * 100, 0, [System.MidpointRounding]::AwayFromZero) } else { $null } $scoreFormula = if ($scoreDen -gt 0) { "($totalPass pass + 0.5×$totalPartial partial) / $scoreDen automated = $scorePercent%; $scoreExcluded of $($assessmentFindingRows.Count) checks excluded (manual/not-assessed)" } else { "No automated checks scored in this assessment; $scoreExcluded of $($assessmentFindingRows.Count) checks are manual/not-assessed." } $excludedReason = '' if ($scoreDen -eq 0 -and $assessmentFindingRows.Count -gt 0) { $unknownRow = $assessmentFindingRows | Where-Object { $_.Status -eq 'Unknown' } | Select-Object -First 1 if ($unknownRow) { $excludedReason = [string]$unknownRow.Remediation } } # Area weight, denormalised onto each finding below (AB#6928 follow-up). The scoring # engine already uses per-area weight internally (Get-Score's AreaWeight); Microsoft's own # WAF export surfaces it on every recommendation row so a reader can see why one finding # moves the score more than another sitting in a lower-weighted area. Looked up by area # NAME rather than re-deriving it, since $areas above is already the authoritative # per-area weight for this assessment. $areaWeightByName = @{} foreach ($areaEntry in $areas) { $areaWeightByName[$areaEntry.name] = $areaEntry.weight } $findingsOut = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($i in $idxList) { $f = $allFindingsRows[$i] $compositeId = "${slug}:$($f.Id)" $evNorm = $normalisedByFindingIndex[$i].Evidence $evidenceOut = @($evNorm | ForEach-Object { [pscustomobject]@{ resourceName = $_.ResourceName resourceId = $_.ResourceId subscriptionId = $_.SubscriptionId detail = Get-ReactEvidenceDetail -Identity $_ } }) foreach ($ident in $evNorm) { if ($ident.ResourceName) { Add-ReactResourceIndexEntry -ResourceName $ident.ResourceName -Identity $ident -Category $category -FindingId $compositeId } } $findingsOut.Add([pscustomobject]@{ id = $compositeId title = $f.Title severity = $f.Severity status = $f.Status area = $f.Area remediation = $f.Remediation learnUrl = (Get-ReactFindingLearnUrl -Id $f.Id -Title $f.Title -AssessmentSlug $slug) # AB#6928 follow-up -- the area's own weight, denormalised here so the CSV export # (and any other per-finding view) can show it without a separate lookup against # `areas[]`. $null when the finding's area carries no explicit weight (Get-Score's # own 1.0 default applies at the SCORING layer; this field reports exactly what # that area object already carries, not a re-guessed default). weight = $areaWeightByName[$f.Area] evidenceCount = (Get-ReactSafeProp $f @('EvidenceCount')) evidence = $evidenceOut }) } # Fails first, weighted by severity; unknown/missing severity sorts LAST -- mirrors # Get-Score's own Gaps ordering so the two never disagree about priority. $findingsOut = @($findingsOut | Sort-Object ` @{ Expression = { if ($_.status -eq 'Fail') { 0 } else { 1 } } }, ` @{ Expression = { if ($_.severity -and $sevRank.ContainsKey($_.severity)) { $sevRank[$_.severity] } else { 99 } } }) $assessments.Add([pscustomobject]@{ slug = $slug name = $assessmentName framework = $frameworkLabel scope = [pscustomobject]@{ subscriptionsInScope = $subscriptions.Count checksTotal = $assessmentFindingRows.Count checksAutomated = $scoreDen checksManual = $totalManual checksNotAssessed = ($totalNotAssessed + $totalUnknown + $totalError) excludedReason = $excludedReason } score = [pscustomobject]@{ percent = $scorePercent numerator = $scoreNum denominator = $scoreDen weight = $totalWeight excludedCount = $scoreExcluded formula = $scoreFormula } areas = @($areas) findings = $findingsOut }) } # ---- inventory{} ----------------------------------------------------------------------------- # Generic recursive walker over $Collect: every array found (at any depth) becomes its own # inventory category keyed by its dotted path, so a category added to Collect tomorrow shows # up here with no renderer change. `_meta` is the only excluded branch (run metadata, not # inventory). Rows are capped so one enormous category (policy compliance can run into the # thousands) doesn't bloat the embedded payload; `truncated` records the honest shown/actual # split so the UI never presents a cap as a total (AB#6864's own rule, applied here too). $inventoryRowCap = 300 function Get-ReactInventoryLabel { param([string[]] $PathSegments) $titled = $PathSegments | ForEach-Object { # camelCase / PascalCase -> spaced Title Case: "virtualNetworks" -> "Virtual Networks". ($_ -creplace '([a-z0-9])([A-Z])', '$1 $2') -replace '^(.)', { $_.Value.ToUpperInvariant() } } return ($titled -join ' → ') } $inventory = [ordered]@{} function Add-ReactInventoryCategory { param($Node, [string[]] $PathSegments) if ($null -eq $Node) { return } if ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string] -and $Node -isnot [System.Collections.IDictionary]) { $rows = @($Node) $key = ($PathSegments -join '.') $shown = @($rows | Select-Object -First $inventoryRowCap) $inventory[$key] = [ordered]@{ label = Get-ReactInventoryLabel -PathSegments $PathSegments count = $rows.Count rows = $shown truncated = if ($rows.Count -gt $inventoryRowCap) { [pscustomobject]@{ shown = $shown.Count; actual = $rows.Count } } else { $null } } return } if ($Node -is [System.Collections.IDictionary]) { foreach ($k in $Node.Keys) { Add-ReactInventoryCategory -Node $Node[$k] -PathSegments ($PathSegments + [string]$k) } return } $props = $Node.PSObject.Properties | Where-Object { $_.Name -ne '_meta' } foreach ($p in $props) { Add-ReactInventoryCategory -Node $p.Value -PathSegments ($PathSegments + $p.Name) } } if ($Collect) { foreach ($p in ($Collect.PSObject.Properties | Where-Object { $_.Name -ne '_meta' })) { Add-ReactInventoryCategory -Node $p.Value -PathSegments @($p.Name) } } # ---- assemble + write ------------------------------------------------------------------------ $payload = [ordered]@{ identity = $identity meta = $meta ran = $ran inventory = $inventory subscriptions = @($subscriptions | ForEach-Object { [pscustomobject]@{ id = (Get-ReactRowProp $_ 'id'); name = (Get-ReactRowProp $_ 'name'); state = (Get-ReactRowProp $_ 'state') } }) assessments = @($assessments) resourceIndex = $resourceIndex drift = $Drift } # </script> inside embedded JSON would otherwise close the <script> tag early. $json = ($payload | ConvertTo-Json -Depth 100) -replace '</', '<\/' $tplPath = "$PSScriptRoot/../templates/report-react.html.template" $tpl = Get-Content $tplPath -Raw $html = $tpl.Replace('/*__SCOUT_DATA__*/', $json) $outFile = Join-Path $OutputPath 'report-react.html' $html | Out-File $outFile -Encoding utf8 return $outFile } |