diff --git a/.github/workflows/configuration-results.yml b/.github/workflows/configuration-results.yml new file mode 100644 index 00000000..6da70e8e --- /dev/null +++ b/.github/workflows/configuration-results.yml @@ -0,0 +1,25 @@ +name: Configuration result regressions +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + configuration-results: + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Mocked regressions in Windows PowerShell 5.1 + shell: powershell + run: ./tests/Test-ConfigurationResults.ps1 + - 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 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 4347f27c..ff13668a 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -6,6 +6,9 @@ [switch]$Auto, [ValidateSet("PreserveOrAudit", "Audit", "Deny")] [string]$OutgoingNtlmMode = "PreserveOrAudit", + [switch]$DryRun, + [string]$BackupPath, + [string]$ResultsPath, [switch]$Help ) @@ -19,6 +22,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 = @( @@ -1036,7 +1040,11 @@ function Get-WelaDomainNtlmState { function Set-WelaDomainNtlmAudit { [CmdletBinding(SupportsShouldProcess = $true)] - param ([switch]$Auto) + param ([switch]$Auto, $Context) + if ($Context) { + Set-WelaNtlmConfigurationControl -Context $Context -Scope Domain -WhatIf:$WhatIfPreference + return + } $state = Get-WelaDomainNtlmState Write-Host "Domain NTLM auditing: $($state.Description)" if (-not $state.Applicable) { @@ -1084,10 +1092,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 @@ -1190,8 +1211,13 @@ function Set-WelaOutgoingNtlmPolicy { param ( [ValidateSet('PreserveOrAudit', 'Audit', 'Deny')] [string]$Mode = 'PreserveOrAudit', - [switch]$Auto + [switch]$Auto, + $Context ) + if ($Context) { + Set-WelaNtlmConfigurationControl -Context $Context -Scope Outgoing -Mode $Mode -WhatIf:$WhatIfPreference + return + } $path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' $name = 'RestrictSendingNTLMTraffic' $state = Get-WelaOutgoingNtlmState @@ -1245,70 +1271,21 @@ function Set-WelaOutgoingNtlmPolicy { function ConfigureAuditSettings { param ( - [switch] $Auto, + [switch]$Auto, [switch]$Debug, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath, [ValidateSet("PreserveOrAudit", "Audit", "Deny")] - [string] $OutgoingNtlmMode = "PreserveOrAudit", - [switch] $Debug + [string]$OutgoingNtlmMode = "PreserveOrAudit" ) - 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", @@ -1335,202 +1312,37 @@ 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 "" - - # Outgoing restriction and audit-only modes must be selected independently. - Set-WelaOutgoingNtlmPolicy -Mode $OutgoingNtlmMode -Auto:$Auto - - # NTLM認証の監査設定 - Write-Host "Configuring NTLM Audit Settings..." - Write-Host "" + # NTLM audit/restriction decisions share the recovery and verification context. + Set-WelaOutgoingNtlmPolicy -Mode $OutgoingNtlmMode -Auto:$Auto -Context $context $regPaths = @( @{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "AuditReceivingNTLMTraffic"; Value = 2} ) - Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto - - Set-WelaDomainNtlmAudit -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 + Set-WelaDomainNtlmAudit -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"}, @@ -1568,112 +1380,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 = @" @@ -1992,11 +1703,14 @@ 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] [-OutgoingNtlmMode ]" + Write-Host "Usage: ./WELA.ps1 configure [-Auto] [-DryRun] [-BackupPath ] [-ResultsPath ] [-OutgoingNtlmMode ]" Write-Host "" Write-Host "Options:" Write-Host " -Auto Automatically configure without prompts" Write-Host " -OutgoingNtlmMode PreserveOrAudit (default): audit, preserving existing deny; Audit: explicitly replace deny; Deny: opt into enforcement" + 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 "" @@ -2007,7 +1721,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 -OutgoingNtlmMode $OutgoingNtlmMode + try { + $report = ConfigureAuditSettings -Auto:$Auto -Debug:$Debug -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath -OutgoingNtlmMode $OutgoingNtlmMode + $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..419cf112 --- /dev/null +++ b/docs/configuration-results.md @@ -0,0 +1,102 @@ +# 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 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 +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, 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 new file mode 100644 index 00000000..07102399 --- /dev/null +++ b/scripts/Configuration.ps1 @@ -0,0 +1,329 @@ +# 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 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 = { + 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 ` + -Target @{ Path = $Path; Name = $Name } -Desired @{ Value = $Value; Type = $Type } ` + -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) + $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 { + 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.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.' } + 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() }) + } +} + +function Set-WelaNtlmConfigurationControl { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + $Context, + [ValidateSet('Outgoing', 'Domain')][string]$Scope, + [ValidateSet('PreserveOrAudit', 'Audit', 'Deny')][string]$Mode = 'PreserveOrAudit' + ) + $path = if ($Scope -eq 'Outgoing') { 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' } else { 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' } + $name = if ($Scope -eq 'Outgoing') { 'RestrictSendingNTLMTraffic' } else { 'AuditNTLMInDomain' } + $desired = if ($Scope -eq 'Domain') { 7 } elseif ($Mode -eq 'Deny') { 2 } else { 1 } + $id = "Registry/$path/$name" + $state = $null + $skipReason = '' + $status = 'Skipped' + try { + $state = if ($Scope -eq 'Outgoing') { Get-WelaOutgoingNtlmState } else { Get-WelaDomainNtlmState } + Write-Host "$Scope NTLM: $($state.Description)" + if ($Scope -eq 'Outgoing') { Write-Host "Policy source: $($state.PolicySource)" } + if ($Scope -eq 'Domain' -and -not $state.Applicable) { + if ($state.Description -notlike 'Not applicable*') { throw "Domain NTLM applicability is unknown: $($state.Description)" } + $skipReason = $state.Description + } elseif (-not $state.Readable) { + throw "$Scope NTLM current state could not be read: $($state.Description)" + } elseif ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit' -and $state.Value -eq 2) { + $skipReason = 'Preserved existing Deny all enforcement (2); use -OutgoingNtlmMode Audit to explicitly replace it.' + } elseif ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit' -and $null -ne $state.Value -and $state.Value -notin @(0, 1, 2)) { + $skipReason = "Preserved unknown outgoing NTLM value ($($state.Value)); select an explicit mode after policy review." + } + if (-not $skipReason) { + if ($Scope -eq 'Outgoing' -and $Mode -eq 'Deny') { + Write-Warning 'Explicit Deny mode can break NTLM authentication. This is enforcement, not audit-only configuration.' + } + if (-not $PSCmdlet.ShouldProcess($id, "Set $Scope NTLM policy to $desired")) { + $skipReason = 'ShouldProcess declined the NTLM change.' + } else { + # Shared runner owns prompts, dry-run suppression, exact registry + # before-state journal, type/value read-back and final drift check. + Set-WelaRegistryControl -Context $Context -Path $path -Name $name -Value $desired + return + } + } + } catch { + $status = 'Failed' + $skipReason = $_.ToString() + } + $Context.Results.Add([pscustomobject][ordered]@{ + Id = $id; Kind = 'Registry'; Target = @{ Path = $path; Name = $name } + Desired = @{ Value = $desired; Type = 'DWord' }; Before = $state; After = $state + Status = $status; Diagnostic = $skipReason + }) + $color = if ($status -eq 'Failed') { 'Red' } else { 'Yellow' } + Write-Host "[$status] $id $skipReason" -ForegroundColor $color +} diff --git a/tests/Test-ConfigurationReadOnlyWindows.ps1 b/tests/Test-ConfigurationReadOnlyWindows.ps1 new file mode 100644 index 00000000..87597fa9 --- /dev/null +++ b/tests/Test-ConfigurationReadOnlyWindows.ps1 @@ -0,0 +1,16 @@ +# 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" } +# 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() } +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..f7b7b954 --- /dev/null +++ b/tests/Test-ConfigurationResults.ps1 @@ -0,0 +1,215 @@ +# No Windows settings are changed. Run with powershell.exe 5.1 or pwsh. +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +$script:ScriptRoot = $repo +# Load trusted source functions into the same scope as the mocks. Windows +# PowerShell 5.1 otherwise resolves a script-local original ahead of global mocks. +$definitions = Get-Content -LiteralPath (Join-Path $repo 'scripts/Configuration.ps1') -Raw +Invoke-Expression ($definitions -replace '(?m)^function ', 'function global:') +$script:passed = 0 +function Assert($Condition, [string]$Message) { + if (-not $Condition) { throw "FAIL: $Message" } + $script:passed++ +} +function New-TestContext([switch]$DryRun) { + $path = Join-Path ([IO.Path]::GetTempPath()) ('wela-results-test-' + [guid]::NewGuid().ToString('N')) + if (-not $DryRun) { $script:cleanup.Add($path) } + New-WelaConfigurationContext -Auto -DryRun:$DryRun -BackupPath $path +} +$script:cleanup = New-Object 'System.Collections.Generic.List[string]' +try { + foreach ($path in @('WELA.ps1', 'scripts/Configuration.ps1')) { + $parseErrors = $null; $tokens = $null + $null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo $path), [ref]$tokens, [ref]$parseErrors) + Assert ($parseErrors.Count -eq 0) "Parser accepts $path" + } + + # An actual child process exercises exit capture and stderr retention. The + # child only emits text and exits; it never calls Windows configuration tools. + $engine = (Get-Process -Id $PID).Path + $caught = '' + try { Invoke-WelaNative -FilePath $engine -Arguments @('-NoProfile', '-Command', "[Console]::Error.WriteLine('injected native diagnostic'); exit 7") } + catch { $caught = $_.ToString() } + Assert ($caught -match 'exit: 7' -and $caught -match 'injected native diagnostic') 'Native failure retains exit code and stderr' + $ok = Invoke-WelaNative -FilePath $engine -Arguments @('-NoProfile', '-Command', "[Console]::Error.WriteLine('non-fatal diagnostic'); exit 0") + Assert ($ok.ExitCode -eq 0 -and $ok.Diagnostic -match 'non-fatal diagnostic') 'Stderr alone is not a native failure' + + $script:state = 1; $script:writes = 0 + $read = { $script:state }; $test = { param($value) $value -eq 2 } + $apply = { $script:writes++; $script:state = 2 } + $c = New-TestContext + Invoke-WelaConfigurationControl $c test Registry @{ Path = 'mock'; Name = 'value' } 2 $read $test $apply + Assert ($c.Results[0].Status -eq 'Applied' -and $script:writes -eq 1) 'Changed state is read back before Applied' + $journal = Get-Content -LiteralPath (Join-Path $c.BackupPath 'before.jsonl') | ConvertFrom-Json + Assert ($journal.Before -eq 1 -and $journal.Desired -eq 2) 'Journal contains exact before and requested state' + Invoke-WelaConfigurationControl $c repeated Registry @{} 2 $read $test $apply + Assert ($c.Results[1].Status -eq 'AlreadyCompliant' -and $script:writes -eq 1) 'Rerun is idempotent' + $r = Complete-WelaConfiguration $c + Assert ($r.ExitCode -eq 0) 'Verified controls produce successful overall status' + $script:state = 1 + $r = Complete-WelaConfiguration $c + Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -eq 'Overridden') 'Final check detects observed drift without attributing its cause' + + $c = New-TestContext -DryRun + $script:writes = 0 + Invoke-WelaConfigurationControl $c dry Registry @{} 2 $read $test $apply + Assert ($c.Results[0].Status -eq 'Skipped' -and $script:writes -eq 0) 'Dry run never invokes mutation' + Assert (-not (Test-Path -LiteralPath $c.BackupPath)) 'Dry run creates no backup or journal' + + $c = New-TestContext + Invoke-WelaConfigurationControl $c false_success Registry @{} 2 $read $test { } + Assert ($c.Results[0].Status -eq 'Failed') 'Successful write command with wrong read-back is Failed' + Assert ((Complete-WelaConfiguration $c).ExitCode -eq 1) 'Read-back failure makes overall status nonzero' + + $c = New-TestContext + $c.BackupPath = Join-Path $c.BackupPath 'missing-parent' + $script:writes = 0 + Invoke-WelaConfigurationControl $c journal_failed Registry @{} 2 $read $test $apply + Assert ($c.Results[0].Status -eq 'Failed' -and $script:writes -eq 0) 'Journal failure prevents mutation' + + # Registry provider failures and false-success writes use the same verified + # control runner; no actual registry provider is touched in these tests. + $script:registryValue = 0; $script:registryWrites = 0; $script:registryThrows = $true + function global:Get-WelaRegistryState { + param($Path, $Name) + [pscustomobject]@{ KeyExists = $true; ValueExists = $true; Value = $script:registryValue; Type = 'DWord' } + } + function global:Test-Path { + param($LiteralPath, $Path, $ErrorAction) + if ($LiteralPath -like 'HKLM:*') { return $true } + Microsoft.PowerShell.Management\Test-Path -LiteralPath $(if ($LiteralPath) { $LiteralPath } else { $Path }) + } + function global:Set-ItemProperty { + param($LiteralPath, $Name, $Value, $Type, $ErrorAction) + $script:registryWrites++ + if ($script:registryThrows) { throw 'Injected registry access denied' } + $script:registryValue = $Value + } + $c = New-TestContext + Set-WelaRegistryControl $c 'HKLM:\mock' Value 1 + Assert ($c.Results[0].Status -eq 'Failed' -and $c.Results[0].Diagnostic -match 'access denied') 'Registry write errors produce failed results' + $script:registryThrows = $false + $c = New-TestContext + Set-WelaRegistryControl $c 'HKLM:\mock' Value 1 + Assert ($c.Results[0].Status -eq 'Applied' -and $c.Results[0].After.Value -eq 1) 'Registry writes require verified value and type' + $beforeWrites = $script:registryWrites + Set-WelaRegistryControl $c 'HKLM:\mock' Value 1 + Assert ($c.Results[1].Status -eq 'AlreadyCompliant' -and $script:registryWrites -eq $beforeWrites) 'Registry reruns preserve compliant values' + + # Missing nested registry parents must be created individually, retaining + # existing parent keys/values. Mock provider rejects children without parents. + $script:mockKeys = @{'HKLM:' = $true; 'HKLM:\SOFTWARE' = $true} + $script:createdKeys = New-Object 'System.Collections.Generic.List[string]' + function global:Test-Path { + param($LiteralPath, $Path, $ErrorAction) + if ($LiteralPath -like 'HKLM:*') { return $script:mockKeys.ContainsKey($LiteralPath) } + Microsoft.PowerShell.Management\Test-Path -LiteralPath $(if ($LiteralPath) { $LiteralPath } else { $Path }) + } + function global:New-Item { + param($Path, $ItemType, [switch]$Force, $ErrorAction) + if ($Path -notlike 'HKLM:*') { return Microsoft.PowerShell.Management\New-Item @PSBoundParameters } + if ($Force) { throw 'Test refuses Force on registry keys' } + if ($script:mockKeys.ContainsKey($Path)) { throw 'Existing parent would be recreated' } + $parent = $Path.Substring(0, $Path.LastIndexOf('\')) + if (-not $script:mockKeys.ContainsKey($parent)) { throw "Missing registry parent: $parent" } + $script:createdKeys.Add($Path); $script:mockKeys[$Path] = $true + } + New-WelaRegistryKey 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging' + Assert ($script:createdKeys.Count -eq 5 -and $script:mockKeys.ContainsKey('HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging')) 'Missing registry ancestors are created safely in order' + New-WelaRegistryKey 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging' + Assert ($script:createdKeys.Count -eq 5) 'Existing registry parents are preserved on rerun' + + # Function stubs stand in for the Windows APIs from this point onward. + $script:logSize = 1048576; $script:nativeFails = $true; $script:nativeWrites = 0 + function global:Get-WinEvent { param($ListLog, $ErrorAction) [pscustomobject]@{ MaximumSizeInBytes = $script:logSize; IsEnabled = $false } } + function global:Invoke-WelaNative { + param($FilePath, $Arguments) + $script:nativeWrites++ + if ($script:nativeFails) { throw 'wevtutil.exe failed (exit: 5). Injected access denied' } + $script:logSize = 134217728 + [pscustomobject]@{ ExitCode = 0; Output = @(); Diagnostic = 'mock success' } + } + $c = New-TestContext + Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728 + $r = Complete-WelaConfiguration $c + Assert ($r.ExitCode -eq 1 -and $r.Results[0].Diagnostic -match 'access denied') 'Injected wevtutil failure survives through the final report' + $script:nativeFails = $false + $c = New-TestContext + Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728 + Assert ($c.Results[0].Status -eq 'Applied') 'Event log helper reads verified size' + Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728 + Assert ($c.Results[1].Status -eq 'AlreadyCompliant') 'Event log helper avoids repeated writes' + + # Compile the interop declaration without invoking Windows APIs on this host. + Initialize-WelaConfigurationAuditApi + Assert ($null -ne ('Wela.ConfigurationAuditApi' -as [type])) 'Audit query interop compiles' + function global:Get-WelaNativeAuditPolicy { param($Guid) return 3 } + Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 3) 'Audit policy uses native numeric flags independent of locale' + function global:Get-WelaNativeAuditPolicy { param($Guid) return 4 } + Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 0) 'Native NONE flag normalizes to no success/failure audit' + function global:Get-WelaNativeAuditPolicy { param($Guid) return 16 } + $caught = '' + try { Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030' } catch { $caught = $_.ToString() } + Assert ($caught -ne '') 'Unexpected native flags cannot be marked compliant' + + # Extract ConfigureAuditSettings without running the WELA command dispatcher. + $tokens = $null; $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors) + $configure = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'ConfigureAuditSettings' }, $false) + Assert ($configure.Extent.Text -notmatch 'Configuration completed successfully|Start-Process|Out-Null') 'Configure has no unverified native execution or unconditional success' + + # Run the actual configure dispatcher in a child process with only the + # configuration function replaced by a harmless failed-report fixture. + $dispatch = $ast.Find({ param($node) $node -is [Management.Automation.Language.SwitchStatementAst] -and $node.Condition.Extent.Text -eq '$Cmd.ToLower()' }, $false) + $clause = @($dispatch.Clauses | Where-Object { $_.Item1.Value -eq 'configure' })[0].Item2.Extent.Text + $child = 'function ConfigureAuditSettings { [pscustomobject]@{ ExitCode = 1; Failed = 1; Results = @() } }; & ' + $clause + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child)) + $childOutput = @(& $engine -NoProfile -EncodedCommand $encoded 2>&1) + $childExit = $global:LASTEXITCODE + 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; $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' } } + $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' + + $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) { + if (Microsoft.PowerShell.Management\Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } + } +}