mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-09-29 10:34:51 +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
194 lines
12 KiB
PowerShell
194 lines
12 KiB
PowerShell
# No Windows settings are changed. Run with powershell.exe 5.1 or pwsh.
|
|
$ErrorActionPreference = 'Stop'
|
|
$repo = Split-Path $PSScriptRoot -Parent
|
|
$script:ScriptRoot = $repo
|
|
# Load trusted source functions into the same scope as the mocks. Windows
|
|
# PowerShell 5.1 otherwise resolves a script-local original ahead of global mocks.
|
|
$definitions = Get-Content -LiteralPath (Join-Path $repo 'scripts/Configuration.ps1') -Raw
|
|
Invoke-Expression ($definitions -replace '(?m)^function ', 'function global:')
|
|
$script:passed = 0
|
|
function Assert($Condition, [string]$Message) {
|
|
if (-not $Condition) { throw "FAIL: $Message" }
|
|
$script:passed++
|
|
}
|
|
function New-TestContext([switch]$DryRun) {
|
|
$path = Join-Path ([IO.Path]::GetTempPath()) ('wela-results-test-' + [guid]::NewGuid().ToString('N'))
|
|
if (-not $DryRun) { $script:cleanup.Add($path) }
|
|
New-WelaConfigurationContext -Auto -DryRun:$DryRun -BackupPath $path
|
|
}
|
|
$script:cleanup = New-Object 'System.Collections.Generic.List[string]'
|
|
try {
|
|
foreach ($path in @('WELA.ps1', 'scripts/Configuration.ps1')) {
|
|
$parseErrors = $null; $tokens = $null
|
|
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo $path), [ref]$tokens, [ref]$parseErrors)
|
|
Assert ($parseErrors.Count -eq 0) "Parser accepts $path"
|
|
}
|
|
|
|
# An actual child process exercises exit capture and stderr retention. The
|
|
# child only emits text and exits; it never calls Windows configuration tools.
|
|
$engine = (Get-Process -Id $PID).Path
|
|
$caught = ''
|
|
try { Invoke-WelaNative -FilePath $engine -Arguments @('-NoProfile', '-Command', "[Console]::Error.WriteLine('injected native diagnostic'); exit 7") }
|
|
catch { $caught = $_.ToString() }
|
|
Assert ($caught -match 'exit: 7' -and $caught -match 'injected native diagnostic') 'Native failure retains exit code and stderr'
|
|
$ok = Invoke-WelaNative -FilePath $engine -Arguments @('-NoProfile', '-Command', "[Console]::Error.WriteLine('non-fatal diagnostic'); exit 0")
|
|
Assert ($ok.ExitCode -eq 0 -and $ok.Diagnostic -match 'non-fatal diagnostic') 'Stderr alone is not a native failure'
|
|
|
|
$script:state = 1; $script:writes = 0
|
|
$read = { $script:state }; $test = { param($value) $value -eq 2 }
|
|
$apply = { $script:writes++; $script:state = 2 }
|
|
$c = New-TestContext
|
|
Invoke-WelaConfigurationControl $c test Registry @{ Path = 'mock'; Name = 'value' } 2 $read $test $apply
|
|
Assert ($c.Results[0].Status -eq 'Applied' -and $script:writes -eq 1) 'Changed state is read back before Applied'
|
|
$journal = Get-Content -LiteralPath (Join-Path $c.BackupPath 'before.jsonl') | ConvertFrom-Json
|
|
Assert ($journal.Before -eq 1 -and $journal.Desired -eq 2) 'Journal contains exact before and requested state'
|
|
Invoke-WelaConfigurationControl $c repeated Registry @{} 2 $read $test $apply
|
|
Assert ($c.Results[1].Status -eq 'AlreadyCompliant' -and $script:writes -eq 1) 'Rerun is idempotent'
|
|
$r = Complete-WelaConfiguration $c
|
|
Assert ($r.ExitCode -eq 0) 'Verified controls produce successful overall status'
|
|
$script:state = 1
|
|
$r = Complete-WelaConfiguration $c
|
|
Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -eq 'Overridden') 'Final check detects observed drift without attributing its cause'
|
|
|
|
$c = New-TestContext -DryRun
|
|
$script:writes = 0
|
|
Invoke-WelaConfigurationControl $c dry Registry @{} 2 $read $test $apply
|
|
Assert ($c.Results[0].Status -eq 'Skipped' -and $script:writes -eq 0) 'Dry run never invokes mutation'
|
|
Assert (-not (Test-Path -LiteralPath $c.BackupPath)) 'Dry run creates no backup or journal'
|
|
|
|
$c = New-TestContext
|
|
Invoke-WelaConfigurationControl $c false_success Registry @{} 2 $read $test { }
|
|
Assert ($c.Results[0].Status -eq 'Failed') 'Successful write command with wrong read-back is Failed'
|
|
Assert ((Complete-WelaConfiguration $c).ExitCode -eq 1) 'Read-back failure makes overall status nonzero'
|
|
|
|
$c = New-TestContext
|
|
$c.BackupPath = Join-Path $c.BackupPath 'missing-parent'
|
|
$script:writes = 0
|
|
Invoke-WelaConfigurationControl $c journal_failed Registry @{} 2 $read $test $apply
|
|
Assert ($c.Results[0].Status -eq 'Failed' -and $script:writes -eq 0) 'Journal failure prevents mutation'
|
|
|
|
# Registry provider failures and false-success writes use the same verified
|
|
# control runner; no actual registry provider is touched in these tests.
|
|
$script:registryValue = 0; $script:registryWrites = 0; $script:registryThrows = $true
|
|
function global:Get-WelaRegistryState {
|
|
param($Path, $Name)
|
|
[pscustomobject]@{ KeyExists = $true; ValueExists = $true; Value = $script:registryValue; Type = 'DWord' }
|
|
}
|
|
function global:Test-Path {
|
|
param($LiteralPath, $Path, $ErrorAction)
|
|
if ($LiteralPath -like 'HKLM:*') { return $true }
|
|
Microsoft.PowerShell.Management\Test-Path -LiteralPath $(if ($LiteralPath) { $LiteralPath } else { $Path })
|
|
}
|
|
function global:Set-ItemProperty {
|
|
param($LiteralPath, $Name, $Value, $Type, $ErrorAction)
|
|
$script:registryWrites++
|
|
if ($script:registryThrows) { throw 'Injected registry access denied' }
|
|
$script:registryValue = $Value
|
|
}
|
|
$c = New-TestContext
|
|
Set-WelaRegistryControl $c 'HKLM:\mock' Value 1
|
|
Assert ($c.Results[0].Status -eq 'Failed' -and $c.Results[0].Diagnostic -match 'access denied') 'Registry write errors produce failed results'
|
|
$script:registryThrows = $false
|
|
$c = New-TestContext
|
|
Set-WelaRegistryControl $c 'HKLM:\mock' Value 1
|
|
Assert ($c.Results[0].Status -eq 'Applied' -and $c.Results[0].After.Value -eq 1) 'Registry writes require verified value and type'
|
|
$beforeWrites = $script:registryWrites
|
|
Set-WelaRegistryControl $c 'HKLM:\mock' Value 1
|
|
Assert ($c.Results[1].Status -eq 'AlreadyCompliant' -and $script:registryWrites -eq $beforeWrites) 'Registry reruns preserve compliant values'
|
|
|
|
# Missing nested registry parents must be created individually, retaining
|
|
# existing parent keys/values. Mock provider rejects children without parents.
|
|
$script:mockKeys = @{'HKLM:' = $true; 'HKLM:\SOFTWARE' = $true}
|
|
$script:createdKeys = New-Object 'System.Collections.Generic.List[string]'
|
|
function global:Test-Path {
|
|
param($LiteralPath, $Path, $ErrorAction)
|
|
if ($LiteralPath -like 'HKLM:*') { return $script:mockKeys.ContainsKey($LiteralPath) }
|
|
Microsoft.PowerShell.Management\Test-Path -LiteralPath $(if ($LiteralPath) { $LiteralPath } else { $Path })
|
|
}
|
|
function global:New-Item {
|
|
param($Path, $ItemType, [switch]$Force, $ErrorAction)
|
|
if ($Path -notlike 'HKLM:*') { return Microsoft.PowerShell.Management\New-Item @PSBoundParameters }
|
|
if ($Force) { throw 'Test refuses Force on registry keys' }
|
|
if ($script:mockKeys.ContainsKey($Path)) { throw 'Existing parent would be recreated' }
|
|
$parent = $Path.Substring(0, $Path.LastIndexOf('\'))
|
|
if (-not $script:mockKeys.ContainsKey($parent)) { throw "Missing registry parent: $parent" }
|
|
$script:createdKeys.Add($Path); $script:mockKeys[$Path] = $true
|
|
}
|
|
New-WelaRegistryKey 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging'
|
|
Assert ($script:createdKeys.Count -eq 5 -and $script:mockKeys.ContainsKey('HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging')) 'Missing registry ancestors are created safely in order'
|
|
New-WelaRegistryKey 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging'
|
|
Assert ($script:createdKeys.Count -eq 5) 'Existing registry parents are preserved on rerun'
|
|
|
|
# Function stubs stand in for the Windows APIs from this point onward.
|
|
$script:logSize = 1048576; $script:nativeFails = $true; $script:nativeWrites = 0
|
|
function global:Get-WinEvent { param($ListLog, $ErrorAction) [pscustomobject]@{ MaximumSizeInBytes = $script:logSize; IsEnabled = $false } }
|
|
function global:Invoke-WelaNative {
|
|
param($FilePath, $Arguments)
|
|
$script:nativeWrites++
|
|
if ($script:nativeFails) { throw 'wevtutil.exe failed (exit: 5). Injected access denied' }
|
|
$script:logSize = 134217728
|
|
[pscustomobject]@{ ExitCode = 0; Output = @(); Diagnostic = 'mock success' }
|
|
}
|
|
$c = New-TestContext
|
|
Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728
|
|
$r = Complete-WelaConfiguration $c
|
|
Assert ($r.ExitCode -eq 1 -and $r.Results[0].Diagnostic -match 'access denied') 'Injected wevtutil failure survives through the final report'
|
|
$script:nativeFails = $false
|
|
$c = New-TestContext
|
|
Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728
|
|
Assert ($c.Results[0].Status -eq 'Applied') 'Event log helper reads verified size'
|
|
Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728
|
|
Assert ($c.Results[1].Status -eq 'AlreadyCompliant') 'Event log helper avoids repeated writes'
|
|
|
|
# Compile the interop declaration without invoking Windows APIs on this host.
|
|
Initialize-WelaConfigurationAuditApi
|
|
Assert ($null -ne ('Wela.ConfigurationAuditApi' -as [type])) 'Audit query interop compiles'
|
|
function global:Get-WelaNativeAuditPolicy { param($Guid) return 3 }
|
|
Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 3) 'Audit policy uses native numeric flags independent of locale'
|
|
function global:Get-WelaNativeAuditPolicy { param($Guid) return 4 }
|
|
Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 0) 'Native NONE flag normalizes to no success/failure audit'
|
|
function global:Get-WelaNativeAuditPolicy { param($Guid) return 16 }
|
|
$caught = ''
|
|
try { Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030' } catch { $caught = $_.ToString() }
|
|
Assert ($caught -ne '') 'Unexpected native flags cannot be marked compliant'
|
|
|
|
# Extract ConfigureAuditSettings without running the WELA command dispatcher.
|
|
$tokens = $null; $errors = $null
|
|
$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors)
|
|
$configure = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'ConfigureAuditSettings' }, $false)
|
|
Assert ($configure.Extent.Text -notmatch 'Configuration completed successfully|Start-Process|Out-Null') 'Configure has no unverified native execution or unconditional success'
|
|
|
|
# Run the actual configure dispatcher in a child process with only the
|
|
# configuration function replaced by a harmless failed-report fixture.
|
|
$dispatch = $ast.Find({ param($node) $node -is [Management.Automation.Language.SwitchStatementAst] -and $node.Condition.Extent.Text -eq '$Cmd.ToLower()' }, $false)
|
|
$clause = @($dispatch.Clauses | Where-Object { $_.Item1.Value -eq 'configure' })[0].Item2.Extent.Text
|
|
$child = 'function ConfigureAuditSettings { [pscustomobject]@{ ExitCode = 1; Failed = 1; Results = @() } }; & ' + $clause
|
|
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child))
|
|
$childOutput = @(& $engine -NoProfile -EncodedCommand $encoded 2>&1)
|
|
$childExit = $global:LASTEXITCODE
|
|
# GitHub's PowerShell wrapper propagates LASTEXITCODE after the script. This
|
|
# child was deliberately failed; assertions below decide the test outcome.
|
|
$global:LASTEXITCODE = 0
|
|
Assert ($childExit -eq 1) 'The actual configure dispatcher returns nonzero for a failed control report'
|
|
|
|
# The legacy wrapper delegates to the same guarded CA engine as the dedicated
|
|
# command. Actual identity/prerequisite/write/restart cases have focused tests
|
|
# in AdcsAuditing.Tests.ps1 and the disposable native CA workflow.
|
|
$script:forwardedContext = $null
|
|
function global:Invoke-WelaLegacyAdcsControl { param($Context) $script:forwardedContext = $Context }
|
|
$c = New-TestContext
|
|
Set-WelaCertificateAuditControl $c
|
|
Assert ([object]::ReferenceEquals($c, $script:forwardedContext)) 'Legacy CA forwards its existing prompt/Auto/recovery context to the shared engine'
|
|
$c = New-TestContext -DryRun
|
|
Set-WelaCertificateAuditControl $c
|
|
Assert ($script:forwardedContext.DryRun) 'Legacy CA forwards dry-run without a separate native implementation'
|
|
|
|
Write-Host "$script:passed configuration-result regression assertions passed. No Windows settings changed."
|
|
} finally {
|
|
foreach ($path in $script:cleanup) {
|
|
if (Microsoft.PowerShell.Management\Test-Path -LiteralPath $path) {
|
|
Remove-Item -LiteralPath $path -Recurse -Force
|
|
}
|
|
}
|
|
}
|