Integrate verified configuration results for review

# Conflicts:
#	WELA.ps1
This commit is contained in:
Shirofune-Security committed 2026-09-18 21:56:07 +09:00
commit f5a19a45fd
7 files changed
+761 -352

No files matched your search

@@ -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
+1
View File
@@ -38,6 +38,7 @@ jobs:
mkdir -p release-binaries
Copy-Item -Path WELA.ps1 -Destination release-binaries/
Copy-Item -Recurse -Path ./config -Destination release-binaries/
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
+73 -352
View File
@@ -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 <PreserveOrAudit|Audit|Deny>]"
Write-Host "Usage: ./WELA.ps1 configure [-Auto] [-DryRun] [-BackupPath <new-directory>] [-ResultsPath <json-file>] [-OutgoingNtlmMode <PreserveOrAudit|Audit|Deny>]"
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" {
+102
View File
@@ -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 <log> /ms:<previous-bytes>` or `/e:<previous-bool>`.
Review shrinking buffers or disabling a channel before proceeding.
- **AuditPolicy:** `auditpol /set /subcategory:{<guid>} /success:<enable|disable>
/failure:<enable|disable>`. Previous mask bit 1 means success, bit 2 means
failure. Restore that subcategory, not unrelated policy.
- **Registry:** restore the previous value using its recorded registry type.
If the value did not exist, remove only that value. Preserve unrelated values
and never recursively delete a newly created parent key. Binary and multistring
old values must be reconstructed with their original types from the JSON.
- **CertificateService:** restore the active CA's previous AuditFilter value (or
its original absence) and separately approve the necessary service restart.
Do not start a CA that was deliberately stopped. A failed restart can leave
the registry and running service out of sync; an operator must resolve this.
4. Check every native exit code and read the restored state. Keep the recovery
commands and observations with the original journal.
A future automated rollback command should require the same host and control
identity, validate journal schema and allowlisted types, check current state against
recorded after-state, refuse unexpected drift, journal recovery itself, and require
explicit approval for CA restarts. It should never import the whole registry or
force a Group Policy setting. These are design constraints, not implemented claims.
## Testing
`tests/Test-ConfigurationResults.ps1` uses mock Windows APIs and disposable temp
journals. It exercises nonzero native exits and stderr, false-success writes,
read-back, idempotence, final drift, dry runs, journal failure, 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.
+329
View File
@@ -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
}
@@ -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."
+215
View File
@@ -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
}
}
}