Public/Invoke-ESCAssessment.ps1

function Invoke-ESCAssessment {
    <#
    .SYNOPSIS
        Read-only AD CS ESC1-ESC16 security assessment. Non-destructive.
    .DESCRIPTION
        Orchestrates the assessment:
          1. Collects AD CS configuration (read-only) via the Get-Esc* collectors,
             or loads normalized fixtures in offline mode.
          2. Cross-references published templates onto CAs (Template.PublishedOnCAs).
          3. Builds a single AssessmentContext and runs every Test-Esc1..16 analyzer.
          4. Scores findings (Get-EscRiskScore) and computes the overall posture
             (Get-EscPostureScore).
          5. Exports an HTML (scored, visual) and/or JSON report.

        ABSOLUTELY read-only: no certificate is ever requested/issued and no AD/CA
        object is modified. Enrollment rights are computed from ACLs, never exercised.

    .PARAMETER Server
        Optional DC/server to target for live collection.
    .PARAMETER OutputPath
        Directory to write reports into (created if missing). Defaults to the current
        directory. Files: esc-assessment-report.html / .json.
    .PARAMETER Format
        One or more of 'Html','Json' (default both).
    .PARAMETER Offline
        Load normalized fixtures from -FixturePath instead of live collection.
    .PARAMETER FixturePath
        Directory of normalized fixture JSON files (used with -Offline):
          templates.json, enrollmentservices.json, caconfigs.json, pkiacls.json,
          dcmappings.json, webendpoints.json, oidgrouplinks.json,
          altsecurityidentities.json (any missing file is treated as empty).
    .PARAMETER ExtraLowPrivSid
        Extra SIDs to treat as low-privileged (e.g., a broad custom group).
    .PARAMETER CollectOnly
        Only run collection and return the AssessmentContext (no analysis/report).
    .PARAMETER ExportCaData
        Also export each CA's registry + issued certificates (live-only) into a
        CA_Assesment_<timestamp> folder under the report output directory, so reports
        and exports sit together in one place.
    .PARAMETER CaExportRoot
        Override the export root. Defaults to -OutputPath when omitted.
    .PARAMETER BackupCa
        With -ExportCaData, also take a CA backup (local CA only) into the same folder.
    .PARAMETER CaBackupPassword
        SecureString protecting the exported CA private key (.p12); omit for a
        database-only backup.
    .PARAMETER AnalyzeTemplateUsage
        Live-only. Analyze which CA-published templates have no still-valid issued
        certificate (runs certutil -view per CA; off by default as it can be slow on
        large CA databases).
    .OUTPUTS
        [pscustomobject] with Summary, Findings, Context, ReportPaths, and (live only)
        CaStatus, CaExports, and UnusedTemplates.
    .EXAMPLE
        Invoke-ESCAssessment -OutputPath .\out
    .EXAMPLE
        Invoke-ESCAssessment -Offline -FixturePath .\tests\fixtures\sample -OutputPath .\out
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string] $Server,

        [Parameter(Mandatory = $false)]
        [string] $OutputPath = '.',

        [Parameter(Mandatory = $false)]
        [ValidateSet('Html', 'Json')]
        [string[]] $Format = @('Html', 'Json'),

        [Parameter(Mandatory = $false)]
        [switch] $Offline,

        [Parameter(Mandatory = $false)]
        [string] $FixturePath,

        [Parameter(Mandatory = $false)]
        [string[]] $ExtraLowPrivSid = @(),

        [Parameter(Mandatory = $false)]
        [switch] $CollectOnly,

        [Parameter(Mandatory = $false)]
        [switch] $ExportCaData,

        [Parameter(Mandatory = $false)]
        [string] $CaExportRoot,

        [Parameter(Mandatory = $false)]
        [switch] $BackupCa,

        [Parameter(Mandatory = $false)]
        [System.Security.SecureString] $CaBackupPassword,

        [Parameter(Mandatory = $false)]
        [switch] $AnalyzeTemplateUsage
    )

    $stamp = (Get-Date).ToString('o')
    Write-EscLog -Component 'Orchestrator' -Message 'Starting AD CS ESC assessment (read-only).'

    $templates = @(); $enrollSvc = @(); $caConfigs = @(); $pkiAcls = @()
    $dcMaps = @(); $webEps = @(); $oidLinks = @(); $altSecIds = @()
    $mode = 'Live'
    $caStatus = $null
    $caExports = @()
    $unusedTemplates = @()

    if ($Offline) {
        $mode = 'Offline'
        if ([string]::IsNullOrWhiteSpace($FixturePath) -or -not (Test-Path -LiteralPath $FixturePath)) {
            throw "Offline mode requires an existing -FixturePath. Got: '$FixturePath'"
        }
        $loadFx = {
            param($File)
            $p = Join-Path -Path $FixturePath -ChildPath $File
            if (Test-Path -LiteralPath $p) {
                $raw = Get-Content -LiteralPath $p -Raw
                if ([string]::IsNullOrWhiteSpace($raw)) { return @() }
                return @($raw | ConvertFrom-Json)
            }
            return @()
        }
        $templates = @(& $loadFx 'templates.json')
        $enrollSvc = @(& $loadFx 'enrollmentservices.json')
        $caConfigs = @(& $loadFx 'caconfigs.json')
        $pkiAcls   = @(& $loadFx 'pkiacls.json')
        $dcMaps    = @(& $loadFx 'dcmappings.json')
        $webEps    = @(& $loadFx 'webendpoints.json')
        $oidLinks  = @(& $loadFx 'oidgrouplinks.json')
        $altSecIds = @(& $loadFx 'altsecurityidentities.json')
        Write-EscLog -Component 'Orchestrator' -Message ("Offline fixtures loaded: {0} templates, {1} CAs, {2} DCs." -f $templates.Count, $caConfigs.Count, $dcMaps.Count)
    }
    else {
        $templates = @(Get-EscCertificateTemplate -Server $Server -ExtraLowPrivSid $ExtraLowPrivSid)
        $enrollSvc = @(Get-EscEnrollmentService -Server $Server)
        $caConfigs = @(Get-EscCaConfiguration -CA $enrollSvc -ExtraLowPrivSid $ExtraLowPrivSid -ErrorAction SilentlyContinue)
        $pkiAcls   = @(Get-EscPkiObjectAcl -Server $Server -ExtraLowPrivSid $ExtraLowPrivSid)
        $dcMaps    = @(Get-EscDomainMapping -ErrorAction SilentlyContinue)
        $webEps    = @(Get-EscWebEnrollmentEndpoint -CA $enrollSvc -ErrorAction SilentlyContinue)
        $oidLinks  = @(Get-EscOidGroupLink -Server $Server)
        $altSecIds = @(Get-EscAltSecurityIdentity -Server $Server -ExtraLowPrivSid $ExtraLowPrivSid)

        $certPublishers = @()
        try { $certPublishers = @(Get-EscCertPublisher -Server $Server) }
        catch { Write-EscLog -Component 'Orchestrator' -Level Warning -Message ("Cert Publishers read failed: {0}" -f $_.Exception.Message) }
        try { $caStatus = Get-EscCaServerStatus -EnrollmentService $enrollSvc -CertPublisher $certPublishers }
        catch { Write-EscLog -Component 'Orchestrator' -Level Warning -Message ("CA status collection failed: {0}" -f $_.Exception.Message) }

        if ($ExportCaData) {
            $exportRoot = $OutputPath
            if (-not [string]::IsNullOrWhiteSpace($CaExportRoot)) { $exportRoot = $CaExportRoot }
            if (@($enrollSvc).Count -gt 0) {
                # De-duplicate enrollment-service records so each CA (host\CAName) is
                # exported once. AD can return stale/partial pKIEnrollmentService
                # objects for the same physical CA; exporting per raw record produced
                # a duplicate (and a failing) card for one CA. Records that cannot be
                # resolved to a concrete host\CAName target are skipped here.
                $seenCa = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
                foreach ($svc in $enrollSvc) {
                    $cfg = $null
                    if ($svc.DnsHostName -and $svc.Name) { $cfg = '{0}\{1}' -f $svc.DnsHostName, $svc.Name }
                    if ([string]::IsNullOrWhiteSpace($cfg)) { continue }
                    if (-not $seenCa.Add($cfg)) { continue }
                    $caExports += Export-EscCaData -Config $cfg -OutputRoot $exportRoot -IncludeBackup:$BackupCa -BackupPassword $CaBackupPassword
                }
                # If no record resolved to a concrete target, fall back to a single
                # local export so a lone on-box CA is still captured.
                if ($seenCa.Count -eq 0) {
                    $caExports += Export-EscCaData -OutputRoot $exportRoot -IncludeBackup:$BackupCa -BackupPassword $CaBackupPassword
                }
            }
            else {
                $caExports += Export-EscCaData -OutputRoot $exportRoot -IncludeBackup:$BackupCa -BackupPassword $CaBackupPassword
            }
        }

        if ($AnalyzeTemplateUsage) {
            try {
                $unusedTemplates = @(Get-EscUnusedPublishedTemplate -EnrollmentService $enrollSvc -Template $templates)
            }
            catch {
                Write-EscLog -Component 'Orchestrator' -Level Warning -Message ("Unused-template analysis failed: {0}" -f $_.Exception.Message)
            }
        }
    }

    if (@($enrollSvc).Count -gt 0) {
        foreach ($tpl in $templates) {
            if ($null -eq $tpl) { continue }
            $pub = @()
            foreach ($svc in $enrollSvc) {
                $names = @($svc.PublishedTemplates)
                if ($names -contains $tpl.Name) { $pub += $svc.Name }
            }
            if ($tpl.PSObject.Properties.Name -contains 'PublishedOnCAs') {
                $tpl.PublishedOnCAs = @($pub)
            }
            else {
                Add-Member -InputObject $tpl -NotePropertyName 'PublishedOnCAs' -NotePropertyValue @($pub) -Force
            }
        }
    }

    $context = [pscustomobject]@{
        Templates             = @($templates)
        EnrollmentServices    = @($enrollSvc)
        CaConfigs             = @($caConfigs)
        PkiAcls               = @($pkiAcls)
        DcMappings            = @($dcMaps)
        WebEndpoints          = @($webEps)
        OidGroupLinks         = @($oidLinks)
        AltSecurityIdentities = @($altSecIds)
        ExtraLowPrivSid       = @($ExtraLowPrivSid)
        Meta                  = @{ GeneratedAt = $stamp; Mode = $mode; Server = $Server }
    }

    if ($CollectOnly) {
        Write-EscLog -Component 'Orchestrator' -Message 'CollectOnly set; returning context without analysis.'
        return [pscustomobject]@{ Summary = $null; Findings = @(); Context = $context; ReportPaths = @() }
    }

    $analyzers = 1..16 | ForEach-Object { "Test-Esc$_" }
    $findings = @()
    foreach ($fn in $analyzers) {
        $cmd = Get-Command -Name $fn -ErrorAction SilentlyContinue
        if ($null -eq $cmd) {
            Write-EscLog -Component 'Orchestrator' -Level Warning -Message "Analyzer '$fn' not found; skipping."
            continue
        }
        try {
            $res = & $fn -Context $context -ExtraLowPrivSid $ExtraLowPrivSid
            if ($null -ne $res) { $findings += @($res) }
        }
        catch {
            Write-EscLog -Component 'Orchestrator' -Level Warning -Message ("Analyzer '{0}' threw: {1}" -f $fn, $_.Exception.Message)
            $findings += [pscustomobject]@{
                Id = ($fn -replace 'Test-Esc', 'ESC'); Title = "$fn failed to run"; Severity = 'Info'
                Status = 'Error'; AffectedObject = 'N/A'
                Evidence = [pscustomobject]@{ Error = $_.Exception.Message }
                Principals = @(); Exploitability = 'Theoretical'; RiskScore = 0
                Remediation = 'Investigate analyzer error / data availability.'; Reference = ''
            }
        }
    }

    $findings = @($findings | Get-EscRiskScore)
    $summary = Get-EscPostureScore -Finding $findings

    Write-EscLog -Component 'Orchestrator' -Message ("Assessment complete. Posture {0} ({1}); {2} finding(s)." -f $summary.PostureScore, $summary.Grade, @($findings).Count)

    $reportPaths = @()
    if (-not (Test-Path -LiteralPath $OutputPath)) {
        New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
    }
    $meta = @{ GeneratedAt = $stamp; Mode = $mode; Server = $Server; Tool = 'ADCS.ESC.Assessment' }
    if ($null -ne $caStatus) { $meta['CaStatus'] = $caStatus }
    if (@($caExports).Count -gt 0) { $meta['CaExports'] = @($caExports) }
    if (@($unusedTemplates).Count -gt 0) { $meta['UnusedTemplates'] = @($unusedTemplates) }

    # CA management-role inventory: who holds Manage CA / Issue and Manage
    # Certificates, with non-default (outside Administrators / Domain Admins /
    # Enterprise Admins) assignments flagged for review.
    $caRoleAssignments = @(Get-EscCaRoleAssignment -CaConfig $caConfigs)
    if (@($caRoleAssignments | Where-Object { $_.AcesAvailable }).Count -gt 0) {
        $meta['CaRoleAssignments'] = @($caRoleAssignments)
    }

    if ($Format -contains 'Json') {
        $jsonPath = Join-Path -Path $OutputPath -ChildPath 'esc-assessment-report.json'
        $reportPaths += (Export-EscJsonReport -Finding $findings -Summary $summary -Meta $meta -Path $jsonPath)
    }
    if ($Format -contains 'Html') {
        $htmlPath = Join-Path -Path $OutputPath -ChildPath 'esc-assessment-report.html'
        $reportPaths += (Export-EscHtmlReport -Finding $findings -Summary $summary -Meta $meta -Path $htmlPath)
    }

    return [pscustomobject]@{
        Summary     = $summary
        Findings    = @($findings)
        Context     = $context
        CaStatus    = $caStatus
        CaExports   = @($caExports)
        UnusedTemplates = @($unusedTemplates)
        CaRoleAssignments = @($caRoleAssignments)
        ReportPaths = @($reportPaths)
    }
}