Reporting/Export-EscJsonReport.ps1

function Export-EscJsonReport {
<#
.SYNOPSIS
    Exports an AD CS security-assessment result set to a single JSON document.

.DESCRIPTION
    Read-only reporter. Serializes the supplied Finding collection plus an optional
    Summary object (from Get-EscPostureScore) into one self-describing JSON document:

        { schemaVersion, generatedAt, meta, summary, findings[] }

    The document is written as UTF-8 (no BOM) using ConvertTo-Json -Depth 8, which keeps
    nested Evidence objects/arrays intact. The function is fully non-destructive: it only
    reads the objects it is given and writes a report file.

    Tolerant by design:
      * If -Summary is $null, a minimal summary (counts by severity/status) is computed
        from the findings.
      * Missing fields never throw; they serialize as $null / empty collections.

.PARAMETER Finding
    Zero or more Finding [pscustomobject] items.

.PARAMETER Summary
    Optional summary object (Get-EscPostureScore shape: PostureScore, Grade, counts,
    BySeverity, TopFindings). If omitted, a minimal summary is derived from -Finding.

.PARAMETER Meta
    Optional hashtable of run metadata (Domain, Forest, GeneratedAt, Mode, Tool, etc.).
    GeneratedAt (if present) is surfaced at the top level as generatedAt.

.PARAMETER Path
    Destination file path for the JSON document.

.OUTPUTS
    [string] The path that was written.

.EXAMPLE
    Export-EscJsonReport -Finding $findings -Summary $summary -Meta @{Domain='corp.local'} -Path .\report.json
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [pscustomobject[]] $Finding,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [pscustomobject] $Summary,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [hashtable] $Meta,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $Path
    )

    if ($null -eq $Finding) { $Finding = @() }

    $generatedAt = $null
    if ($null -ne $Meta -and $Meta.ContainsKey('GeneratedAt') -and $null -ne $Meta['GeneratedAt']) {
        $generatedAt = [string]$Meta['GeneratedAt']
    }

    $summaryObj = $Summary
    if ($null -eq $summaryObj) {
        $summaryObj = New-EscMinimalSummary -Finding $Finding
    }

    $metaObj = $null
    if ($null -ne $Meta) {
        $metaObj = [ordered]@{}
        foreach ($k in $Meta.Keys) { $metaObj[$k] = $Meta[$k] }
    }

    $orderedFindings = @($Finding | Sort-Object -Property `
        @{ Expression = { $n = 9999; if ([string]$_.Id -match 'ESC0*(\d+)') { $n = [int]$Matches[1] }; $n } }, `
        @{ Expression = { $_.RiskScore }; Descending = $true })

    $doc = [ordered]@{
        schemaVersion = '1.0'
        generatedAt   = $generatedAt
        meta          = $metaObj
        summary       = $summaryObj
        findings      = $orderedFindings
    }

    $json = $doc | ConvertTo-Json -Depth 8

    $dir = Split-Path -Path $Path -Parent
    if ($dir -and -not (Test-Path -LiteralPath $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }

    $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
    [System.IO.File]::WriteAllText($Path, $json, $utf8NoBom)

    return $Path
}

function New-EscMinimalSummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [pscustomobject[]] $Finding
    )

    $sevOrder = @('Critical', 'High', 'Medium', 'Low', 'Info')
    $bySeverity = [ordered]@{}
    foreach ($s in $sevOrder) { $bySeverity[$s] = 0 }
    $byStatus = [ordered]@{}

    foreach ($f in $Finding) {
        if ($null -eq $f) { continue }

        $sev = $null
        if ($f.PSObject.Properties['Severity']) { $sev = [string]$f.Severity }
        if ([string]::IsNullOrEmpty($sev)) { $sev = 'Info' }
        if (-not $bySeverity.Contains($sev)) { $bySeverity[$sev] = 0 }
        $bySeverity[$sev] = [int]$bySeverity[$sev] + 1

        $st = $null
        if ($f.PSObject.Properties['Status']) { $st = [string]$f.Status }
        if ([string]::IsNullOrEmpty($st)) { $st = 'Unknown' }
        if (-not $byStatus.Contains($st)) { $byStatus[$st] = 0 }
        $byStatus[$st] = [int]$byStatus[$st] + 1
    }

    return [pscustomobject]@{
        PostureScore = $null
        Grade        = $null
        TotalCount   = @($Finding).Count
        BySeverity   = [pscustomobject]$bySeverity
        ByStatus     = [pscustomobject]$byStatus
        TopFindings  = @()
    }
}