Merge latest dev before registry probe merge

This commit is contained in:
Shirofune-Security committed 2026-09-22 18:16:38 +09:00
commit f180ca72ae
20 files changed
+990 -4

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
$parameterAst=[Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'),[ref]$null,[ref]$null).ParamBlock.Parameters
$names=@($parameterAst|ForEach-Object {$_.Name.VariablePath.UserPath});$helpIndex=[array]::IndexOf($names,'Help')
foreach($name in @('PowerShellLoggingAction','PowerShellLoggingControl','PowerShellLoggingModuleName')){if([array]::IndexOf($names,$name) -le $helpIndex){throw 'New logging parameters must follow existing Help to preserve legacy positional binding.'};$count++}
$cases=@(
@{Args=@('powershell-logging','-Help');Code=0;Pattern='Windows PowerShell 5.1'},
@{Args=@('configure','-PowerShellLoggingAction','Configure');Code=1;Pattern='require powershell-logging'},
@{Args=@('audit','-PowerShellLoggingControl','ScriptBlock');Code=1;Pattern='require powershell-logging'},
@{Args=@('powershell-logging','-PowerShellLoggingAction','Configure');Code=1;Pattern='explicit PowerShellLoggingControl'},
@{Args=@('powershell-logging','-PowerShellLoggingAction','Plan');Code=1;Pattern='explicit PowerShellLoggingControl'},
@{Args=@('powershell-logging','-PowerShellLoggingAction','Configure','-PowerShellLoggingControl','Module');Code=1;Pattern='PowerShellLoggingModuleName'},
@{Args=@('powershell-logging','-PowerShellLoggingControl','ScriptBlock','-PowerShellLoggingModuleName','Microsoft.PowerShell.Utility');Code=1;Pattern='Module selection'},
@{Args=@('powershell-logging','-Role','DomainController');Code=1;Pattern='dedicated options'},
@{Args=@('powershell-logging','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'},
@{Args=@('powershell-logging','-Auto');Code=1;Pattern='PowerShellLoggingAction'},
@{Args=@('powershell-logging','-DryRun');Code=1;Pattern='PowerShellLoggingAction'},
@{Args=@('powershell-logging','-PowerShellLoggingControl','Module','-PowerShellLoggingModuleName','Mod*');Code=1;Pattern='literal module'},
@{Args=@('powershell-logging','-Help','-Typo');Code=1;Pattern='Unsupported trailing arguments'}
)
foreach($case in $cases){
# ProcessStartInfo preserves literal wildcard arguments on Unix PowerShell hosts too.
$arguments=@('-NoLogo','-NoProfile','-NonInteractive','-File',"$repo/WELA.ps1")+@($case.Args)
foreach($argument in $arguments){if($argument.Contains('"') -or $argument.EndsWith('\')){throw 'Ambiguous fixture argument.'}}
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($arguments|ForEach-Object{'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
try{if(-not $process.Start()){throw 'CLI child did not start.'};$started=$true;$stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync();if(-not $process.WaitForExit(30000)){throw 'CLI child timeout.'};$code=$process.ExitCode;$output=$stdout.Result+"`n"+$stderr.Result}
finally{if($started -and -not $process.HasExited){$process.Kill();$process.WaitForExit()};$process.Dispose()}
if(($code -eq 0) -ne ($case.Code -eq 0) -or $output -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $output"};$count++
}
Write-Host "PASS: $count scoped PowerShell logging CLI guards."
exit 0
+104
View File
@@ -0,0 +1,104 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/PowerShellLogging.ps1')
$script:count=0;$script:ScriptRoot=$repo;$script:writes=0;$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-pslogging-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
function Assert($Value,$Message){if(-not $Value){throw "FAIL: $Message"};$script:count++}
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected rejection $Pattern; got $message"}
function CloneFixture($Value){ConvertTo-WelaPsLoggingKey $Value|ConvertFrom-Json}
function Row($Path,$Values=@(),$Children=@()){[pscustomobject]@{Path=$Path;Values=@($Values);Children=@($Children);Access='original-acl'}}
function Fixture {
[pscustomobject][ordered]@{Operator=[pscustomobject]@{Sid='S-1-5-21-1';ImpersonationLevel='None'};Host=[pscustomobject]@{Computer='fixture';Build=20348};Engine=[pscustomobject]@{Target='Windows PowerShell 5.1'};Sources=@('sha');Machine=[pscustomobject]@{Exists=$true;Keys=@((Row '' @() @('ModuleLogging','ScriptBlockLogging','Transcription')),(Row 'ModuleLogging' @([pscustomobject]@{Name='EnableModuleLogging';Type='DWord';Value=0}) @('ModuleNames')),(Row 'ModuleLogging\ModuleNames' @([pscustomobject]@{Name='existing';Type='String';Value='Existing.Module'})),(Row 'ScriptBlockLogging' @([pscustomobject]@{Name='EnableScriptBlockLogging';Type='DWord';Value=0},[pscustomobject]@{Name='EnableScriptBlockInvocationLogging';Type='DWord';Value=1})),(Row 'Transcription' @([pscustomobject]@{Name='EnableTranscripting';Type='DWord';Value=1})))};CurrentUser=@('preserved-user');PowerShellCoreMachine=@('preserved-core');PowerShellCoreUser=@('preserved-core-user');ProtectedEventLogging=@('preserved-protected');Channel=@('preserved-channel')}
}
function Mutate($Before,$Definition){
$after=CloneFixture $Before;$after.Machine.Exists=$true
$allowed=@('');$p='';foreach($segment in $Definition.Path.Split('\')){$p=if($p){$p+'\'+$segment}else{$segment};$allowed+=$p}
foreach($path in $allowed){if(-not @($after.Machine.Keys|Where-Object Path -ieq $path).Count){$after.Machine.Keys+=Row $path}}
foreach($key in $after.Machine.Keys){$key.Children=@($after.Machine.Keys|Where-Object {$_.Path -and $(if($_.Path.Contains('\')){$_.Path.Substring(0,$_.Path.LastIndexOf('\'))}else{''}) -ceq $key.Path}|ForEach-Object {$_.Path.Split('\')[-1]}|Sort-Object)}
$row=@($after.Machine.Keys|Where-Object Path -ieq $Definition.Path)[0];$row.Values=@($row.Values|Where-Object Name -ine $Definition.Name)+[pscustomobject]@{Name=$Definition.Name;Type=$Definition.Type;Value=$Definition.Value};$row.Values=@($row.Values|Sort-Object Name);$after.Machine.Keys=@($after.Machine.Keys|Sort-Object Path);return $after
}
function Get-WelaPsLoggingSnapshot {CloneFixture $script:observed}
function Set-WelaPsLoggingValue {param($Definition)$script:writes++;if($script:failWrite){throw 'injected native write failure'};$script:observed=Mutate $script:observed $Definition;if($script:corrupt){$script:observed.CurrentUser=@('changed-user')}}
function Reset {$script:observed=Fixture;$script:writes=0;$script:failWrite=$false;$script:corrupt=$false}
try {
foreach($action in @('Plan','Configure')){Reject {Assert-WelaPsLoggingSelection $action @() @()} 'explicit'}
Reject {Assert-WelaPsLoggingSelection Configure @('Module') @()} 'ModuleName'
Reject {Assert-WelaPsLoggingSelection Plan @('ScriptBlock') @('x')} 'Module selection'
foreach($name in @('','C:\module','Mod*','../a','a?','a\b',('x'*129))){Reject {Assert-WelaPsLoggingSelection Plan @('Module') @($name)} 'literal'}
Reject {Assert-WelaPsLoggingSelection Plan @('Module','module') @('a')} 'unique'
Reject {Assert-WelaPsLoggingSelection Plan @('Module') @('A','a')} 'unique'
Assert-WelaPsLoggingSelection Plan @('Module') @('*');Assert $true 'Explicit all-module accepted'
Assert-WelaPsLoggingSelection Audit @() @();Assert $true 'Unselected Audit accepted'
$definitions=@(Get-WelaPsLoggingDefinitions @('Module','ScriptBlock') @('Microsoft.PowerShell.Utility'))
Assert ($definitions.Count -eq 3 -and $definitions[0].Type -eq 'String' -and $definitions[1].Name -eq 'EnableModuleLogging' -and $definitions[2].Name -eq 'EnableScriptBlockLogging') 'Name-before-enable ordering'
Reset;$before=CloneFixture $script:observed;$report=Invoke-WelaPowerShellLogging -Action Audit
Assert ($report.ExitCode -eq 0 -and $script:writes -eq 0 -and $report.ReadyRuleCredit -eq 0) 'Audit is read-only with zero readiness credit'
$plan=Invoke-WelaPowerShellLogging -Action Plan -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility
Assert ($plan.Plan.Controls.Count -eq 3 -and @($plan.Plan.Controls|Where-Object Status -eq ChangeRequired).Count -eq 3) 'Plan identifies all selected values'
$dry=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -DryRun -BackupPath (Join-Path $root 'dry')
Assert ($dry.ExitCode -eq 0 -and $dry.Skipped -eq 3 -and $script:writes -eq 0 -and -not (Test-Path (Join-Path $root 'dry'))) 'Dry run creates no journal or write'
$run=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'run')
Assert ($run.ExitCode -eq 0 -and $script:writes -eq 3 -and @($run.Results|Where-Object Status -eq Applied).Count -eq 3) 'Three exact writes applied'
$journal=@(Get-Content (Join-Path $root 'run/before.jsonl')|ForEach-Object {$_|ConvertFrom-Json})
Assert ($journal.Count -eq 3 -and (ConvertTo-WelaPsLoggingKey $journal[0].Before) -ceq (ConvertTo-WelaPsLoggingKey $before)) 'Complete original snapshot journaled before any mutation'
Assert ((Get-WelaPsLoggingValue $script:observed.Machine 'ModuleLogging\ModuleNames' 'existing').Value -ceq 'Existing.Module') 'Other module names retained'
Assert ((Get-WelaPsLoggingValue $script:observed.Machine 'ScriptBlockLogging' EnableScriptBlockInvocationLogging).Value -eq 1) 'Invocation logging retained'
Assert ((ConvertTo-WelaPsLoggingKey $script:observed.CurrentUser) -ceq (ConvertTo-WelaPsLoggingKey $before.CurrentUser)) 'User policy retained'
$again=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'again')
Assert ($again.ExitCode -eq 0 -and $script:writes -eq 3 -and @($again.Results|Where-Object Status -eq AlreadyCompliant).Count -eq 3) 'Repeat is idempotent'
Reset;$script:observed.Machine=[pscustomobject]@{Exists=$false;Keys=@()}
$absent=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'absent')
Assert ($absent.ExitCode -eq 0 -and $script:writes -eq 3) 'Absent root creates only declared ancestors'
foreach($case in @(@{Type='String';Value='0'},@{Type='DWord';Value=2})){
Reset;$value=Get-WelaPsLoggingValue $script:observed.Machine 'ScriptBlockLogging' EnableScriptBlockLogging;$value.Type=$case.Type;$value.Value=$case.Value
$bad=Invoke-WelaPowerShellLogging -Action Configure -Control ScriptBlock -Auto -BackupPath (Join-Path $root ([guid]::NewGuid().ToString('N')))
Assert ($bad.ExitCode -eq 1 -and $script:writes -eq 0) 'Wrong type/unknown DWORD refused before write'
}
Reset;$badName=Get-WelaPsLoggingValue $script:observed.Machine 'ModuleLogging\ModuleNames' existing;$badName.Type='DWord';$badName.Value=1
$bad=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'badname')
Assert ($bad.ExitCode -eq 1 -and -not (Test-Path (Join-Path $root 'badname'))) 'Unknown module value fails entire preflight'
foreach($countBefore in @(127,128)){
Reset;$row=@($script:observed.Machine.Keys|Where-Object Path -eq 'ModuleLogging\ModuleNames')[0]
$row.Values=@(1..$countBefore|ForEach-Object{[pscustomobject]@{Name=('Existing'+$_);Type='String';Value=('Existing'+$_)}})
$capacityPath=Join-Path $root ('value-capacity-'+$countBefore);$captured=ConvertTo-WelaPsLoggingKey $script:observed
$capacity=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName NewModule -DryRun -BackupPath $capacityPath
Assert (($capacity.ExitCode -eq 0) -eq ($countBefore -eq 127)) '127 names permit one addition; 128 names refuse before the first write'
Assert ($script:writes -eq 0 -and -not (Test-Path $capacityPath) -and (ConvertTo-WelaPsLoggingKey $script:observed) -ceq $captured) 'Capacity preflight preserves the snapshot without writes or journals'
}
foreach($countBefore in @(63,64)){
Reset;$script:observed.Machine.Keys=@((Row '' @() @('ModuleLogging')),(Row 'ModuleLogging' @([pscustomobject]@{Name='EnableModuleLogging';Type='DWord';Value=0})))
foreach($index in 1..($countBefore-2)){$name='Existing'+$index;$script:observed.Machine.Keys+=Row $name;$script:observed.Machine.Keys[0].Children+=$name}
$capacity=Invoke-WelaPowerShellLogging -Action Plan -Control Module -ModuleName NewModule
Assert (($capacity.ExitCode -eq 0) -eq ($countBefore -eq 63)) '63 keys permit the missing selected key; 64 keys refuse predictable readback overflow'
}
Reset;$row=@($script:observed.Machine.Keys|Where-Object Path -eq Transcription)[0];$row.Values+=[pscustomobject]@{Name='LargeUnrelated';Type='String';Value=''}
$space=1048576-(ConvertTo-WelaPsLoggingKey $script:observed.Machine).Length;$row.Values[-1].Value='x'*$space
Assert ((ConvertTo-WelaPsLoggingKey $script:observed.Machine).Length -eq 1048576) 'Character-cap boundary fixture fits the current reader exactly'
$capacity=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName NewModule -Auto -BackupPath (Join-Path $root 'character-capacity')
Assert ($capacity.ExitCode -eq 1 -and $script:writes -eq 0 -and -not (Test-Path (Join-Path $root 'character-capacity'))) 'Predictable serialized-value growth beyond character cap is refused before writes'
$literalSnapshot=Fixture;$row=@($literalSnapshot.Machine.Keys|Where-Object Path -eq Transcription)[0]
$row.Values+=@(1..64|ForEach-Object{[pscustomobject]@{Name=('LiteralDate'+$_);Type='String';Value='2026-09-21T00:00:00.000Z'}})
$row.Values+=[pscustomobject]@{Name='LiteralBytes';Type='Binary';Value=[byte[]]@(0,127,255)},[pscustomobject]@{Name='LiteralStrings';Type='MultiString';Value=[string[]]@('2026-09-21T00:00:00.000Z','')};$row.Values+=[pscustomobject]@{Name='LiteralFiller';Type='String';Value=''};$space=1048576-(ConvertTo-WelaPsLoggingKey $literalSnapshot.Machine).Length;$row.Values[-1].Value='x'*$space
$literalBefore=ConvertTo-WelaPsLoggingKey $literalSnapshot
Reject {Assert-WelaPsLoggingCapacity $literalSnapshot @(Get-WelaPsLoggingDefinitions @('Module') @('NewModule'))} 'character capacity'
Assert ((ConvertTo-WelaPsLoggingKey $literalSnapshot) -ceq $literalBefore -and $row.Values[1].Value -is [string]) 'Capacity projection preserves literal ISO strings and the caller snapshot'
Assert (($row.Values|Where-Object Name -eq LiteralBytes).Value -is [byte[]] -and ($row.Values|Where-Object Name -eq LiteralStrings).Value -is [string[]]) 'Projection retains typed binary and multi-string caller data'
Reset;$script:failWrite=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'writefailure')
Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1 -and @($failed.Results|Where-Object Status -eq Skipped).Count -eq 2) 'Native failure stops and explicitly reports later writes'
Reset;$script:corrupt=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'corrupt')
Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1) 'Preservation failure stops later writes'
Reset;$snapshot=CloneFixture $script:observed;$definition=$definitions[0];$after=Mutate $snapshot $definition;Assert-WelaPsLoggingTransition $snapshot $after $definition;Assert $true 'Exact additive transition accepted'
foreach($property in @('Operator','Host','Sources','CurrentUser','PowerShellCoreMachine','PowerShellCoreUser','ProtectedEventLogging','Channel')){$changed=CloneFixture $after;$changed.$property='drift';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'Unselected state'}
$changed=CloneFixture $after;$changed.Machine.Keys[0].Access='new-acl';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'descriptor'
$changed=CloneFixture $after;$changed.Machine.Keys=@($changed.Machine.Keys|Where-Object Path -ne Transcription);Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'disappeared'
$changed=CloneFixture $after;$changed.Machine.Keys+=Row 'extra';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'unrequested'
$changed=CloneFixture $after;(Get-WelaPsLoggingValue $changed.Machine 'Transcription' EnableTranscripting).Value=0;Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'Unrelated policy values'
$changed=CloneFixture $after;$changed.Machine.Keys[0].Children+= 'extra';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'subkeys'
Reset;$script:promptBefore=CloneFixture $script:observed
function Read-Host {param($Prompt)$script:observed.Host.Computer='changed-during-prompt';'Y'}
$drift=Invoke-WelaPowerShellLogging -Action Configure -Control ScriptBlock -BackupPath (Join-Path $root 'drift')
Assert ($drift.ExitCode -eq 1 -and $script:writes -eq 0) 'Prompt-time drift refused before mutation'
Remove-Item Function:\Read-Host
Reset;Set-Content -LiteralPath (Join-Path $root 'existing.json') -Value 'keep';Reject {Invoke-WelaPowerShellLogging -ResultsPath (Join-Path $root 'existing.json')} 'new file';Assert ((Get-Content -Raw (Join-Path $root 'existing.json')).Trim() -ceq 'keep') 'Existing report preserved'
foreach($options in @(@{Auto=$true},@{DryRun=$true},@{BackupPath='x'})){Reject {Invoke-WelaPowerShellLogging @options} 'require PowerShellLoggingAction Configure'}
Write-Host "PASS: $script:count scoped PowerShell logging assertions."
} finally {Remove-Item -LiteralPath $root -Recurse -Force}
+143
View File
@@ -0,0 +1,143 @@
param([switch]$AllowDisposableLoggingWrite)
$ErrorActionPreference='Stop'
if(-not $AllowDisposableLoggingWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on disposable GitHub-hosted Windows is required.'}
$repo=Split-Path $PSScriptRoot -Parent
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/PowerShellLogging.ps1')
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
$root=Join-Path $env:RUNNER_TEMP ('wela-powershell-logging-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
$engine=(Get-Process -Id $PID).Path;$script:count=0;$failure=$null;$cleanupErrors=@();$original=$null;$prepared=$null;$masks=$null;$workerPath=$null;$mutationStarted=$false
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Save($Name,$Value){$Value|ConvertTo-Json -Depth 28|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'}
Add-Type -TypeDefinition @'
using System;using System.IO;using System.Text;using System.Threading.Tasks;using System.Runtime.InteropServices;
public static class WelaPsLoggingFixture {
[DllImport("kernel32.dll")] static extern void GetSystemTimePreciseAsFileTime(out long value);
public static DateTime Now(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
public static async Task<string> Read(TextReader reader){var text=new StringBuilder();var buffer=new char[1024];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Owned child output exceeds one Mi character bound.");text.Append(buffer,0,n);}}
}
'@
function Child([string]$Label,[string]$Executable,[string[]]$Arguments){
foreach($a in $Arguments){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture process argument.'}}
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$Executable;$info.Arguments=(@($Arguments|ForEach-Object{'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
$p=[Diagnostics.Process]::new();$p.StartInfo=$info;$started=$false
try{
$start=[WelaPsLoggingFixture]::Now();if(-not $p.Start()){throw 'Child did not start.'};$started=$true;$ownedId=$p.Id
$stdout=[WelaPsLoggingFixture]::Read($p.StandardOutput);$stderr=[WelaPsLoggingFixture]::Read($p.StandardError)
if(-not $p.WaitForExit(180000)){throw 'Owned child exceeded three minutes.'};$end=[WelaPsLoggingFixture]::Now()
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Owned child output drain timed out.'}
$receipt=[pscustomobject]@{Executable=$Executable;Arguments=$info.Arguments;Pid=$ownedId;StartedUtc=$start.ToString('o');ExitedUtc=$end.ToString('o');ExitCode=$p.ExitCode;Output=$stdout.Result;Error=$stderr.Result}
Save ($Label+'-process.json') $receipt;return $receipt
}finally{if($started -and -not $p.HasExited){$p.Kill();if(-not $p.WaitForExit(5000)){throw 'Owned child exit could not be confirmed; cleanup may be incomplete.'}};$p.Dispose()}
}
$wrapper=Join-Path $root 'public.ps1'
@'
param([string]$Repository,[string]$Request)
$ErrorActionPreference='Stop'
$data=Get-Content -LiteralPath $Request -Raw|ConvertFrom-Json;$options=@{}
foreach($property in $data.PSObject.Properties){$options[$property.Name]=$property.Value}
$global:LASTEXITCODE=0
& (Join-Path $Repository 'WELA.ps1') @options
exit $LASTEXITCODE
'@ | Set-Content -LiteralPath $wrapper -Encoding UTF8
function Public([string]$Label,[hashtable]$Parameters,[int]$ExpectedExit=0){
$Parameters.Cmd='powershell-logging';$Parameters.ResultsPath=Join-Path $root ($Label+'.json');$request=Join-Path $root ($Label+'-request.json');$Parameters|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $request -Encoding UTF8
$process=Child $Label $engine @('-NoLogo','-NoProfile','-NonInteractive','-File',$wrapper,'-Repository',$repo,'-Request',$request)
Assert (($process.ExitCode -eq 0) -eq ($ExpectedExit -eq 0)) "Public $Label unexpected exit $($process.ExitCode): $($process.Output) $($process.Error)"
if(Test-Path -LiteralPath $Parameters.ResultsPath){return Get-Content -Raw -LiteralPath $Parameters.ResultsPath|ConvertFrom-Json};throw 'Public report missing.'
}
function RestoreValue($Tree,[string]$Path,[string]$Name){
$originalValue=Get-WelaPsLoggingValue $Tree $Path $Name;$base=$null;$key=$null
try{$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$Path,$true);if($originalValue){if(-not $key){throw 'Original policy key disappeared.'};$value=$originalValue.Value;if($originalValue.Type -eq 'DWord'){$value=[int]$value};if($originalValue.Type -eq 'QWord'){$value=[long]$value};if($originalValue.Type -eq 'Binary'){$value=[byte[]]$value};if($originalValue.Type -eq 'MultiString'){$value=[string[]]$value};$key.SetValue($Name,$value,[Microsoft.Win32.RegistryValueKind]::$($originalValue.Type));$key.Flush()}elseif($key){$key.DeleteValue($Name,$false);$key.Flush()}}
finally{if($key){$key.Dispose()};if($base){$base.Dispose()}}
}
function RemoveCreatedKeys($Tree){
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
try{foreach($path in @('ModuleLogging\ModuleNames','ScriptBlockLogging','ModuleLogging','')){
if(@($Tree.Keys|Where-Object Path -ieq $path).Count){continue};$full='SOFTWARE\Policies\Microsoft\Windows\PowerShell';if($path){$full+='\'+$path};$key=$null
try{$key=$base.OpenSubKey($full,$false);if(-not $key){continue};if($key.ValueCount -or $key.SubKeyCount){throw "Created key is no longer empty: $full"}}finally{if($key){$key.Dispose()}}
$base.DeleteSubKey($full,$false)
}}finally{$base.Dispose()}
}
function ReadEvents([int]$OwnedId,[long]$Watermark){
$query="*[System[(EventID=4103 or EventID=4104) and Execution[@ProcessID='$OwnedId'] and EventRecordID > $Watermark]]"
$q=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-PowerShell/Operational',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$q.TolerateQueryErrors=$false
$reader=$null;$list=New-Object 'System.Collections.Generic.List[string]'
try{$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);for($i=0;$i -le 64;$i++){$record=$reader.ReadEvent([TimeSpan]::FromSeconds(2));if(-not $record){break};try{$xml=$record.ToXml();if($xml.Length -gt 262144){throw 'Owned child event exceeds XML bound.'};$list.Add($xml)}finally{$record.Dispose()};if($i -eq 64){throw 'Owned child event candidate cap exceeded.'}};$statuses=@($reader.LogStatus);if($statuses.Count -ne 1 -or $statuses[0].LogName -cne 'Microsoft-Windows-PowerShell/Operational' -or $statuses[0].StatusCode -ne 0){throw 'Native query must report exactly one complete successful expected channel.'};return @($list.ToArray())}finally{if($reader){$reader.Dispose()}}
}
try{
$original=Get-WelaPsLoggingSnapshot;Save 'original.json' $original;$masks=Masks
Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain -and -not $original.Host.CertSvcPresent) 'Fixture requires a standalone disposable non-CA server.'
Assert $original.Channel.Enabled 'Existing PowerShell operational channel must already be enabled.'
$protected=@($original.ProtectedEventLogging.Keys|ForEach-Object {$_.Values}|Where-Object {$_.Name -eq 'EnableProtectedEventLogging' -and $_.Value -ne 0})
Assert ($protected.Count -eq 0) 'Protected logging must not obscure this plaintext event fixture.'
# Test fixture only: prepare explicit disabled values; production has no disable action.
$mutationStarted=$true
foreach($pair in @(@('ModuleLogging','EnableModuleLogging'),@('ScriptBlockLogging','EnableScriptBlockLogging'))){
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null
try{$key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$pair[0]);$key.SetValue($pair[1],0,[Microsoft.Win32.RegistryValueKind]::DWord);$key.Flush()}finally{if($key){$key.Dispose()};$base.Dispose()}
}
$selectedName=Get-WelaPsLoggingValue $original.Machine 'ModuleLogging\ModuleNames' 'Microsoft.PowerShell.Utility'
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null
try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging\ModuleNames',$true);if($key){$key.DeleteValue('Microsoft.PowerShell.Utility',$false);$key.Flush()}}finally{if($key){$key.Dispose()};$base.Dispose()}
$prepared=Get-WelaPsLoggingSnapshot;Save 'prepared.json' $prepared
$audit=Public audit @{};Assert ($audit.ExitCode -eq 0 -and $audit.ReadyRuleCredit -eq 0) 'Unselected public Audit is observational.'
$plan=Public plan @{PowerShellLoggingAction='Plan';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility')}
Assert (@($plan.Plan.Controls|Where-Object Status -eq ChangeRequired).Count -eq 3) 'Public plan contains three exact value changes.'
$dry=Public dry @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility');DryRun=$true;BackupPath=(Join-Path $root 'dry-backup')}
Assert ($dry.Skipped -eq 3 -and -not (Test-Path (Join-Path $root 'dry-backup'))) 'Public dry run writes neither policy nor backup.'
Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $prepared)) 'Audit/Plan/DryRun preserve the complete prepared snapshot.'
$apply=Public configure @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility');Auto=$true;BackupPath=(Join-Path $root 'configure-backup')}
Assert ($apply.ExitCode -eq 0 -and @($apply.Results|Where-Object Status -eq Applied).Count -eq 3) 'Public Configure applies three named values.'
$journal=@(Get-Content -LiteralPath (Join-Path $root 'configure-backup/before.jsonl')|ForEach-Object{$_|ConvertFrom-Json});Assert ($journal.Count -eq 3) 'Each native write has its own original journal.'
Assert ((ConvertTo-WelaPsLoggingKey $journal[0].Before) -ceq (ConvertTo-WelaPsLoggingKey $prepared)) 'First journal matches exact typed prepared state.'
$configured=Get-WelaPsLoggingSnapshot;Save 'configured.json' $configured
$again=Public repeat @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility');Auto=$true;BackupPath=(Join-Path $root 'repeat-backup')}
Assert (@($again.Results|Where-Object Status -eq AlreadyCompliant).Count -eq 3 -and -not (Test-Path (Join-Path $root 'repeat-backup/before.jsonl'))) 'Second Configure makes no native writes.'
Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $configured)) 'Repeated configuration preserves complete observed state.'
$nonce='WELA_PS_LOG_'+[guid]::NewGuid().ToString('N');$workerPath=Join-Path $root ('worker-'+[guid]::NewGuid().ToString('N')+'.ps1')
$workerText="Microsoft.PowerShell.Utility\Write-Output -InputObject '$nonce'`n"
[IO.File]::WriteAllText($workerPath,$workerText,[Text.UTF8Encoding]::new($false));Save 'worker-source.json' @{Path=$workerPath;Sha256=(Get-FileHash $workerPath -Algorithm SHA256).Hash;Text=$workerText;Nonce=$nonce}
$latest=Get-WinEvent -LogName 'Microsoft-Windows-PowerShell/Operational' -MaxEvents 1 -ErrorAction Stop;try{$watermark=[long]$latest.RecordId}finally{$latest.Dispose()}
$process=Child 'event-worker' $configured.Engine.Path @('-NoLogo','-NoProfile','-NonInteractive','-File',$workerPath)
Assert ($process.ExitCode -eq 0 -and $process.Output.Trim() -ceq $nonce) 'Fixed native Windows PowerShell child executed the benign marker.'
$selected=@{};$candidates=@();$timer=[Diagnostics.Stopwatch]::StartNew()
do{
$candidates=@(ReadEvents $process.Pid $watermark);$selected=@{}
foreach($xml in $candidates){
$settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=262144;$xmlReader=[Xml.XmlReader]::Create([IO.StringReader]::new($xml),$settings)
try{$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.PreserveWhitespace=$true;$doc.Load($xmlReader)}finally{$xmlReader.Dispose()};$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$system=$doc.SelectSingleNode('/e:Event/e:System',$ns);$data=@{}
foreach($node in $doc.SelectNodes('/e:Event/e:EventData/e:Data',$ns)){if($data.ContainsKey($node.GetAttribute('Name'))){throw 'Duplicate native event field.'};$data[$node.GetAttribute('Name')]=$node.InnerText}
$id=[int]$system.SelectSingleNode('e:EventID',$ns).InnerText
if($system.SelectSingleNode('e:Provider',$ns).GetAttribute('Name') -cne 'Microsoft-Windows-PowerShell' -or $system.SelectSingleNode('e:Provider',$ns).GetAttribute('Guid').Trim('{}') -ine 'a0c1853b-5c40-4b15-8766-3cf1c58f985a' -or [int]$system.SelectSingleNode('e:Execution',$ns).GetAttribute('ProcessID') -ne $process.Pid -or $system.SelectSingleNode('e:Channel',$ns).InnerText -cne 'Microsoft-Windows-PowerShell/Operational' -or $system.SelectSingleNode('e:Computer',$ns).InnerText -ine $env:COMPUTERNAME){continue}
$time=[DateTimeOffset]::Parse($system.SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime')).UtcDateTime;if($time -lt [DateTimeOffset]::Parse($process.StartedUtc).UtcDateTime -or $time -gt [DateTimeOffset]::Parse($process.ExitedUtc).UtcDateTime){continue}
if($system.SelectSingleNode('e:Security',$ns).GetAttribute('UserID') -cne $original.Operator.Sid){continue}
if($id -eq 4104 -and $data.ScriptBlockText -ceq $workerText -and $data.Path -ieq $workerPath -and $data.MessageNumber -ceq '1' -and $data.MessageTotal -ceq '1' -and $data.ScriptBlockId -match '^[0-9a-f-]{36}$'){$selected['4104']=@($selected['4104'])+ $xml}
if($id -eq 4103 -and $data.ContainsKey('Payload') -and $data.ContainsKey('ContextInfo') -and $data.Payload.Contains($nonce) -and $data.ContextInfo.Contains($workerPath)){$selected['4103']=@($selected['4103'])+ $xml}
}
if(@($selected['4103']|Where-Object {$_}).Count -eq 1 -and @($selected['4104']|Where-Object {$_}).Count -eq 1){break};Start-Sleep -Milliseconds 200
}while($timer.Elapsed.TotalSeconds -lt 15)
Save 'event-candidates.json' $candidates
foreach($id in @('4103','4104')){$events=@($selected[$id]|Where-Object {$_});Assert ($events.Count -eq 1) "Exactly one worker-attributed native $id event required; found $($events.Count).";[IO.File]::WriteAllText((Join-Path $root ('event-'+$id+'.xml')),$events[0],[Text.UTF8Encoding]::new($false))}
Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $configured)) 'Fresh event probe changes no policy or channel state.'
Assert ((Masks) -ceq $masks) 'All 59 audit masks remain unchanged.'
# Wrong-type selected value is refused by the public command without repairing it.
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null
try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging',$true);$key.SetValue('EnableScriptBlockLogging','1',[Microsoft.Win32.RegistryValueKind]::String)}finally{if($key){$key.Dispose()};$base.Dispose()}
$wrong=Get-WelaPsLoggingSnapshot;$refused=Public wrong-type @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('ScriptBlock');Auto=$true;BackupPath=(Join-Path $root 'wrong-backup')} 1
Assert ($refused.ExitCode -eq 1 -and -not (Test-Path (Join-Path $root 'wrong-backup'))) 'Wrong-type preflight refuses before backup or writes.'
Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $wrong)) 'Refusal preserves the typed wrong value.'
}catch{$failure=$_.ToString();Save 'failure.json' @{Error=$failure;Stack=$_.ScriptStackTrace}}
finally{
if($original -and $mutationStarted){
foreach($item in @(@('ScriptBlockLogging','EnableScriptBlockLogging'),@('ModuleLogging','EnableModuleLogging'),@('ModuleLogging\ModuleNames','Microsoft.PowerShell.Utility'))){try{RestoreValue $original.Machine $item[0] $item[1]}catch{$cleanupErrors+=$_.ToString()}}
try{RemoveCreatedKeys $original.Machine}catch{$cleanupErrors+=$_.ToString()}
try{$after=Get-WelaPsLoggingSnapshot;Save 'cleanup-after.json' $after;if((ConvertTo-WelaPsLoggingKey $after) -cne (ConvertTo-WelaPsLoggingKey $original)){$cleanupErrors+='Full policy/host/source/channel snapshot did not restore exactly.'};if($masks -and (Masks) -cne $masks){$cleanupErrors+='Audit masks changed.'}}catch{$cleanupErrors+=$_.ToString()}
}
Save 'cleanup.json' @{Status=$(if($cleanupErrors.Count){'Failed'}elseif($mutationStarted){'Restored'}else{'NotMutated'});Errors=$cleanupErrors;OriginalCaptured=[bool]$original;MutationStarted=$mutationStarted;All59MasksUnchanged=($masks -and (Masks) -ceq $masks)}
$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object{[pscustomobject]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}})
Save 'manifest.json' @{Kind='WelaPowerShellLoggingNativeFixture';Head=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$script:count;Failure=$failure;CleanupErrors=$cleanupErrors;ReadyRuleCredit=0;Artifacts=$artifacts}
}
if($failure -or $cleanupErrors.Count){throw "Native fixture failed: $failure Cleanup: $($cleanupErrors -join '; ')"}
Write-Host "PASS: $script:count public PowerShell policy/native4103/native4104 assertions with exact cleanup."
+19
View File
@@ -0,0 +1,19 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-cli-'+[guid]::NewGuid().ToString('N'))
$cases=@(
@{Args=@('process-commandline','-ProcessCommandlineAction','Configure','-DryRun','-Help');Code=0;Pattern='Enables only'},
@{Args=@('process-commandline','-Help');Code=0;Pattern='Enables only'},
@{Args=@('configure','-ProcessCommandlineAction','Configure');Code=1;Pattern='requires process-commandline'},
@{Args=@('process-commandline','-OutgoingNtlmMode','Deny');Code=1;Pattern='dedicated options'},
@{Args=@('process-commandline','-Role','Client');Code=1;Pattern='dedicated options'},
@{Args=@('process-commandline','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'},
@{Args=@('process-commandline','-Auto');Code=1;Pattern='options require'},
@{Args=@('process-commandline','-DryRun');Code=1;Pattern='options require'},
@{Args=@('process-commandline','-BackupPath',$root);Code=1;Pattern='options require'},
@{Args=@('process-commandline','-Help','-ProviderAction','Configure');Code=1;Pattern='dedicated options'},
@{Args=@('process-commandline','-ProcessCommandlineAction','Configure','-Typo');Code=1;Pattern='Unsupported trailing arguments'}
)
foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($code -eq 0) -ne ($case.Code -eq 0) -or ($text -join "`n") -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $($text -join ' ')"};$count++}
if(Test-Path $root){throw 'Refused CLI input created unexpected output.'}
Write-Host "PASS: $count scoped process command-line CLI guards."
exit 0
+60
View File
@@ -0,0 +1,60 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/ProcessCommandline.ps1')
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-test-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
$count=0;$sequence=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 12 -Compress}
function Reset($Value,$Type='DWord'){
$script:policy=[pscustomobject][ordered]@{KeyExists=$true;ValueExists=($null -ne $Value);Value=$Value;Type=$(if($null -ne $Value){$Type}else{$null})}
$script:unselected=[pscustomobject]@{Values=@();Children=@()};$script:writes=0;$script:reads=0;$script:failRead=$false;$script:failWrite=$false;$script:ignoreWrite=$false;$script:promptChange=$null;$script:onRead=$null
}
function Get-WelaProcessCommandlineSnapshot {
$script:reads++;if($script:onRead){& $script:onRead};if($script:failRead){throw 'Access denied'}
[pscustomobject][ordered]@{Host=[pscustomobject][ordered]@{Build=26100;ProductType=3;DomainRole=2;PartOfDomain=$false};Policy=($script:policy|ConvertTo-Json|ConvertFrom-Json);Unselected=($script:unselected|ConvertTo-Json -Depth 12|ConvertFrom-Json)}
}
function Get-WelaProcessCommandlinePrerequisite {[pscustomobject]@{State='SuccessMissing';Mask=0}}
function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction)
Assert ($LiteralPath -ceq 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit' -and $Name -ceq 'ProcessCreationIncludeCmdLine_Enabled' -and $Value -eq 1 -and $Type -ceq 'DWord') 'Only the one exact audit-only target may be written.'
$script:writes++;if($script:failWrite){throw 'Write denied'};if(-not $script:ignoreWrite){$script:policy.ValueExists=$true;$script:policy.Value=1;$script:policy.Type='DWord'}
}
function New-WelaRegistryKey {param($Path) Assert (-not $script:policy.KeyExists) 'Only an absent key may be created.';$script:policy.KeyExists=$true}
function Read-Host {param($Prompt) if($script:promptChange){& $script:promptChange};return 'Y'}
function Configure([switch]$DryRun,[switch]$Prompt){
$script:sequence++;$script:backup=Join-Path $root ('case-'+$script:sequence)
Invoke-WelaProcessCommandline -Action Configure -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath $script:backup
}
try{
foreach($initial in @($null,0,1)){
Reset $initial;$old=Key $script:policy;$r=Configure
Assert ($r.ExitCode -eq 0 -and $r.Scope -ceq 'process-commandline-policy-only' -and $r.ReadyRuleCredit -eq 0) 'Public report scopes success to one policy, without detection credit.'
Assert ($script:policy.Value -eq 1 -and $script:writes -eq $(if($initial -eq 1){0}else{1})) 'Absent/disabled are enabled; existing enabled policy is idempotent.'
if($initial -ne 1){$j=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json);Assert ($j.Count -eq 1 -and (Key $j[0].Before.Policy) -ceq $old) 'Typed original snapshot is durable before the one write.'}
else{Assert (-not(Test-Path (Join-Path $backup 'before.jsonl'))) 'Already configured mode does not journal a write.'}
}
foreach($value in @(2,42,'1')) {
Reset $value $(if($value -is [string]){'String'}else{'DWord'});$r=Configure
Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0 -and $r.Results[0].Status -ceq 'Failed') 'Unknown values/types remain untouched.'
}
Reset $null;$script:policy.KeyExists=$false;$r=Configure
Assert ($r.ExitCode -eq 0 -and $script:policy.KeyExists -and $script:writes -eq 1) 'Missing Audit key is created without replacing the parent.'
Reset 0;$r=Configure -DryRun;Assert ($script:writes -eq 0 -and $r.DryRun -and -not(Test-Path $backup)) 'Dry run has no policy or journal-directory mutation.'
Reset 0;$script:failRead=$true;$r=Configure;Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0) 'An unreadable policy fails closed.'
foreach($kind in @('failWrite','ignoreWrite')){
Reset 0;Set-Variable -Scope Script -Name $kind -Value $true;$r=Configure
Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Failed') 'Native failure and ignored-write readback cannot report success.'
}
foreach($changed in @(1,2,42)){
Reset 0;$script:changed=$changed;$script:promptChange={$script:policy.Value=$script:changed};$r=Configure -Prompt
Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0 -and $script:policy.Value -eq $changed) 'Prompt-time drift refuses writes after preserving the exact original receipt.'
}
Reset 0;$script:onRead={if($script:reads -eq 5){$script:policy.Value=0}};$r=Configure
Assert ($script:writes -eq 1 -and $r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Overridden') 'A later policy change fails final verification.'
Reset 0;$script:promptChange={$script:unselected.Values=@('new sibling')};$r=Configure -Prompt;Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0) 'Unrelated policy drift refuses mutation.'
Reset 0;$r=Invoke-WelaProcessCommandline -Action Plan;Assert ($r.Plan.Status -ceq 'ChangeRequired' -and $script:writes -eq 0) 'Plan is current-host assessment and does not mutate policy.'
foreach($action in @('Audit','Plan')){foreach($option in @('Auto','DryRun','BackupPath')){
$a=@{Action=$action};$a[$option]=$(if($option -eq 'BackupPath'){'unused'}else{$true});$threw=$false;try{Invoke-WelaProcessCommandline @a}catch{$threw=$true};Assert $threw 'Read-only actions reject mutation-only options.'
}}
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
Write-Host "PASS: $count scoped process command-line assertions."
exit 0
+115
View File
@@ -0,0 +1,115 @@
param([switch]$AllowDisposableAuditWrite)
$ErrorActionPreference='Stop'
if(-not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on a disposable GitHub-hosted Windows runner is required.'}
$repo=Split-Path $PSScriptRoot -Parent
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/ProcessCommandline.ps1')
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force
. (Join-Path $repo 'scripts/ControlApplicability.ps1')
. (Join-Path $repo 'scripts/NativeValidation.ps1')
$engine=(Get-Process -Id $PID).Path;$count=0;$failure=$null;$errors=@()
$root=Join-Path $env:RUNNER_TEMP ('wela-process-commandline-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
$path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit';$name='ProcessCreationIncludeCmdLine_Enabled'
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'}
function Other {
[pscustomobject][ordered]@{Unselected=(Get-WelaProcessCommandlineSnapshot).Unselected;SecurityChannel=Get-WelaNativeChannel Security;Services=@(Get-Service Winmgmt,EventLog,WinRM,Wecsvc|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;State=[string]$_.Status}})}
}
Add-Type -TypeDefinition @'
using System;using System.IO;using System.Text;using System.Threading.Tasks;
public static class WelaCommandlineFixturePipe {
public static async Task<string> Read(TextReader reader){var text=new StringBuilder();var buffer=new char[1024];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeds one Mi character bound.");text.Append(buffer,0,n);}}
}
'@
function Public([string]$Label,[string[]]$Arguments){
$all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $repo 'WELA.ps1'),'process-commandline')+$Arguments
foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}}
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
try{
if(-not $process.Start()){throw 'Public process did not start.'};$started=$true
$stdout=[WelaCommandlineFixturePipe]::Read($process.StandardOutput);$stderr=[WelaCommandlineFixturePipe]::Read($process.StandardError)
if(-not $process.WaitForExit(180000)){throw 'Public command exceeded three minutes.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public output drain timed out.'}
$output=$stdout.Result+"`n"+$stderr.Result
$output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8
Assert ($process.ExitCode -eq 0) "Public $Label exited $($process.ExitCode) : $output"
Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json
}finally{
if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.Exception.Message}};if(-not $exited){$script:errors+='Owned public process termination unconfirmed.'}}
$process.Dispose()
}
}
$original=Get-WelaProcessCommandlineSnapshot
$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy'
$precedence=Get-WelaRegistryState $precedencePath $precedenceName
$allMasks=Get-WelaEffectiveAuditPolicy;$masks=Masks;$guid='0cce922b-69ae-11d9-bed3-505054503030'
$other=Other;$touched=$false;$nativeEvents=0
Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain) 'Actual unjoined disposable Server required.'
Assert (-not $original.Policy.ValueExists -or ($original.Policy.Type -ceq 'DWord' -and $original.Policy.Value -in 0,1)) 'Unknown original values are preserved.'
Assert (-not $precedence.ValueExists -or ($precedence.Type -ceq 'DWord' -and $precedence.Value -in 0,1)) 'Unknown original precedence is preserved.'
Save 'original.json' @{Snapshot=$original;Unselected=$other;Masks=$masks;Precedence=$precedence;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();Commit=$env:GITHUB_SHA;Sources=@(foreach($file in @('WELA.ps1','scripts/ProcessCommandline.ps1','scripts/Configuration.ps1','scripts/NativeValidation.ps1','scripts/ControlApplicability.ps1','modules/AuditProfiles.psm1')){[pscustomobject]@{Name=$file;Sha256=(Get-FileHash (Join-Path $repo $file)).Hash.ToLowerInvariant()}})}
try {
# This owned fixture prepares only the independent prerequisites. The public command must preserve them.
$touched=$true
Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Value 1 -Type DWord -ErrorAction Stop
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 -Mode minimum
$preparedMasks=Masks;$preparedPrecedence=Get-WelaRegistryState $precedencePath $precedenceName
foreach ($case in @('absent','disabled')) {
if ((Get-WelaRegistryState $path $name).ValueExists) {Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}
if ($case -eq 'disabled') {$null=New-WelaRegistryKey $path;Set-ItemProperty -LiteralPath $path -Name $name -Value 0 -Type DWord -ErrorAction Stop}
$prepared=Get-WelaProcessCommandlineSnapshot
$plan=Public ($case+'-plan') @('-ProcessCommandlineAction','Plan','-ResultsPath',(Join-Path $root ($case+'-plan.json')))
Assert ($plan.Plan.Status -ceq 'ChangeRequired' -and (Key $plan.Plan.Before) -ceq (Key $prepared) -and $plan.Plan.Prerequisite.State -ceq 'SuccessEnabled') 'Public plan records exact typed state and separate success prerequisite.'
$dryBackup=Join-Path $root ($case+'-dry-backup')
$dry=Public ($case+'-dry') @('-ProcessCommandlineAction','Configure','-Auto','-DryRun','-BackupPath',$dryBackup,'-ResultsPath',(Join-Path $root ($case+'-dry.json')))
Assert ($dry.DryRun -and $dry.Results[0].Status -ceq 'Skipped' -and -not(Test-Path $dryBackup) -and (Key (Get-WelaProcessCommandlineSnapshot)) -ceq (Key $prepared)) 'Dry run changes no registry state and creates no backup directory.'
$backup=Join-Path $root ($case+'-backup')
$report=Public $case @('-ProcessCommandlineAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',(Join-Path $root ($case+'.json')))
$after=Get-WelaProcessCommandlineSnapshot
Assert ($report.Scope -ceq 'process-commandline-policy-only' -and $report.Results.Count -eq 1 -and $report.Results[0].Status -ceq 'Applied' -and $report.ReadyRuleCredit -eq 0) 'Public Configure applies exactly one policy with zero rule credit.'
Assert ($after.Policy.Type -ceq 'DWord' -and $after.Policy.Value -eq 1 -and (Key $report.Results[0].After) -ceq (Key $after)) 'Actual DWORD readback matches the public result.'
$journal=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json)
Assert ($journal.Count -eq 1 -and (Key $journal[0].Before) -ceq (Key $prepared)) 'Original journal retains exact typed prior state.'
$repeat=Public ($case+'-repeat') @('-ProcessCommandlineAction','Configure','-Auto','-BackupPath',(Join-Path $root ($case+'-repeat-backup')),'-ResultsPath',(Join-Path $root ($case+'-repeat.json')))
Assert ($repeat.Results[0].Status -ceq 'AlreadyCompliant') 'Repeat is idempotent.'
Assert ((Masks) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq (Key $preparedPrecedence) -and (Key (Other)) -ceq (Key $other)) 'Public command preserves all59 masks, typed precedence, other values, Security channel and services.'
$destination=Join-Path $root ($case+'-4688')
$event=Invoke-WelaNativeValidation -Action Run -OutputPath $destination -TimeoutSeconds 30
Save ($case+'-4688-report.json') $event
Assert ($event.ExitCode -eq 0 -and $event.Status -ceq 'NativeEventObserved' -and $event.ReadyRuleCredit -eq 0) 'The separate fixed native probe observes an actual attributed4688.'
$xml=[IO.File]::ReadAllText((Join-Path $destination 'event.xml'))
Assert (Test-WelaProbeEvent $xml $event.Process $event.BeforeState ([DateTime]::UtcNow)) 'Exact native process IDs, executable, command line, provider, host and interval match.'
foreach ($artifact in $event.Artifacts) {Assert ((Get-FileHash -LiteralPath (Join-Path $destination $artifact.path)).Hash.ToLowerInvariant() -ceq $artifact.sha256) 'Probe artifact hash matches.'}
$nativeEvents++
}
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact
$missing=Public 'missing-prerequisite' @('-ProcessCommandlineAction','Audit','-ResultsPath',(Join-Path $root 'missing-prerequisite.json'))
Assert ($missing.Plan.Status -ceq 'AlreadyCompliant' -and $missing.Plan.Prerequisite.State -ceq 'SuccessMissing' -and $missing.ReadyRuleCredit -eq 0) 'An enabled DWORD never hides the separate missing Process Creation prerequisite.'
Save 'completed.json' @{Status='Passed';Assertions=$count;NativeWrites=2;Exact4688=$nativeEvents;Scope='Actual standalone Server only; no Windows11/DC/CA, forwarding, backend or complete-rule credit.'}
} catch {$failure=$_.ToString();throw} finally {
if ($touched) {
foreach ($c in @(@($path,$name,$original.Policy),@($precedencePath,$precedenceName,$precedence))) {
try {
if ((Get-WelaRegistryState $c[0] $c[1]).ValueExists) {Remove-ItemProperty -LiteralPath $c[0] -Name $c[1] -ErrorAction Stop}
if ($c[2].ValueExists) {$null=New-ItemProperty -LiteralPath $c[0] -Name $c[1] -Value $c[2].Value -PropertyType $c[2].Type -ErrorAction Stop}
if (-not $c[2].KeyExists -and (Test-Path -LiteralPath $c[0])) {
$k=Get-Item -LiteralPath $c[0];if($k.ValueCount -or $k.SubKeyCount){throw 'New policy key contains unrelated data; refusing deletion.'}
Remove-Item -LiteralPath $c[0] -ErrorAction Stop
}
} catch {$errors+=$_.ToString()}
}
try {Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $allMasks[$guid] -Mode exact}catch{$errors+=$_.ToString()}
}
$checks=[ordered]@{}
foreach($pair in @(@('Policy',{(Key (Get-WelaProcessCommandlineSnapshot)) -ceq (Key $original)}),@('Unselected',{(Key (Other)) -ceq (Key $other)}),@('All59Masks',{(Masks) -ceq $masks}),@('Precedence',{(Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq (Key $precedence)}))) {try{$checks[$pair[0]]=& $pair[1]}catch{$checks[$pair[0]]=$false;$errors+=$_.ToString()}}
$complete=$errors.Count -eq 0 -and @($checks.Values|Where-Object {-not $_}).Count -eq 0
Save 'cleanup.json' @{Complete=$complete;Checks=$checks;Errors=$errors;Failure=$failure;Assertions=$count}
Save 'artifact-hashes.json' @(Get-ChildItem -LiteralPath $root -Recurse -File|Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object{[pscustomobject]@{Name=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName).Hash.ToLowerInvariant()}})
if(-not $complete){throw 'Process command-line native fixture cleanup failed.'}
}
Write-Host "PASS: $count native command-line assertions, $nativeEvents exact4688 records and exact cleanup."
exit 0