mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-02 04:24:44 +02:00
* Add guarded native CA auditing and disposable request evidence * Link AD CS changelog to PR 421 * Retain primary native CA failure before cleanup diagnostics * Normalize native CA certificate hashes and record pending feature removal * Emit bounded disposable CA request matching diagnostics * Match observed version 1 CA request events with exact pending disposition
278 lines
27 KiB
PowerShell
278 lines
27 KiB
PowerShell
# Native CA auditing; production functions never provision a CA or submit requests.
|
|
function Get-WelaAdcsSource {
|
|
param([string]$Profile='microsoft-identity-ca-2026-09')
|
|
if ($Profile -cne 'microsoft-identity-ca-2026-09') { throw 'Unknown AD CS source profile. Use microsoft-identity-ca-2026-09.' }
|
|
$path=Join-Path $PSScriptRoot '../config/audit_profiles.json'
|
|
$before=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
|
$plan=Get-WelaAuditProfilePlan -Profile microsoft-identity-reviewed-2026-09 -Role ADCS -Build 20348
|
|
$selected=@($plan.policies | Where-Object { $_.mode -in @('exact','minimum') })
|
|
if ($selected.Count -ne 1 -or $selected[0].id -cne 'Certification Services' -or $selected[0].guid -ine '0cce9221-69ae-11d9-bed3-505054503030' -or $selected[0].mode -ne 'minimum' -or $selected[0].requiredMask -ne 3 -or
|
|
$plan.schemaSha256.ToLowerInvariant() -cne $before -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $before) { throw 'Reviewed Certification Services source profile changed or is inconsistent.' }
|
|
[pscustomobject]@{Id=$Profile;AdvancedProfile=$plan.profile;SchemaPath=$path;SchemaSha256=$before;AuditGuid=$selected[0].guid;AuditMask=3;AuditMode='minimum';Precedence=1;AuditFilter=127;SourceUrl='https://learn.microsoft.com/en-us/defender-for-identity/deploy/event-collection-overview';Reviewed='2026-09-20';Scope='Native CA audit settings only; no MDI sensor, template, AD-object or complete baseline configuration.'}
|
|
}
|
|
function Assert-WelaAdcsSource {
|
|
param($Source)
|
|
if ((Get-FileHash -LiteralPath $Source.SchemaPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $Source.SchemaSha256) { throw 'AD CS source profile changed after planning.' }
|
|
}
|
|
function ConvertTo-WelaAdcsThumbprints {
|
|
param($Values)
|
|
$items=@($Values)
|
|
if($items.Count -lt 1){throw 'CA certificate hash list is empty.'}
|
|
$seen=New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
|
foreach($value in $items){
|
|
# Native CACertHash REG_MULTI_SZ uses twenty space-separated octets;
|
|
# certificate-store thumbprints use the same forty hex digits unspaced.
|
|
if($value -isnot [string] -or $value -notmatch '^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{2}(?: [0-9a-fA-F]{2}){19})$'){throw 'CA certificate hash list is malformed.'}
|
|
$normalized=$value.Replace(' ','').ToUpperInvariant()
|
|
if(-not $seen.Add($normalized)){throw 'CA certificate hash identity is duplicated.'}
|
|
$normalized
|
|
}
|
|
}
|
|
function Get-WelaAdcsCertificates {
|
|
param([string[]]$Thumbprints)
|
|
$store=New-Object Security.Cryptography.X509Certificates.X509Store('My','LocalMachine')
|
|
$store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly -bor [Security.Cryptography.X509Certificates.OpenFlags]::OpenExistingOnly)
|
|
try {
|
|
foreach ($thumbprint in $Thumbprints) {
|
|
$certificates=@($store.Certificates.Find([Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint,$thumbprint,$false))
|
|
if ($certificates.Count -ne 1) { throw 'Configured CA certificate is absent or ambiguous in LocalMachine/My.' }
|
|
$certificate=$certificates[0];$hash=[Security.Cryptography.SHA256]::Create()
|
|
try { $sha=([BitConverter]::ToString($hash.ComputeHash($certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() }
|
|
[pscustomobject]@{Thumbprint=$certificate.Thumbprint.ToUpperInvariant();Sha256=$sha;Subject=$certificate.Subject;SerialNumber=$certificate.SerialNumber}
|
|
}
|
|
} finally { $store.Close() }
|
|
}
|
|
function Get-WelaAdcsSnapshot {
|
|
$result=[pscustomobject][ordered]@{Status='Unknown';Diagnostic='';CapturedUtc=[DateTime]::UtcNow.ToString('o');Host=$null;Active=$null;Path=$null;CaType=$null;CertificateHashes=$null;Certificates=@();Filter=$null;Service=$null;AuditMask=$null;Precedence=$null}
|
|
try {
|
|
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess) { throw '64-bit Windows is required for native CA observation.' }
|
|
$os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
|
|
$computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
|
if ($os.ProductType -notin @(1,2,3) -or ($os.ProductType -eq 1 -and $computer.DomainRole -notin @(0,1)) -or ($os.ProductType -eq 2 -and $computer.DomainRole -notin @(4,5)) -or ($os.ProductType -eq 3 -and $computer.DomainRole -notin @(2,3)) -or $computer.PartOfDomain -isnot [bool]) { throw 'Windows role observations are incomplete or contradictory.' }
|
|
$version=Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop
|
|
if ([string]$os.BuildNumber -notmatch '^\d+$' -or $null -eq $version.UBR -or -not $version.EditionID -or -not $computer.Name) { throw 'Exact Windows build/patch/edition/host identity is unavailable.' }
|
|
$result.Host=[pscustomobject]@{Computer=[string]$computer.Name;DnsHostName=[string]$computer.DNSHostName;Build=[int]$os.BuildNumber;UBR=[int]$version.UBR;Edition=[string]$version.EditionID;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;DomainJoined=[bool]$computer.PartOfDomain;Domain=[string]$computer.Domain}
|
|
$root='HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration'
|
|
if (-not (Test-Path -LiteralPath $root -ErrorAction Stop)) { $result.Status='NotApplicable';$result.Diagnostic='No configured local CA. No CA is installed by this command.';return $result }
|
|
if ($os.ProductType -ne 3 -or $computer.DomainRole -notin @(2,3)) { throw 'Only a dedicated server CA is supported; client and combined DC/CA configuration are refused.' }
|
|
if ($result.Host.Build -notin @(20348,26100)) { throw 'CA host build is outside the reviewed Server 2022/2025 families.' }
|
|
$result.Active=Get-WelaRegistryState -Path $root -Name Active
|
|
if (-not $result.Active.ValueExists -or $result.Active.Type -ne 'String' -or $result.Active.Value -isnot [string] -or [string]::IsNullOrWhiteSpace($result.Active.Value) -or $result.Active.Value -match '[\\/\x00-\x1f]' -or $result.Active.Value -in @('.','..')) { throw 'The active CA name must identify exactly one existing registry child.' }
|
|
$result.Path=$root+'\'+$result.Active.Value
|
|
$result.CaType=Get-WelaRegistryState -Path $result.Path -Name CAType
|
|
if (-not $result.CaType.ValueExists -or $result.CaType.Type -ne 'DWord' -or $result.CaType.Value -notin @(0,1,3,4)) { throw 'CA type is missing or unsupported.' }
|
|
$result.CertificateHashes=Get-WelaRegistryState -Path $result.Path -Name CACertHash
|
|
if (-not $result.CertificateHashes.ValueExists -or $result.CertificateHashes.Type -ne 'MultiString') { throw 'CA certificate identity is unavailable.' }
|
|
$hashes=@(ConvertTo-WelaAdcsThumbprints $result.CertificateHashes.Value)
|
|
$result.Certificates=@(Get-WelaAdcsCertificates $hashes)
|
|
$result.Filter=Get-WelaRegistryState -Path $result.Path -Name AuditFilter
|
|
if (-not $result.Filter.KeyExists -or ($result.Filter.ValueExists -and ($result.Filter.Type -ne 'DWord' -or ($result.Filter.Value -isnot [int] -and $result.Filter.Value -isnot [long]) -or $result.Filter.Value -lt 0 -or $result.Filter.Value -gt 127))) { throw 'Unknown CA AuditFilter type/bits are preserved for manual review.' }
|
|
$services=@(Get-CimInstance Win32_Service -Filter "Name='CertSvc'" -ErrorAction Stop)
|
|
if ($services.Count -ne 1 -or $services[0].StartMode -notin @('Auto','Manual','Disabled') -or $services[0].State -notin @('Running','Stopped')) { throw 'Certificate Services state is absent or transitional.' }
|
|
$service=$services[0];$start=$null
|
|
if ($service.State -eq 'Running') {
|
|
if (-not $service.ProcessId) { throw 'Running CA service has no process identity.' }
|
|
$start=(Get-Process -Id $service.ProcessId -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')
|
|
}
|
|
$dependents=@((Get-Service -Name CertSvc -ErrorAction Stop).DependentServices | ForEach-Object { [pscustomobject]@{Name=[string]$_.Name;Status=[string]$_.Status} } | Sort-Object Name)
|
|
$result.Service=[pscustomobject]@{Name='CertSvc';Status=[string]$service.State;StartMode=[string]$service.StartMode;ProcessId=[long]$service.ProcessId;StartUtc=$start;Dependents=$dependents}
|
|
$result.AuditMask=Get-WelaAuditPolicyMask -Guid '0cce9221-69ae-11d9-bed3-505054503030'
|
|
if (($result.AuditMask -isnot [int] -and $result.AuditMask -isnot [long]) -or $result.AuditMask -notin @(0,1,2,3)) { throw 'Effective Certification Services audit mask is unknown.' }
|
|
$result.Precedence=Get-WelaRegistryState -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy
|
|
if ($result.Precedence.ValueExists -and ($result.Precedence.Type -ne 'DWord' -or $result.Precedence.Value -notin @(0,1))) { throw 'Unknown audit precedence type/value is preserved.' }
|
|
$result.Status='Supported';$result.Diagnostic='CA identity and current settings observed. Service activation of AuditFilter and event generation remain unverified.'
|
|
} catch { $result.Diagnostic=$_.Exception.Message }
|
|
return $result
|
|
}
|
|
function Get-WelaAdcsStateKey {
|
|
param($State,[ValidateSet('None','Precedence','AuditMask','Filter','Restart')][string]$Omit='None')
|
|
$service=$State.Service
|
|
if ($Omit -eq 'Restart' -and $service) { $service=[ordered]@{Name=$service.Name;Status=$service.Status;StartMode=$service.StartMode;Dependents=@($service.Dependents)} }
|
|
[ordered]@{Status=$State.Status;Host=$State.Host;Active=$State.Active;Path=$State.Path;CaType=$State.CaType;CertificateHashes=$State.CertificateHashes;Certificates=@($State.Certificates);Filter=$(if($Omit -ne 'Filter'){$State.Filter});Service=$service;AuditMask=$(if($Omit -ne 'AuditMask'){$State.AuditMask});Precedence=$(if($Omit -ne 'Precedence'){$State.Precedence})} | ConvertTo-Json -Depth 12 -Compress
|
|
}
|
|
function Test-WelaAdcsPrecedence {
|
|
param($State)
|
|
return $State.Precedence.ValueExists -and $State.Precedence.Type -eq 'DWord' -and $State.Precedence.Value -eq 1
|
|
}
|
|
function Assert-WelaAdcsPrerequisites {
|
|
param($State)
|
|
if ($State.Status -ne 'Supported') { throw "CA unavailable: $($State.Diagnostic)" }
|
|
if (-not (Test-WelaAdcsPrecedence $State) -or $State.AuditMask -ne 3) { throw 'Effective Certification Services Success+Failure and typed audit precedence are required before CA changes.' }
|
|
if ($State.Service.Status -ne 'Running' -or $State.Service.StartMode -eq 'Disabled') { throw 'CertSvc must already be running; WELA never starts a stopped or disabled CA.' }
|
|
}
|
|
function Test-WelaAdcsControl {
|
|
param($Snapshot,[string]$Control)
|
|
switch ($Control) {
|
|
'Precedence' { return (Test-WelaAdcsPrecedence $Snapshot) }
|
|
'AuditMask' { return $Snapshot.AuditMask -eq 3 }
|
|
'Filter' { return $Snapshot.Filter.ValueExists -and $Snapshot.Filter.Type -eq 'DWord' -and $Snapshot.Filter.Value -eq 127 }
|
|
}
|
|
}
|
|
function Restart-WelaAdcsService {
|
|
# No Force: dependent services must not be stopped implicitly.
|
|
Restart-Service -Name CertSvc -ErrorAction Stop
|
|
(Get-Service -Name CertSvc -ErrorAction Stop).WaitForStatus([ServiceProcess.ServiceControllerStatus]::Running,[TimeSpan]::FromSeconds(30))
|
|
}
|
|
function Set-WelaAdcsControls {
|
|
param($Context,$Source,$Snapshot,[switch]$ConfigurePrerequisites,[switch]$AllowRestart)
|
|
$shared=@{Expected=$Snapshot;Source=$Source;Activation='Unverified';Blocked=$false;AllowRestart=[bool]$AllowRestart}
|
|
$definitions=@()
|
|
if ($ConfigurePrerequisites) { $definitions+=@('Precedence','AuditMask') }
|
|
$definitions+='Filter'
|
|
$filterChange=-not (Test-WelaAdcsControl $Snapshot Filter)
|
|
$failure=$null
|
|
try {
|
|
Assert-WelaAdcsSource $Source
|
|
if ($Snapshot.Status -ne 'Supported') { throw "CA unavailable: $($Snapshot.Diagnostic)" }
|
|
if ($Snapshot.Service.Status -ne 'Running' -or $Snapshot.Service.StartMode -eq 'Disabled') { throw 'CertSvc must already be running; no stopped CA is started.' }
|
|
if (-not $ConfigurePrerequisites) {
|
|
if (-not $Context.DryRun) { Assert-WelaAdcsPrerequisites $Snapshot }
|
|
if (@($Context.Results | Where-Object { ($_.Id -eq 'AuditPolicy/Certification Services' -or $_.Id -like '*SCENoApplyLegacyAuditPolicy') -and $_.Status -notin @('Applied','AlreadyCompliant') -and -not ($Context.DryRun -and $_.Status -eq 'Skipped' -and $_.Diagnostic -like 'Dry run:*') }).Count) { throw 'Earlier audit prerequisite control was not verified; CA changes are blocked.' }
|
|
}
|
|
if ($filterChange -and -not $AllowRestart -and -not $Context.DryRun) { throw 'An AuditFilter change requires explicit -AllowRestart on the dedicated command; no settings were changed.' }
|
|
if ($filterChange -and @($Snapshot.Service.Dependents | Where-Object Status -ne 'Stopped').Count) { throw 'Running dependent services prevent an isolated CertSvc restart; no settings were changed.' }
|
|
} catch { $failure=$_.Exception.Message }
|
|
if ($failure) {
|
|
$Context.Results.Add([pscustomobject]@{Id='ADCS/Prerequisites';Kind='AdcsAudit';Target=$Snapshot.Path;Desired=$Source;Before=$Snapshot;After=$null;Status='Failed';Diagnostic=$failure})
|
|
return [pscustomobject]@{Activation='Unverified';State=$Snapshot}
|
|
}
|
|
foreach ($control in $definitions) {
|
|
if ($shared.Blocked) { $Context.Results.Add([pscustomobject]@{Id="ADCS/$control";Kind='AdcsAudit';Target=$Snapshot.Path;Desired=$control;Before=$null;After=$null;Status='Skipped';Diagnostic='A prior CA/audit control failed or was declined.'});continue }
|
|
$state=@{Shared=$shared;Control=$control}
|
|
$read={ param($state)
|
|
Assert-WelaAdcsSource $state.Shared.Source
|
|
$current=Get-WelaAdcsSnapshot
|
|
if ($current.Status -ne 'Supported' -or (Get-WelaAdcsStateKey $current) -cne (Get-WelaAdcsStateKey $state.Shared.Expected)) { throw "CA identity, certificate, filter, service or audit state changed: $($current.Diagnostic)" }
|
|
return $current
|
|
}
|
|
$test={ param($current,$state) Test-WelaAdcsControl $current $state.Control }
|
|
$apply={ param($state)
|
|
Assert-WelaAdcsSource $state.Shared.Source
|
|
$fresh=Get-WelaAdcsSnapshot
|
|
if ($fresh.Status -ne 'Supported' -or (Get-WelaAdcsStateKey $fresh) -cne (Get-WelaAdcsStateKey $state.Shared.Expected)) { throw 'CA state changed after journaling; write refused.' }
|
|
if ($state.Control -eq 'AuditMask' -and -not (Test-WelaAdcsPrecedence $fresh)) { throw 'Audit precedence was not verified.' }
|
|
if ($state.Control -eq 'Filter') { Assert-WelaAdcsPrerequisites $fresh }
|
|
switch ($state.Control) {
|
|
'Precedence' { Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1 -Type DWord -ErrorAction Stop }
|
|
'AuditMask' { Set-WelaEffectiveAuditPolicy -Guid $state.Shared.Source.AuditGuid -Mask 3 -Mode minimum }
|
|
'Filter' { Set-ItemProperty -LiteralPath $fresh.Path -Name AuditFilter -Value 127 -Type DWord -ErrorAction Stop }
|
|
}
|
|
$after=Get-WelaAdcsSnapshot
|
|
if ($after.Status -ne 'Supported' -or (Get-WelaAdcsStateKey $fresh $state.Control) -cne (Get-WelaAdcsStateKey $after $state.Control) -or -not (Test-WelaAdcsControl $after $state.Control)) { throw 'CA control readback or preservation check failed; inspect the journal.' }
|
|
$state.Shared.Expected=$after
|
|
if ($state.Control -eq 'Filter') {
|
|
$state.Shared.Activation='RestartPending'
|
|
Assert-WelaAdcsSource $state.Shared.Source
|
|
$ready=Get-WelaAdcsSnapshot;Assert-WelaAdcsPrerequisites $ready
|
|
if ((Get-WelaAdcsStateKey $ready) -cne (Get-WelaAdcsStateKey $after)) { throw 'CA state changed before restart; service was not restarted.' }
|
|
if (-not $state.Shared.AllowRestart) { throw 'Restart was not authorized.' }
|
|
Restart-WelaAdcsService
|
|
$restarted=Get-WelaAdcsSnapshot;Assert-WelaAdcsPrerequisites $restarted
|
|
if ((Get-WelaAdcsStateKey $ready Restart) -cne (Get-WelaAdcsStateKey $restarted Restart) -or $ready.Service.StartUtc -ceq $restarted.Service.StartUtc -or [DateTime]$restarted.Service.StartUtc -le [DateTime]$ready.Service.StartUtc) { throw 'CA restart/readback or preservation could not be verified.' }
|
|
$state.Shared.Expected=$restarted;$state.Shared.Activation='RestartObservedAfterWrite; event generation unverified'
|
|
}
|
|
}
|
|
$target=if ($control -eq 'Filter') { [ordered]@{Path=$Snapshot.Path;Name='AuditFilter';Service='CertSvc';ActiveCa=$Snapshot.Active.Value;Certificates=$Snapshot.Certificates} } elseif ($control -eq 'AuditMask') { @{Guid=$Source.AuditGuid} } else { @{Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';Name='SCENoApplyLegacyAuditPolicy'} }
|
|
$desired=if($control -eq 'Filter'){@{Value=127;Type='DWord';RestartIfChanged=[bool]$AllowRestart}} elseif($control -eq 'AuditMask'){@{Mask=3;Mode='minimum'}} else {@{Value=1;Type='DWord'}}
|
|
Invoke-WelaConfigurationControl -Context $Context -Id "ADCS/$control" -Kind AdcsAudit -Target $target -Desired $desired -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description $(if($control -eq 'Filter'){'Set the reviewed CA AuditFilter and restart this running Certificate Services instance.'}else{'Apply the source-required audit prerequisite.'})
|
|
$row=$Context.Results[$Context.Results.Count-1]
|
|
$row | Add-Member NoteProperty Source $Source
|
|
$row | Add-Member NoteProperty VerificationScope 'Current settings and preservation only; registry 127 and Running do not establish service activation or events.'
|
|
if ($row.Status -eq 'Failed' -or ($row.Status -eq 'Skipped' -and -not $Context.DryRun)) { $shared.Blocked=$true }
|
|
}
|
|
return [pscustomobject]@{Activation=$shared.Activation;State=$shared.Expected}
|
|
}
|
|
function Invoke-WelaLegacyAdcsControl {
|
|
param($Context)
|
|
try {
|
|
$snapshot=Get-WelaAdcsSnapshot
|
|
if ($snapshot.Status -eq 'NotApplicable') { $Context.Results.Add([pscustomobject]@{Id='ADCS/AuditFilter';Kind='AdcsAudit';Target=$null;Desired=127;Before=$snapshot;After=$snapshot;Status='Skipped';Diagnostic=$snapshot.Diagnostic});return }
|
|
$source=Get-WelaAdcsSource
|
|
$outcome=Set-WelaAdcsControls -Context $Context -Source $source -Snapshot $snapshot -AllowRestart
|
|
foreach ($row in @($Context.Results | Where-Object { $_.Kind -eq 'AdcsAudit' })) { $row | Add-Member NoteProperty Activation $outcome.Activation -Force }
|
|
} catch { $Context.Results.Add([pscustomobject]@{Id='ADCS/AuditFilter';Kind='AdcsAudit';Target=$null;Desired=127;Before=$snapshot;After=$null;Status='Failed';Diagnostic=$_.Exception.Message}) }
|
|
}
|
|
function Get-WelaAdcsReportPath {
|
|
param([string]$Path)
|
|
$provider=$null;$drive=$null
|
|
$full=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive)
|
|
if ($provider.Name -ne 'FileSystem' -or $full -match '^[\\/]{2}' -or (Test-Path -LiteralPath $full -ErrorAction Stop)) { throw 'AD CS reports require a new local filesystem file.' }
|
|
if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT -and ([IO.DriveInfo]::new([IO.Path]::GetPathRoot($full))).DriveType -ne [IO.DriveType]::Fixed) { throw 'AD CS evidence requires a local fixed drive.' }
|
|
$parent=[IO.DirectoryInfo]([IO.Path]::GetDirectoryName($full))
|
|
if (-not $parent.Exists) { throw 'AD CS report parent must exist.' }
|
|
while($parent){if($parent.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'AD CS output cannot traverse a reparse-point directory.'};$parent=$parent.Parent}
|
|
return $full
|
|
}
|
|
function Protect-WelaAdcsDirectory {
|
|
param([string]$Path)
|
|
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return }
|
|
$acl=New-Object Security.AccessControl.DirectorySecurity
|
|
$acl.SetAccessRuleProtection($true,$false)
|
|
foreach($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User.Value,'S-1-5-18','S-1-5-32-544')|Select-Object -Unique){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))}
|
|
Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop
|
|
}
|
|
function Invoke-WelaAdcsCommand {
|
|
param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[string]$Profile,[switch]$AllowRestart,[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath)
|
|
if ($Action -ne 'Audit' -and -not $Profile) { throw 'AD CS Plan/Configure requires explicit -AdcsProfile microsoft-identity-ca-2026-09.' }
|
|
if ($Action -ne 'Configure' -and ($AllowRestart -or $Auto -or $DryRun -or $BackupPath)) { throw 'Restart, consent, dry-run and backup options require AD CS Configure.' }
|
|
if (-not $Profile) {$Profile='microsoft-identity-ca-2026-09'}
|
|
$source=Get-WelaAdcsSource $Profile
|
|
if($ResultsPath){$ResultsPath=Get-WelaAdcsReportPath $ResultsPath}
|
|
$before=Get-WelaAdcsSnapshot
|
|
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAdcsAuditing';Action=$Action;Source=$source;Before=$before;After=$before;ExitCode=$(if($before.Status -eq 'Unknown'){1}else{0});Results=@();Activation='Unverified';EventGeneration='Unverified';UsableRuleCredit=0;Scope='Native local CA auditing only. Sysmon, enrollment/template permissions, AD objects, leaf issuance and forwarding excluded.'}
|
|
$report | Add-Member NoteProperty Plan @(
|
|
foreach($control in @('Precedence','AuditMask','Filter')) {
|
|
$current=switch($control){'Precedence'{$before.Precedence}'AuditMask'{$before.AuditMask}'Filter'{$before.Filter}}
|
|
[pscustomobject]@{Control=$control;Current=$current;Desired=$(if($control -eq 'Filter'){'DWORD127, restart after change'}elseif($control -eq 'AuditMask'){'Success+Failure, minimum3'}else{'DWORD1'});State=$(if($before.Status -ne 'Supported'){$before.Status}elseif(Test-WelaAdcsControl $before $control){'PolicyMatches'}else{'ChangeRequired'})}
|
|
}
|
|
)
|
|
if($Action -eq 'Configure' -and $before.Status -ne 'NotApplicable'){
|
|
if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){
|
|
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
|
try{if(-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Elevated administrator rights are required for CA configuration.'}}finally{$identity.Dispose()}
|
|
}
|
|
|
|
if(-not $DryRun){
|
|
if(-not $BackupPath){$BackupPath=Join-Path $script:ScriptRoot ('wela-adcs-backup-'+[guid]::NewGuid().ToString('N'))}
|
|
$BackupPath=Get-WelaAdcsReportPath $BackupPath
|
|
}
|
|
$context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
|
|
if(-not $DryRun){Protect-WelaAdcsDirectory $context.BackupPath}
|
|
$outcome=Set-WelaAdcsControls -Context $context -Source $source -Snapshot $before -ConfigurePrerequisites -AllowRestart:$AllowRestart
|
|
$completed=Complete-WelaConfiguration -Context $context -Scope adcs-audit-settings-only -SuccessMessage 'CA settings verified. Activation and event evidence are reported separately.'
|
|
$report.Results=$completed.Results;$report.ExitCode=$completed.ExitCode;$report.Activation=$outcome.Activation
|
|
$report | Add-Member NoteProperty Configuration $completed
|
|
$report.After=Get-WelaAdcsSnapshot
|
|
if($report.After.Status -ne 'Supported' -or (Get-WelaAdcsStateKey $report.After) -cne (Get-WelaAdcsStateKey $outcome.State)){$report.ExitCode=1;$report.Activation='Unverified: final CA state changed'}
|
|
}
|
|
$report | Add-Member NoteProperty PolicyState $(if($report.After.Status -ne 'Supported'){$report.After.Status}elseif((Test-WelaAdcsControl $report.After Filter) -and (Test-WelaAdcsPrecedence $report.After) -and $report.After.AuditMask -eq 3){'PolicyMatches'}else{'ChangeRequired'})
|
|
if($ResultsPath){$full=Get-WelaAdcsReportPath $ResultsPath;$bytes=([Text.UTF8Encoding]::new($false)).GetBytes(($report|ConvertTo-Json -Depth 22));$stream=[IO.File]::Open($full,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()}}
|
|
return $report
|
|
}
|
|
|
|
# Validate only components of a pending-request event. This is not Sigma/backend
|
|
# evidence and never submits, approves, retrieves or installs a certificate.
|
|
function Test-WelaAdcsRequestEvent {
|
|
param([string]$Xml,$Expected,[ValidateSet(4886,4889)][int]$EventId)
|
|
try {
|
|
$settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null
|
|
$reader=[Xml.XmlReader]::Create((New-Object IO.StringReader($Xml)),$settings)
|
|
try{$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader)}finally{$reader.Dispose()}
|
|
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
|
|
$system=$doc.SelectSingleNode('/e:Event/e:System',$ns)
|
|
if(-not $system){return $false}
|
|
$provider=$system.SelectSingleNode('e:Provider',$ns)
|
|
if($provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or [guid]$provider.GetAttribute('Guid') -ne [guid]'54849625-5478-4994-a5ba-3e3b0328c30d' -or
|
|
$system.SelectSingleNode('e:EventID',$ns).InnerText -cne [string]$EventId -or $system.SelectSingleNode('e:Version',$ns).InnerText -cnotin @('0','1') -or $system.SelectSingleNode('e:Channel',$ns).InnerText -cne 'Security' -or
|
|
$system.SelectSingleNode('e:Computer',$ns).InnerText -ine $Expected.Computer -or $system.SelectSingleNode('e:Keywords',$ns).InnerText -ine '0x8020000000000000'){return $false}
|
|
$utc=[DateTimeOffset]::Parse($system.SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime'),[Globalization.CultureInfo]::InvariantCulture).UtcDateTime
|
|
if($utc -lt ([DateTime]$Expected.StartUtc).ToUniversalTime() -or $utc -gt ([DateTime]$Expected.EndUtc).ToUniversalTime()){return $false}
|
|
$data=New-Object 'System.Collections.Generic.Dictionary[string,string]' ([StringComparer]::Ordinal)
|
|
foreach($node in @($doc.SelectNodes('/e:Event/e:EventData/e:Data',$ns))){$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name)){return $false};$data.Add($name,$node.InnerText)}
|
|
if(-not $data.ContainsKey('RequestId') -or $data['RequestId'] -cne [string]$Expected.RequestId -or -not $data.ContainsKey('Requester') -or $data['Requester'] -ine $Expected.Requester -or -not $data.ContainsKey('Attributes')){return $false}
|
|
if($EventId -eq 4889 -and (-not $data.ContainsKey('Disposition') -or $data['Disposition'] -cne '5')){return $false}
|
|
# Retain and match the random request attribute without localized message parsing.
|
|
return @($data['Attributes'] -split '\r?\n' | Where-Object { $_.Trim() -ceq ('WELAProbe:'+$Expected.Nonce) }).Count -eq 1
|
|
}catch{return $false}
|
|
}
|