Verify configure changes and propagate per-control failures

This commit is contained in:
Shirofune-Security committed 2026-09-18 21:48:27 +09:00
1 parent 8ef938f096
commit 1ae4930438
7 files changed
+601 -346

No files matched your search

@@ -0,0 +1,22 @@
name: Configuration result regressions
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
configuration-results:
runs-on: windows-latest
strategy:
matrix:
shell: [powershell, pwsh]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Mocked regression tests (no Windows configuration changes)
shell: ${{ matrix.shell }}
run: ./tests/Test-ConfigurationResults.ps1
- name: Real Windows read-only smoke tests
shell: ${{ matrix.shell }}
run: ./tests/Test-ConfigurationReadOnlyWindows.ps1
+1
View File
@@ -38,6 +38,7 @@ jobs:
mkdir -p release-binaries
Copy-Item -Path WELA.ps1 -Destination release-binaries/
Copy-Item -Recurse -Path ./config -Destination release-binaries/
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
+60 -346
View File
@@ -4,6 +4,9 @@
[switch]$Debug,
[string]$Baseline,
[switch]$Auto,
[switch]$DryRun,
[string]$BackupPath,
[string]$ResultsPath,
[switch]$Help
)
@@ -17,6 +20,7 @@ $SecurityRulesPath = Join-Path $ScriptRoot "config/security_rules.json"
$EidMappingPath = Join-Path $ScriptRoot "config/eid_subcategory_mapping.csv"
$AuditpolTxtPath = Join-Path $ScriptRoot "auditpol.txt"
$SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/Configuration.ps1")
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。
$PowerShellPolicyRoots = @(
@@ -986,10 +990,23 @@ function Set-RegistryConfig {
[array]$RegPaths,
[Parameter(Mandatory = $false)]
[switch]$Auto
[switch]$Auto,
$Context
)
foreach ($reg in $RegPaths) {
if ($Context) {
if ($PSCmdlet.ShouldProcess("$($reg.Path)\$($reg.Name)", "Set to $($reg.Value)")) {
Set-WelaRegistryControl -Context $Context -Path $reg.Path -Name $reg.Name -Value $reg.Value
} else {
$Context.Results.Add([pscustomobject]@{
Id = "Registry/$($reg.Path)/$($reg.Name)"; Kind = 'Registry'
Target = @{ Path = $reg.Path; Name = $reg.Name }; Desired = $reg.Value
Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'ShouldProcess declined the change.'
})
}
continue
}
try {
$currentValue = "Not Set"
$pathExists = Test-Path $reg.Path
@@ -1031,69 +1048,18 @@ function Set-RegistryConfig {
function ConfigureAuditSettings {
param (
[switch] $Auto,
[switch] $Debug
)
param ([switch]$Auto, [switch]$Debug, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
if (-not (TestWindows)) {
Write-Host "[ERROR] 'configure' changes Windows settings and can only run on Windows." -ForegroundColor Red
return
if (-not (TestWindows)) { throw "'configure' can only run on Windows." }
if (-not (TestAdministrator)) { throw 'This script requires Administrator privileges.' }
# Never use the debug cache to decide whether mutating controls are compliant.
if ($Debug) { Write-Host 'configure always reads live state; the auditpol debug cache is not used.' -ForegroundColor Yellow }
$context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
if (-not $DryRun) { Write-Host "Recovery journal: $($context.BackupPath)" }
foreach ($log in @('Security', 'Microsoft-Windows-PowerShell/Operational', 'Windows PowerShell')) {
Set-WelaEventLogControl -Context $context -Log $log -Property MaximumSizeInBytes -Desired 1073741824
}
# 管理者権限の確認
if (-not (TestAdministrator)) {
Write-Error "This script requires Administrator privileges"
exit 1
}
if (-not (CollectAuditpol -UseCached:$Debug)) {
return
}
# ログサイズ定数
$oneGB = 1073741824
$oneTwentyEightMB = 134217728
# セキュリティおよびPowerShellログを1GBに設定
Write-Host "Configuring Event Logs..."
Write-Host ""
$largeLogs = @(
"Security",
"Microsoft-Windows-PowerShell/Operational",
"Windows PowerShell"
)
foreach ($log in $largeLogs) {
try {
$logInfo = Get-WinEvent -ListLog $log -ErrorAction Stop
$currentSize = [math]::Floor($logInfo.MaximumSizeInBytes / 1MB)
$newSize = 1024
Write-Host "Log: $log"
if ($currentSize -ge $newSize) {
Write-Host "[SKIPPED] $log : Current size ($currentSize MB) is already greater than or equal to $newSize MB." -ForegroundColor Yellow
Write-Host ""
continue
}
if ($Auto) {
$response = "Y"
} else {
$response = Read-Host "Your current setting is $currentSize MB. Do you want to change it to 1024 MB? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y") {
wevtutil sl $log /ms:$oneGB 2>&1 | Out-Null
Write-Host "[OK] $log : 1024 MB" -ForegroundColor Green
} else {
Write-Host "[SKIPPED] $log" -ForegroundColor Yellow
}
}
catch {
Write-Host "[ERROR] $log : $_" -ForegroundColor Red
}
Write-Host ""
}
# その他の重要なログを128MBに設定
$mediumLogs = @(
"System",
"Application",
@@ -1120,199 +1086,38 @@ function ConfigureAuditSettings {
)
foreach ($log in $mediumLogs) {
try {
$logInfo = Get-WinEvent -ListLog $log -ErrorAction Stop
$currentSize = [math]::Floor($logInfo.MaximumSizeInBytes / 1MB)
$newSize = 128
Write-Host "Log: $log"
if ($currentSize -ge $newSize) {
Write-Host "[SKIPPED] $log : Current size ($currentSize MB) is already greater than or equal to $newSize MB." -ForegroundColor Yellow
Write-Host ""
continue
}
if ($Auto) {
$response = "Y"
} else {
$response = Read-Host "Your current setting is $currentSize MB. Do you want to change it to 128 MB? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y") {
wevtutil sl $log /ms:$oneTwentyEightMB 2>&1 | Out-Null
Write-Host "[OK] $log : 128 MB" -ForegroundColor Green
} else {
Write-Host "[SKIPPED] $log" -ForegroundColor Yellow
}
}
catch {
Write-Host "[ERROR] $log : $_" -ForegroundColor Red
}
Write-Host ""
Set-WelaEventLogControl -Context $context -Log $log -Property MaximumSizeInBytes -Desired 134217728
}
foreach ($log in @('Microsoft-Windows-TaskScheduler/Operational', 'Microsoft-Windows-DriverFrameworks-UserMode/Operational', 'Microsoft-Windows-Crypto-DPAPI/Debug')) {
Set-WelaEventLogControl -Context $context -Log $log -Property IsEnabled -Desired $true
}
# 特定のログの有効化
Write-Host "Enabling Event Logs..."
Write-Host ""
foreach ($log in @("Microsoft-Windows-TaskScheduler/Operational", "Microsoft-Windows-DriverFrameworks-UserMode/Operational", "Microsoft-Windows-Crypto-DPAPI/Debug")) {
try {
$logInfo = Get-WinEvent -ListLog $log -ErrorAction Stop
$currentState = if ($logInfo.IsEnabled) { "Enabled" } else { "Disabled" }
$newState = "Enabled"
Write-Host "Log: $log"
if ($currentState -eq $newState) {
Write-Host "[SKIPPED] $log : Already Enabled." -ForegroundColor Yellow
Write-Host ""
continue
}
if ($Auto) {
$response = "Y"
} else {
$response = Read-Host "Your current setting is $currentState. Do you want to change it to Enabled? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y") {
wevtutil sl $log /e:true 2>&1 | Out-Null
Write-Host "[OK] Enabled: $log" -ForegroundColor Green
} else {
Write-Host "[SKIPPED] $log" -ForegroundColor Yellow
}
}
catch {
Write-Host "[ERROR] Failed to enable $log : $_" -ForegroundColor Red
}
Write-Host ""
}
# PowerShell ロギングの設定
Write-Host "Configuring PowerShell Logging..."
Write-Host ""
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。
# 32bit の PowerShell 用に Wow6432Node 側も併せて設定する。
$regPaths = @()
foreach ($root in $script:PowerShellPolicyRoots) {
$regPaths += @{Path = "$root\ModuleLogging"; Name = "EnableModuleLogging"; Value = 1}
$regPaths += @{Path = "$root\ScriptBlockLogging"; Name = "EnableScriptBlockLogging"; Value = 1}
$regPaths += @{Path = "$root\ModuleLogging"; Name = 'EnableModuleLogging'; Value = 1}
$regPaths += @{Path = "$root\ScriptBlockLogging"; Name = 'EnableScriptBlockLogging'; Value = 1}
}
Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto
# モジュール名レジストリの設定
Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto -Context $context
foreach ($root in $script:PowerShellPolicyRoots) {
try {
$moduleLoggingPath = "$root\ModuleLogging\ModuleNames"
$currentValue = "Not Set"
$pathExists = Test-Path $moduleLoggingPath
if ($pathExists) {
$prop = Get-ItemProperty -Path $moduleLoggingPath -Name "*" -ErrorAction SilentlyContinue
if ($prop) {
$currentValue = $prop."*"
}
}
Write-Host "Registry: $moduleLoggingPath"
if ($currentValue -eq "*") {
Write-Host "[SKIPPED] Module logging : Already set to * (all modules)." -ForegroundColor Yellow
Write-Host ""
} else
{
if ($Auto)
{
$response = "Y"
}
else
{
$response = Read-Host "Your current setting is $currentValue. Do you want to change it to * (all modules)? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y")
{
if (-not $pathExists)
{
New-Item -Path $moduleLoggingPath -Force | Out-Null
}
Set-ItemProperty -Path $moduleLoggingPath -Name "*" -Value "*" -Type String
Write-Host "[OK] Module logging enabled for all modules" -ForegroundColor Green
}
else
{
Write-Host "[SKIPPED] Module logging" -ForegroundColor Yellow
}
}
}
catch {
Write-Host "[ERROR] Failed to configure module names: $_" -ForegroundColor Red
}
Write-Host ""
Set-WelaRegistryControl -Context $context -Path "$root\ModuleLogging\ModuleNames" -Name '*' -Value '*' -Type String
}
Set-WelaRegistryControl -Context $context -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit' `
-Name ProcessCreationIncludeCmdLine_Enabled -Value 1
# コマンドライン監査の有効化
Write-Host "Enabling Command Line Auditing..."
Write-Host ""
$regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit"
$valueName = "ProcessCreationIncludeCmdLine_Enabled"
try {
$currentValue = "Not Set"
if (Test-Path $regPath) {
$prop = Get-ItemProperty -Path $regPath -Name $valueName -ErrorAction SilentlyContinue
$currentValue = $prop.$valueName
}
Write-Host "Registry: $regPath"
if ($currentValue -eq 1) {
Write-Host "[SKIPPED] Command Line Auditing : Already Enabled." -ForegroundColor Yellow
Write-Host ""
} else
{
if ($Auto)
{
$response = "Y"
}
else
{
$response = Read-Host "Your current setting is $currentValue. Do you want to change it to 1 (Enabled)? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y")
{
$regPath = $regPath -replace "HKLM:", "HKLM"
$arguments = "add $regPath /v $valueName /f /t REG_DWORD /d 1"
$process = Start-Process -FilePath "reg.exe" -ArgumentList $arguments -Wait -PassThru -NoNewWindow -RedirectStandardOutput "NUL"
if ($process.ExitCode -eq 0)
{
Write-Host "[OK] Command line auditing enabled" -ForegroundColor Green
}
else
{
Write-Host "[ERROR] Command line auditing failed (ExitCode: $( $process.ExitCode ))" -ForegroundColor Red
}
}
else
{
Write-Host "[SKIPPED] Command line auditing" -ForegroundColor Yellow
}
}
}
catch {
Write-Host "[ERROR] Failed to check command line auditing: $_" -ForegroundColor Red
}
Write-Host ""
# NTLM認証の監査設定
Write-Host "Configuring NTLM Audit Settings..."
Write-Host ""
# NTLM policy values are unchanged here; separate policy corrections can use
# the same verified registry-control helper.
$regPaths = @(
@{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "RestrictSendingNTLMTraffic"; Value = 2},
@{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "AuditReceivingNTLMTraffic"; Value = 2},
@{Path = "HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters"; Name = "AuditNTLMInDomain"; Value = 2}
)
Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto
# LDAP query logging (Directory Service EventID 1644) - domain controllers only.
# "15 Field Engineering" = 5 makes expensive / inefficient LDAP searches log as 1644, which surfaces
# BloodHound / SharpHound-style directory reconnaissance. Only applied where the NTDS role is present.
if (Test-Path "HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters") {
Write-Host "Configuring LDAP query logging (1644) on this domain controller..."
Write-Host ""
Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto -Context $context
if (Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters') {
Set-RegistryConfig -RegPaths @(
@{Path = "HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics"; Name = "15 Field Engineering"; Value = 5}
) -Auto:$Auto
@{Path = 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics'; Name = '15 Field Engineering'; Value = 5}
) -Auto:$Auto -Context $context
}
# 監査ポリシーの設定
Write-Host "Configuring Audit Policies..."
Write-Host ""
$auditPolicies = @(
@{Category = "Account Logon"; Name = "Credential Validation"; GUID = "0CCE923F-69AE-11D9-BED3-505054503030"},
@{Category = "Account Logon"; Name = "Kerberos Authentication Service"; GUID = "0CCE9242-69AE-11D9-BED3-505054503030"},
@@ -1350,112 +1155,11 @@ function ConfigureAuditSettings {
@{Category = "System"; Name = "Other System Events"; GUID = "0CCE9214-69AE-11D9-BED3-505054503030"}
)
$currentAuditPol = GetAuditpol
foreach ($policy in $auditPolicies)
{
$newSetting = "Success and Failure"
$currentSetting = if ($currentAuditPol.ContainsKey($policy.GUID))
{
$currentAuditPol[$policy.GUID]
}
else
{
"Unknown"
}
Write-Host "Audit Policy: $( $policy.Category ) - $( $policy.Name )"
if ($currentSetting -eq $newSetting)
{
Write-Host "[SKIPPED] $( $policy.Category ) - $( $policy.Name ) : Already set to $newSetting." -ForegroundColor Yellow
Write-Host ""
continue
}
if ($Auto) {
$response = "Y"
} else {
$response = Read-Host "Your current setting is $currentSetting. Do you want to change it to $newSetting? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y") {
$arguments = "/set /subcategory:{$($policy.GUID)} /success:enable /failure:enable"
$process = Start-Process -FilePath "auditpol.exe" -ArgumentList $arguments -Wait -PassThru -NoNewWindow -RedirectStandardOutput "NUL"
if ($process.ExitCode -eq 0) {
Write-Host "[OK] $($policy.Category) - $($policy.Name)" -ForegroundColor Green
}
else {
Write-Host "[ERROR] $($policy.Category) - $($policy.Name) (ExitCode: $($process.ExitCode))" -ForegroundColor Red
}
} else {
Write-Host "[SKIPPED] $($policy.Category) - $($policy.Name)" -ForegroundColor Yellow
}
Write-Host ""
foreach ($policy in $auditPolicies) {
Set-WelaAuditPolicyControl -Context $context -Policy $policy
}
# AD CS AuditFilter の設定
Write-Host "Configuring AD CS Audit Settings..."
try {
$installed = (Get-WindowsFeature -Name AD-Certificate).InstallState -eq "Installed"
} catch {
$installed = $false
}
if ($installed) {
try {
$csRootKey = "HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration\"
$caName = (Get-ItemProperty $csRootKey -ErrorAction Stop).Active
$regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration\$caName"
$prop = Get-ItemProperty -Path $regPath -Name "AuditFilter" -ErrorAction SilentlyContinue
$currentValue = if ($null -ne $prop) { [int]$prop.AuditFilter } else { "Not Set" }
if ($currentValue -eq 127) {
Write-Host "[OK] AuditFilter is already 127" -ForegroundColor Green
}
else {
$proceed = $false
if ($Auto) {
$proceed = $true
}
else {
$response = Read-Host "Do you want to set AuditFilter to 127 and restart Certificate Services? (Y/n)"
$proceed = ($response -eq "" -or $response -match "^[Yy]$")
}
if ($proceed) {
try {
# AuditFilter の設定
& certutil.exe -setreg "CA\AuditFilter" 127 >$null 2>&1
# 証明書サービスの再起動
Restart-Service -Name "CertSvc" -Force -ErrorAction Stop
# 反映確認
$propAfter = Get-ItemProperty -Path $regPath -Name "AuditFilter" -ErrorAction SilentlyContinue
$newValue = if ($null -ne $propAfter) { [int]$propAfter.AuditFilter } else { $null }
if ($newValue -eq 127) {
Write-Host "[OK] AuditFilter set to 127 and CertSvc restarted" -ForegroundColor Green
}
else {
Write-Host "[ERROR] AuditFilter did not apply as expected (current: $newValue)" -ForegroundColor Red
}
}
catch {
Write-Host "[ERROR] Failed to set AuditFilter or restart CertSvc: $_" -ForegroundColor Red
}
}
else {
Write-Host "[SKIP] No changes applied to AuditFilter"
}
}
}
catch {
Write-Host "[ERROR] Failed to process AD CS audit settings: $_" -ForegroundColor Red
}
}
else {
Write-Host "[INFO] AD Certificate Services is not installed. Skipping." -ForegroundColor Yellow
}
Write-Host ""
Write-Host "Configuration completed successfully" -ForegroundColor Green
Set-WelaCertificateAuditControl -Context $context
Complete-WelaConfiguration -Context $context -ResultsPath $ResultsPath
}
$logo = @"
@@ -1774,10 +1478,13 @@ switch ($Cmd.ToLower()) {
if ($Help){
Write-Host "Configure Windows Event Log audit settings based on the YamatoSecurity baseline"
Write-Host ""
Write-Host "Usage: ./WELA.ps1 configure [-Auto]"
Write-Host "Usage: ./WELA.ps1 configure [-Auto] [-DryRun] [-BackupPath <new-directory>] [-ResultsPath <json-file>]"
Write-Host ""
Write-Host "Options:"
Write-Host " -Auto Automatically configure without prompts"
Write-Host " -DryRun Read live state and report proposed changes without writing Windows settings"
Write-Host " -BackupPath New directory for the pre-change recovery journal (unique default beside WELA)"
Write-Host " -ResultsPath Save structured per-control outcomes as JSON"
Write-Host ""
Write-Host "Note: only the YamatoSecurity baseline is currently supported for 'configure'."
Write-Host ""
@@ -1788,7 +1495,14 @@ switch ($Cmd.ToLower()) {
Write-Host "Re-run with '-Baseline YamatoSecurity' (or omit -Baseline) if that is what you want."
break
}
ConfigureAuditSettings -Auto:$Auto -Debug:$Debug
try {
$report = ConfigureAuditSettings -Auto:$Auto -Debug:$Debug -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath
$report
if ($report.ExitCode -ne 0) { exit $report.ExitCode }
} catch {
Write-Host "[Failed] Configuration aborted: $_" -ForegroundColor Red
exit 1
}
}
"configure-sacl" {
+101
View File
@@ -0,0 +1,101 @@
# Verified configuration and recovery
`configure` reads live state, records each proposed write before executing it,
checks native exit codes, and reads the resulting state. It returns an object with
`ExitCode`, `DryRun`, `BackupPath`, `Failed`, `Skipped`, and a `Results` array.
`-ResultsPath` also saves that object as JSON. The command exits with status 1 when
any control fails or changes again before the final verification. A fatal preflight
or result-file error also exits with status 1.
```powershell
# Read live settings; do not change Windows settings, restart services or create a journal.
.\WELA.ps1 configure -DryRun -ResultsPath .\proposed-results.json
# Apply with interactive approval for each change, including the CA restart.
.\WELA.ps1 configure -BackupPath C:\WELA-Recovery\run-001 -ResultsPath .\results.json
# Apply the existing WELA choices without individual prompts.
.\WELA.ps1 configure -Auto -ResultsPath .\results.json
```
Keep the complete WELA directory, including `scripts/Configuration.ps1`. Choose a
recovery path whose parent directory is writable only by the operators who manage
these settings. The backup directory must not already exist. Without `-BackupPath`,
a unique directory is created beside WELA. `-Debug` does not substitute cached
audit policy data during configuration. `-DryRun` may write the explicitly requested
result file, but performs no Windows configuration writes.
| Status | Meaning |
| --- | --- |
| Applied | Write succeeded and immediate read-back matched. |
| AlreadyCompliant | The initial live value already met the requirement; no write. |
| Skipped | Dry run, operator decline, or no configured local CA. |
| Failed | State could not be read, journaling failed, write/restart failed, or verification failed. |
| Overridden | A value verified earlier became noncompliant by the final read. Cause is unknown. |
Unknown and unavailable channels are reported as failed observations rather than
silently claiming that logging is enabled. Partial runs and runs with skipped
controls do not claim universal success. Verification is an observation at that
moment; it does not prove future GPO persistence, event production, collection or
Sigma rule coverage. A zero exit code with skipped controls is not full compliance.
Audit policy reads use GUIDs and the numeric value in `auditpol /r` output, rather
than localized setting names. Registry writes use terminating errors and verify
both the value and registry type. Log sizes retain larger existing buffers. A CA
is detected from its configured registry state; certutil must succeed before a
restart is attempted, and the restart must return to Running. A stopped CA is not
started automatically. A restart failure remains failed even if the registry value
was already written.
## Recovery journal and rollback design
Each line of `before.jsonl` records the computer, timestamp, control identity,
requested setting and exact pre-change state. Registry entries include whether the
key/value existed and the previous registry type. Event-log entries capture size or
enabled state; audit policies capture the numeric mask; CA entries also capture
service state. A journal write failure prevents that control's mutation. The
journal is per control, not a full system backup, and can contain records for failed
or declined downstream actions. Save the final result file alongside it.
This change provides a guarded **manual recovery procedure**, not an automatic
rollback command. Automatic bulk rollback could overwrite a later administrator or
GPO change and could interrupt certificate services. Before recovery:
1. Use an elevated shell on the journal's recorded computer. Review the specific
failed or applied control and capture its current live state.
2. Compare current state with the recorded requested/verified after-state. If it
differs, stop and determine whether another writer made an intentional change.
Do not blindly replay a journal or restore an entire audit policy backup.
3. Restore only the intended controls, normally in reverse application order:
- **EventLog:** `wevtutil sl <log> /ms:<previous-bytes>` or `/e:<previous-bool>`.
Review shrinking buffers or disabling a channel before proceeding.
- **AuditPolicy:** `auditpol /set /subcategory:{<guid>} /success:<enable|disable>
/failure:<enable|disable>`. Previous mask bit 1 means success, bit 2 means
failure. Restore that subcategory, not unrelated policy.
- **Registry:** restore the previous value using its recorded registry type.
If the value did not exist, remove only that value. Preserve unrelated values
and never recursively delete a newly created parent key. Binary and multistring
old values must be reconstructed with their original types from the JSON.
- **CertificateService:** restore the active CA's previous AuditFilter value (or
its original absence) and separately approve the necessary service restart.
Do not start a CA that was deliberately stopped. A failed restart can leave
the registry and running service out of sync; an operator must resolve this.
4. Check every native exit code and read the restored state. Keep the recovery
commands and observations with the original journal.
A future automated rollback command should require the same host and control
identity, validate journal schema and allowlisted types, check current state against
recorded after-state, refuse unexpected drift, journal recovery itself, and require
explicit approval for CA restarts. It should never import the whole registry or
force a Group Policy setting. These are design constraints, not implemented claims.
## Testing
`tests/Test-ConfigurationResults.ps1` uses mock Windows APIs and disposable temp
journals. It exercises nonzero native exits and stderr, false-success writes,
read-back, idempotence, final drift, dry runs, journal failure, localized audit CSV
labels, and CA write/restart failure. It does not change Windows settings.
`tests/Test-ConfigurationReadOnlyWindows.ps1` runs real read-only `auditpol /get`
and a child `cmd.exe` diagnostic/exit test. CI runs both scripts in Windows PowerShell
5.1 and PowerShell 7. Mutating behavior still requires isolated Windows/CA lab
validation; mock and read-only tests do not establish end-to-end event production.
+222
View File
@@ -0,0 +1,222 @@
# Execution helpers for configure. Compatible with Windows PowerShell 5.1.
function Invoke-WelaNative {
param([string]$FilePath, [string[]]$Arguments)
# Windows PowerShell sends native stderr through the error stream. Collect it
# without treating stderr alone as failure; the process exit code is decisive.
$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
$null = Get-Command $FilePath -ErrorAction Stop
$global:LASTEXITCODE = $null
$output = @(& $FilePath @Arguments 2>&1)
$exitCode = $global:LASTEXITCODE # Capture immediately, before invoking anything else.
$diagnostic = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
if ($null -eq $exitCode -or $exitCode -ne 0) {
throw "$FilePath $($Arguments -join ' ') failed (exit: $exitCode). $diagnostic"
}
[pscustomobject]@{ ExitCode = $exitCode; Output = $output; Diagnostic = $diagnostic }
}
function New-WelaConfigurationContext {
param([switch]$Auto, [switch]$DryRun, [string]$BackupPath)
if (-not $DryRun) {
if (-not $BackupPath) {
$BackupPath = Join-Path $script:ScriptRoot ("wela-backup-{0}-{1}" -f (Get-Date -Format 'yyyyMMdd-HHmmss'), [guid]::NewGuid().ToString('N'))
}
# Refuse reuse: a prior run's recovery evidence must never be overwritten.
$null = New-Item -ItemType Directory -Path $BackupPath -ErrorAction Stop
$BackupPath = (Resolve-Path -LiteralPath $BackupPath -ErrorAction Stop).Path
}
[pscustomobject]@{
Auto = [bool]$Auto; DryRun = [bool]$DryRun; BackupPath = $BackupPath
Results = New-Object 'System.Collections.Generic.List[object]'
Checks = New-Object 'System.Collections.Generic.List[object]'
}
}
function Invoke-WelaConfigurationControl {
param($Context, [string]$Id, [string]$Kind, $Target, $Desired,
[scriptblock]$Read, [scriptblock]$Compliant, [scriptblock]$Apply,
[string]$Description = '')
$result = [pscustomobject][ordered]@{
Id = $Id; Kind = $Kind; Target = $Target; Desired = $Desired
Before = $null; After = $null; Status = 'Failed'; Diagnostic = ''
}
try {
$result.Before = & $Read
if (& $Compliant $result.Before) {
$result.Status = 'AlreadyCompliant'
$result.After = $result.Before
} elseif ($Context.DryRun) {
$result.Status = 'Skipped'; $result.Diagnostic = 'Dry run: change required; no write or restart performed.'
} else {
$proceed = $Context.Auto
if (-not $proceed) {
$response = Read-Host "$Id : $Description Apply this change? (Y/n)"
$proceed = ($response -eq '' -or $response -match '^[Yy]$')
}
if (-not $proceed) {
$result.Status = 'Skipped'; $result.Diagnostic = 'Declined by operator.'
} else {
# Persist the exact pre-change value before any mutation. A journal
# failure stops this control, including service restarts.
$entry = [ordered]@{
Version = 1; ComputerName = $env:COMPUTERNAME
RecordedUtc = [DateTime]::UtcNow.ToString('o')
Id = $Id; Kind = $Kind; Target = $Target
Before = $result.Before; Desired = $Desired
}
$entry | ConvertTo-Json -Depth 12 -Compress |
Add-Content -LiteralPath (Join-Path $Context.BackupPath 'before.jsonl') -Encoding UTF8 -ErrorAction Stop
$applied = @(& $Apply)
$result.Diagnostic = ($applied | ForEach-Object {
if ($_.PSObject.Properties['Diagnostic']) { $_.Diagnostic } else { $_.ToString() }
}) -join [Environment]::NewLine
$result.After = & $Read
if (-not (& $Compliant $result.After)) {
throw "Post-apply verification did not match the requested state. $($result.Diagnostic)"
}
$result.Status = 'Applied'
}
}
if ($result.Status -in @('Applied', 'AlreadyCompliant')) {
$Context.Checks.Add([pscustomobject]@{ Result = $result; Read = $Read; Compliant = $Compliant })
}
} catch {
$result.Status = 'Failed'; $result.Diagnostic = $_.ToString()
}
$Context.Results.Add($result)
$color = if ($result.Status -eq 'Failed') { 'Red' } elseif ($result.Status -eq 'Skipped') { 'Yellow' } else { 'Green' }
Write-Host "[$($result.Status)] $Id $($result.Diagnostic)" -ForegroundColor $color
}
function Complete-WelaConfiguration {
param($Context, [string]$ResultsPath)
# A second read detects a value that was compliant earlier but changed during
# this run. It does not establish whether GPO or another writer caused drift.
foreach ($check in $Context.Checks) {
try {
$check.Result.After = & $check.Read
if (-not (& $check.Compliant $check.Result.After)) {
$check.Result.Status = 'Overridden'
$check.Result.Diagnostic = 'State was compliant earlier but changed before the final check; cause unknown.'
}
} catch {
$check.Result.Status = 'Failed'
$check.Result.Diagnostic = "Final verification failed: $_"
}
}
$failed = @($Context.Results | Where-Object { $_.Status -in @('Failed', 'Overridden') }).Count
$skipped = @($Context.Results | Where-Object { $_.Status -eq 'Skipped' }).Count
$report = [pscustomobject][ordered]@{
ExitCode = $(if ($failed) { 1 } else { 0 }); DryRun = $Context.DryRun
BackupPath = $Context.BackupPath; Failed = $failed; Skipped = $skipped
Results = @($Context.Results.ToArray())
}
if ($ResultsPath) {
try { $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
catch { $report.ExitCode = 1; Write-Host "[Failed] Writing results: $_" -ForegroundColor Red }
}
if ($report.ExitCode) { Write-Host "Configuration incomplete: $failed failed or overridden control(s). Review results and recovery journal." -ForegroundColor Red }
elseif ($Context.DryRun) { Write-Host 'Dry run completed. No Windows configuration was changed.' -ForegroundColor Cyan }
elseif ($skipped) { Write-Host "Configuration completed with $skipped skipped control(s)." -ForegroundColor Yellow }
else { Write-Host 'Configuration completed; all requested controls verified.' -ForegroundColor Green }
return $report
}
function Set-WelaEventLogControl {
param($Context, [string]$Log, [string]$Property, $Desired)
$read = { (Get-WinEvent -ListLog $Log -ErrorAction Stop).$Property }.GetNewClosure()
$test = if ($Property -eq 'MaximumSizeInBytes') {
{ param($value) $value -ge $Desired }.GetNewClosure()
} else { { param($value) $value -eq $Desired }.GetNewClosure() }
$argument = if ($Property -eq 'MaximumSizeInBytes') { "/ms:$Desired" } else { '/e:true' }
$apply = { Invoke-WelaNative -FilePath 'wevtutil.exe' -Arguments @('sl', $Log, $argument) }.GetNewClosure()
Invoke-WelaConfigurationControl -Context $Context -Id "EventLog/$Log/$Property" -Kind EventLog `
-Target @{ Log = $Log; Property = $Property } -Desired $Desired -Read $read -Compliant $test -Apply $apply
}
function Get-WelaRegistryState {
param([string]$Path, [string]$Name)
if (-not (Test-Path -LiteralPath $Path -ErrorAction Stop)) {
return [pscustomobject]@{ KeyExists = $false; ValueExists = $false; Value = $null; Type = $null }
}
$key = Get-Item -LiteralPath $Path -ErrorAction Stop
if ($key.GetValueNames() -notcontains $Name) {
return [pscustomobject]@{ KeyExists = $true; ValueExists = $false; Value = $null; Type = $null }
}
[pscustomobject]@{
KeyExists = $true; ValueExists = $true
Value = $key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
Type = $key.GetValueKind($Name).ToString()
}
}
function Set-WelaRegistryControl {
param($Context, [string]$Path, [string]$Name, $Value, [string]$Type = 'DWord')
$read = { Get-WelaRegistryState -Path $Path -Name $Name }.GetNewClosure()
$test = { param($state) $state.ValueExists -and $state.Value -eq $Value -and $state.Type -eq $Type }.GetNewClosure()
$apply = {
if (-not (Test-Path -LiteralPath $Path -ErrorAction Stop)) {
$null = New-Item -Path $Path -ErrorAction Stop
}
Set-ItemProperty -LiteralPath $Path -Name $Name -Value $Value -Type $Type -ErrorAction Stop
}.GetNewClosure()
Invoke-WelaConfigurationControl -Context $Context -Id "Registry/$Path/$Name" -Kind Registry `
-Target @{ Path = $Path; Name = $Name } -Desired @{ Value = $Value; Type = $Type } `
-Read $read -Compliant $test -Apply $apply
}
function Get-WelaAuditPolicyMask {
param([string]$Guid)
$native = Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments @('/get', "/subcategory:{$Guid}", '/r')
# Column order is stable; names and Inclusion Setting text are localized.
$rows = $native.Output | ConvertFrom-Csv -Header Machine, Target, Name, Guid, Inclusion, Exclusion, SettingValue
$row = @($rows | Where-Object { $_.Guid -and $_.Guid.Trim('{}') -eq $Guid })
if ($row.Count -ne 1 -or $row[0].SettingValue -notmatch '^[0-3]$') {
throw "auditpol returned no unambiguous numeric setting for $Guid. $($native.Diagnostic)"
}
return [int]$row[0].SettingValue
}
function Set-WelaAuditPolicyControl {
param($Context, $Policy)
$guid = $Policy.GUID
$read = { Get-WelaAuditPolicyMask -Guid $guid }.GetNewClosure()
$apply = { Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments @('/set', "/subcategory:{$guid}", '/success:enable', '/failure:enable') }.GetNewClosure()
Invoke-WelaConfigurationControl -Context $Context -Id "AuditPolicy/$($Policy.Name)" -Kind AuditPolicy `
-Target @{ Guid = $guid } -Desired 3 -Read $read -Compliant { param($value) $value -eq 3 } -Apply $apply
}
function Set-WelaCertificateAuditControl {
param($Context)
$root = 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration'
try {
if (-not (Test-Path -LiteralPath $root -ErrorAction Stop)) {
$Context.Results.Add([pscustomobject]@{ Id = 'ADCS/AuditFilter'; Kind = 'CertificateService'; Target = $root; Desired = 127; Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'No configured local CA.' })
return
}
$caName = (Get-ItemProperty -LiteralPath $root -Name Active -ErrorAction Stop).Active
if (-not $caName) { throw 'CA configuration has no active CA name.' }
$path = Join-Path $root $caName
$read = {
[pscustomobject]@{
Registry = Get-WelaRegistryState -Path $path -Name AuditFilter
ServiceStatus = (Get-Service -Name CertSvc -ErrorAction Stop).Status.ToString()
}
}.GetNewClosure()
$test = { param($value) $value.Registry.ValueExists -and $value.Registry.Value -eq 127 -and $value.ServiceStatus -eq 'Running' }
$apply = {
$state = Get-Service -Name CertSvc -ErrorAction Stop
if ($state.Status -ne 'Running') { throw 'CertSvc is not running; refusing to start a previously stopped CA. Start it deliberately before retrying.' }
Invoke-WelaNative -FilePath 'certutil.exe' -Arguments @('-setreg', 'CA\AuditFilter', '127')
Restart-Service -Name CertSvc -Force -ErrorAction Stop
$service = Get-Service -Name CertSvc -ErrorAction Stop
$service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds(30))
}
Invoke-WelaConfigurationControl -Context $Context -Id 'ADCS/AuditFilter' -Kind CertificateService `
-Target @{ Path = $path; Name = 'AuditFilter'; Service = 'CertSvc' } -Desired 127 `
-Read $read -Compliant $test -Apply $apply -Description 'Set AuditFilter=127 and restart Certificate Services.'
} catch {
$Context.Results.Add([pscustomobject]@{ Id = 'ADCS/AuditFilter'; Kind = 'CertificateService'; Target = $root; Desired = 127; Before = $null; After = $null; Status = 'Failed'; Diagnostic = $_.ToString() })
}
}
@@ -0,0 +1,13 @@
# Read-only smoke test of real Windows commands, independent of the mock suite.
$ErrorActionPreference = 'Stop'
if ($env:OS -ne 'Windows_NT') { throw 'Run this smoke test on Windows.' }
. (Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts/Configuration.ps1')
$mask = Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030'
if ($mask -notin @(0, 1, 2, 3)) { throw "Unexpected process-creation audit mask: $mask" }
$caught = ''
try { Invoke-WelaNative -FilePath $env:ComSpec -Arguments @('/d', '/c', 'echo WELA-smoke-diagnostic 1>&2 & exit /b 9') }
catch { $caught = $_.ToString() }
if ($caught -notmatch 'exit: 9' -or $caught -notmatch 'WELA-smoke-diagnostic') {
throw "Native exit/stderr capture failed: $caught"
}
Write-Host "Read-only Windows smoke checks passed (process creation audit mask: $mask). No Windows settings changed."
+182
View File
@@ -0,0 +1,182 @@
# No Windows settings are changed. Run with powershell.exe 5.1 or pwsh.
$ErrorActionPreference = 'Stop'
$repo = Split-Path $PSScriptRoot -Parent
$script:ScriptRoot = $repo
. (Join-Path $repo 'scripts/Configuration.ps1')
$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'
# 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'
function global:Invoke-WelaNative {
param($FilePath, $Arguments)
[pscustomobject]@{ ExitCode = 0; Output = @('Localized,header,labels,here,x,y,z', 'host,System,localized name,{0CCE922B-69AE-11D9-BED3-505054503030},localized text,,3'); Diagnostic = '' }
}
Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 3) 'Audit policy parser uses numeric mask and GUID, not localized labels'
function global:Invoke-WelaNative { param($FilePath, $Arguments) [pscustomobject]@{ Output = @('unparseable'); Diagnostic = 'bad data' } }
$caught = ''
try { Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030' } catch { $caught = $_.ToString() }
Assert ($caught -ne '') 'Unparseable audit state 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
Assert ($childExit -eq 1) 'The actual configure dispatcher returns nonzero for a failed control report'
# CA-specific wrapper: registry read succeeds, certutil succeeds, restart
# fails. All APIs below are mocks, including Test-Path for the mock CA only.
$realTestPath = (Get-Command Test-Path).Name
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:Get-ItemProperty { param($LiteralPath, $Name, $ErrorAction) [pscustomobject]@{ Active = 'MockCA' } }
function global:Join-Path {
param($Path, $ChildPath)
if ($Path -like 'HKLM:*') { return "$Path\$ChildPath" }
Microsoft.PowerShell.Management\Join-Path -Path $Path -ChildPath $ChildPath
}
$script:filter = 0; $script:restartCalls = 0
function global:Get-WelaRegistryState { param($Path, $Name) [pscustomobject]@{ ValueExists = $true; Value = $script:filter; Type = 'DWord'; KeyExists = $true } }
function global:Get-Service { param($Name, $ErrorAction) [pscustomobject]@{ Status = 'Running' } }
function global:Restart-Service { param($Name, [switch]$Force, $ErrorAction) $script:restartCalls++; throw 'Injected CertSvc restart failure' }
function global:Invoke-WelaNative { param($FilePath, $Arguments) $script:filter = 127; [pscustomobject]@{ ExitCode = 0; Diagnostic = 'mock certutil' } }
$c = New-TestContext
Set-WelaCertificateAuditControl $c
$r = Complete-WelaConfiguration $c
Assert ($r.ExitCode -eq 1 -and $r.Results[0].Diagnostic -match 'restart failure') 'CA restart failure cannot report success even when registry now equals 127'
$script:filter = 0; $script:restartCalls = 0
function global:Invoke-WelaNative { param($FilePath, $Arguments) throw 'certutil failed (exit: 5)' }
$c = New-TestContext
Set-WelaCertificateAuditControl $c
Assert ($c.Results[0].Status -eq 'Failed' -and $script:restartCalls -eq 0) 'Failed certutil never restarts the CA'
$c = New-TestContext -DryRun
Set-WelaCertificateAuditControl $c
Assert ($c.Results[0].Status -eq 'Skipped' -and $script:restartCalls -eq 0) 'CA dry run never writes or restarts'
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
}
}
}