From 1ae4930438d56fc6b325749847d8b905e7ad1a9a Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:48:27 +0900 Subject: [PATCH 1/3] Verify configure changes and propagate per-control failures --- .github/workflows/configuration-results.yml | 22 ++ .github/workflows/release.yml | 1 + WELA.ps1 | 406 +++----------------- docs/configuration-results.md | 101 +++++ scripts/Configuration.ps1 | 222 +++++++++++ tests/Test-ConfigurationReadOnlyWindows.ps1 | 13 + tests/Test-ConfigurationResults.ps1 | 182 +++++++++ 7 files changed, 601 insertions(+), 346 deletions(-) create mode 100644 .github/workflows/configuration-results.yml create mode 100644 docs/configuration-results.md create mode 100644 scripts/Configuration.ps1 create mode 100644 tests/Test-ConfigurationReadOnlyWindows.ps1 create mode 100644 tests/Test-ConfigurationResults.ps1 diff --git a/.github/workflows/configuration-results.yml b/.github/workflows/configuration-results.yml new file mode 100644 index 00000000..688cba9a --- /dev/null +++ b/.github/workflows/configuration-results.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96c929f5..4785fb1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/WELA.ps1 b/WELA.ps1 index a5e00cd6..e0d87f97 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -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 ] [-ResultsPath ]" 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" { diff --git a/docs/configuration-results.md b/docs/configuration-results.md new file mode 100644 index 00000000..63f787c8 --- /dev/null +++ b/docs/configuration-results.md @@ -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 /ms:` or `/e:`. + Review shrinking buffers or disabling a channel before proceeding. + - **AuditPolicy:** `auditpol /set /subcategory:{} /success: + /failure:`. 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. diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 new file mode 100644 index 00000000..feec7e0d --- /dev/null +++ b/scripts/Configuration.ps1 @@ -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() }) + } +} diff --git a/tests/Test-ConfigurationReadOnlyWindows.ps1 b/tests/Test-ConfigurationReadOnlyWindows.ps1 new file mode 100644 index 00000000..4b3b38f8 --- /dev/null +++ b/tests/Test-ConfigurationReadOnlyWindows.ps1 @@ -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." diff --git a/tests/Test-ConfigurationResults.ps1 b/tests/Test-ConfigurationResults.ps1 new file mode 100644 index 00000000..146583b9 --- /dev/null +++ b/tests/Test-ConfigurationResults.ps1 @@ -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 + } + } +} From 36c4b4018f2aa265d0222e61f15b29e44a1a5a43 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:49:46 +0900 Subject: [PATCH 2/3] Run configuration checks with explicit PowerShell shells --- .github/workflows/configuration-results.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/configuration-results.yml b/.github/workflows/configuration-results.yml index 688cba9a..6da70e8e 100644 --- a/.github/workflows/configuration-results.yml +++ b/.github/workflows/configuration-results.yml @@ -9,14 +9,17 @@ permissions: 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 }} + - name: Mocked regressions in Windows PowerShell 5.1 + shell: powershell run: ./tests/Test-ConfigurationResults.ps1 - - name: Real Windows read-only smoke tests - shell: ${{ matrix.shell }} + - name: Read-only Windows smoke in Windows PowerShell 5.1 + shell: powershell + run: ./tests/Test-ConfigurationReadOnlyWindows.ps1 + - name: Mocked regressions in PowerShell 7 + shell: pwsh + run: ./tests/Test-ConfigurationResults.ps1 + - name: Read-only Windows smoke in PowerShell 7 + shell: pwsh run: ./tests/Test-ConfigurationReadOnlyWindows.ps1 From eb3232faf5762784b6229973a5f8fbdb6311a9fb Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:53:44 +0900 Subject: [PATCH 3/3] Read audit masks through Windows API and preserve missing registry parents --- docs/configuration-results.md | 11 +-- scripts/Configuration.ps1 | 77 +++++++++++++++++---- tests/Test-ConfigurationReadOnlyWindows.ps1 | 3 + tests/Test-ConfigurationResults.ps1 | 53 +++++++++++--- 4 files changed, 117 insertions(+), 27 deletions(-) diff --git a/docs/configuration-results.md b/docs/configuration-results.md index 63f787c8..419cf112 100644 --- a/docs/configuration-results.md +++ b/docs/configuration-results.md @@ -39,8 +39,10 @@ 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 +Audit policy reads use GUIDs and numeric flags from the Windows +[AuditQuerySystemPolicy API](https://learn.microsoft.com/en-us/windows/win32/api/ntsecapi/nf-ntsecapi-auditquerysystempolicy). +`auditpol /get /r` contains localized labels and no numeric setting column; it is +not parsed as though it were `auditpol /backup` output. 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 @@ -93,9 +95,8 @@ force a Group Policy setting. These are design constraints, not implemented clai `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` +read-back, idempotence, final drift, dry runs, journal failure, locale-independent native audit flags, and CA write/restart failure. It does not change Windows settings. +`tests/Test-ConfigurationReadOnlyWindows.ps1` runs real read-only Windows audit-policy API and `auditpol /get` queries 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. diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index feec7e0d..0ec682a1 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -151,14 +151,24 @@ function Get-WelaRegistryState { } } +function New-WelaRegistryKey { + param([string]$Path) + if (Test-Path -LiteralPath $Path -ErrorAction Stop) { return } + $separator = $Path.TrimEnd('\').LastIndexOf('\') + if ($separator -lt 1) { throw "Registry root is unavailable: $Path" } + $parent = $Path.Substring(0, $separator) + # Registry New-Item without Force requires its immediate parent. Build only + # missing ancestors; never run New-Item -Force against an existing key. + New-WelaRegistryKey -Path $parent + $null = New-Item -Path $Path -ErrorAction Stop +} + 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 - } + New-WelaRegistryKey -Path $Path Set-ItemProperty -LiteralPath $Path -Name $Name -Value $Value -Type $Type -ErrorAction Stop }.GetNewClosure() Invoke-WelaConfigurationControl -Context $Context -Id "Registry/$Path/$Name" -Kind Registry ` @@ -166,16 +176,59 @@ function Set-WelaRegistryControl { -Read $read -Compliant $test -Apply $apply } +function Initialize-WelaConfigurationAuditApi { + if ('Wela.ConfigurationAuditApi' -as [type]) { return } + # Querying the Windows API avoids localized auditpol /get CSV (six columns; + # unlike /backup output, it has no numeric Setting Value column). + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +namespace Wela { + public static class ConfigurationAuditApi { + [StructLayout(LayoutKind.Sequential)] + private struct AuditPolicyInformation { + public Guid Subcategory; + public UInt32 Information; + public Guid Category; + } + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.U1)] + private static extern bool AuditQuerySystemPolicy( + [In] Guid[] subcategories, UInt32 count, out IntPtr policy); + [DllImport("advapi32.dll")] + private static extern void AuditFree(IntPtr buffer); + public static UInt32 Query(Guid subcategory) { + IntPtr buffer = IntPtr.Zero; + if (!AuditQuerySystemPolicy(new Guid[] { subcategory }, 1, out buffer)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + try { + if (buffer == IntPtr.Zero) throw new InvalidOperationException("Audit policy query returned no buffer."); + AuditPolicyInformation policy = (AuditPolicyInformation)Marshal.PtrToStructure(buffer, typeof(AuditPolicyInformation)); + if (policy.Subcategory != subcategory) throw new InvalidOperationException("Audit policy query returned a different subcategory."); + return policy.Information; + } finally { + if (buffer != IntPtr.Zero) AuditFree(buffer); + } + } + } +} +'@ -ErrorAction Stop +} + +function Get-WelaNativeAuditPolicy { + param([string]$Guid) + Initialize-WelaConfigurationAuditApi + return [Wela.ConfigurationAuditApi]::Query([guid]$Guid) +} + 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 + $flags = Get-WelaNativeAuditPolicy -Guid $Guid + if ($flags -notin @(0, 1, 2, 3, 4)) { throw "Unexpected audit policy flags $flags for $Guid." } + # POLICY_AUDIT_EVENT_NONE is 4; the success/failure mask is zero. + return [int]($flags -band 3) } function Set-WelaAuditPolicyControl { @@ -204,7 +257,7 @@ function Set-WelaCertificateAuditControl { 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' } + $test = { param($value) $value.Registry.ValueExists -and $value.Registry.Value -eq 127 -and $value.Registry.Type -eq 'DWord' -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.' } diff --git a/tests/Test-ConfigurationReadOnlyWindows.ps1 b/tests/Test-ConfigurationReadOnlyWindows.ps1 index 4b3b38f8..87597fa9 100644 --- a/tests/Test-ConfigurationReadOnlyWindows.ps1 +++ b/tests/Test-ConfigurationReadOnlyWindows.ps1 @@ -4,6 +4,9 @@ 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" } +# Also exercise the real read-only auditpol command and its native exit status. +$csv = Invoke-WelaNative -FilePath auditpol.exe -Arguments @('/get', '/subcategory:{0CCE922B-69AE-11D9-BED3-505054503030}', '/r') +if ($csv.Diagnostic -notmatch '0CCE922B-69AE-11D9-BED3-505054503030') { throw 'auditpol query returned no requested subcategory.' } $caught = '' try { Invoke-WelaNative -FilePath $env:ComSpec -Arguments @('/d', '/c', 'echo WELA-smoke-diagnostic 1>&2 & exit /b 9') } catch { $caught = $_.ToString() } diff --git a/tests/Test-ConfigurationResults.ps1 b/tests/Test-ConfigurationResults.ps1 index 146583b9..f7b7b954 100644 --- a/tests/Test-ConfigurationResults.ps1 +++ b/tests/Test-ConfigurationResults.ps1 @@ -2,7 +2,10 @@ $ErrorActionPreference = 'Stop' $repo = Split-Path $PSScriptRoot -Parent $script:ScriptRoot = $repo -. (Join-Path $repo 'scripts/Configuration.ps1') +# 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" } @@ -93,6 +96,29 @@ try { 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 } } @@ -114,15 +140,17 @@ try { 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' } } + # 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 '') 'Unparseable audit state cannot be marked compliant' + Assert ($caught -ne '') 'Unexpected native flags cannot be marked compliant' # Extract ConfigureAuditSettings without running the WELA command dispatcher. $tokens = $null; $errors = $null @@ -154,8 +182,8 @@ try { 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 } } + $script:filter = 0; $script:restartCalls = 0; $script:filterType = 'DWord' + function global:Get-WelaRegistryState { param($Path, $Name) [pscustomobject]@{ ValueExists = $true; Value = $script:filter; Type = $script:filterType; 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' } } @@ -172,6 +200,11 @@ try { Set-WelaCertificateAuditControl $c Assert ($c.Results[0].Status -eq 'Skipped' -and $script:restartCalls -eq 0) 'CA dry run never writes or restarts' + $script:filter = '127'; $script:filterType = 'String' + $c = New-TestContext -DryRun + Set-WelaCertificateAuditControl $c + Assert ($c.Results[0].Status -eq 'Skipped') 'REG_SZ 127 is not accepted as a compliant CA DWORD AuditFilter' + Write-Host "$script:passed configuration-result regression assertions passed. No Windows settings changed." } finally { foreach ($path in $script:cleanup) {