From 595f123327d9c449923a1a8334f6f9cc08f692fd Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:12:30 +0900 Subject: [PATCH] Test unsupported dry-run rejection and NTLM policy races safely --- docs/configuration-results.md | 11 +++ tests/IntegrationSafety.Tests.ps1 | 132 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 tests/IntegrationSafety.Tests.ps1 diff --git a/docs/configuration-results.md b/docs/configuration-results.md index 986acf85..6f4fc9dc 100644 --- a/docs/configuration-results.md +++ b/docs/configuration-results.md @@ -145,3 +145,14 @@ selection behavior while adding shared execution and recovery reporting. `tests/IntegrationProfileConfiguration.Tests.ps1` tests the composed command paths without touching Windows policy, including exact/minimum behavior, concurrent flags, unknown-state preflight, reference-only defaults, metadata and dry runs. + +`-DryRun` is supported only by `configure`, including its `-Profile` form. Other +commands reject the flag before dispatch, so `configure-sacl -DryRun` and +`update-rules -DryRun` cannot silently perform their normal mutations. + +Outgoing `PreserveOrAudit` checks the shared runner's fresh registry snapshot and +checks again after prompting and journaling, immediately before the value write. +Newly observed deny or unknown states are preserved or refused with an explicit +result; changing them requires an explicit `Audit` or `Deny` choice. Windows does +not provide an atomic compare-and-set through this registry provider, so a +concurrent writer after the final check remains outside this guarantee. diff --git a/tests/IntegrationSafety.Tests.ps1 b/tests/IntegrationSafety.Tests.ps1 new file mode 100644 index 00000000..d655b9ac --- /dev/null +++ b/tests/IntegrationSafety.Tests.ps1 @@ -0,0 +1,132 @@ +# Safety regressions use extracted dispatcher statements with stub mutators and mocked registry APIs. +# Never dot-source WELA or invoke configure-sacl/update-rules implementations from this test. +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +$tokens = $null; $errors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +$dispatch = $ast.Find({ param($node) $node -is [Management.Automation.Language.SwitchStatementAst] -and $node.Condition.Extent.Text -eq '$Cmd.ToLower()' }, $false) +$guard = $ast.EndBlock.Statements | Where-Object { + $_ -is [Management.Automation.Language.IfStatementAst] -and $_.Extent.Text -match '-DryRun is supported only by configure' +} | Select-Object -First 1 +if (-not $guard -or $guard.Extent.StartOffset -ge $dispatch.Extent.StartOffset) { throw 'DryRun rejection guard must precede command dispatch.' } +$source = Get-Content -LiteralPath (Join-Path $repo 'WELA.ps1') -Raw +$dispatchOnly = [scriptblock]::Create($source.Substring($guard.Extent.StartOffset, $dispatch.Extent.EndOffset - $guard.Extent.StartOffset)) +$script:assertions = 0 +function Assert($Condition, [string]$Message) { + if (-not $Condition) { throw "FAIL: $Message" } + $script:assertions++ +} +# These stubs are the only mutators visible to the extracted command dispatcher. +function Set-AuditSacl { param([switch]$Auto) $script:saclCalls++ } +function UpdateRules { $script:updateCalls++ } +function ConfigureAuditSettings { $script:configureCalls++; [pscustomobject]@{ ExitCode = 0 } } +function Invoke-WelaProfileCommand { param($Command) $script:profileCalls++ } +$Help = $false; $Auto = $true; $Baseline = $null; $Profile = $null; $Debug = $false +$BackupPath = $null; $ResultsPath = $null; $OutgoingNtlmMode = 'PreserveOrAudit' +foreach ($command in @('configure-sacl', 'update-rules')) { + $Cmd = $command; $DryRun = $true + $script:saclCalls = 0; $script:updateCalls = 0 + $caught = '' + try { & $dispatchOnly | Out-Null } catch { $caught = $_.ToString() } + Assert ($caught -match '-DryRun is supported only by configure') "Unsupported DryRun for $command is rejected" + Assert ($script:saclCalls -eq 0 -and $script:updateCalls -eq 0) "DryRun rejection occurs before $command mutator" + $DryRun = $false + & $dispatchOnly | Out-Null + Assert (($script:saclCalls + $script:updateCalls) -eq 1) "Safe fixture would detect dispatch to $command without the guard" +} +$Cmd = 'configure'; $DryRun = $true; $script:configureCalls = 0 +& $dispatchOnly | Out-Null +Assert ($script:configureCalls -eq 1) 'Supported configure DryRun still dispatches' +$Profile = 'wela-2.2.0'; $script:profileCalls = 0 +& $dispatchOnly | Out-Null +Assert ($script:profileCalls -eq 1) 'Supported profile DryRun still dispatches' + +. (Join-Path $repo 'scripts/Configuration.ps1') +$script:cleanup = New-Object 'System.Collections.Generic.List[string]' +function Reset-Race($Value = 0, [string]$Type = 'DWord') { + $script:value = $Value; $script:type = $Type + $script:writes = 0; $script:changeOnPrompt = $null; $script:changeAfterJournal = $null + $script:prewriteReadFails = $false; $script:journalWritten = $false +} +function New-RaceContext([switch]$Prompt) { + $path = Join-Path ([IO.Path]::GetTempPath()) ('wela-safety-' + [guid]::NewGuid().ToString('N')) + $script:cleanup.Add($path) + New-WelaConfigurationContext -Auto:(-not $Prompt) -BackupPath $path +} +function global:Get-WelaOutgoingNtlmState { + # The first display is deliberately stale; the shared runner must trust its own fresh read. + [pscustomobject]@{ Readable = $true; Value = 0; Description = 'Allow all (initial read)'; PolicySource = 'mock' } +} +function global:Get-WelaRegistryState { + param($Path, $Name) + if ($script:journalWritten -and $script:prewriteReadFails) { throw 'Mock prewrite read failure' } + [pscustomobject]@{ KeyExists = $true; ValueExists = $true; Value = $script:value; Type = $script:type } +} +function global:New-WelaRegistryKey { param($Path) } +function global:Set-ItemProperty { + param($LiteralPath, $Name, $Value, $Type, $ErrorAction) + $script:value = $Value; $script:type = $Type; $script:writes++ +} +function global:Read-Host { + param($Prompt) + if ($null -ne $script:changeOnPrompt) { $script:value = $script:changeOnPrompt } + return 'Y' +} +function global:Add-Content { + param($LiteralPath, $Value, $Encoding, $ErrorAction) + process { + # Preserve real temporary recovery files; only the mocked policy state changes. + $text = if ($PSBoundParameters.ContainsKey('Value')) { $Value } else { $_ } + Microsoft.PowerShell.Management\Add-Content -LiteralPath $LiteralPath -Value $text -Encoding $Encoding -ErrorAction Stop + $script:journalWritten = $true + if ($null -ne $script:changeAfterJournal) { $script:value = $script:changeAfterJournal } + } +} +try { + foreach ($value in @(2, 42)) { + Reset-Race $value + $context = New-RaceContext + Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing -Mode PreserveOrAudit + Assert ($script:writes -eq 0 -and $script:value -eq $value) "Fresh Before value $value is preserved despite stale display" + Assert ($context.Results[0].Status -eq 'Skipped' -and $context.Results[0].Before.Value -eq $value) 'Preservation records actual fresh snapshot' + Assert (-not $script:journalWritten) 'Initially preserved value produces no mutation journal' + } + Reset-Race 0 String + $context = New-RaceContext + Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing + Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Skipped') 'Unknown registry type is preserved by default' + + foreach ($changed in @(2, 42)) { + Reset-Race + $script:changeOnPrompt = $changed + $context = New-RaceContext -Prompt + Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing + Assert ($script:writes -eq 0 -and $script:value -eq $changed) "New value $changed introduced while prompting is never overwritten" + Assert ($context.Results[0].Status -eq 'Failed' -and $context.Results[0].Diagnostic -match 'Refused registry write') 'Changed policy is refused and reported for operator review' + } + foreach ($changed in @(2, 42)) { + Reset-Race + $script:changeAfterJournal = $changed + $context = New-RaceContext + Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing + Assert ($script:writes -eq 0 -and $script:value -eq $changed) "New value $changed introduced after journaling is preserved" + Assert ($context.Results[0].Status -eq 'Failed') 'Prewrite refusal contributes to overall failure' + } + Reset-Race + $script:prewriteReadFails = $true + $context = New-RaceContext + Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing + Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Unreadable prewrite policy prevents mutation' + + Reset-Race 2 + $context = New-RaceContext + Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing -Mode Audit + Assert ($script:writes -eq 1 -and $script:value -eq 1) 'Explicit Audit still overrides fresh deny intentionally' + Assert ($context.Results[0].Status -eq 'Applied') 'Explicit override requires successful verification' + Write-Host "PASS: $script:assertions integration safety assertions (stub dispatcher and registry; no Windows changes)." +} finally { + foreach ($path in $script:cleanup) { + if (Microsoft.PowerShell.Management\Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } + } +}