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, ...)
          }
        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 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',
        [Parameter(Mandatory)] [object[]] $Findings,
        [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) {
        $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 }
        })

        [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
        }
    }
    $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
        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)."
}