diff --git a/Classes/LS2AdcsObject.ps1 b/Classes/LS2AdcsObject.ps1 index f071535..c46527e 100644 --- a/Classes/LS2AdcsObject.ps1 +++ b/Classes/LS2AdcsObject.ps1 @@ -66,6 +66,7 @@ class LS2AdcsObject { [Nullable[int]]$AuditFilter [object[]]$DisableExtensionList [Nullable[bool]]$SecurityExtensionDisabled + [object[]]$WebEnrollmentEndpoints # Schema class name for easy type checking [string]$SchemaClassName diff --git a/Locksmith2.psd1 b/Locksmith2.psd1 index da4c7d8..507859e 100644 --- a/Locksmith2.psd1 +++ b/Locksmith2.psd1 @@ -8,7 +8,7 @@ Description='An AD CS toolkit for AD Admins, Defensive Security Professionals, and Filthy Red Teamers' FunctionsToExport=@('*') GUID='e32f7d0d-2b10-4db2-b776-a193958e3d69' - ModuleVersion='2026.5.101055' + ModuleVersion='2026.5.121255' PowerShellVersion='5.1' PrivateData=@{ PSData=@{ diff --git a/Private/Data/ESCDefinitions.ps1 b/Private/Data/ESCDefinitions.ps1 index 1524b54..842cd26 100644 --- a/Private/Data/ESCDefinitions.ps1 +++ b/Private/Data/ESCDefinitions.ps1 @@ -1,3 +1,11 @@ +# MAINTENANCE: When adding a new ESC entry here, you MUST also: +# 1. Add the technique name to the [ValidateSet(...)] in the appropriate Find-LS2Vulnerable* function: +# - Template techniques -> Public/Find-LS2VulnerableTemplate.ps1 +# - CA techniques -> Public/Find-LS2VulnerableCA.ps1 +# - Object techniques -> Public/Find-LS2VulnerableObject.ps1 +# 2. Add the technique name to the matching $*Techniques array in Private/Initialize/Initialize-LS2Scan.ps1 +# 3. Add the technique name to the $techniques array in Public/Invoke-Locksmith2.ps1 +# 4. Add an elseif detection branch in the appropriate Find-LS2Vulnerable* function $script:ESCDefinitions = data { @{ ESC1 = @{ @@ -623,5 +631,38 @@ $script:ESCDefinitions = data { "`$Object.CommitChanges()" ) } + + ESC8 = @{ + # ESC8: NTLM Relay to AD CS HTTP Endpoints + Technique = 'ESC8' + + # EndpointBased signals Find-LS2VulnerableCA to use the per-endpoint branch + EndpointBased = $true + + # Issue text is built dynamically per endpoint in Find-LS2VulnerableCA. + # These templates are used as fallback / documentation only. + IssueTemplate = @( + "The web enrollment endpoint at `$(URL) is vulnerable to NTLM relay attacks.`n`n" + "An attacker who can intercept network traffic (e.g., via responder, mitm6, or similar) " + "can relay NTLM authentication to this endpoint and obtain a certificate on behalf of the " + "intercepted account.`n`n" + "More info:`n" + " - https://posts.specterops.io/certified-pre-owned-d95910965cd2" + ) + + FixTemplate = @( + "# ESC8 Fix: require HTTPS with EPA and disable NTLM where possible.`n" + "# 1. Enable EPA (Extended Protection for Authentication) on IIS.`n" + "# 2. Disable NTLM authentication on the web enrollment site and use Kerberos only.`n" + "# 3. If HTTP is enabled, redirect all traffic to HTTPS.`n" + "# Reference: https://support.microsoft.com/kb/5005413" + ) + + RevertTemplate = @( + "# ESC8 Revert: re-enable NTLM or HTTP as required by your environment.`n" + "# Review IIS authentication settings on the CA host.`n" + "# Reference: https://learn.microsoft.com/en-us/windows-server/networking/core-network-guide/cncg/server-certs/configure-server-certificate-autoenrollment" + ) + } } } diff --git a/Private/Get/Get-WebEnrollmentEndpointStatus.ps1 b/Private/Get/Get-WebEnrollmentEndpointStatus.ps1 new file mode 100644 index 0000000..42d80b4 --- /dev/null +++ b/Private/Get/Get-WebEnrollmentEndpointStatus.ps1 @@ -0,0 +1,143 @@ +function Get-WebEnrollmentEndpointStatus { + <# + .SYNOPSIS + Probes a single web enrollment URL and returns its authentication posture. + + .DESCRIPTION + Sends anonymous and (for HTTPS) Negotiate HTTP requests to the given URL using + System.Net.Http.HttpClient. Returns $null when the endpoint does not respond. + For responding endpoints, returns a PSCustomObject describing whether NTLM is + offered and whether Extended Protection for Authentication (EPA) is not required. + + Probe 1 (anonymous GET): + - Determines whether the endpoint exists. + - Reads the WWW-Authenticate header to detect NTLM. + - HTTP endpoints stop here; NtlmOffered and EpaNotRequired are both $null. + + Probe 2 (Negotiate GET, HTTPS only): + - Uses HttpClientHandler with DefaultNetworkCredentials and Negotiate. + - A 200 response indicates channel binding (EPA) was not required. + - A 401/403 response indicates EPA may be enforced (conservative $false). + + .PARAMETER Url + The full URL to probe (e.g., 'http://ca1.contoso.com/certsrv/'). + + .OUTPUTS + PSCustomObject with properties URL, NtlmOffered, EpaNotRequired. + Returns $null when the endpoint is unreachable or times out. + + .NOTES + Intentionally has no unit tests - HttpClient cannot be mocked in Pester. + Use integration tests (Get-WebEnrollmentEndpointStatus.Integration.Tests.ps1) + against a live AD CS environment. + SSL certificate validation is intentionally disabled to probe self-signed certs. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [string]$Url + ) + + $isHttps = $Url -match '^https://' + + # --- Probe 1: anonymous GET --- + $anonHandler = $null + $anonClient = $null + try { + $anonHandler = [System.Net.Http.HttpClientHandler]::new() + $anonHandler.AllowAutoRedirect = $false + $anonHandler.UseDefaultCredentials = $false + + if ($isHttps) { + try { + # .NET 4.6.1+ / Core: disable SSL cert validation for self-signed certs + $anonHandler.ServerCertificateCustomValidationCallback = { + param($sender, $cert, $chain, $sslPolicyErrors) + return $true + } + } catch { + # PS5.1 fallback - ServicePointManager is process-wide but necessary + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } + } + } + + $anonClient = [System.Net.Http.HttpClient]::new($anonHandler) + $anonClient.Timeout = [System.TimeSpan]::FromSeconds(10) + + $anonResponse = $anonClient.GetAsync($Url).GetAwaiter().GetResult() + + if ($isHttps) { + # Detect NTLM in WWW-Authenticate + $wwwAuth = '' + if ($anonResponse.Headers.Contains('WWW-Authenticate')) { + $wwwAuth = $anonResponse.Headers.GetValues('WWW-Authenticate') -join ', ' + } + $ntlmOffered = $wwwAuth -match '(?i)\bNTLM\b' + } else { + # HTTP: always exists, no auth probing + return [PSCustomObject]@{ + URL = $Url + NtlmOffered = $null + EpaNotRequired = $null + } + } + } catch [System.Net.Http.HttpRequestException] { + Write-Verbose "Get-WebEnrollmentEndpointStatus: connection failed for $Url - $($_.Exception.Message)" + return $null + } catch [System.OperationCanceledException] { + Write-Verbose "Get-WebEnrollmentEndpointStatus: timeout for $Url" + return $null + } catch { + Write-Verbose "Get-WebEnrollmentEndpointStatus: unexpected error for $Url - $($_.Exception.Message)" + return $null + } finally { + if ($null -ne $anonClient) { $anonClient.Dispose() } + if ($null -ne $anonHandler) { $anonHandler.Dispose() } + } + + # --- Probe 2 (HTTPS only): Negotiate auth to detect EPA --- + $authHandler = $null + $authClient = $null + $epaNotRequired = $false + try { + $authHandler = [System.Net.Http.HttpClientHandler]::new() + $authHandler.AllowAutoRedirect = $false + + try { + $authHandler.ServerCertificateCustomValidationCallback = { + param($sender, $cert, $chain, $sslPolicyErrors) + return $true + } + } catch { + # ServicePointManager already set above - no-op + } + + $credentialCache = [System.Net.CredentialCache]::new() + $credentialCache.Add([System.Uri]::new($Url), 'Negotiate', [System.Net.CredentialCache]::DefaultNetworkCredentials) + $authHandler.Credentials = $credentialCache + $authHandler.PreAuthenticate = $false + + $authClient = [System.Net.Http.HttpClient]::new($authHandler) + $authClient.Timeout = [System.TimeSpan]::FromSeconds(10) + + $authResponse = $authClient.GetAsync($Url).GetAwaiter().GetResult() + $statusCode = [int]$authResponse.StatusCode + + # 200 = auth succeeded without EPA -- not required + # 401/403 = server rejected -- EPA may be enforced (conservative) + $epaNotRequired = ($statusCode -eq 200) + } catch { + Write-Verbose "Get-WebEnrollmentEndpointStatus: Negotiate probe failed for $Url - $($_.Exception.Message)" + $epaNotRequired = $false + } finally { + if ($null -ne $authClient) { $authClient.Dispose() } + if ($null -ne $authHandler) { $authHandler.Dispose() } + } + + return [PSCustomObject]@{ + URL = $Url + NtlmOffered = [bool]$ntlmOffered + EpaNotRequired = [bool]$epaNotRequired + } +} diff --git a/Private/Initialize/Initialize-AdcsObjectStore.ps1 b/Private/Initialize/Initialize-AdcsObjectStore.ps1 index 6cb42e4..74f0896 100644 --- a/Private/Initialize/Initialize-AdcsObjectStore.ps1 +++ b/Private/Initialize/Initialize-AdcsObjectStore.ps1 @@ -94,6 +94,7 @@ function Initialize-AdcsObjectStore { Set-CADisableExtensionList | Set-CAAdministrator | Set-CACertificateManager | + Set-CAWebEnrollmentEndpoints | Set-DangerousCAAdministrator | Set-LowPrivilegeCAAdministrator | Set-DangerousCACertificateManager | diff --git a/Private/Initialize/Initialize-LS2Scan.ps1 b/Private/Initialize/Initialize-LS2Scan.ps1 index 787e310..ff70f6a 100644 --- a/Private/Initialize/Initialize-LS2Scan.ps1 +++ b/Private/Initialize/Initialize-LS2Scan.ps1 @@ -155,7 +155,7 @@ function Initialize-LS2Scan { # Scan all CA techniques Write-Verbose "Scanning certification authorities..." - $caTechniques = @('ESC6', 'ESC7a', 'ESC7m', 'ESC11', 'ESC16') + $caTechniques = @('ESC6', 'ESC7a', 'ESC7m', 'ESC8', 'ESC11', 'ESC16') foreach ($tech in $caTechniques) { Find-LS2VulnerableCA -Technique $tech | Out-Null } diff --git a/Private/Set/Set-CAWebEnrollmentEndpoints.ps1 b/Private/Set/Set-CAWebEnrollmentEndpoints.ps1 new file mode 100644 index 0000000..a7d3de6 --- /dev/null +++ b/Private/Set/Set-CAWebEnrollmentEndpoints.ps1 @@ -0,0 +1,94 @@ +function Set-CAWebEnrollmentEndpoints { + <# + .SYNOPSIS + Probes web enrollment endpoints on each CA and stores results on the CA object. + + .DESCRIPTION + For each pKIEnrollmentService (CA) object, constructs candidate URLs from the known + web enrollment paths and the CA's dNSHostName, then probes each URL using + Get-WebEnrollmentEndpointStatus. Responding endpoints are collected into the + WebEnrollmentEndpoints property on the CA object. + + Paths probed (both http:// and https://): + certsrv/ + {CAName}_CES_Kerberos/service.svc + {CAName}_CES_Kerberos/service.svc/CES + ADPolicyProvider_CEP_Kerberos/service.svc + certsrv/mscep/ + + .PARAMETER AdcsObject + One or more LS2AdcsObject instances. Non-CA objects are passed through unchanged. + + .INPUTS + LS2AdcsObject[] + + .OUTPUTS + LS2AdcsObject[] + + .EXAMPLE + $CAs | Set-CAWebEnrollmentEndpoints + + .NOTES + Requires Get-WebEnrollmentEndpointStatus (Private/Get). + CAs without dNSHostName are passed through without probing. + Probe errors on individual URLs are suppressed; remaining URLs still probed. + #> + [CmdletBinding()] + [OutputType([LS2AdcsObject[]])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [LS2AdcsObject[]]$AdcsObject + ) + + begin { + Write-Verbose 'Set-CAWebEnrollmentEndpoints: probing web enrollment endpoints...' + } + + process { + foreach ($object in $AdcsObject) { + if (-not $object.IsCertificationAuthority()) { + $object + continue + } + + $hostName = $object.dNSHostName + if ([string]::IsNullOrEmpty($hostName)) { + Write-Verbose "Set-CAWebEnrollmentEndpoints: skipping $($object.cn) - no dNSHostName" + $object + continue + } + + $caName = $object.cn + $paths = @( + 'certsrv/', + "${caName}_CES_Kerberos/service.svc", + "${caName}_CES_Kerberos/service.svc/CES", + 'ADPolicyProvider_CEP_Kerberos/service.svc', + 'certsrv/mscep/' + ) + + $endpoints = [System.Collections.Generic.List[object]]::new() + + foreach ($path in $paths) { + foreach ($scheme in @('http', 'https')) { + $url = "${scheme}://${hostName}/${path}" + try { + $status = Get-WebEnrollmentEndpointStatus -Url $url + if ($null -ne $status) { + $endpoints.Add($status) + } + } catch { + Write-Verbose "Set-CAWebEnrollmentEndpoints: probe error for $url - $($_.Exception.Message)" + } + } + } + + $object.WebEnrollmentEndpoints = $endpoints.ToArray() + $object + } + } + + end { + Write-Verbose 'Set-CAWebEnrollmentEndpoints: done.' + } +} diff --git a/Private/Test/Test-IsUtf8.ps1 b/Private/Test/Test-IsUtf8.ps1 index e57e99c..5cfab90 100644 Binary files a/Private/Test/Test-IsUtf8.ps1 and b/Private/Test/Test-IsUtf8.ps1 differ diff --git a/Public/Find-LS2VulnerableCA.ps1 b/Public/Find-LS2VulnerableCA.ps1 index 6e2e1be..01b234d 100644 --- a/Public/Find-LS2VulnerableCA.ps1 +++ b/Public/Find-LS2VulnerableCA.ps1 @@ -10,11 +10,12 @@ function Find-LS2VulnerableCA { ESC6: Detects CAs with EDITF_ATTRIBUTESUBJECTALTNAME2 enabled ESC7a: Detects dangerous CA Administrator role assignments ESC7m: Detects dangerous Certificate Manager role assignments + ESC8: Detects vulnerable web enrollment endpoints (HTTP always; HTTPS if NTLM offered or EPA not required) ESC11: Detects CAs that don't require RPC encryption ESC16: Detects CAs with disabled CRL/AIA extensions .PARAMETER Technique - ESC technique name to scan for (e.g., 'ESC6', 'ESC7a', 'ESC7m', 'ESC11', 'ESC16') + ESC technique name to scan for (e.g., 'ESC6', 'ESC7a', 'ESC7m', 'ESC8', 'ESC11', 'ESC16') .EXAMPLE Find-LS2VulnerableCA -Technique ESC6 @@ -57,6 +58,7 @@ function Find-LS2VulnerableCA { - ESC6: EDITF_ATTRIBUTESUBJECTALTNAME2 flag enabled - ESC7a: Dangerous CA Administrator role assignments - ESC7m: Dangerous Certificate Manager role assignments + - ESC8: Vulnerable web enrollment endpoints - ESC11: Missing RPC encryption requirement - ESC16: Disabled CRL/AIA security extensions @@ -72,7 +74,7 @@ function Find-LS2VulnerableCA { [CmdletBinding()] param( [Parameter()] - [ValidateSet('ESC6', 'ESC7a', 'ESC7m', 'ESC11', 'ESC16')] + [ValidateSet('ESC6', 'ESC7a', 'ESC7m', 'ESC8', 'ESC11', 'ESC16')] [string]$Technique, [Parameter()] @@ -104,7 +106,7 @@ function Find-LS2VulnerableCA { if (-not $Technique) { Write-Verbose "No technique specified. Returning all CA issues..." $allIssues = Get-FlattenedIssues - $caTechniques = @('ESC6', 'ESC7a', 'ESC7m', 'ESC11', 'ESC16') + $caTechniques = @('ESC6', 'ESC7a', 'ESC7m', 'ESC8', 'ESC11', 'ESC16') $caIssues = $allIssues | Where-Object { $_.Technique -in $caTechniques } if ($ExpandGroups) { @@ -265,6 +267,90 @@ function Find-LS2VulnerableCA { } } } + # ESC8: endpoint-based (one issue per vulnerable web enrollment endpoint) + elseif ($Technique -eq 'ESC8') { + $fixText = if ($config.FixTemplate -is [array]) { $config.FixTemplate -join "`n" } else { $config.FixTemplate } + $revertText = if ($config.RevertTemplate -is [array]) { $config.RevertTemplate -join "`n" } else { $config.RevertTemplate } + + foreach ($ca in $allCAs) { + $caName = if ($ca.cn) { $ca.cn } else { 'Unknown CA' } + $caFullName = $ca.CAFullName + if (-not $caFullName) { + Write-Verbose " CA '$caName' has no CAFullName - skipping ESC8 check" + continue + } + + $forestName = if ($ca.distinguishedName -match 'DC=([^,]+)') { + $ca.distinguishedName -replace '^.*?DC=(.*)$', '$1' -replace ',DC=', '.' + } else { + 'Unknown' + } + + $endpoints = @($ca.WebEnrollmentEndpoints) + if (-not $endpoints -or $endpoints.Count -eq 0) { + Write-Verbose " CA '$caName' has no web enrollment endpoints" + continue + } + + foreach ($endpoint in $endpoints) { + $url = $endpoint.URL + $isHttp = $url -match '^http://' + + # Determine if this endpoint is vulnerable + $vulnerable = $false + if ($isHttp) { + $vulnerable = $true + } elseif ($endpoint.NtlmOffered -eq $true -or $endpoint.EpaNotRequired -eq $true) { + $vulnerable = $true + } + + if (-not $vulnerable) { + Write-Verbose " Endpoint $url is not vulnerable - skipping" + continue + } + + # Build issue text describing the applicable attack vectors + if ($isHttp) { + $issueText = "The web enrollment endpoint at $url uses plain HTTP and is vulnerable to NTLM relay attacks.`n`n" + + "Any attacker who can intercept network traffic can relay NTLM credentials to this endpoint " + + "and request a certificate on behalf of the victim.`n`nMore info:`n - https://posts.specterops.io/certified-pre-owned-d95910965cd2" + } else { + $vectors = @() + if ($endpoint.NtlmOffered -eq $true) { $vectors += 'NTLM relay (NTLM offered on HTTPS endpoint)' } + if ($endpoint.EpaNotRequired -eq $true) { $vectors += 'Kerberos relay (EPA not required)' } + $vectorList = $vectors -join ' and ' + $issueText = "The web enrollment endpoint at $url is vulnerable to $vectorList.`n`n" + + "An attacker who can intercept network traffic can relay credentials to this endpoint " + + "and request a certificate on behalf of the victim.`n`nMore info:`n - https://posts.specterops.io/certified-pre-owned-d95910965cd2" + } + + $issue = [LS2Issue]@{ + Technique = 'ESC8' + Forest = $forestName + Name = $caName + DistinguishedName = $ca.distinguishedName + ObjectClass = 'pKIEnrollmentService' + CAFullName = $caFullName + Issue = $issueText + Fix = $fixText + Revert = $revertText + } + + $dn = $ca.distinguishedName + $issueKey = "ESC8:$url" + if (-not $script:IssueStore) { $script:IssueStore = @{} } + if (-not $script:IssueStore.ContainsKey($dn)) { $script:IssueStore[$dn] = @{} } + if (-not $script:IssueStore[$dn].ContainsKey($issueKey)) { $script:IssueStore[$dn][$issueKey] = @() } + + if (-not (Test-IssueExists -Issue $issue -DistinguishedName $dn -Technique $Technique)) { + $script:IssueStore[$dn][$issueKey] += $issue + $issueCount++ + } + + $issue + } + } + } # ESC6, ESC11, and ESC16 are configuration-based (no enrollee/principal iteration) else { $vulnerableCAs = @(foreach ($ca in $allCAs) { diff --git a/Public/Invoke-Locksmith2.ps1 b/Public/Invoke-Locksmith2.ps1 index 46731c5..f81f984 100644 --- a/Public/Invoke-Locksmith2.ps1 +++ b/Public/Invoke-Locksmith2.ps1 @@ -198,7 +198,7 @@ function Invoke-Locksmith2 { Write-Verbose "`nScan complete. Issue summary:" $techniques = @( 'ESC1', 'ESC2', 'ESC3c1', 'ESC3c2', 'ESC4a', 'ESC4o', - 'ESC5a', 'ESC5o', 'ESC6', 'ESC7a', 'ESC7m', 'ESC9', 'ESC11', 'ESC16' + 'ESC5a', 'ESC5o', 'ESC6', 'ESC7a', 'ESC7m', 'ESC8', 'ESC9', 'ESC11', 'ESC16' ) foreach ($technique in $techniques) { diff --git a/Public/New-LS2Dashboard.ps1 b/Public/New-LS2Dashboard.ps1 index 583f879..d47f322 100644 --- a/Public/New-LS2Dashboard.ps1 +++ b/Public/New-LS2Dashboard.ps1 @@ -7,10 +7,7 @@ function New-LS2Dashboard { Creates a comprehensive HTML dashboard with left navigation menu showing: - All issues with expanded principals - Issues filtered by type (Template, CA, Object) - - Risky principals analysis - - Supports light/dark mode toggle and interactive filtering/sorting. - Requires PSWriteHTML module (Install-Module PSWriteHTML). + - Risky principals analysis. .PARAMETER FilePath Path where the HTML dashboard will be saved. @@ -99,291 +96,162 @@ function New-LS2Dashboard { Write-Warning "IssueStore is empty. Run Invoke-Locksmith2 or Find-LS2Vulnerable* functions first." Write-Warning "Generating empty dashboard..." } - - # Get all issues from IssueStore + $allIssues = Get-FlattenedIssues - - # Expand groups if requested if ($ExpandGroups) { Write-Verbose "Expanding group memberships for dashboard..." $allIssues = $allIssues | ForEach-Object { Expand-IssueByGroup $_ } } - - # Filter issues by category - $templateTechniques = @('ESC1', 'ESC2', 'ESC3c1', 'ESC3c2', 'ESC4a', 'ESC4o', 'ESC9') - $caTechniques = @('ESC6', 'ESC7a', 'ESC7m', 'ESC11', 'ESC16') - $objectTechniques = @('ESC5a', 'ESC5o') - $misconfigurationTechniques = @('ESC1', 'ESC2', 'ESC3c1', 'ESC3c1', 'ESC6', 'ESC9', 'ESC11', 'ESC16') - $accessTechniques = @('ESC4a', 'ESC5a') - $ownershipTechniques = @('ESC4o', 'ESC5o') - - $templateIssues = $allIssues | Where-Object { $_.Technique -in $templateTechniques } - $caIssues = $allIssues | Where-Object { $_.Technique -in $caTechniques } - $objectIssues = $allIssues | Where-Object { $_.Technique -in $objectTechniques } - $misconfigurationIssues = $allIssues | Where-Object { $_.Technique -in $misconfigurationTechniques } - $accessIssues = $allIssues | Where-Object { $_.Technique -in $accessTechniques } - $ownershipIssues = $allIssues | Where-Object { $_.Technique -in $ownershipTechniques } - - # Get risky principals - Write-Verbose "Calculating principal risk scores..." - $riskyPrincipals = Find-LS2RiskyPrincipal - - # Prepare data for tables - ALL tabs show same columns, just filtered/sorted differently + $standardColumns = @( 'Technique' 'Forest' 'Name' 'DistinguishedName' - @{N = 'ObjectClass'; E = { if ($_.ObjectClass) { $_.ObjectClass } else { 'N/A' } } } - @{N = 'IdentityReference'; E = { if ($_.IdentityReference) { $_.IdentityReference } else { 'N/A' } } } - @{N = 'IdentityReferenceSID'; E = { if ($_.IdentityReferenceSID) { $_.IdentityReferenceSID } else { 'N/A' } } } - @{N = 'IdentityReferenceClass'; E = { if ($_.IdentityReferenceClass) { $_.IdentityReferenceClass } else { 'N/A' } } } - @{N = 'ActiveDirectoryRights'; E = { if ($_.ActiveDirectoryRights) { $_.ActiveDirectoryRights } else { 'N/A' } } } - @{N = 'AceObjectTypeGUID'; E = { if ($_.AceObjectTypeGUID) { $_.AceObjectTypeGUID } else { 'N/A' } } } - @{N = 'AceObjectTypeName'; E = { if ($_.AceObjectTypeName) { $_.AceObjectTypeName } else { 'N/A' } } } - @{N = 'Enabled'; E = { if ($null -ne $_.Enabled) { $_.Enabled } else { 'N/A' } } } - @{N = 'EnabledOn'; E = { if ($_.EnabledOn) { $_.EnabledOn -join ', ' } else { 'N/A' } } } - @{N = 'CAFullName'; E = { if ($_.CAFullName) { $_.CAFullName } else { 'N/A' } } } - @{N = 'Owner'; E = { if ($_.Owner) { $_.Owner } else { 'N/A' } } } - @{N = 'HasNonStandardOwner'; E = { if ($null -ne $_.HasNonStandardOwner) { $_.HasNonStandardOwner } else { 'N/A' } } } - @{N = 'Members'; E = { if ($_.MemberCount) { $_.MemberCount } else { 'N/A' } } } - @{N = 'Issue'; E = { if ($_.Issue) { $_.Issue -replace "`n", "`n`n" } else { 'N/A' } } } - @{N = 'Fix'; E = { if ($_.Fix) { $_.Fix -replace "`n", "`n`n" } else { 'N/A' } } } - @{N = 'Revert'; E = { if ($_.Revert) { $_.Revert -replace "`n", "`n`n" } else { 'N/A' } } } + @{N = 'ObjectClass'; E = { if ($_.ObjectClass) { $_.ObjectClass } else { 'N/A' } } } + @{N = 'IdentityReference'; E = { if ($_.IdentityReference) { $_.IdentityReference } else { 'N/A' } } } + @{N = 'IdentityReferenceSID'; E = { if ($_.IdentityReferenceSID) { $_.IdentityReferenceSID } else { 'N/A' } } } + @{N = 'IdentityReferenceClass'; E = { if ($_.IdentityReferenceClass) { $_.IdentityReferenceClass } else { 'N/A' } } } + @{N = 'ActiveDirectoryRights'; E = { if ($_.ActiveDirectoryRights) { $_.ActiveDirectoryRights } else { 'N/A' } } } + @{N = 'AceObjectTypeGUID'; E = { if ($_.AceObjectTypeGUID) { $_.AceObjectTypeGUID } else { 'N/A' } } } + @{N = 'AceObjectTypeName'; E = { if ($_.AceObjectTypeName) { $_.AceObjectTypeName } else { 'N/A' } } } + @{N = 'Enabled'; E = { if ($null -ne $_.Enabled) { $_.Enabled } else { 'N/A' } } } + @{N = 'EnabledOn'; E = { if ($_.EnabledOn) { $_.EnabledOn -join ', ' } else { 'N/A' } } } + @{N = 'CAFullName'; E = { if ($_.CAFullName) { $_.CAFullName } else { 'N/A' } } } + @{N = 'Owner'; E = { if ($_.Owner) { $_.Owner } else { 'N/A' } } } + @{N = 'HasNonStandardOwner'; E = { if ($null -ne $_.HasNonStandardOwner) { $_.HasNonStandardOwner } else { 'N/A' } } } + @{N = 'Members'; E = { if ($_.MemberCount) { $_.MemberCount } else { 'N/A' } } } + @{N = 'Issue'; E = { if ($_.Issue) { $_.Issue -replace "`n", "`n`n" } else { 'N/A' } } } + @{N = 'Fix'; E = { if ($_.Fix) { $_.Fix -replace "`n", "`n`n" } else { 'N/A' } } } + @{N = 'Revert'; E = { if ($_.Revert) { $_.Revert -replace "`n", "`n`n" } else { 'N/A' } } } ) - - $allIssuesTable = $allIssues | Select-Object $standardColumns - $templateIssuesTable = $templateIssues | Select-Object $standardColumns - $caIssuesTable = $caIssues | Select-Object $standardColumns - $objectIssuesTable = $objectIssues | Select-Object $standardColumns - $misconfigurationIssuesTable = $misconfigurationIssues | Select-Object $standardColumns - $accessIssuesTable = $accessIssues | Select-Object $standardColumns - $ownershipIssuesTable = $ownershipIssues | Select-Object $standardColumns - + + # Tab definitions — single source of truth for filter, chrome, and table config. + # Techniques = $null -> no filter (All Issues). + # IsPrincipals = $true -> use the principals table and formatting instead of issue table. + $tabDefs = [ordered]@{ + 'All Issues' = @{ Icon = 'exclamation-triangle'; IconColor = 'Red'; Techniques = $null; Subtitle = 'All discovered AD CS vulnerabilities with principals expanded'; Title = 'All AD CS Security Issues'; SortColumn = 'Technique' } + 'Templates' = @{ Icon = 'file-contract'; IconColor = 'Orange'; Techniques = @('ESC1','ESC2','ESC3c1','ESC3c2','ESC4a','ESC4o','ESC9'); Subtitle = 'Misconfigured templates allowing SAN abuse, weak enrollment restrictions, or enrollment agent exploitation'; Title = 'Template Vulnerabilities'; SortColumn = 'Technique' } + 'CAs' = @{ Icon = 'certificate'; IconColor = 'Yellow'; Techniques = @('ESC6','ESC7a','ESC7m','ESC8','ESC11','ESC16'); Subtitle = 'Insecure CA configurations and dangerous role assignments (ESC6, ESC7, ESC8, ESC11, ESC16)'; Title = 'CA Configuration Issues'; SortColumn = 'Name' } + 'Objects' = @{ Icon = 'folder'; IconColor = 'Blue'; Techniques = @('ESC5a','ESC5o'); Subtitle = 'Dangerous permissions on PKI infrastructure objects (ESC5)'; Title = 'Infrastructure Object Issues'; SortColumn = 'Name' } + 'Risky Principals' = @{ Icon = 'user-shield'; IconColor = 'Purple'; IsPrincipals = $true; Subtitle = 'Ranked by number of exploitable AD CS vulnerabilities' } + 'Dangerous Configurations' = @{ Icon = 'cog'; IconColor = 'Red'; Techniques = @('ESC1','ESC2','ESC3c1','ESC3c2','ESC6','ESC8','ESC9','ESC11','ESC16'); Subtitle = 'Insecure template/CA configurations enabling certificate abuse (ESC1, ESC2, ESC6, ESC8, ESC9, ESC11, ESC16)'; Title = 'Configuration-Based Vulnerabilities'; SortColumn = 'Technique' } + 'Access Control' = @{ Icon = 'key'; IconColor = 'Green'; Techniques = @('ESC4a','ESC5a'); Subtitle = 'Excessive write/modify permissions on templates and PKI objects (ESC4a, ESC5a)'; Title = 'Write/Modify Permission Issues'; SortColumn = 'ActiveDirectoryRights' } + 'Ownership' = @{ Icon = 'crown'; IconColor = 'Gold'; Techniques = @('ESC4o','ESC5o'); Subtitle = 'Non-standard owners with full control over templates or PKI objects (ESC4o, ESC5o)'; Title = 'Dangerous Ownership Configurations'; SortColumn = 'Owner' } + } + + # Build filtered + projected table for each issue tab + foreach ($tabName in $tabDefs.Keys) { + $def = $tabDefs[$tabName] + if ($def.IsPrincipals) { continue } + $filtered = if ($def.Techniques) { $allIssues | Where-Object { $_.Technique -in $def.Techniques } } else { $allIssues } + $def.Issues = @($filtered) + $def.Table = $filtered | Select-Object $standardColumns + } + + # Principals table (separate schema) + Write-Verbose "Calculating principal risk scores..." + $riskyPrincipals = Find-LS2RiskyPrincipal $principalsTable = $riskyPrincipals | Select-Object ` Principal, - IssueCount, - @{N = 'Techniques'; E = { $_.Techniques -join ', ' } }, - @{N = 'VulnerableObjects'; E = { $_.VulnerableObjects.Count } } - - $forestName = if ($script:Forest) { $script:Forest } else { 'Unknown Forest' } + IssueCount, + @{N = 'Techniques'; E = { $_.Techniques -join ', ' } }, + @{N = 'VulnerableObjects'; E = { $_.VulnerableObjects.Count } } + + $forestName = if ($script:Forest) { $script:Forest } else { 'Unknown Forest' } $generatedAt = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' - - # Define conditional formatting rules - applies consistently to all issue tables + $scanUser = if ($script:Credential) { $script:Credential.UserName } else { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name } + $scanComputer = "$env:USERDOMAIN\$env:COMPUTERNAME" + # Resolve logo — try module base first, then walk up for source-tree runs + $logoPath = $null + $moduleBase = (Get-Module -Name Locksmith2 -ErrorAction SilentlyContinue).ModuleBase + foreach ($candidate in @( + (Join-Path $moduleBase 'Images\Locksmith2.png'), + (Join-Path $moduleBase '..\..\..\Images\Locksmith2.png') + )) { + if (Test-Path $candidate) { $logoPath = (Resolve-Path $candidate).Path; break } + } + + # Conditional formatting shared by all issue tables $issueFormatting = { # Template issues (red-purple range) - New-HTMLTableCondition -Name 'Technique' -Value 'ESC1' -BackgroundColor '#ffcdd2' -Color Black - New-HTMLTableCondition -Name 'Technique' -Value 'ESC2' -BackgroundColor '#f8bbd0' -Color Black + New-HTMLTableCondition -Name 'Technique' -Value 'ESC1' -BackgroundColor '#ffcdd2' -Color Black + New-HTMLTableCondition -Name 'Technique' -Value 'ESC2' -BackgroundColor '#f8bbd0' -Color Black New-HTMLTableCondition -Name 'Technique' -Value 'ESC4a' -BackgroundColor '#e1bee7' -Color Black New-HTMLTableCondition -Name 'Technique' -Value 'ESC4o' -BackgroundColor '#d1c4e9' -Color Black - New-HTMLTableCondition -Name 'Technique' -Value 'ESC9' -BackgroundColor '#ce93d8' -Color Black - + New-HTMLTableCondition -Name 'Technique' -Value 'ESC9' -BackgroundColor '#ce93d8' -Color Black # CA issues (yellow-orange range) - New-HTMLTableCondition -Name 'Technique' -Value 'ESC6' -BackgroundColor '#fff59d' -Color Black + New-HTMLTableCondition -Name 'Technique' -Value 'ESC6' -BackgroundColor '#fff59d' -Color Black New-HTMLTableCondition -Name 'Technique' -Value 'ESC7a' -BackgroundColor '#ffcc80' -Color Black New-HTMLTableCondition -Name 'Technique' -Value 'ESC7m' -BackgroundColor '#ffb74d' -Color Black + New-HTMLTableCondition -Name 'Technique' -Value 'ESC8' -BackgroundColor '#ffe082' -Color Black New-HTMLTableCondition -Name 'Technique' -Value 'ESC11' -BackgroundColor '#ff9800' -Color Black New-HTMLTableCondition -Name 'Technique' -Value 'ESC16' -BackgroundColor '#ffa726' -Color Black - # Object issues (green-blue range) New-HTMLTableCondition -Name 'Technique' -Value 'ESC5a' -BackgroundColor '#a5d6a7' -Color Black New-HTMLTableCondition -Name 'Technique' -Value 'ESC5o' -BackgroundColor '#80cbc4' -Color Black - - # Rights-based formatting (high severity) - New-HTMLTableCondition -Name 'ActiveDirectoryRights' -ComparisonType string -Operator like -Value '*GenericAll*' -BackgroundColor '#d32f2f' -Color White - New-HTMLTableCondition -Name 'ActiveDirectoryRights' -ComparisonType string -Operator like -Value '*WriteDacl*' -BackgroundColor '#ef5350' -Color White - New-HTMLTableCondition -Name 'ActiveDirectoryRights' -ComparisonType string -Operator like -Value '*WriteOwner*' -BackgroundColor '#ff9800' -Color White + # Rights-based (high severity) + New-HTMLTableCondition -Name 'ActiveDirectoryRights' -ComparisonType string -Operator like -Value '*GenericAll*' -BackgroundColor '#d32f2f' -Color White + New-HTMLTableCondition -Name 'ActiveDirectoryRights' -ComparisonType string -Operator like -Value '*WriteDacl*' -BackgroundColor '#ef5350' -Color White + New-HTMLTableCondition -Name 'ActiveDirectoryRights' -ComparisonType string -Operator like -Value '*WriteOwner*' -BackgroundColor '#ff9800' -Color White New-HTMLTableCondition -Name 'ActiveDirectoryRights' -ComparisonType string -Operator like -Value '*WriteProperty*' -BackgroundColor '#ffa726' -Color Black - New-HTMLTableCondition -Name 'ActiveDirectoryRights' -ComparisonType string -Operator like -Value '*GenericWrite*' -BackgroundColor '#ffa726' -Color Black - - # Status-based formatting + New-HTMLTableCondition -Name 'ActiveDirectoryRights' -ComparisonType string -Operator like -Value '*GenericWrite*' -BackgroundColor '#ffa726' -Color Black + # Status New-HTMLTableCondition -Name 'Enabled' -Value $true -BackgroundColor '#fff9c4' -Color Black } - - # Define conditional formatting for principals table (different schema) - # Order matters: most severe conditions last so they override less severe ones + + # Conditional formatting for principals (different schema; most severe last so they override) $principalFormatting = { - New-HTMLTableCondition -Name 'IssueCount' -ComparisonType number -Operator ge -Value 1 -BackgroundColor '#fdd835' -Color Black - New-HTMLTableCondition -Name 'IssueCount' -ComparisonType number -Operator ge -Value 5 -BackgroundColor '#ff9800' -Color White + New-HTMLTableCondition -Name 'IssueCount' -ComparisonType number -Operator ge -Value 1 -BackgroundColor '#fdd835' -Color Black + New-HTMLTableCondition -Name 'IssueCount' -ComparisonType number -Operator ge -Value 5 -BackgroundColor '#ff9800' -Color White New-HTMLTableCondition -Name 'IssueCount' -ComparisonType number -Operator gt -Value 10 -BackgroundColor '#ef5350' -Color White } - - # Generate HTML Dashboard - New-HTML -TitleText "Locksmith2 Security Dashboard - $forestName - $generatedAt" -Online:$Online -FilePath $FilePath -Show:$Show { - - # Header band — forest name + generation timestamp - New-HTMLSection -Invisible { - New-HTMLPanel { - New-HTMLText -Text "Locksmith2 Security Dashboard" -FontSize 28 -FontWeight bold - New-HTMLText -Text "Forest: $forestName" -FontSize 16 -Color '#555' - New-HTMLText -Text "Generated: $generatedAt" -FontSize 12 -Color '#888' - } - } - # Use tabs for single-page navigation with content switching - New-HTMLTabStyle -SlimTabs -Transition -SelectorColor Magenta - - New-HTMLTab -Name 'All Issues' -IconSolid exclamation-triangle -IconColor Red { - New-HTMLSection -Invisible { - New-HTMLPanel -Width 10% { - New-HTMLText -Text "All Issues - Expanded Principals ($($allIssues.Count) total)" -FontSize 20 -FontWeight bold - New-HTMLText -Text @" -This view shows all discovered AD CS vulnerabilities with group memberships expanded to individual principals. -Issues are marked with the ESC technique and show which principals can exploit each configuration. -"@ -Color '#888' -FontSize 14 - } - New-HTMLPanel { - New-HTMLTable -DataTable $allIssuesTable ` - -Filtering ` - -PagingLength 25 ` - -Buttons @('copyHtml5', 'excelHtml5', 'csvHtml5', 'pdfHtml5', 'searchBuilder', 'searchPanes') ` - -Title 'All AD CS Security Issues' {& $issueFormatting} - } - } - } - - New-HTMLTab -Name 'Templates' -IconSolid file-contract -IconColor Orange { - New-HTMLSection -Invisible { - New-HTMLPanel -Width 10% { - New-HTMLText -Text "Certificate Template Issues ($($templateIssues.Count) issues)" -FontSize 20 -FontWeight bold - New-HTMLText -Text @" -Certificate templates are the most common source of AD CS vulnerabilities. These issues allow principals to request -certificates with dangerous permissions, subject alternative names, or enrollment agent capabilities. -"@ -Color '#888' -FontSize 14 - } - New-HTMLPanel { - New-HTMLTable -DataTable $templateIssuesTable ` - -Filtering ` - -PagingLength 25 ` - -Buttons @('copyHtml5', 'excelHtml5', 'csvHtml5', 'pdfHtml5', 'searchBuilder', 'searchPanes') ` - -Title 'Template Vulnerabilities' ` - -DefaultSortColumn 'Technique' {& $issueFormatting} - } - } - } - - New-HTMLTab -Name 'CAs' -IconSolid certificate -IconColor Yellow { - New-HTMLSection -Invisible { - New-HTMLPanel -Width 10% { - New-HTMLText -Text "Certification Authority Issues ($($caIssues.Count) issues)" -FontSize 20 -FontWeight bold - New-HTMLText -Text @" -CA-level issues involve dangerous role assignments (ESC7) or insecure CA configurations (ESC6, ESC11, ESC16). -These vulnerabilities grant principals excessive control over certificate issuance. -"@ -Color '#888' -FontSize 14 - } - New-HTMLPanel { - New-HTMLTable -DataTable $caIssuesTable ` - -Filtering ` - -PagingLength 25 ` - -Buttons @('copyHtml5', 'excelHtml5', 'csvHtml5', 'pdfHtml5', 'searchBuilder', 'searchPanes') ` - -Title 'CA Configuration Issues' ` - -DefaultSortColumn 'Name' {& $issueFormatting} - } - } - } - - New-HTMLTab -Name 'Objects' -IconSolid folder -IconColor Blue { - New-HTMLSection -Invisible { - New-HTMLPanel -Width 10% { - New-HTMLText -Text "PKI Object Issues ($($objectIssues.Count) issues)" -FontSize 20 -FontWeight bold - New-HTMLText -Text @" -ESC5 vulnerabilities involve dangerous ownership or write permissions on PKI infrastructure objects. -These allow principals to modify templates, CAs, or other critical AD CS components. -"@ -Color '#888' -FontSize 14 - } - New-HTMLPanel { - New-HTMLTable -DataTable $objectIssuesTable ` - -Filtering ` - -PagingLength 25 ` - -Buttons @('copyHtml5', 'excelHtml5', 'csvHtml5', 'pdfHtml5', 'searchBuilder', 'searchPanes') ` - -Title 'Infrastructure Object Issues' ` - -DefaultSortColumn 'Name' {& $issueFormatting} - } + $tableButtons = @('copyHtml5', 'excelHtml5', 'csvHtml5', 'pdfHtml5', 'searchBuilder', 'searchPanes') + + New-HTML -TitleText "Locksmith 2 Dashboard - $forestName - $generatedAt" -Online:$Online -FilePath $FilePath -Show:$Show { + + # Persistent header — logo + collection context. New-HTMLHeader renders outside the tab + # container and does not create a tab-content slot (unlike New-HTMLSection/Panel at this level). + New-HTMLHeader { + Add-HTMLStyle -Content 'header { text-align: center; padding: 12px 0; } header img { max-width: 50%; height: auto; display: inline-block; }' + if ($logoPath) { + New-HTMLImage -Source $logoPath -Width '50%' -DisableCache -AlternativeText 'Locksmith 2' } + New-HTMLText -Text "Forest: $forestName | User: $scanUser | Computer: $scanComputer | Generated: $generatedAt" -FontSize 12 -Color '#555' -Alignment center } - - New-HTMLTab -Name 'Risky Principals' -IconSolid user-shield -IconColor Purple { - New-HTMLSection -Invisible { - New-HTMLPanel -Width 10% { - New-HTMLText -Text "Principal Risk Analysis ($($principalsTable.Count) principals)" -FontSize 20 -FontWeight bold - New-HTMLText -Text @" -This analysis shows which principals have access to the most AD CS vulnerabilities. Principals with high issue counts -represent concentrated risk and should be prioritized for remediation or monitoring. -"@ -Color '#888' -FontSize 14 - } - New-HTMLPanel { + + # NOTE: No New-HTMLSection/Panel before the first New-HTMLTab — PSWriteHTML counts every + # top-level content block as a tab-content slot, shifting click handlers off by one. + New-HTMLTabStyle -SlimTabs -SelectorColor Magenta + + foreach ($tabName in $tabDefs.Keys) { + $def = $tabDefs[$tabName] + New-HTMLTab -Name $tabName -IconSolid $def.Icon -IconColor $def.IconColor { + if ($def.IsPrincipals) { New-HTMLTable -DataTable $principalsTable ` -Filtering ` -PagingLength 25 ` - -Buttons @('copyHtml5', 'excelHtml5', 'csvHtml5', 'pdfHtml5', 'searchBuilder', 'searchPanes') ` + -Buttons $tableButtons ` -Title 'Principals by Risk Score' ` -DefaultSortColumn 'IssueCount' ` -DefaultSortOrder Descending {& $principalFormatting} - } - } - } - - New-HTMLTab -Name 'Misconfigurations' -IconSolid cog -IconColor Red { - New-HTMLSection -Invisible { - New-HTMLPanel -Width 10% { - New-HTMLText -Text "Misconfiguration Issues ($($misconfigurationIssues.Count) issues)" -FontSize 20 -FontWeight bold - New-HTMLText -Text @" -These vulnerabilities result from insecure template or CA configurations that allow certificate abuse. -Examples include weak enrollment restrictions (ESC1, ESC2), SubCA attacks (ESC6), or weak certificate mappings (ESC9). -"@ -Color '#888' -FontSize 14 - } - New-HTMLPanel { - New-HTMLTable -DataTable $misconfigurationIssuesTable ` - -Filtering ` - -PagingLength 25 ` - -Buttons @('copyHtml5', 'excelHtml5', 'csvHtml5', 'pdfHtml5', 'searchBuilder', 'searchPanes') ` - -Title 'Configuration-Based Vulnerabilities' ` - -DefaultSortColumn 'Technique' {& $issueFormatting} - } - } - } - - New-HTMLTab -Name 'Access Control' -IconSolid key -IconColor Green { - New-HTMLSection -Invisible { - New-HTMLPanel -Width 10% { - New-HTMLText -Text "Dangerous Access Control Issues ($($accessIssues.Count) issues)" -FontSize 20 -FontWeight bold - New-HTMLText -Text @" -These vulnerabilities involve principals with excessive write or modify permissions on templates or PKI objects. -ESC4a and ESC5a allow principals to modify certificate templates or infrastructure to create exploitable configurations. -"@ -Color '#888' -FontSize 14 - } - New-HTMLPanel { - New-HTMLTable -DataTable $accessIssuesTable ` + New-HTMLHorizontalLine + New-HTMLText -Text "$(@($principalsTable).Count) principals -- $($def.Subtitle)" -Color '#666' -FontSize 13 -FontStyle italic + } else { + New-HTMLTable -DataTable $def.Table ` -Filtering ` -PagingLength 25 ` - -Buttons @('copyHtml5', 'excelHtml5', 'csvHtml5', 'pdfHtml5', 'searchBuilder', 'searchPanes') ` - -Title 'Write/Modify Permission Issues' ` - -DefaultSortColumn 'ActiveDirectoryRights' {& $issueFormatting} - } - } - } - - New-HTMLTab -Name 'Ownership' -IconSolid crown -IconColor Gold { - New-HTMLSection -Invisible { - New-HTMLPanel -Width 10% { - New-HTMLText -Text "Non-Standard Ownership Issues ($($ownershipIssues.Count) issues)" -FontSize 20 -FontWeight bold - New-HTMLText -Text @" -These vulnerabilities involve templates or PKI objects owned by non-standard principals. -Owners have full control and can modify or delete objects. ESC4o and ESC5o identify dangerous ownership configurations. -"@ -Color '#888' -FontSize 14 - } - New-HTMLPanel { - New-HTMLTable -DataTable $ownershipIssuesTable ` - -Filtering ` - -PagingLength 25 ` - -Buttons @('copyHtml5', 'excelHtml5', 'csvHtml5', 'pdfHtml5', 'searchBuilder', 'searchPanes') ` - -Title 'Dangerous Ownership Configurations' ` - -DefaultSortColumn 'Owner' {& $issueFormatting} + -Buttons $tableButtons ` + -Title $def.Title ` + -DefaultSortColumn $def.SortColumn {& $issueFormatting} + New-HTMLHorizontalLine + New-HTMLText -Text "$($def.Issues.Count) issues -- $($def.Subtitle)" -Color '#666' -FontSize 13 -FontStyle italic } } } } - + Write-Verbose "Dashboard generated: $FilePath" if (-not $Show) { Write-Host "Dashboard saved to: $FilePath" diff --git a/Tests/Integration/Get-WebEnrollmentEndpointStatus.Integration.Tests.ps1 b/Tests/Integration/Get-WebEnrollmentEndpointStatus.Integration.Tests.ps1 new file mode 100644 index 0000000..20f6044 --- /dev/null +++ b/Tests/Integration/Get-WebEnrollmentEndpointStatus.Integration.Tests.ps1 @@ -0,0 +1,58 @@ +#requires -Version 5.1 +# Integration tests — require a live AD CS environment. +# Set $env:LS2_TEST_FOREST to enable all integration tests. +# Set $env:LS2_TEST_CA_HOST to target a specific CA host (e.g., 'ca1.contoso.com'). +# Tests tagged 'Integration' and auto-skipped in CI when env vars are absent. + +BeforeDiscovery { + $ModuleRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + Import-Module (Join-Path $ModuleRoot 'Locksmith2.psd1') -Force -ErrorAction Stop +} +BeforeAll { + $ModuleRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + Import-Module (Join-Path $ModuleRoot 'Locksmith2.psd1') -Force -ErrorAction Stop + + $script:TestForest = $env:LS2_TEST_FOREST + $script:TestCaHost = $env:LS2_TEST_CA_HOST +} + +InModuleScope 'Locksmith2' { + Describe 'Get-WebEnrollmentEndpointStatus — Integration' -Tag 'Integration' { + Context 'Endpoint existence probe (HTTP)' { + It 'should return null for a URL that does not respond' -Skip:([string]::IsNullOrEmpty($script:TestCaHost)) { + $result = Get-WebEnrollmentEndpointStatus -Url "http://does-not-exist.invalid/certsrv/" + $result | Should -BeNullOrEmpty + } + + It 'should return a hashtable with URL, NtlmOffered, EpaNotRequired when HTTP certsrv responds' -Skip:([string]::IsNullOrEmpty($script:TestCaHost)) { + $url = "http://$($script:TestCaHost)/certsrv/" + $result = Get-WebEnrollmentEndpointStatus -Url $url + if ($null -ne $result) { + $result.URL | Should -Be $url + $result.NtlmOffered | Should -BeNullOrEmpty + $result.EpaNotRequired | Should -BeNullOrEmpty + } + } + } + + Context 'NTLM detection (HTTPS)' { + It 'should return NtlmOffered=$true or $false for a responding HTTPS endpoint' -Skip:([string]::IsNullOrEmpty($script:TestCaHost)) { + $url = "https://$($script:TestCaHost)/certsrv/" + $result = Get-WebEnrollmentEndpointStatus -Url $url + if ($null -ne $result) { + $result.NtlmOffered | Should -BeIn @($true, $false) + } + } + } + + Context 'EPA detection (HTTPS + Negotiate)' { + It 'should return EpaNotRequired=$true or $false for a responding HTTPS endpoint' -Skip:([string]::IsNullOrEmpty($script:TestCaHost)) { + $url = "https://$($script:TestCaHost)/certsrv/" + $result = Get-WebEnrollmentEndpointStatus -Url $url + if ($null -ne $result) { + $result.EpaNotRequired | Should -BeIn @($true, $false) + } + } + } + } +} diff --git a/Tests/Locksmith2.ESCCoverage.Tests.ps1 b/Tests/Locksmith2.ESCCoverage.Tests.ps1 new file mode 100644 index 0000000..8f07b76 --- /dev/null +++ b/Tests/Locksmith2.ESCCoverage.Tests.ps1 @@ -0,0 +1,122 @@ +#requires -Version 5.1 +# Tests that every technique in ESCDefinitions is wired up in all the places it needs to be, +# and that no ValidateSet member is orphaned from ESCDefinitions. +# Reads source files directly - no module import required. +# +# NOTE ON PESTER V5 SCOPING: variables set in a Describe body are only available during +# Discovery. It body code runs during the Run phase and cannot see those variables unless +# they are embedded in the -ForEach hashtable. All test cases are therefore built as +# hashtables that carry their own data, so the It body only uses $_ or named hash keys. + +Describe 'ESC Definition Coverage' -Tag 'Unit' { + + # ---------- parse source files (Discovery phase) ---------- + + $moduleRoot = Split-Path -Parent $PSScriptRoot + + $definitionsContent = Get-Content (Join-Path $moduleRoot 'Private\Data\ESCDefinitions.ps1') -Raw + $templateContent = Get-Content (Join-Path $moduleRoot 'Public\Find-LS2VulnerableTemplate.ps1') -Raw + $caContent = Get-Content (Join-Path $moduleRoot 'Public\Find-LS2VulnerableCA.ps1') -Raw + $objectContent = Get-Content (Join-Path $moduleRoot 'Public\Find-LS2VulnerableObject.ps1') -Raw + $scanContent = Get-Content (Join-Path $moduleRoot 'Private\Initialize\Initialize-LS2Scan.ps1') -Raw + $invokeContent = Get-Content (Join-Path $moduleRoot 'Public\Invoke-Locksmith2.ps1') -Raw + + $definedTechniques = [regex]::Matches($definitionsContent, "Technique\s*=\s*'(ESC\w+)'") | + ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique + + $extractValidateSet = { + param([string]$fileContent) + $vsMatch = [regex]::Match($fileContent, '\[ValidateSet\(([^)]+)\)\]') + if ($vsMatch.Success) { + @([regex]::Matches($vsMatch.Groups[1].Value, "'(ESC[^']+)'") | + ForEach-Object { $_.Groups[1].Value }) + } else { @() } + } + + $templateValidated = & $extractValidateSet $templateContent + $caValidated = & $extractValidateSet $caContent + $objectValidated = & $extractValidateSet $objectContent + $allValidated = $templateValidated + $caValidated + $objectValidated + + $scanTechniques = @([regex]::Matches($scanContent, "'(ESC\w+)'") | + ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) + + $invokeMatch = [regex]::Match($invokeContent, '(?s)\$techniques\s*=\s*@\(([^)]+)\)') + $invokeTechniques = @(if ($invokeMatch.Success) { + [regex]::Matches($invokeMatch.Groups[1].Value, "'(ESC\w+)'") | + ForEach-Object { $_.Groups[1].Value } + }) + + # ---------- build test-case hashtables (data baked in at Discovery time) ---------- + + # Each hashtable key becomes a named variable inside the It body. + $validateSetCases = $definedTechniques | ForEach-Object { + @{ + Technique = $_ + AllValidated = $allValidated + TemplateValidated = $templateValidated + CAValidated = $caValidated + ObjectValidated = $objectValidated + } + } + + $scanCases = $definedTechniques | ForEach-Object { + @{ Technique = $_; ScanTechniques = $scanTechniques } + } + + $invokeCases = $definedTechniques | ForEach-Object { + @{ Technique = $_; InvokeTechniques = $invokeTechniques } + } + + $templateReverseCases = $templateValidated | ForEach-Object { + @{ Technique = $_; DefinedTechniques = $definedTechniques } + } + $caReverseCases = $caValidated | ForEach-Object { + @{ Technique = $_; DefinedTechniques = $definedTechniques } + } + $objectReverseCases = $objectValidated | ForEach-Object { + @{ Technique = $_; DefinedTechniques = $definedTechniques } + } + + # ---------- tests ---------- + + Context 'Every defined technique appears in exactly one Find-LS2Vulnerable* ValidateSet' { + It ' is in a Find-LS2Vulnerable* ValidateSet' -ForEach $validateSetCases { + $AllValidated | Should -Contain $Technique -Because "$Technique must be reachable via a Find-LS2Vulnerable* -Technique parameter" + } + + It ' is not assigned to multiple Find-LS2Vulnerable* functions' -ForEach $validateSetCases { + $count = 0 + if ($TemplateValidated -contains $Technique) { $count++ } + if ($CAValidated -contains $Technique) { $count++ } + if ($ObjectValidated -contains $Technique) { $count++ } + $count | Should -BeLessOrEqual 1 -Because "$Technique should belong to exactly one Find-LS2Vulnerable* function" + } + } + + Context 'No ValidateSet member is orphaned from ESCDefinitions' { + It 'Find-LS2VulnerableTemplate ValidateSet member exists in ESCDefinitions' -ForEach $templateReverseCases { + $DefinedTechniques | Should -Contain $Technique -Because "$Technique is in ValidateSet but missing from ESCDefinitions" + } + + It 'Find-LS2VulnerableCA ValidateSet member exists in ESCDefinitions' -ForEach $caReverseCases { + $DefinedTechniques | Should -Contain $Technique -Because "$Technique is in ValidateSet but missing from ESCDefinitions" + } + + It 'Find-LS2VulnerableObject ValidateSet member exists in ESCDefinitions' -ForEach $objectReverseCases { + $DefinedTechniques | Should -Contain $Technique -Because "$Technique is in ValidateSet but missing from ESCDefinitions" + } + } + + Context 'Every defined technique is scanned by Initialize-LS2Scan' { + It ' is in an Initialize-LS2Scan technique array' -ForEach $scanCases { + $ScanTechniques | Should -Contain $Technique -Because "$Technique must be in Initialize-LS2Scan so Invoke-Locksmith2 triggers it" + } + } + + Context 'Every defined technique appears in Invoke-Locksmith2 reporting list' { + It ' is in Invoke-Locksmith2 $techniques' -ForEach $invokeCases { + $InvokeTechniques | Should -Contain $Technique -Because "$Technique must be in the verbose issue count loop in Invoke-Locksmith2" + } + } +} diff --git a/Tests/Private/Set/Set-CAWebEnrollmentEndpoints.Tests.ps1 b/Tests/Private/Set/Set-CAWebEnrollmentEndpoints.Tests.ps1 new file mode 100644 index 0000000..8e4f0b7 --- /dev/null +++ b/Tests/Private/Set/Set-CAWebEnrollmentEndpoints.Tests.ps1 @@ -0,0 +1,194 @@ +BeforeDiscovery { + $ModuleRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent + Import-Module (Join-Path $ModuleRoot 'Locksmith2.psd1') -Force -ErrorAction Stop +} +BeforeAll { + $ModuleRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent + Import-Module (Join-Path $ModuleRoot 'Locksmith2.psd1') -Force -ErrorAction Stop + Import-Module (Join-Path $ModuleRoot 'Tests\Shared\TestHelpers.psm1') -Force -ErrorAction Stop +} + +Describe 'Set-CAWebEnrollmentEndpoints' -Tag 'Unit' { + InModuleScope 'Locksmith2' { + BeforeEach { + $script:IssueStore = @{}; $script:PrincipalStore = @{}; $script:AdcsObjectStore = @{} + $script:DomainStore = @{}; $script:SafePrincipals = @(); $script:DangerousPrincipals = @() + $script:StandardOwners = @(); $script:DangerousAces = $null; $script:InitializingStores = $false + $script:RootDSE = $null; $script:Server = $null; $script:Forest = $null; $script:Credential = $null + } + + Context 'CA with no responding endpoints' { + It 'should set WebEnrollmentEndpoints to an empty array when all probes return null' { + $ca = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + cn = 'MyCA' + dNSHostName = 'ca1.contoso.com' + } + Mock Get-WebEnrollmentEndpointStatus { $null } + $result = $ca | Set-CAWebEnrollmentEndpoints + $result.WebEnrollmentEndpoints.Count | Should -Be 0 + } + } + + Context 'CA with an HTTP endpoint' { + It 'should add an entry to WebEnrollmentEndpoints for a responding HTTP endpoint' { + $ca = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + cn = 'MyCA' + dNSHostName = 'ca1.contoso.com' + } + Mock Get-WebEnrollmentEndpointStatus { + param([string]$Url) + if ($Url -match '^http://') { + [PSCustomObject]@{ URL = $Url; NtlmOffered = $null; EpaNotRequired = $null } + } else { + $null + } + } + $result = $ca | Set-CAWebEnrollmentEndpoints + $result.WebEnrollmentEndpoints | Should -Not -BeNullOrEmpty + $result.WebEnrollmentEndpoints[0].URL | Should -Match '^http://' + } + + It 'should set NtlmOffered=$null for HTTP endpoints' { + $ca = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + cn = 'MyCA' + dNSHostName = 'ca1.contoso.com' + } + Mock Get-WebEnrollmentEndpointStatus { + param([string]$Url) + if ($Url -match '^http://') { + [PSCustomObject]@{ URL = $Url; NtlmOffered = $null; EpaNotRequired = $null } + } else { + $null + } + } + $result = $ca | Set-CAWebEnrollmentEndpoints + $httpEndpoint = $result.WebEnrollmentEndpoints | Where-Object { $_.URL -match '^http://' } | Select-Object -First 1 + $httpEndpoint.NtlmOffered | Should -BeNullOrEmpty + } + } + + Context 'CA with an HTTPS endpoint offering NTLM' { + It 'should set NtlmOffered=$true on the endpoint entry' { + $ca = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + cn = 'MyCA' + dNSHostName = 'ca1.contoso.com' + } + Mock Get-WebEnrollmentEndpointStatus { + param([string]$Url) + if ($Url -match '^https://.*certsrv') { + [PSCustomObject]@{ URL = $Url; NtlmOffered = $true; EpaNotRequired = $false } + } else { + $null + } + } + $result = $ca | Set-CAWebEnrollmentEndpoints + $httpsEndpoint = $result.WebEnrollmentEndpoints | Where-Object { $_.URL -match '^https://' } | Select-Object -First 1 + $httpsEndpoint.NtlmOffered | Should -BeTrue + } + } + + Context 'CA with an HTTPS endpoint where EPA is not required' { + It 'should set EpaNotRequired=$true on the endpoint entry' { + $ca = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + cn = 'MyCA' + dNSHostName = 'ca1.contoso.com' + } + Mock Get-WebEnrollmentEndpointStatus { + param([string]$Url) + if ($Url -match '^https://.*certsrv') { + [PSCustomObject]@{ URL = $Url; NtlmOffered = $false; EpaNotRequired = $true } + } else { + $null + } + } + $result = $ca | Set-CAWebEnrollmentEndpoints + $httpsEndpoint = $result.WebEnrollmentEndpoints | Where-Object { $_.URL -match '^https://' } | Select-Object -First 1 + $httpsEndpoint.EpaNotRequired | Should -BeTrue + } + } + + Context 'CA with multiple responding endpoints' { + It 'should collect all responding endpoints into WebEnrollmentEndpoints' { + $ca = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + cn = 'MyCA' + dNSHostName = 'ca1.contoso.com' + } + Mock Get-WebEnrollmentEndpointStatus { + param([string]$Url) + if ($Url -match 'certsrv' -or $Url -match 'mscep') { + [PSCustomObject]@{ URL = $Url; NtlmOffered = $null; EpaNotRequired = $null } + } else { + $null + } + } + $result = $ca | Set-CAWebEnrollmentEndpoints + $result.WebEnrollmentEndpoints.Count | Should -BeGreaterThan 1 + } + } + + Context 'Non-CA objects (passthrough)' { + It 'should pass through certificate template objects unchanged' { + $template = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKICertificateTemplate') + SchemaClassName = 'pKICertificateTemplate' + cn = 'MyTemplate' + } + Mock Get-WebEnrollmentEndpointStatus { throw 'should not be called for templates' } + $result = $template | Set-CAWebEnrollmentEndpoints + $result.cn | Should -Be 'MyTemplate' + Should -Invoke 'Get-WebEnrollmentEndpointStatus' -Times 0 + } + } + + Context 'CA with no dNSHostName' -Tag 'EdgeCase' { + It 'should pass through the CA object without probing when dNSHostName is absent' { + $ca = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + cn = 'MyCA' + dNSHostName = $null + } + Mock Get-WebEnrollmentEndpointStatus { throw 'should not be called without dNSHostName' } + $result = $ca | Set-CAWebEnrollmentEndpoints + $result.cn | Should -Be 'MyCA' + Should -Invoke 'Get-WebEnrollmentEndpointStatus' -Times 0 + } + } + + Context 'Get-WebEnrollmentEndpointStatus throws on one URL' -Tag 'EdgeCase' { + It 'should skip the failing URL and still collect other responding endpoints' { + $ca = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + cn = 'MyCA' + dNSHostName = 'ca1.contoso.com' + } + # Throw only for the first URL probed; all other certsrv URLs succeed + Mock Get-WebEnrollmentEndpointStatus { + param([string]$Url) + if ($Url -eq 'http://ca1.contoso.com/certsrv/') { throw 'simulated network error' } + if ($Url -match 'certsrv') { + [PSCustomObject]@{ URL = $Url; NtlmOffered = $null; EpaNotRequired = $null } + } else { + $null + } + } + { $ca | Set-CAWebEnrollmentEndpoints } | Should -Not -Throw + $result = $ca | Set-CAWebEnrollmentEndpoints + $result.WebEnrollmentEndpoints | Should -Not -BeNullOrEmpty + } + } + } +} diff --git a/Tests/Public/Find-LS2VulnerableCA.Tests.ps1 b/Tests/Public/Find-LS2VulnerableCA.Tests.ps1 index b45f7ce..8d1409e 100644 --- a/Tests/Public/Find-LS2VulnerableCA.Tests.ps1 +++ b/Tests/Public/Find-LS2VulnerableCA.Tests.ps1 @@ -143,5 +143,177 @@ InModuleScope 'Locksmith2' { $result.Count | Should -Be 0 } } + + Context 'Path C — ESC8 endpoint-based scan' { + BeforeEach { + Mock 'Test-IssueExists' { $false } + } + + Context 'HTTP endpoint always a finding' { + It 'should return one LS2Issue for an HTTP certsrv endpoint' { + $mockCA = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + CAFullName = 'CONTOSO\CA01' + cn = 'CA01' + distinguishedName = 'CN=CA01,...' + WebEnrollmentEndpoints = @( + [PSCustomObject]@{ URL = 'http://ca1.contoso.com/certsrv/'; NtlmOffered = $null; EpaNotRequired = $null } + ) + } + $script:AdcsObjectStore = @{ $mockCA.distinguishedName = $mockCA } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result.Count | Should -Be 1 + $result[0].Technique | Should -Be 'ESC8' + } + + It 'should set Technique to ESC8 on the returned issue' { + $mockCA = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + CAFullName = 'CONTOSO\CA01' + cn = 'CA01' + distinguishedName = 'CN=CA01,...' + WebEnrollmentEndpoints = @( + [PSCustomObject]@{ URL = 'http://ca1.contoso.com/certsrv/'; NtlmOffered = $null; EpaNotRequired = $null } + ) + } + $script:AdcsObjectStore = @{ $mockCA.distinguishedName = $mockCA } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result[0].Technique | Should -Be 'ESC8' + } + } + + Context 'HTTPS endpoint with NTLM offered' { + It 'should return one LS2Issue for an HTTPS endpoint where NtlmOffered is true' { + $mockCA = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + CAFullName = 'CONTOSO\CA01' + cn = 'CA01' + distinguishedName = 'CN=CA01,...' + WebEnrollmentEndpoints = @( + [PSCustomObject]@{ URL = 'https://ca1.contoso.com/certsrv/'; NtlmOffered = $true; EpaNotRequired = $false } + ) + } + $script:AdcsObjectStore = @{ $mockCA.distinguishedName = $mockCA } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result.Count | Should -Be 1 + } + } + + Context 'HTTPS endpoint where EPA is not required (Kerberos relay)' { + It 'should return one LS2Issue for an HTTPS endpoint where EpaNotRequired is true' { + $mockCA = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + CAFullName = 'CONTOSO\CA01' + cn = 'CA01' + distinguishedName = 'CN=CA01,...' + WebEnrollmentEndpoints = @( + [PSCustomObject]@{ URL = 'https://ca1.contoso.com/certsrv/'; NtlmOffered = $false; EpaNotRequired = $true } + ) + } + $script:AdcsObjectStore = @{ $mockCA.distinguishedName = $mockCA } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result.Count | Should -Be 1 + } + } + + Context 'HTTPS endpoint with NTLM and Kerberos relay both applicable' { + It 'should return one LS2Issue (not two) when both NtlmOffered and EpaNotRequired are true' { + $mockCA = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + CAFullName = 'CONTOSO\CA01' + cn = 'CA01' + distinguishedName = 'CN=CA01,...' + WebEnrollmentEndpoints = @( + [PSCustomObject]@{ URL = 'https://ca1.contoso.com/certsrv/'; NtlmOffered = $true; EpaNotRequired = $true } + ) + } + $script:AdcsObjectStore = @{ $mockCA.distinguishedName = $mockCA } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result.Count | Should -Be 1 + } + } + + Context 'HTTPS endpoint that is safe' { + It 'should return no issues for an HTTPS endpoint where NtlmOffered=$false and EpaNotRequired=$false' { + $mockCA = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + CAFullName = 'CONTOSO\CA01' + cn = 'CA01' + distinguishedName = 'CN=CA01,...' + WebEnrollmentEndpoints = @( + [PSCustomObject]@{ URL = 'https://ca1.contoso.com/certsrv/'; NtlmOffered = $false; EpaNotRequired = $false } + ) + } + $script:AdcsObjectStore = @{ $mockCA.distinguishedName = $mockCA } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result.Count | Should -Be 0 + } + } + + Context 'CA with no WebEnrollmentEndpoints' { + It 'should return no issues when WebEnrollmentEndpoints is empty' { + $mockCA = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + CAFullName = 'CONTOSO\CA01' + cn = 'CA01' + distinguishedName = 'CN=CA01,...' + WebEnrollmentEndpoints = @() + } + $script:AdcsObjectStore = @{ $mockCA.distinguishedName = $mockCA } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result.Count | Should -Be 0 + } + + It 'should return no issues when WebEnrollmentEndpoints is null' { + $mockCA = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + CAFullName = 'CONTOSO\CA01' + cn = 'CA01' + distinguishedName = 'CN=CA01,...' + WebEnrollmentEndpoints = $null + } + $script:AdcsObjectStore = @{ $mockCA.distinguishedName = $mockCA } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result.Count | Should -Be 0 + } + } + + Context 'Multiple endpoints — mixed vulnerable and safe' { + It 'should return one issue per vulnerable endpoint' { + $mockCA = New-MockLS2AdcsObject -Properties @{ + objectClass = @('top', 'pKIEnrollmentService') + SchemaClassName = 'pKIEnrollmentService' + CAFullName = 'CONTOSO\CA01' + cn = 'CA01' + distinguishedName = 'CN=CA01,...' + WebEnrollmentEndpoints = @( + [PSCustomObject]@{ URL = 'http://ca1.contoso.com/certsrv/'; NtlmOffered = $null; EpaNotRequired = $null }, + [PSCustomObject]@{ URL = 'https://ca1.contoso.com/certsrv/'; NtlmOffered = $false; EpaNotRequired = $false }, + [PSCustomObject]@{ URL = 'https://ca1.contoso.com/certsrv/mscep/'; NtlmOffered = $true; EpaNotRequired = $false } + ) + } + $script:AdcsObjectStore = @{ $mockCA.distinguishedName = $mockCA } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result.Count | Should -Be 2 + } + } + + Context 'Initialize-LS2Scan returns false' { + It 'should return no issues when Initialize-LS2Scan returns false' { + Mock 'Initialize-LS2Scan' { $false } + $result = @(Find-LS2VulnerableCA -Technique 'ESC8') + $result.Count | Should -Be 0 + } + } + } } } +