Private/Test-AGDeployPreflight.ps1

function Test-AGDeployPreflight {
    <#
    .SYNOPSIS
        Runs the ADCSGoat preflight report before any AD write.

    .DESCRIPTION
        Implements the preflight report settled in ADCSGoat issue #24. Every
        check is read-only; nothing is ever changed to satisfy a check.

        Hard prerequisites (abort with a prerequisite error naming the check
        and remediation, before any AD write):

        - SelectedCAResolves The selected CA resolves to exactly one
                                    pKIEnrollmentService object.
        - WebServerNameFlag The Web Server source's
                                    msPKI-Certificate-Name-Flag carries bit
                                    0x1 (CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT).
        - DomainUsersCanEnrollUser Domain Users can enroll through the
                                    built-in User template.

    .PARAMETER SelectedCA
        The pscustomobject returned by Get-AGEnrollmentService.

    .PARAMETER Server
        The domain controller to query. Defaults to the logon server.

    .OUTPUTS
        System.Management.Automation.PSCustomObject with HardChecks and
        SoftObservations lists. Terminates on the first failed hard check.

    .EXAMPLE
        $ca = Get-AGEnrollmentService
        $report = Test-AGDeployPreflight -SelectedCA $ca
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [pscustomobject]$SelectedCA,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$Server
    )

    begin {
        Add-Type -AssemblyName System.DirectoryServices

        if ([string]::IsNullOrEmpty($Server)) {
            $Server = [System.Net.Dns]::GetHostEntry($env:LOGONSERVER.TrimStart('\')).HostName
        }

        $rootDSE = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$Server/RootDSE")
        $configurationPartition = "$($rootDSE.configurationNamingContext)"
        $defaultNamingContext = "$($rootDSE.defaultNamingContext)"
        $templatesDN = "CN=Certificate Templates,CN=Public Key Services,CN=Services,$configurationPartition"

        $hardChecks = [System.Collections.Generic.List[object]]::new()
        $softObservations = [System.Collections.Generic.List[object]]::new()
    }

    process {
        #region Hard check 1: selected CA resolves to exactly one Enrollment Services object
        $caPath = "LDAP://$Server/$($SelectedCA.DistinguishedName)"
        $caCount = 0
        $caEntry = $null
        if ([System.DirectoryServices.DirectoryEntry]::Exists($caPath)) {
            $caEntry = New-Object System.DirectoryServices.DirectoryEntry($caPath)
            if ("$($caEntry.SchemaClassName)" -eq 'pKIEnrollmentService') { $caCount = 1 }
        }
        Resolve-AGPreflightHardCheck -Check 'SelectedCAResolves' -Value $caCount
        $hardChecks.Add([pscustomobject]@{ Check = 'SelectedCAResolves'; Passed = $true; Detail = $SelectedCA.DistinguishedName })
        #endregion

        #region Hard check 2: Web Server msPKI-Certificate-Name-Flag bit 0x1
        $webServerPath = "LDAP://$Server/CN=WebServer,$templatesDN"
        if (-not [System.DirectoryServices.DirectoryEntry]::Exists($webServerPath)) {
            $exception = New-Object System.InvalidOperationException("Web Server source template not found at 'CN=WebServer,$templatesDN'. AD CS built-in templates must be present. No AD changes were made.")
            $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'PrerequisiteFailed:WebServerNameFlag', [System.Management.Automation.ErrorCategory]::ObjectNotFound, 'WebServer')
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
        $webServer = New-Object System.DirectoryServices.DirectoryEntry($webServerPath)
        $nameFlag = 0
        if ($webServer.Properties['msPKI-Certificate-Name-Flag'].Count -gt 0) {
            $nameFlag = [int]$webServer.Properties['msPKI-Certificate-Name-Flag'].Value
        }
        $webServer.Dispose()
        Resolve-AGPreflightHardCheck -Check 'WebServerNameFlag' -Value $nameFlag
        $hardChecks.Add([pscustomobject]@{ Check = 'WebServerNameFlag'; Passed = $true; Detail = "msPKI-Certificate-Name-Flag = 0x$($nameFlag.ToString('X'))" })
        #endregion

        #region Hard check 3: Domain Users can enroll through the built-in User template
        $userPath = "LDAP://$Server/CN=User,$templatesDN"
        if (-not [System.DirectoryServices.DirectoryEntry]::Exists($userPath)) {
            $exception = New-Object System.InvalidOperationException("Built-in User template not found at 'CN=User,$templatesDN'. AD CS built-in templates must be present. No AD changes were made.")
            $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'PrerequisiteFailed:DomainUsersCanEnrollUser', [System.Management.Automation.ErrorCategory]::ObjectNotFound, 'User')
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        # Resolve Domain Users SID from the domain naming context.
        $domainNC = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$Server/$defaultNamingContext")
        $domainSidBytes = $domainNC.Properties['objectSid'].Value
        $domainSid = New-Object System.Security.Principal.SecurityIdentifier($domainSidBytes, 0)
        $domainUsersSid = New-Object System.Security.Principal.SecurityIdentifier("$($domainSid.Value)-513")
        $domainNC.Dispose()

        $searcher = New-Object System.DirectoryServices.DirectorySearcher
        $searcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry($userPath)
        $searcher.SearchScope = 'Base'
        $searcher.Filter = '(objectClass=*)'
        $searcher.PropertiesToLoad.Add('nTSecurityDescriptor') | Out-Null
        $searcher.SecurityMasks = [System.DirectoryServices.SecurityMasks]::Dacl
        $result = $searcher.FindOne()
        $sdBytes = $result.Properties['ntsecuritydescriptor'][0]
        $rawSd = New-Object System.Security.AccessControl.RawSecurityDescriptor($sdBytes, 0)

        $canEnroll = Test-AGDomainUsersEnrollAllowed -Dacl $rawSd.DiscretionaryAcl -DomainUsersSid $domainUsersSid.Value
        Resolve-AGPreflightHardCheck -Check 'DomainUsersCanEnrollUser' -Value $canEnroll
        $hardChecks.Add([pscustomobject]@{ Check = 'DomainUsersCanEnrollUser'; Passed = $true; Detail = "Domain Users ($($domainUsersSid.Value)) holds Enroll on User" })
        #endregion

        # No soft observations are currently collected; the list stays on the
        # output so the report shape is stable for future additions.

        Write-Output ([pscustomobject]@{
            SelectedCA       = $SelectedCA
            HardChecks       = $hardChecks.ToArray()
            SoftObservations = $softObservations.ToArray()
        })
    }
}

function Resolve-AGPreflightHardCheck {
    <#
    .SYNOPSIS
        Enforces one preflight hard prerequisite. Internal seam: pure
        evaluation, unit-tested without touching a forest. A failed check
        aborts with a prerequisite error naming the check and remediation.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('SelectedCAResolves', 'WebServerNameFlag', 'DomainUsersCanEnrollUser')]
        [string]$Check,

        [Parameter()]
        [AllowNull()]
        $Value
    )

    process {
        switch ($Check) {
            'SelectedCAResolves' {
                if ([int]$Value -ne 1) {
                    $exception = New-Object System.InvalidOperationException("Prerequisite failed: the selected CA must resolve to exactly one pKIEnrollmentService object under CN=Enrollment Services (resolved $Value). Verify -CAName matches an installed enterprise CA. No AD changes were made.")
                    $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'PrerequisiteFailed:SelectedCAResolves', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Check)
                    $PSCmdlet.ThrowTerminatingError($errorRecord)
                }
            }
            'WebServerNameFlag' {
                $flag = 0
                if ($null -ne $Value) { $flag = [int]$Value }
                if (($flag -band 0x1) -ne 0x1) {
                    $exception = New-Object System.InvalidOperationException("Prerequisite failed: the Web Server source template's msPKI-Certificate-Name-Flag (0x$($flag.ToString('X'))) lacks bit 0x1 (CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT), which ESC1 depends on. Remediation: restore the Web Server template's default 'Supply in the request' subject setting. ADCSGoat never changes a source template to satisfy a check. No AD changes were made.")
                    $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'PrerequisiteFailed:WebServerNameFlag', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Check)
                    $PSCmdlet.ThrowTerminatingError($errorRecord)
                }
            }
            'DomainUsersCanEnrollUser' {
                if ($Value -ne $true) {
                    $exception = New-Object System.InvalidOperationException("Prerequisite failed: Domain Users cannot enroll through the built-in User template, which the VMware 6.x enroll-on-behalf-of chain requires. Remediation: restore the User template's default DACL granting Domain Users the Enroll extended right. ADCSGoat never changes the User ACL to satisfy a check. No AD changes were made.")
                    $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'PrerequisiteFailed:DomainUsersCanEnrollUser', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Check)
                    $PSCmdlet.ThrowTerminatingError($errorRecord)
                }
            }
        }
    }
}

function Test-AGDomainUsersEnrollAllowed {
    <#
    .SYNOPSIS
        Returns $true when the supplied DACL grants the Domain Users SID the
        certificate Enroll extended right ({0e10c968-78fb-11d2-90d4-00c04f79dc55}).
        Internal seam: pure descriptor evaluation, unit-testable.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [System.Security.AccessControl.GenericAcl]$Dacl,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$DomainUsersSid
    )

    process {
        $enrollGuid = [guid]'0e10c968-78fb-11d2-90d4-00c04f79dc55'
        foreach ($ace in $Dacl) {
            if ($ace.SecurityIdentifier.Value -ne $DomainUsersSid) { continue }
            if ($ace.AceType -ne [System.Security.AccessControl.AceType]::AccessAllowedObject) { continue }
            if ($ace -isnot [System.Security.AccessControl.ObjectAce]) { continue }
            if ($ace.ObjectAceType -ne $enrollGuid) { continue }
            # Extended right: mask 0x100 (ADS_RIGHT_DS_CONTROL_ACCESS).
            if (($ace.AccessMask -band 0x100) -eq 0x100) { return $true }
        }
        return $false
    }
}