Public/Add-DhFindings.ps1
|
function Add-DhFindings { <# .SYNOPSIS Add a list of assessment findings - severity, description, recommendation, affected objects and references - as one expandable block. .DESCRIPTION v2.2.0. The block an audit or assessment report is built around. Each finding is one row (severity badge, title, area, affected count) that expands to show what is wrong, what to do about it, which objects are affected, a "Show in table" jump to the detail table, and reference links (for example Microsoft Learn). Above the list: severity counters that double as filters (click to show only that severity, click more to combine), and a text filter over every field of every finding. Findings are sorted critical -> warning -> info -> ok, then by affected count, largest first (-SortBy None keeps your order). When printed, every finding is shown expanded. All text is HTML-encoded. There is no -AllowHtml: finding text usually comes from collected data. .PARAMETER Report Dashboard object from New-DhDashboard. .PARAMETER Id Unique block identifier (alphanumeric, - or _). .PARAMETER Title Block heading. .PARAMETER Findings Array of hashtables: @{ Severity = 'critical' # REQUIRED critical|warning|info|ok # (aliases: high, medium, low) Title = 'AS-REP roastable accounts' # REQUIRED Area = 'Accounts' # optional grouping / filter text Description = 'Accounts with DoesNotRequirePreAuth ...' Recommendation = 'Clear DONT_REQ_PREAUTH on every listed account.' Affected = @('svc_old','test1') # optional names AffectedCount = 2 # optional; defaults to Affected.Count AffectedTableId = 'risky-accounts' # optional: "Show in table" target AffectedFilter = 'AS-REP' # optional text filter for that table References = @(@{ Label='MS Learn'; Url='https://learn.microsoft.com/...' }) Id = 'asrep' # optional stable id (default f1, f2, ...) # ---- v2.3.0 ---- Facts = @(@{ Label='Recommended'; Value='5 - NTLMv2 only'; Status='ok' }) # tiles Evidence = @(@{ Object='VIT00-DC01'; Value='Level 4'; Status='warn'; Note='' }) # one row per object Change = 'new' # new | worse | better | resolved: change since the last run Detail = @{ Title='..'; Sections=@(...) } # "Open details" opens it in the side panel } Facts are label/value tiles (Status colours them). Evidence is the per-object proof: Object is required, Value / Status / Note are optional (a column appears only if some row uses it); at most -MaxEvidenceShown rows are drawn, the rest as "+N more" (use AffectedTableId for the full list). Change adds a badge and a filter chip; the script does the comparison with the previous run, DashHtml only shows it. Detail takes the same shapes as a status-grid cell's Detail (text, a section, an array of sections, or @{ Title; Subtitle; Sections }). Reference URLs must be http(s), mailto, tel or an in-page #anchor - the same rule as alert and tile actions. Anything else throws. .PARAMETER AllClearText v2.3.0. A short sentence shown with a large check mark when the block has no findings (an empty -Findings array, or every finding dropped by -HideOk), for example 'No stale or lingering objects found.' Shows that the check ran and passed. Without it an empty block says "No findings." .PARAMETER Layout v2.3.0. List (default): one expandable row per finding. Split: the findings as a list on the left and the selected finding's detail on the right, which suits many findings with evidence (a settings audit). Both print fully expanded, and on a phone the detail shows under the list. .PARAMETER MaxEvidenceShown Evidence rows drawn per finding before "+N more" (default 50). .PARAMETER GroupBy None (default) | Area | Severity - group header rows. .PARAMETER ShowCounters Severity counter chips that filter the list (default $true). .PARAMETER Filterable Text filter box (default $true). .PARAMETER ExpandSeverity Severities whose findings start expanded, e.g. 'critical'. .PARAMETER MaxAffectedShown Affected names listed per finding before "+N more" (default 20). .PARAMETER HideOk Drop findings with Severity ok. .PARAMETER SortBy Severity (default) | None. .EXAMPLE Add-DhFindings -Report $r -Id 'sec' -Title 'Security findings' -NavPath 'Security' ` -GroupBy Area -ExpandSeverity critical -Findings @( @{ Severity='critical'; Title='AS-REP roastable accounts'; Area='Accounts' Description='Accounts with DoesNotRequirePreAuth expose crackable hashes.' Recommendation='Clear DONT_REQ_PREAUTH on every listed account.' Affected=@('svc_old','test1'); AffectedTableId='accounts'; AffectedFilter='AS-REP' References=@(@{ Label='MS Learn'; Url='https://learn.microsoft.com/' }) } @{ Severity='medium'; Title='Stale computer objects'; Area='Hygiene'; AffectedCount=148 } ) #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report, [Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9_-]+$')] [string] $Id, [string] $Title = 'Findings', # v2.3.0: no longer mandatory, so a block that found nothing (-AllClearText) can be declared, # and so a YAML round trip, which omits an empty list, still rebuilds it. [AllowEmptyCollection()] [object[]] $Findings = @(), [string] $AllClearText = '', # v2.3.0 - shown (with a check mark) when there are no findings [ValidateSet('List','Split')] [string] $Layout = 'List', # v2.3.0 - List (accordion) | Split (list + detail pane) [ValidateRange(1, 10000)] [int] $MaxEvidenceShown = 50, # v2.3.0 - evidence rows per finding [ValidateSet('None','Area','Severity')] [string] $GroupBy = 'None', [bool] $ShowCounters = $true, [bool] $Filterable = $true, [string[]] $ExpandSeverity = @(), [ValidateRange(0, 10000)] [int] $MaxAffectedShown = 20, [switch] $HideOk, [ValidateSet('Severity','None')] [string] $SortBy = 'Severity', [bool] $Collapsible = $true, # wrap the block in a collapsible header [bool] $DefaultOpen = $true, # collapsible body starts expanded [ValidateRange(0,12)] [int] $GridSpan = 0, # columns (1-12) in the -GridSpan grid layout; 0 = default flow [string] $NavGroup = '', [string] $NavSubGroup = '', # ---- v2.0.0 navigation (see readme-dev.md section 5) ---- [string] $NavPath = '', # 'A/B/C' - where this item lives. '//' escapes a literal '/' [ValidateSet('','Exact','Subtree','Global')] [string] $NavScope = '', # Exact (default) | Subtree (this node and below) | Global (everywhere) [string] $NavLabel = '', # display text for this node, decoupled from the path key [int] $NavOrder = 0 # explicit sibling ordering; 0 = declaration order ) $navDecl = Resolve-DhNavDeclaration -NavPath $NavPath -NavScope $NavScope ` -NavGroup $NavGroup -NavSubGroup $NavSubGroup ` -ItemKind 'Block' -Context 'Add-DhFindings' if (-not $Report.Contains('_DhNavSeq')) { $Report['_DhNavSeq'] = 0 } $navSeqNo = [int]$Report['_DhNavSeq']; $Report['_DhNavSeq'] = $navSeqNo + 1 if (-not $Report.Contains('Blocks')) { $Report['Blocks'] = [System.Collections.Generic.List[hashtable]]::new() } foreach ($existing in $Report.Blocks) { if ($existing.Id -eq $Id) { throw "Add-DhFindings: A block with Id '$Id' already exists in this report. Use a unique Id." } } $expand = @(foreach ($s in $ExpandSeverity) { ConvertTo-DhSeverity -Severity $s -Context "Add-DhFindings '$Id' -ExpandSeverity" }) $seenIds = @{} $n = 0 $norm = foreach ($f in @($Findings | Where-Object { $null -ne $_ })) { $n++ if ($f -isnot [System.Collections.IDictionary]) { throw "Add-DhFindings '$Id': finding #$n must be a hashtable. Got: $($f.GetType().Name)" } if (-not $f.Contains('Title') -or [string]::IsNullOrWhiteSpace([string]$f['Title'])) { throw "Add-DhFindings '$Id': finding #$n has no Title." } $ctx = "Add-DhFindings '$Id' finding '$($f['Title'])'" $sev = ConvertTo-DhSeverity -Severity ([string]$f['Severity']) -Context $ctx if ($HideOk -and $sev -eq 'ok') { continue } $fid = if ($f.Contains('Id') -and -not [string]::IsNullOrWhiteSpace([string]$f['Id'])) { [string]$f['Id'] } else { "f$n" } if ($fid -notmatch '^[A-Za-z0-9_-]+$') { throw "${ctx}: Id must be alphanumeric, - or _. Got: '$fid'" } if ($seenIds.ContainsKey($fid)) { throw "${ctx}: duplicate finding Id '$fid'." } $seenIds[$fid] = $true $affected = @(if ($f.Contains('Affected') -and $null -ne $f['Affected']) { @($f['Affected']) | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ } }) $count = if ($f.Contains('AffectedCount') -and $null -ne $f['AffectedCount']) { $c = 0 if (-not [int]::TryParse([string]$f['AffectedCount'], [ref]$c) -or $c -lt 0) { throw "${ctx}: AffectedCount must be a whole number >= 0. Got: $($f['AffectedCount'])" } $c } else { $affected.Count } $tableId = if ($f.Contains('AffectedTableId')) { [string]$f['AffectedTableId'] } else { '' } $filter = if ($f.Contains('AffectedFilter')) { [string]$f['AffectedFilter'] } else { '' } if ($filter -and -not $tableId) { throw "${ctx}: AffectedFilter requires AffectedTableId." } $refs = @(foreach ($ref in @($f['References'])) { if ($null -eq $ref) { continue } if ($ref -isnot [System.Collections.IDictionary]) { throw "${ctx}: each References entry must be @{ Label; Url }." } $url = Test-DhSafeActionUrl -Url ([string]$ref['Url']) -Context "$ctx reference" if (-not $url) { throw "${ctx}: a References entry has no Url." } @{ Label = if ($ref['Label']) { [string]$ref['Label'] } else { $url }; Url = $url } }) # v2.3.0 - facts, evidence, change marker and drawer detail $facts = @() if ($f.Contains('Facts') -and $null -ne $f['Facts']) { $facts = @((ConvertTo-DhSections -Context $ctx -Sections @(@{ Kind = 'kv'; Items = @($f['Facts']) }))[0].Items) } $evidence = @(foreach ($e in @($(if ($f.Contains('Evidence')) { $f['Evidence'] } else { $null }))) { if ($null -eq $e) { continue } if ($e -isnot [System.Collections.IDictionary] -or -not $e.Contains('Object') -or [string]::IsNullOrWhiteSpace([string]$e['Object'])) { throw "${ctx}: each Evidence entry must be @{ Object; Value; Status; Note } with an Object." } [ordered]@{ Object = [string]$e['Object'] Value = if ($e.Contains('Value') -and $null -ne $e['Value']) { [string]$e['Value'] } else { '' } Status = ConvertTo-DhStatusName $(if ($e.Contains('Status')) { $e['Status'] } else { $null }) Note = if ($e.Contains('Note') -and $null -ne $e['Note']) { [string]$e['Note'] } else { '' } } }) $change = if ($f.Contains('Change') -and -not [string]::IsNullOrWhiteSpace([string]$f['Change'])) { ([string]$f['Change']).Trim().ToLowerInvariant() } else { '' } if ($change -and $change -notin 'new', 'worse', 'better', 'resolved') { throw "${ctx}: Change must be new, worse, better or resolved. Got: '$($f['Change'])'" } $detail = ConvertTo-DhDetailSpec -Detail $(if ($f.Contains('Detail')) { $f['Detail'] } else { $null }) -Context $ctx [ordered]@{ Id = $fid Seq = $n Severity = $sev Title = [string]$f['Title'] Area = if ($f.Contains('Area')) { [string]$f['Area'] } else { '' } Description = if ($f.Contains('Description')) { [string]$f['Description'] } else { '' } Recommendation = if ($f.Contains('Recommendation')) { [string]$f['Recommendation'] } else { '' } Affected = $affected AffectedCount = $count AffectedTableId = $tableId AffectedFilter = $filter References = $refs Facts = $facts Evidence = $evidence Change = $change Detail = $detail } } $norm = @($norm) if ($SortBy -eq 'Severity') { $rank = @{ critical = 0; warning = 1; info = 2; ok = 3 } # Seq as the last key keeps the sort stable for equal severity and count $norm = @($norm | Sort-Object { $rank[$_.Severity] }, @{ Expression = { $_.AffectedCount }; Descending = $true }, { $_.Seq }) } $Report.Blocks.Add([ordered]@{ BlockType = 'findings' Id = $Id Title = $Title Findings = $norm AllClearText = $AllClearText Layout = $Layout.ToLowerInvariant() MaxEvidenceShown = $MaxEvidenceShown GroupBy = $GroupBy.ToLowerInvariant() ShowCounters = $ShowCounters Filterable = $Filterable ExpandSeverity = $expand MaxAffectedShown = $MaxAffectedShown NavGroup = $NavGroup NavSubGroup = $NavSubGroup NavPath = $NavPath NavSegments = $navDecl.Path NavSeqNo = $navSeqNo NavScope = $navDecl.Scope NavLabel = $NavLabel NavOrder = $NavOrder GridSpan = $GridSpan Collapsible = $Collapsible DefaultOpen = $DefaultOpen }) Write-Verbose "Add-DhFindings: '$Id' ($($norm.Count) finding(s), groupBy=$GroupBy)." } |