mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 07:15:25 +02:00
Integrate current dev with intended-reader EVTX verification
This commit is contained in:
commit
4598bb36f7
175 files changed
+11391
-70
No files matched your search
@@ -0,0 +1,25 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
$engine=Join-Path $PSHOME $(if($PSEdition -eq 'Core'){if($env:OS -eq 'Windows_NT'){'pwsh.exe'}else{'pwsh'}}else{'powershell.exe'})
|
||||
$count=0
|
||||
function Check-Command([string]$Arguments,[int]$ExpectedExit,[string]$Pattern) {
|
||||
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments='-NoProfile -File "'+(Join-Path $repo 'WELA.ps1')+'" '+$Arguments;$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
|
||||
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
|
||||
try {
|
||||
$started=$process.Start();$out=$process.StandardOutput.ReadToEndAsync();$err=$process.StandardError.ReadToEndAsync()
|
||||
if(-not $process.WaitForExit(30000)){throw 'CLI timeout'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($out,$err),5000)){throw 'CLI output timeout'}
|
||||
$text=$out.GetAwaiter().GetResult()+$err.GetAwaiter().GetResult()
|
||||
if(($process.ExitCode -eq 0) -ne ($ExpectedExit -eq 0) -or $text -notmatch $Pattern){throw "CLI mismatch: $Arguments ; Exit=$($process.ExitCode) ; $text"};$script:count++
|
||||
}finally{if($started -and -not $process.HasExited){$process.Kill();$null=$process.WaitForExit(5000)};$process.Dispose()}
|
||||
}
|
||||
Check-Command 'applocker-script-probe -Help' 0 'existing Script AuditOnly'
|
||||
Check-Command 'help' 0 'applocker-script-probe'
|
||||
Check-Command 'version -AppLockerScriptAction Run' 1 'AppLockerScript options require'
|
||||
Check-Command 'applocker-script-probe -AppLockerScriptAction Run -WhatIf' 1 'only its dedicated'
|
||||
Check-Command 'applocker-script-probe -AppLockerScriptAction Run -DryRun' 1 'only its dedicated'
|
||||
Check-Command 'applocker-script-probe -Auto' 1 'only its dedicated'
|
||||
Check-Command 'applocker-script-probe -AppLockerProbeAction Run' 1 'only its dedicated'
|
||||
Check-Command 'applocker-script-probe -AppLockerScriptAction Run' 1 'Run requires'
|
||||
Check-Command 'applocker-script-probe -AppLockerScriptAction Plan -AppLockerScriptOutputPath ignored' 1 'Run requires'
|
||||
Write-Host "AppLocker Script public CLI fixtures passed: $count checks."
|
||||
@@ -0,0 +1,88 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
. "$repo/scripts/AppLockerReadiness.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/AppLockerScriptProbe.ps1"
|
||||
# Mocking Get-Service skips the cmdlet's normal .NET Framework assembly load.
|
||||
if(-not ('System.ServiceProcess.ServiceControllerStatus' -as [type])){Add-Type -AssemblyName System.ServiceProcess -ErrorAction Stop}
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"}
|
||||
# Prove stopped service refusal happens before the actual state reader reaches CIM.
|
||||
$script:missingService='';$script:scm=@();$script:cimCalls=0
|
||||
function Get-Service {param($Name);$script:scm+=$Name;[pscustomobject]@{Name=$Name;Status=$(if($Name -ceq $script:missingService){[ServiceProcess.ServiceControllerStatus]::Stopped}else{[ServiceProcess.ServiceControllerStatus]::Running})}}
|
||||
function Get-WelaAppLockerHost {$script:cimCalls++;throw 'CIM boundary reached after SCM checks'}
|
||||
foreach($name in @('Winmgmt','EventLog','AppIDSvc')){
|
||||
$script:missingService=$name;$script:scm=@();$script:cimCalls=0
|
||||
Reject {Get-WelaAppLockerScriptState} ($name+' must already be running')
|
||||
Assert ($script:cimCalls -eq 0) 'Stopped service refusal precedes all CIM observations'
|
||||
}
|
||||
$script:missingService='';$script:scm=@();$script:cimCalls=0
|
||||
Reject {Get-WelaAppLockerScriptState} 'CIM boundary reached after SCM checks'
|
||||
Assert (($script:scm -join ',') -ceq 'Winmgmt,EventLog,AppIDSvc' -and $script:cimCalls -eq 1) 'All direct SCM checks precede the first CIM observation'
|
||||
$policy='<AppLockerPolicy Version="1"><RuleCollection Type="Script" EnforcementMode="AuditOnly"><FilePathRule Id="12345678-1234-1234-1234-123456789abc" Name="Windows" UserOrGroupSid="S-1-1-0" Action="Allow"><Conditions><FilePathCondition Path="%WINDIR%\*" /></Conditions></FilePathRule></RuleCollection></AppLockerPolicy>'
|
||||
$state=[pscustomobject]@{Services=@([pscustomobject]@{Name='Winmgmt';Status='Running'},[pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='AppIDSvc';Status='Running'});Host=[pscustomobject]@{Status='Candidate';Is64BitProcess=$true;PartOfDomain=$false;ProductType=3;Build=20348};MachineGuid='11111111-1111-1111-1111-111111111111';Management=[pscustomobject]@{Status='Observed'};Computer='TEST';Domain='WORKGROUP';Reader=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1000'};EffectivePolicy=[pscustomobject]@{Status='Observed';Policy=(ConvertFrom-WelaAppLockerXml $policy)};Service=[pscustomobject]@{Status='Observed';State='Running';StartMode='Manual'};Channel=[pscustomobject]@{Name='Microsoft-Windows-AppLocker/MSI and Script';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)'};Source='C:\Windows\System32\cmd.ps1';SourceHash=('a'*64)}
|
||||
$state|Add-Member NoteProperty LocalPolicy ($state.EffectivePolicy|ConvertTo-Json -Depth 20|ConvertFrom-Json)
|
||||
$null=Get-WelaAppLockerScriptStateKey $state;Assert $true 'Valid audit-only prereqs'
|
||||
foreach($parent in @('Host','LocalPolicy','EffectivePolicy','Service','Management')){
|
||||
$saved=$state.$parent.Status;$state.$parent.Status=$true
|
||||
Reject {Get-WelaAppLockerScriptStateKey $state} 'typed string'
|
||||
$state.$parent.Status=$saved
|
||||
}
|
||||
$state.Services[0].Status=$true;Reject {Get-WelaAppLockerScriptStateKey $state} 'preflight';$state.Services[0].Status='Running'
|
||||
|
||||
foreach($mode in @('Enabled','NotConfigured')){$state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml ($policy.Replace('AuditOnly',$mode));Reject {Get-WelaAppLockerScriptStateKey $state} 'AuditOnly'}
|
||||
$state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml $policy
|
||||
$state.Service.State='Stopped';Reject {Get-WelaAppLockerScriptStateKey $state} 'already be running';$state.Service.State='Running'
|
||||
$state.Channel.Enabled=$false;Reject {Get-WelaAppLockerScriptStateKey $state} 'enabled';$state.Channel.Enabled=$true
|
||||
$process=[pscustomobject]@{ScriptPath='C:\Temp\wela-owned.ps1';ProcessId=1234;UserSid=$state.Reader.Sid;StartedUtc='2026-09-01T00:00:00.0000000Z';CompletedUtc='2026-09-01T00:00:02.0000000Z'}
|
||||
$event='<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-AppLocker" Guid="{cbda4dbf-8d5d-4f69-9578-be14aa540d22}"/><EventID>8006</EventID><Version>0</Version><EventRecordID>42</EventRecordID><TimeCreated SystemTime="2026-09-01T00:00:01.0000000Z"/><Channel>Microsoft-Windows-AppLocker/MSI and Script</Channel><Computer>TEST</Computer></System><UserData><RuleAndFileData xmlns="http://schemas.microsoft.com/schemas/event/Microsoft.Windows/1.0.0.0"><PolicyName>SCRIPT</PolicyName><TargetUser>S-1-5-21-1-2-3-1000</TargetUser><TargetProcessId>1234</TargetProcessId><FilePath>C:\Temp\wela-owned.ps1</FilePath></RuleAndFileData></UserData></Event>'
|
||||
$end=[long]41
|
||||
Assert (Test-WelaAppLockerScriptEvent $event $process $state $end) 'Exact fixture must match'
|
||||
Assert (Test-WelaAppLockerScriptEvent ($event.Replace('8006','8005')) $process $state $end) 'Allowed event matches but has distinct EventId'
|
||||
$mutations=@(@('8006','8007'),@('1234','1235'),@('S-1-5-21-1-2-3-1000','S-1-5-21-1-2-3-1001'),@('C:\Temp\wela-owned.ps1','C:\Temp\other.ps1'),@('<PolicyName>SCRIPT','<PolicyName>DLL'),@('<Computer>TEST','<Computer>OTHER'),@('cbda4dbf','abda4dbf'),@('<Version>0','<Version>1'),@('00:00:01.0000000Z','00:00:03.0000000Z'),@('</System>','<EventID>8006</EventID></System>'),@('</RuleAndFileData>','<TargetUser>S-1-1-0</TargetUser></RuleAndFileData>'))
|
||||
foreach($pair in $mutations){$bad=$event.Replace($pair[0],$pair[1]);Assert ($bad -cne $event) 'Mutation changed fixture';Assert (-not(Test-WelaAppLockerScriptEvent $bad $process $state $end)) ('Reject '+$pair[0])}
|
||||
Assert (-not(Test-WelaAppLockerScriptEvent ('<!DOCTYPE Event [<!ENTITY x SYSTEM "file:///etc/passwd">]>'+$event) $process $state $end)) 'DTD rejected'
|
||||
Reject {Invoke-WelaAppLockerScriptProbe -Action Run} 'requires'
|
||||
Reject {Invoke-WelaAppLockerScriptProbe -Action Plan -OutputPath ignored} 'requires'
|
||||
|
||||
Assert (-not(Test-WelaAppLockerScriptEvent $event $process $state 42)) 'Previously observed record is rejected'
|
||||
Assert (-not(Test-WelaAppLockerScriptEvent ($event.Replace('00:00:01.0000000Z','00:00:02.0000001Z')) $process $state $end)) 'A 100ns late record is rejected without clock padding'
|
||||
Assert (Test-WelaAppLockerScriptEvent ($event.Replace('00:00:01.0000000Z','00:00:00.0000000Z')) $process $state $end) 'Exact inclusive start is accepted'
|
||||
Assert (Test-WelaAppLockerScriptEvent ($event.Replace('00:00:01.0000000Z','00:00:02.0000000Z')) $process $state $end) 'Exact inclusive completion is accepted'
|
||||
$worker=[IO.File]::ReadAllText("$repo/scripts/AppLockerScriptWorker.ps1")
|
||||
$text=New-WelaAppLockerScriptText $worker ('a'*32)
|
||||
Assert ($text -notlike '*__WELA_SCRIPT_NONCE__*') 'All three fixed nonce placeholders replaced'
|
||||
Reject {New-WelaAppLockerScriptText $worker ('a'*31+"'" )} 'nonce'
|
||||
Reject {New-WelaAppLockerScriptText ($worker+'__WELA_SCRIPT_NONCE__') ('a'*32)} 'template'
|
||||
$tokens=$null;$errors=$null;$null=[Management.Automation.Language.Parser]::ParseInput($text,[ref]$tokens,[ref]$errors)
|
||||
Assert (-not $errors.Count) 'Generated worker parses'
|
||||
# Use the production orchestration with mocked native read/launch boundaries.
|
||||
# These fixtures never stand in for actual native success; the Windows matrix does that.
|
||||
$script:ScriptRoot=$repo;$script:scenario='ok';$script:reads=0;$script:state=$state;$script:event=$event
|
||||
$script:token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1000';AuthenticationId='0x123';Groups=@();Privileges=@();TokenId='0x456';ModifiedId='0x789'}
|
||||
function Initialize-WelaAppLockerScriptNative {}
|
||||
function Get-WelaAppLockerScriptReader {if($script:scenario -eq 'reader-drift' -and $script:reads -gt 2){$script:token.ModifiedId='0xabc'};$script:token|ConvertTo-Json -Depth 8|ConvertFrom-Json}
|
||||
function Get-WelaAppLockerScriptUtcNow {([DateTimeOffset]::Parse('2026-09-01T00:00:00Z')).UtcDateTime}
|
||||
function Get-WelaAppLockerScriptState {$script:reads++;if($script:scenario -eq 'drift' -and $script:reads -gt 2){$script:state.Service.StartMode='Auto'};$script:state|ConvertTo-Json -Depth 20|ConvertFrom-Json}
|
||||
function Read-WelaAppLockerScriptBoundary {41}
|
||||
function Start-WelaAppLockerScriptProcess {
|
||||
param($Root,$State,$Reader,$SourcesKey)
|
||||
if($script:scenario -eq 'reader-drift'){$script:token.ModifiedId='0xabc'}
|
||||
$artifact=Write-WelaArrivalArtifact $Root 'fixed.ps1' 'fixed'
|
||||
[pscustomobject]@{ScriptPath=(Join-Path $Root 'fixed.ps1');ScriptSha256=$artifact.Sha256;ScriptArtifact=$artifact;Nonce=('a'*32)}
|
||||
}
|
||||
function Read-WelaAppLockerScriptEvents {param($Boundary);if($script:scenario -eq 'denied'){throw [UnauthorizedAccessException]::new('Native query denied')};[pscustomobject]@{Xml=if($script:scenario -eq 'duplicate'){@($script:event,$script:event)}elseif($script:scenario -eq 'absent'){@()}else{@($script:event)};Complete=($script:scenario -ne 'cap')}}
|
||||
function Test-WelaAppLockerScriptEvent {$true}
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-script-test-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
try {
|
||||
foreach($scenario in @('ok','duplicate','drift','cap','denied','absent','reader-drift')){
|
||||
$script:scenario=$scenario;$script:reads=0;$state.Service.StartMode='Manual';$script:token.ModifiedId='0x789'
|
||||
$result=Invoke-WelaAppLockerScriptProbe Run (Join-Path $root $scenario) 1
|
||||
Assert ($result.ReadyRuleCredit -eq 0 -and $result.PolicyChanges -eq 0) 'No readiness or configuration credit'
|
||||
Assert (($result.ExitCode -eq 0) -eq ($scenario -eq 'ok')) "Expected outcome $scenario : $($result.Diagnostic)"
|
||||
Assert (($result.Status -ceq 'NativeScriptEventObserved') -eq ($scenario -eq 'ok')) 'Only complete success receives observed status'
|
||||
Assert (Test-Path (Join-Path $result.OutputPath 'manifest.json')) 'Success/failure manifest retained'
|
||||
}
|
||||
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
Write-Host "AppLocker Script fixtures passed: $count assertions."
|
||||
@@ -0,0 +1,134 @@
|
||||
param([switch]$AllowDisposablePolicyWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($env:OS -ne 'Windows_NT'){Write-Host 'Skipped: Windows required.';exit 0}
|
||||
if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted policy-write opt-in required.'}
|
||||
function Refresh-DisposableComputerPolicy {
|
||||
$info=New-Object Diagnostics.ProcessStartInfo
|
||||
$info.FileName=Join-Path ([Environment]::SystemDirectory) 'gpupdate.exe';$info.Arguments='/target:computer /force /wait:30';$info.UseShellExecute=$false
|
||||
$process=[Diagnostics.Process]::Start($info)
|
||||
try {if(-not $process.WaitForExit(60000)){$process.Kill();throw 'Disposable computer policy refresh exceeded 60 seconds.'};if($process.ExitCode -ne 0){throw ('Disposable computer policy refresh failed: '+$process.ExitCode)}} finally {$process.Dispose()}
|
||||
}
|
||||
function Stop-DisposablePolicyConverter {
|
||||
$task=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop
|
||||
if($task.State -in @('Running','Queued')) {Stop-ScheduledTask -InputObject $task -ErrorAction Stop}
|
||||
$deadline=[DateTime]::UtcNow.AddSeconds(15)
|
||||
do {$task=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop;if($task.State -in @('Ready','Disabled')){return};Start-Sleep -Milliseconds 200}while([DateTime]::UtcNow -lt $deadline)
|
||||
throw 'The verified borrowed PolicyConverter task did not become idle.'
|
||||
}
|
||||
function Run-DisposablePolicyConverter {
|
||||
# The Task Scheduler CIM provider can retain stale LastRunTime on Server2022.
|
||||
# Follow the actual COM Run instance and native completion state instead.
|
||||
$scheduler=$null;$folder=$null;$registered=$null;$instance=$null;$running=$null;$definition=$null;$settings=$null
|
||||
try {
|
||||
$scheduler=New-Object -ComObject 'Schedule.Service';$scheduler.Connect()
|
||||
$folder=$scheduler.GetFolder('\Microsoft\Windows\AppID');$registered=$folder.GetTask('PolicyConverter')
|
||||
$definition=$registered.Definition;$settings=$definition.Settings
|
||||
if(-not $settings.AllowDemandStart){throw 'The verified PolicyConverter task does not allow an on-demand invocation.'}
|
||||
$running=$registered.GetInstances(0)
|
||||
if($running.Count -ne 0 -or $registered.State -ne 3){throw 'The verified borrowed PolicyConverter task must be idle before invocation.'}
|
||||
$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($running);$running=$null
|
||||
$instance=$registered.Run($null)
|
||||
if($null -eq $instance -or [string]::IsNullOrWhiteSpace($instance.InstanceGuid)){throw 'Native PolicyConverter did not return a task instance identity.'}
|
||||
$instanceId=[string]$instance.InstanceGuid;$deadline=[DateTime]::UtcNow.AddSeconds(30)
|
||||
do {
|
||||
$running=$registered.GetInstances(0)
|
||||
try {$idle=$running.Count -eq 0 -and $registered.State -eq 3}finally{$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($running);$running=$null}
|
||||
if($idle){if($registered.LastTaskResult -ne 0){throw ('Native policy conversion failed: '+$registered.LastTaskResult)};Write-Host ('Native PolicyConverter instance completed: '+$instanceId);return}
|
||||
Start-Sleep -Milliseconds 200
|
||||
}while([DateTime]::UtcNow -lt $deadline)
|
||||
Stop-DisposablePolicyConverter
|
||||
throw 'The owned native PolicyConverter instance did not complete within thirty seconds.'
|
||||
}finally{foreach($item in @($running,$instance,$settings,$definition,$registered,$folder,$scheduler)){if($null -ne $item -and [Runtime.InteropServices.Marshal]::IsComObject($item)){$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($item)}}}
|
||||
}
|
||||
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/AppLockerReadiness.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/AppLockerScriptProbe.ps1"
|
||||
$script:ScriptRoot=$repo
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-applocker-script-native-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
Write-Host ('Native fixture process session: '+[Diagnostics.Process]::GetCurrentProcess().SessionId)
|
||||
$converter=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop
|
||||
$converterBefore=Export-ScheduledTask -InputObject $converter -ErrorAction Stop
|
||||
$converterDisabled=$converter.State -eq 'Disabled';$converterChanged=$false
|
||||
$actions=@($converter.Actions)
|
||||
if($converter.State -notin @('Disabled','Ready') -or $actions.Count -ne 1 -or [Environment]::ExpandEnvironmentVariables($actions[0].Execute).Trim('"') -ine (Join-Path ([Environment]::SystemDirectory) 'appidpolicyconverter.exe') -or $actions[0].Arguments){throw ('Only the unchanged native PolicyConverter action is permitted: '+($actions|ConvertTo-Json -Depth 8))}
|
||||
$before=Get-WelaAppLockerReadiness
|
||||
if($before.Host.PartOfDomain -or $before.Management.Status -ne 'Observed' -or $before.LocalPolicy.Status -ne 'Observed' -or $before.EffectiveGpPolicy.Status -ne 'Observed' -or $before.LocalPolicy.Policy.TotalRules -ne 0 -or $before.EffectiveGpPolicy.Policy.TotalRules -ne 0 -or $before.LocalPolicy.Policy.HasUnknownPolicyData -or $before.EffectiveGpPolicy.Policy.HasUnknownPolicyData){Write-Host ($before | ConvertTo-Json -Depth 16);throw 'Disposable test requires empty, understood local/effective policies on a non-domain disposable host.'}
|
||||
$backup=Join-Path $root 'policy-before.xml';[IO.File]::WriteAllText($backup,$before.LocalPolicy.Policy.Xml)
|
||||
[IO.File]::WriteAllText((Join-Path $root 'prerequisites-before.json'),($before | ConvertTo-Json -Depth 16))
|
||||
$fixture='<AppLockerPolicy Version="1"><RuleCollection Type="Script" EnforcementMode="AuditOnly"><FilePathRule Id="12345678-1234-1234-1234-123456789abc" Name="Disposable Windows path only" Description="Owned native event fixture" UserOrGroupSid="S-1-1-0" Action="Allow"><Conditions><FilePathCondition Path="%WINDIR%\*" /></Conditions></FilePathRule></RuleCollection></AppLockerPolicy>'
|
||||
$policyPath=Join-Path $root 'fixture.xml';[IO.File]::WriteAllText($policyPath,$fixture)
|
||||
$log=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script');$enabled=$log.IsEnabled;$touched=$false;$cleanup=@();$primary=$null
|
||||
try {
|
||||
$touched=$true
|
||||
# Test-only preparation under explicit disposable-host and empty-GP gates.
|
||||
# Hosted images contain enrollment/provider keys: preserve them and CSP Unknown.
|
||||
# The production importer must continue to reject those observations.
|
||||
$preparedUtc=[DateTime]::UtcNow
|
||||
Set-AppLockerPolicy -XmlPolicy $policyPath -ErrorAction Stop
|
||||
if($before.Service.StartMode -eq 'Disabled'){throw 'Test will not change protected AppIDSvc startup mode.'}
|
||||
if($before.Service.State -ne 'Running'){Start-Service AppIDSvc -ErrorAction Stop}
|
||||
$log.IsEnabled=$true;$log.SaveChanges()
|
||||
if($converterDisabled){$converterChanged=$true;$null=Enable-ScheduledTask -InputObject $converter -ErrorAction Stop}
|
||||
Refresh-DisposableComputerPolicy
|
||||
Run-DisposablePolicyConverter
|
||||
$applied=$false;$applyDeadline=[DateTime]::UtcNow.AddSeconds(30)
|
||||
do {
|
||||
$records=@()
|
||||
try {try{$records=@(Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-AppLocker/EXE and DLL';Id=8001;StartTime=$preparedUtc} -MaxEvents 10 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}};$applied=$records.Count -gt 0} finally {foreach($record in $records){$record.Dispose()}}
|
||||
if($applied){break};Start-Sleep -Milliseconds 250
|
||||
} while([DateTime]::UtcNow -lt $applyDeadline)
|
||||
if(-not $applied){throw 'No native 8001 policy-applied event after disposable GP refresh.'}
|
||||
Write-Host 'Native 8001 policy-applied evidence observed after disposable GP refresh.'
|
||||
# Wait for actual effective audit-only policy, without treating elapsed time as success.
|
||||
$deadline=[DateTime]::UtcNow.AddSeconds(30)
|
||||
do {$state=Get-WelaAppLockerScriptState;$ready=$false;try{$null=Get-WelaAppLockerScriptStateKey $state;$ready=$true}catch{};if($ready){break};Start-Sleep -Milliseconds 500}while([DateTime]::UtcNow -lt $deadline)
|
||||
foreach($decision in @('WouldBlock','Allowed')) {
|
||||
if($decision -eq 'Allowed'){
|
||||
[IO.File]::WriteAllText($policyPath,$fixture.Replace('%WINDIR%\*','*'))
|
||||
Set-AppLockerPolicy -XmlPolicy $policyPath -ErrorAction Stop
|
||||
Refresh-DisposableComputerPolicy;Run-DisposablePolicyConverter
|
||||
$expected=ConvertFrom-WelaAppLockerXml ([IO.File]::ReadAllText($policyPath))
|
||||
$actual=Get-WelaAppLockerPolicySnapshot Effective
|
||||
if($actual.Status -ne 'Observed' -or (Get-WelaAppLockerXmlKey $actual.Policy.Xml) -cne (Get-WelaAppLockerXmlKey $expected.Xml)){throw 'Actual allowed Script policy differs from the disposable fixture.'}
|
||||
}
|
||||
$probe=& "$repo/WELA.ps1" applocker-script-probe -AppLockerScriptAction Run -AppLockerScriptOutputPath (Join-Path $root ('evidence-'+$decision)) -AppLockerScriptTimeoutSeconds 30
|
||||
$expectedId=if($decision -eq 'Allowed'){8005}else{8006}
|
||||
if($probe.ExitCode -or $probe.Status -cne 'NativeScriptEventObserved' -or $probe.EventId -ne $expectedId){throw ($probe|ConvertTo-Json -Depth 32)}
|
||||
if($probe.ReadyRuleCredit -ne 0 -or $probe.PolicyChanges -ne 0){throw 'Unsupported policy/credit claim.'}
|
||||
foreach($artifact in $probe.Artifacts){if((Get-FileHash (Join-Path $probe.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Artifact hash mismatch'}}
|
||||
Write-Host "Native AppLocker $expectedId observed via public CLI under PowerShell $($PSVersionTable.PSVersion), build $($probe.Before.Host.Build). Zero Sigma credit."
|
||||
}
|
||||
|
||||
} catch {
|
||||
$primary=$_;Write-Host $_
|
||||
Get-ChildItem -LiteralPath $root -Recurse -Filter 'candidate-*.xml'|ForEach-Object {Write-Host ([IO.File]::ReadAllText($_.FullName))}
|
||||
# Read-only diagnostic independent of the production XPath filter and parser.
|
||||
try {
|
||||
$recent=@(Get-WinEvent -LogName 'Microsoft-Windows-AppLocker/MSI and Script' -MaxEvents 12 -ErrorAction Stop)
|
||||
try {foreach($record in $recent){Write-Host ('Recent native channel XML: '+$record.ToXml())}} finally {foreach($record in $recent){$record.Dispose()}}
|
||||
} catch {Write-Host ('Recent native channel read: '+$_.Exception.Message)}
|
||||
Get-CimInstance Win32_SystemDriver -Filter "Name='AppID'" | Select-Object Name,State,StartMode | ConvertTo-Json | Write-Host
|
||||
try {Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -ErrorAction Stop | Select-Object TaskName,State | ConvertTo-Json | Write-Host}catch{Write-Host ('AppID task read: '+$_.Exception.Message)}
|
||||
try {$nativeLog=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script');try{$nativeLog | Select-Object IsEnabled,LogType,ProviderLevel,ProviderKeywords,LogIsolation | ConvertTo-Json | Write-Host}finally{$nativeLog.Dispose()}}catch{Write-Host ('Channel metadata read: '+$_.Exception.Message)}
|
||||
Write-Host ((Get-WelaAppLockerPolicySnapshot Effective) | ConvertTo-Json -Depth 12)
|
||||
}
|
||||
finally {
|
||||
if($touched){
|
||||
try {Stop-DisposablePolicyConverter}catch{$cleanup+=$_.Exception.Message}
|
||||
try {Set-AppLockerPolicy -XmlPolicy $backup -ErrorAction Stop;Refresh-DisposableComputerPolicy;if($converterChanged -or -not $converterDisabled){Run-DisposablePolicyConverter};$restored=Get-WelaAppLockerPolicySnapshot Local;if($restored.Status -ne 'Observed' -or (Get-WelaAppLockerXmlKey $restored.Policy.Xml) -cne (Get-WelaAppLockerXmlKey $before.LocalPolicy.Policy.Xml)){throw 'Local policy restoration differs'};$effectiveRestored=Get-WelaAppLockerPolicySnapshot Effective;if($effectiveRestored.Status -ne 'Observed' -or (Get-WelaAppLockerXmlKey $effectiveRestored.Policy.Xml) -cne (Get-WelaAppLockerXmlKey $before.EffectiveGpPolicy.Policy.Xml)){throw 'Effective GP policy restoration differs'}}catch{$cleanup+=$_.Exception.Message}
|
||||
try {Stop-DisposablePolicyConverter;if($converterChanged){$null=Disable-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop};$taskAfter=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop;if($taskAfter.State -ne $(if($converterDisabled){'Disabled'}else{'Ready'}) -or (Export-ScheduledTask -InputObject $taskAfter -ErrorAction Stop) -cne $converterBefore){throw 'Native PolicyConverter task definition was not restored'}}catch{$cleanup+=$_.Exception.Message}
|
||||
try {$log.IsEnabled=$enabled;$log.SaveChanges();$verify=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($log.LogName);try{if($verify.IsEnabled -ne $enabled){throw 'Channel restoration differs'}}finally{$verify.Dispose()}}catch{$cleanup+=$_.Exception.Message}
|
||||
if($before.Service.State -ne 'Running') {try {Stop-Service AppIDSvc -ErrorAction Stop}catch{Write-Host 'Protected AppIDSvc could not stop; startup mode was untouched. The disposable hosted VM is discarded after this job.'}}
|
||||
$afterService=Get-WelaAppLockerService;if($afterService.StartMode -ne $before.Service.StartMode){$cleanup+='AppIDSvc startup mode changed'}
|
||||
}
|
||||
$log.Dispose()
|
||||
}
|
||||
if($cleanup.Count){throw ('Native cleanup failed: '+($cleanup -join '; '))}
|
||||
if($primary){throw $primary}
|
||||
$cleanupReceipt=[pscustomobject]@{Head=$env:GITHUB_SHA;Engine=[string]$PSVersionTable.PSVersion;PolicyRestored=$true;ChannelRestored=$true;TaskRestored=$true;ServiceBefore=$before.Service;ServiceAfter=(Get-WelaAppLockerService);ServiceStateRestored=($before.Service.State -ceq (Get-WelaAppLockerService).State);ServiceStartupPreserved=($before.Service.StartMode -ceq (Get-WelaAppLockerService).StartMode);ProtectedServiceBoundary='If AppIDSvc refuses Stop, its running state is left for disposable VM teardown; no full service-state rollback claim.'}
|
||||
[IO.File]::WriteAllText((Join-Path $root 'cleanup.json'),($cleanupReceipt|ConvertTo-Json -Depth 8))
|
||||
Write-Host 'Original local/effective GP policy, channel enablement and converter task restored; service startup mode preserved.'
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -1,5 +1,6 @@
|
||||
# Mocked registry/audit policy; no Windows policy changes.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force
|
||||
. (Join-Path $PSScriptRoot '../scripts/Configuration.ps1')
|
||||
$script:assertions = 0
|
||||
$script:paths = @()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$preview=Join-Path ([IO.Path]::GetTempPath()) ('wela-capi2-whatif-'+[guid]::NewGuid().ToString('N'))
|
||||
$cases=@(
|
||||
@{Args=@('capi2-probe','-Help');Code=0;Pattern='Fixed offline'},
|
||||
@{Args=@('capi2-probe','-Capi2ProbeAction','Run','-Capi2ProbeOutputPath',$preview,'-WhatIf');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','unexpected-positional-value');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('configure','-Capi2ProbeAction','Run','-Auto');Code=1;Pattern='require capi2-probe'},
|
||||
@{Args=@('wmi-auditing','-Capi2ProbeAction','Run');Code=1;Pattern='require capi2-probe'},
|
||||
@{Args=@('capi2-probe','-Help','-WmiAction','Configure');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','-Auto');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','-DryRun');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','-ResultsPath','unused');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Capi2ProbeAction','Run');Code=1;Pattern='new Capi2ProbeOutputPath'})
|
||||
foreach($case in $cases){$ErrorActionPreference='Continue';$output=& $engine -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $output -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') [$code] $output"};$count++}
|
||||
if(Test-Path -LiteralPath $preview){throw 'Unsupported preview created a probe output directory.'};$count++
|
||||
Write-Host "PASS: $count CAPI2 probe public CLI checks."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,65 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WmiProbe.ps1"
|
||||
. "$repo/scripts/Capi2Probe.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action,$Message){$failed=$false;try{&$Action|Out-Null}catch{$failed=$true};Assert $failed $Message}
|
||||
function Clone($Value){ConvertFrom-WelaArrivalJson ($Value|ConvertTo-Json -Depth 24)}
|
||||
$sources=Get-WelaCapi2ProbeSources|ConvertFrom-Json
|
||||
Assert ($sources.'scripts/CustomAuditProfiles.ps1' -ceq (Get-FileHash -LiteralPath "$repo/scripts/CustomAuditProfiles.ps1" -Algorithm SHA256).Hash.ToLowerInvariant()) 'Strict worker-receipt parser implementation is included in source identity.'
|
||||
$nonce='0123456789abcdef0123456789abcdef';$now=[DateTime]::UtcNow
|
||||
$token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='HOST\user';AuthenticationId='0x1234';AuthenticationType='NTLM';Groups=@([pscustomobject]@{Sid='S-1-5-32-545';Attributes=7});Privileges=@()}
|
||||
$state=[pscustomobject]@{Computer='HOST';Services=@([pscustomobject]@{Name='CryptSvc';Status='Running'},[pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='Winmgmt';Status='Running'});Host=[pscustomobject]@{Computer='HOST';Build=20348;UBR=1;ProductType=3;DomainJoined=$false;Domain='WORKGROUP'};Token=$token;Channel=[pscustomobject]@{Name='Microsoft-Windows-CAPI2/Operational';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;1;;;SY)';Type='Operational';Provider='Microsoft-Windows-CAPI2'};Provider=[pscustomobject]@{Name='Microsoft-Windows-CAPI2';Guid='5bbca4a8-b209-48dc-a8c7-b23d3e5216fb';Event11Versions=@(0);LogNames=@('Microsoft-Windows-CAPI2/Operational')}}
|
||||
if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){$rsa=[Security.Cryptography.RSACng]::new(2048)}else{$rsa=[Security.Cryptography.RSA]::Create();$rsa.KeySize=2048};$cert=$null
|
||||
try{
|
||||
$request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||
$cert=$request.CreateSelfSigned(([DateTimeOffset]$now).AddMinutes(-5),([DateTimeOffset]$now).AddMinutes(5))
|
||||
$operation=[pscustomobject]@{Nonce=$nonce;CertificateDerBase64=[Convert]::ToBase64String($cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert));Subject=$cert.Subject;Thumbprint=$cert.Thumbprint;KeyEphemeral=$true;ProcessId=5678;ProcessName='pwsh.exe';StartedUtc=$now.AddSeconds(-1).ToString('o');CompletedUtc=$now.AddSeconds(1).ToString('o');Clock='GetSystemTimePreciseAsFileTime';RecordIdBefore=10;BeforeToken=$token;AfterToken=$token;Chain=[pscustomobject]@{Flags=2147492100;ErrorStatus=32;Chains=1;Elements=1}}
|
||||
$der=Assert-WelaCapi2ProbeCertificate $operation $nonce;Assert ($der.Length -gt 128) 'Generated test DER is validated.'
|
||||
}finally{if($cert){$cert.Dispose()};$rsa.Dispose()}
|
||||
$bad=Clone $operation;$bad.CertificateDerBase64=[Convert]::ToBase64String(([byte[]]([Convert]::FromBase64String($operation.CertificateDerBase64)+[byte[]]@(0))));Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Reject trailing data after the actual certificate DER.'
|
||||
foreach($field in @('Nonce','Subject','Thumbprint','CertificateDerBase64')){$bad=Clone $operation;$bad.$field='wrong';Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} "Reject certificate $field mismatch"}
|
||||
foreach($value in @($false,'true',$null)){$bad=Clone $operation;$bad.KeyEphemeral=$value;Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Ephemeral key evidence must be true Boolean.'}
|
||||
foreach($field in @('Flags','ErrorStatus','Chains','Elements')){$bad=Clone $operation;$bad.Chain.$field=0;Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} "Reject unexpected native chain $field"}
|
||||
$bad=Clone $operation;$bad.CompletedUtc=$now.AddMinutes(20).ToString('o');Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Certificate must cover operation.'
|
||||
Assert ([bool](Get-WelaCapi2ProbeStateKey $state)) 'Exact observed prerequisites accepted.'
|
||||
foreach($edit in @({param($s)$s.Services[0].Status='Stopped'},{param($s)$s.Services=@()},{param($s)$s.Host.Build=19045},{param($s)$s.Host.UBR=$null},{param($s)$s.Host.ProductType=1},{param($s)$s.Host.Computer='OTHER'},{param($s)$s.Channel.Enabled=$false},{param($s)$s.Channel.Enabled='true'},{param($s)$s.Channel.Type='Analytical'},{param($s)$s.Channel.Provider='Other'},{param($s)$s.Channel.SecurityDescriptor=$null},{param($s)$s.Provider.Guid=[guid]::Empty.ToString()},{param($s)$s.Provider.Event11Versions=@(1)},{param($s)$s.Provider.Event11Versions=@(0,0)},{param($s)$s.Provider.LogNames=@('Security')})){$bad=Clone $state;&$edit $bad;Reject {Get-WelaCapi2ProbeStateKey $bad} 'Reject incomplete or unsupported prerequisites.'}
|
||||
$xml=@"
|
||||
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-CAPI2" Guid="{5bbca4a8-b209-48dc-a8c7-b23d3e5216fb}"/><EventID>11</EventID><Version>0</Version><Level>2</Level><Task>11</Task><Opcode>2</Opcode><Keywords>0x4000000000000003</Keywords><TimeCreated SystemTime="$($now.ToString('o'))"/><EventRecordID>11</EventRecordID><Execution ProcessID="5678" ThreadID="1"/><Channel>Microsoft-Windows-CAPI2/Operational</Channel><Computer>HOST</Computer><Security UserID="$($token.Sid)"/></System><UserData><CertGetCertificateChain><Certificate fileRef="$($operation.Thumbprint).cer" subjectName="WelaCapi2Probe_$nonce"/><ExtendedKeyUsage/><URLRetrievalTimeout>PT1S</URLRetrievalTimeout><Flags value="80002104" CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL="true" CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY="true" CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE="true" CERT_CHAIN_DISABLE_AIA="true"/><ChainEngineInfo context="user"/><CertificateChain><TrustStatus><ErrorStatus value="20"/></TrustStatus><ChainElement><Certificate fileRef="$($operation.Thumbprint).cer" subjectName="WelaCapi2Probe_$nonce"/><SignatureAlgorithm oid="1.2.840.113549.1.1.11" hashName="SHA256" publicKeyName="RSA"/><PublicKeyAlgorithm oid="1.2.840.113549.1.1.1" publicKeyLength="2048"/><TrustStatus><ErrorStatus value="20"/></TrustStatus><ApplicationUsage any="true"/><IssuanceUsage any="true"/></ChainElement></CertificateChain><EventAuxInfo ProcessName="pwsh.exe"/><CorrelationAuxInfo TaskId="{00000000-0000-0000-0000-000000000001}" SeqNumber="3"/><Result value="800B0109"/></CertGetCertificateChain></UserData></Event>
|
||||
"@
|
||||
Assert (Test-WelaCapi2ProbeEvent $xml $operation $state) 'Exact source/certificate/PID/token/time/chain fixture matches.'
|
||||
$changes=@(
|
||||
@('Name="Microsoft-Windows-CAPI2"','Name="Other"'),@('5bbca4a8-b209-48dc-a8c7-b23d3e5216fb','00000000-0000-0000-0000-000000000000'),@('<EventID>11</EventID>','<EventID>70</EventID>'),@('<Version>0</Version>','<Version>1</Version>'),@('<Level>2</Level>','<Level>4</Level>'),@('<Task>11</Task>','<Task>10</Task>'),@('<Opcode>2</Opcode>','<Opcode>1</Opcode>'),@('0x4000000000000003','0x4000000000000001'),@('<EventRecordID>11</EventRecordID>','<EventRecordID>10</EventRecordID>'),@('<EventRecordID>11</EventRecordID>','<EventRecordID>x</EventRecordID>'),@('ProcessID="5678"','ProcessID="5679"'),@( ('UserID="'+$token.Sid+'"'), 'UserID="S-1-5-18"'),@('<Computer>HOST</Computer>','<Computer>OTHER</Computer>'),@('80002104','80000104'),@('800B0109','0'),@('value="20"','value="0"'),@('context="user"','context="machine"'),@('ProcessName="pwsh.exe"','ProcessName="other.exe"'),@($operation.Thumbprint,('0'*40)),@($nonce,('f'*32)),@('<UserData>','<UserData><Other/>'),@('<CertGetCertificateChain>','<CertGetCertificateChain xmlns="urn:other">'),@('<Version>0</Version>','<Version>0</Version><Version>0</Version>'),@('<ExtendedKeyUsage/>','<ExtendedKeyUsage/><ExtendedKeyUsage/>'),@('PT1S','PT2S'),@('CERT_CHAIN_DISABLE_AIA="true"','CERT_CHAIN_DISABLE_AIA="false"'),@('<ChainEngineInfo','<AdditionalStore/><ChainEngineInfo'),@('publicKeyLength="2048"','publicKeyLength="1024"'),@('hashName="SHA256"','hashName="SHA1"'),@('<ApplicationUsage','<RevocationInfo/><ApplicationUsage'),@('</UserData>','</UserData><EventData/>'),@('<Event xmlns=','<!DOCTYPE Event [<!ENTITY test "x">]><Event xmlns=')
|
||||
)
|
||||
foreach($pair in $changes){Assert (-not(Test-WelaCapi2ProbeEvent ($xml.Replace($pair[0],$pair[1])) $operation $state)) ('Reject altered XML '+$pair[0])}
|
||||
foreach($time in @($now.AddSeconds(-2).ToString('o'),$now.AddSeconds(2).ToString('o'),$now.ToString('yyyy-MM-ddTHH:mm:ss'),$now.ToString('yyyy-MM-ddTHH:mm:ss')+'+00:00')){Assert (-not(Test-WelaCapi2ProbeEvent ($xml.Replace($now.ToString('o'),$time)) $operation $state)) 'Reject outside or ambiguous UTC.'}
|
||||
foreach($time in @($operation.StartedUtc,$operation.CompletedUtc)){Assert (Test-WelaCapi2ProbeEvent ($xml.Replace($now.ToString('o'),$time)) $operation $state) 'Accept exact inclusive operation boundary.'}
|
||||
$null=Assert-WelaWmiProbeInterval $operation ([DateTimeOffset]$now.AddSeconds(-2)) ([DateTimeOffset]$now.AddSeconds(2));$count++
|
||||
$bad=Clone $operation;$bad.Clock='UtcNow';Reject {Assert-WelaWmiProbeInterval $bad ([DateTimeOffset]$now.AddSeconds(-2)) ([DateTimeOffset]$now.AddSeconds(2))} 'Require precise native clock.'
|
||||
Reject {Invoke-WelaCapi2Probe -Action Run} 'Run requires a new private output path.'
|
||||
Reject {Invoke-WelaCapi2Probe -Action Plan -OutputPath unused} 'Plan creates no files.'
|
||||
# Lifecycle fixtures test failure receipts and no-operation planning independently of Windows telemetry.
|
||||
$script:FixtureState=$state;$script:FixtureOperation=$operation;$script:FixtureXml=$xml;$script:FixtureMode='success';$script:FixtureStateReads=0;$script:FixtureActions=0
|
||||
function Get-WelaCapi2ProbeState {$script:FixtureStateReads++;$value=Clone $script:FixtureState;if($script:FixtureMode -eq 'drift' -and $script:FixtureStateReads -gt 1){$value.Host.UBR++};$value}
|
||||
function Get-WelaCapi2ProbeWatermark {if($script:FixtureMode -eq 'denied'){throw 'Reader denied.'};if($script:FixtureMode -eq 'rollback' -and $script:FixtureActions){return [long]0};[long]11}
|
||||
function Start-WelaCapi2ProbeBuild {param($State);$script:FixtureActions++;if($script:FixtureMode -eq 'worker'){throw 'Bounded worker failed.'};Clone $script:FixtureOperation}
|
||||
function Read-WelaCapi2ProbeEvents {param($Operation);$items=@($script:FixtureXml);if($script:FixtureMode -eq 'none'){$items=@($script:FixtureXml.Replace('800B0109','0'))};if($script:FixtureMode -eq 'duplicate'){$items=@($script:FixtureXml,$script:FixtureXml)};[pscustomobject]@{Xml=$items;Capped=($script:FixtureMode -eq 'cap');Query='fixed-fixture';MaximumEvents=64}}
|
||||
$planned=Invoke-WelaCapi2Probe
|
||||
Assert ($planned.Status -eq 'PrerequisitesObserved' -and $script:FixtureActions -eq 0 -and -not $planned.OutputPath) 'Plan observes prerequisites without operation or files.'
|
||||
$private=Join-Path ([IO.Path]::GetTempPath()) ('wela-capi2-fixture-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $private
|
||||
try{
|
||||
foreach($mode in @('success','none','duplicate','cap','drift','rollback','worker','denied')){
|
||||
$script:FixtureMode=$mode;$script:FixtureStateReads=0;$script:FixtureActions=0
|
||||
$out=Join-Path $private $mode;$result=Invoke-WelaCapi2Probe -Action Run -OutputPath $out -TimeoutSeconds 1
|
||||
if($mode -eq 'success'){Assert ($result.Status -eq 'LocalChainEventObserved' -and $result.ExitCode -eq 0 -and (Test-Path "$out/event.xml")) 'Success retains one exact matched event.'}
|
||||
else{Assert ($result.Status -eq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) ('Failure remains explicit: '+$mode)}
|
||||
Assert (Test-Path "$out/manifest.json") 'Every started bundle retains its manifest.'
|
||||
Assert ($script:FixtureActions -le 1 -and $result.ChannelChanges -eq 0 -and $result.StoreChanges -eq 0 -and $result.TrustPolicyChanges -eq 0 -and $result.ReadyRuleCredit -eq 0) 'No operation retry or configuration/coverage credit.'
|
||||
foreach($artifact in $result.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Lifecycle artifact hash matches.'}
|
||||
}
|
||||
Reject {Invoke-WelaCapi2Probe -Action Run -OutputPath (Join-Path $private 'success')} 'Existing evidence cannot be overwritten.'
|
||||
}finally{Remove-Item -LiteralPath $private -Recurse -Force}
|
||||
Write-Host "PASS: $count portable CAPI2 assertions. No native event proof is claimed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,62 @@
|
||||
# Genuine public fixed probe. Only the disposable fixture may toggle the channel.
|
||||
param([switch]$AllowDisposableChannelWrite,[ValidateRange(1,3)][int]$ProbeRuns=3)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable GitHub-hosted Windows opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/ChannelRead.ps1"
|
||||
. "$repo/scripts/WmiProbe.ps1"
|
||||
. "$repo/scripts/Capi2Probe.ps1"
|
||||
$hostState=Get-WelaChannelReadHost
|
||||
if($hostState.ProductType -ne 3 -or $hostState.DomainRole -ne 2 -or $hostState.DomainJoined -or $hostState.Build -notin @(20348,26100)){throw 'A disposable standalone Server 2022/2025 is required.'}
|
||||
$script:count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 16 -Compress}
|
||||
function Read-Stores {
|
||||
$result=[ordered]@{}
|
||||
foreach($location in @('CurrentUser','LocalMachine')){foreach($name in @('My','Root','CertificateAuthority')){
|
||||
$store=[Security.Cryptography.X509Certificates.X509Store]::new([Security.Cryptography.X509Certificates.StoreName]$name,[Security.Cryptography.X509Certificates.StoreLocation]$location)
|
||||
try{$store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly -bor [Security.Cryptography.X509Certificates.OpenFlags]::OpenExistingOnly);$certificates=$store.Certificates;try{$result[$location+'/'+$name]=@($certificates|ForEach-Object Thumbprint|Sort-Object)}finally{foreach($c in $certificates){$c.Dispose()}}}finally{$store.Dispose()}
|
||||
}}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
$engine=(Get-Process -Id $PID).Path;$original=Get-WelaCapi2ProbeChannel;$originalStores=Read-Stores
|
||||
$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-capi2-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot
|
||||
$null=Write-WelaArrivalArtifact $root 'channel-original.json' ($original|ConvertTo-Json)
|
||||
$null=Write-WelaArrivalArtifact $root 'stores-original.json' ($originalStores|ConvertTo-Json -Depth 8)
|
||||
$failure=$null;$cleanupErrors=@();$nonces=@();$thumbprints=@();$changed=$false;$channelRestored=$false;$storesPreserved=$false
|
||||
try{
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($original.Name)
|
||||
try{if(-not $channel.IsEnabled){$changed=$true;$channel.IsEnabled=$true;$channel.SaveChanges()}}finally{$channel.Dispose()}
|
||||
$enabled=Get-WelaCapi2ProbeChannel
|
||||
$expected=$original|ConvertTo-Json|ConvertFrom-Json;$expected.Enabled=$true
|
||||
Assert ((Key $enabled) -ceq (Key $expected)) 'Fixture changed only channel Enabled.'
|
||||
for($trial=1;$trial -le $ProbeRuns;$trial++){
|
||||
$out=Join-Path $root ('probe-'+$trial)
|
||||
$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$cli=&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" capi2-probe -Capi2ProbeAction Run -Capi2ProbeOutputPath $out -Capi2ProbeTimeoutSeconds 15 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old}
|
||||
if(-not(Test-Path "$out/manifest.json")){throw ('Public probe did not retain a manifest: '+$cli)}
|
||||
$manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText("$out/manifest.json"))
|
||||
Write-Host ($manifest|ConvertTo-Json -Depth 24)
|
||||
foreach($file in @(Get-ChildItem -LiteralPath $out -Filter '*.xml')){Write-Host ([IO.File]::ReadAllText($file.FullName))}
|
||||
Assert ($code -eq 0 -and $manifest.Status -eq 'LocalChainEventObserved' -and $manifest.ExitCode -eq 0) ('Actual CAPI2 probe failed: '+$manifest.Diagnostic+' '+$cli)
|
||||
Assert ($manifest.Matches -eq 1 -and $manifest.ChannelChanges -eq 0 -and $manifest.StoreChanges -eq 0 -and $manifest.TrustPolicyChanges -eq 0 -and $manifest.ReadyRuleCredit -eq 0) 'Bounded event evidence grants no configuration or Sigma claim.'
|
||||
Assert ($manifest.Operation.Nonce -notin $nonces -and $manifest.Operation.Thumbprint -notin $thumbprints) 'Independent public invocation generated a fresh nonce and certificate.'
|
||||
$nonces+=$manifest.Operation.Nonce;$thumbprints+=$manifest.Operation.Thumbprint
|
||||
$der=Assert-WelaCapi2ProbeCertificate $manifest.Operation $manifest.Operation.Nonce
|
||||
Assert ((Get-WelaArrivalHash $der) -ceq $manifest.Operation.CertificateSha256) 'Actual DER matches retained SHA256.'
|
||||
Assert (Test-WelaCapi2ProbeEvent ([IO.File]::ReadAllText("$out/event.xml")) $manifest.Operation $manifest.Before) 'Actual event11 matches certificate, nonce, PID, SID, UTC, offline flags and expected chain outcome.'
|
||||
foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Artifact hash verifies.'}
|
||||
Assert ((Key (Get-WelaCapi2ProbeChannel)) -ceq (Key $enabled)) 'Public probe preserved channel configuration.'
|
||||
Assert ((Key (Read-Stores)) -ceq (Key $originalStores)) 'CurrentUser and LocalMachine My/Root/CA certificate inventories preserved.'
|
||||
Assert ((Get-Acl -LiteralPath $out).AreAccessRulesProtected) 'Private evidence blocks inherited broad access.'
|
||||
}
|
||||
}catch{$failure=$_}
|
||||
finally{
|
||||
try{$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($original.Name);try{if($channel.IsEnabled -ne $original.Enabled){$channel.IsEnabled=$original.Enabled;$channel.SaveChanges()}}finally{$channel.Dispose()};$restored=Get-WelaCapi2ProbeChannel;$null=Write-WelaArrivalArtifact $root 'channel-restored.json' ($restored|ConvertTo-Json);if((Key $restored) -cne (Key $original)){throw 'Original channel configuration was not restored.'};$channelRestored=$true}catch{$cleanupErrors+='Channel restoration: '+$_.Exception.Message}
|
||||
try{$storesAfter=Read-Stores;$null=Write-WelaArrivalArtifact $root 'stores-after.json' ($storesAfter|ConvertTo-Json -Depth 8);if((Key $storesAfter) -cne (Key $originalStores)){throw 'Certificate store inventory changed.'};$storesPreserved=$true}catch{$cleanupErrors+='Store observation: '+$_.Exception.Message}
|
||||
$null=Write-WelaArrivalArtifact $root 'cleanup.json' ([pscustomobject]@{ChangedEnabled=$changed;Failure=$(if($failure){$failure.Exception.Message}else{$null});CleanupErrors=$cleanupErrors;ChannelRestored=$channelRestored;SelectedStoresPreserved=$storesPreserved;Complete=($null -eq $failure -and $cleanupErrors.Count -eq 0);Evidence=$root}|ConvertTo-Json)
|
||||
}
|
||||
if($failure){throw $failure};if($cleanupErrors.Count){throw ($cleanupErrors -join '; ')}
|
||||
Write-Host "PASS: $script:count actual CAPI2 assertions across $ProbeRuns independent public runs; original channel restored and selected certificate inventories preserved."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,16 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
|
||||
$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$cases=@(
|
||||
@{Args=@('channel-recovery','-ChannelRecoveryAction','Restore','-WhatIf');Code=1;Pattern='only dedicated|Unknown|unbound'},
|
||||
@{Args=@('channel-recovery','-Help');Code=0;Pattern='AllowShrink'},
|
||||
@{Args=@('configure','-ChannelRecoveryAction','Restore','-Auto');Code=1;Pattern='require channel-recovery'},
|
||||
@{Args=@('channel-recovery','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated|Unknown|unbound'},
|
||||
@{Args=@('channel-recovery','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated|Unknown|unbound'},
|
||||
@{Args=@('channel-recovery','-Help','-Auto');Code=1;Pattern='only dedicated|Unknown|unbound'},
|
||||
@{Args=@('channel-recovery','-Help','-DryRun');Code=1;Pattern='only dedicated|Unknown|unbound'},
|
||||
@{Args=@('channel-recovery','-ChannelRecoveryAction','Restore','-ChannelRecoveryOutputPath','not-created');Code=1;Pattern='reviewed plan'},
|
||||
@{Args=@('channel-recovery','-ChannelRecoveryOutputPath','not-created');Code=1;Pattern='Plan requires'}
|
||||
)
|
||||
foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++}
|
||||
Write-Host "Channel recovery CLI: $count checks passed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,99 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/EventLogSettings.psm1" -Force
|
||||
Import-Module "$repo/modules/NativeChannelAccess.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/NativeChannelConfiguration.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/ChannelRecovery.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Copy-State($Value){ConvertFrom-WelaArrivalJson (Get-WelaChannelRecoveryKey $Value)}
|
||||
function Get-WelaChannelRecoveryContext {[pscustomobject]@{Host=[ordered]@{Computer='TEST';MachineGuid='owned-host'};Reader='owned-logon'}}
|
||||
function Get-WelaChannelReader {[pscustomobject]@{UserSid='TEST';TokenId='token';ModifiedId=$script:token}}
|
||||
# Portable tests exercise authority, reconstruction and write ordering; real descriptor
|
||||
# bytes are exercised separately by the native public Configure/Restore workflow.
|
||||
function Get-WelaChannelRecoveryDescriptorKey {param($Sddl) if($Sddl -cnotin @('original','original+read','foreign')){throw 'Invalid fixture descriptor'};$Sddl}
|
||||
function Get-WelaChannelAccessPlan {param($SecurityDescriptor) if($SecurityDescriptor -ceq 'original'){[pscustomobject]@{State='GrantRequired';ProposedDescriptor='original+read'}}else{[pscustomobject]@{State='GrantPresent'}}}
|
||||
function Test-WelaChannelDescriptorEqual {param($First,$Second) $First -ceq $Second}
|
||||
function Get-WelaNativeChannel {param($Name) [pscustomobject][ordered]@{Name=$Name;State=$(if($script:settings.IsEnabled){'Enabled'}else{'Disabled'});IsEnabled=$script:settings.IsEnabled;LogMode=$script:settings.LogMode;SecurityDescriptor=$script:settings.SecurityDescriptor;MaximumSizeInBytes=$script:settings.MaximumSizeInBytes;ProviderNames='Microsoft-Windows-CAPI2';MetadataErrors=[pscustomobject]@{};Error=$null}}
|
||||
function Invoke-WelaNative {param($FilePath,$Arguments) foreach($arg in $Arguments){if($arg -like '/ms:*'){$script:settings.MaximumSizeInBytes=[long]$arg.Substring(4)};if($arg -like '/ca:*'){$script:settings.SecurityDescriptor=$arg.Substring(4)};if($arg -ceq '/e:true'){$script:settings.IsEnabled=$true}}}
|
||||
function Read-WelaChannelRecoveryState {
|
||||
param($Channel)
|
||||
$script:reads++
|
||||
if($script:case -eq 'fresh-drift' -and $script:reads -eq 2){$script:settings.MaximumSizeInBytes+=65536}
|
||||
[pscustomobject]@{Channel=$Channel;Settings=(Copy-State $script:settings);Guard=[ordered]@{Path=$script:path;Provider='CAPI2';Other='preserved'}}
|
||||
}
|
||||
function Set-WelaChannelRecoveryField {
|
||||
param($Definition,$Field)
|
||||
$script:writes++
|
||||
Assert (Test-Path (Join-Path $script:output ('pending-'+$script:writes+'-'+$Field+'.json'))) 'Durable per-field pending receipt precedes each write.'
|
||||
if($script:case -eq 'native-fail' -and $script:writes -eq 2){throw 'Native second write failed'}
|
||||
if($script:case -ne 'false-success'){$script:settings.$Field=$Definition.RecoverTo.$Field}
|
||||
if($script:case -eq 'preservation'){$script:path='changed'}
|
||||
if($script:case -eq 'token'){$script:token='changed'}
|
||||
if($script:writes -eq 3 -and $script:case -eq 'last-history'){[IO.File]::AppendAllText($script:originalFile,' ')}
|
||||
if($script:writes -eq 3 -and $script:case -eq 'last-artifact'){[IO.File]::AppendAllText((Join-Path $script:output 'pending-3-IsEnabled.json'),' ')}
|
||||
}
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-channel-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$oldComputer=$env:COMPUTERNAME;$env:COMPUTERNAME='TEST';$channel='Microsoft-Windows-CAPI2/Operational'
|
||||
function Original([string]$Dir,[bool]$Grant=$true){
|
||||
$script:settings=[pscustomobject][ordered]@{IsEnabled=$false;MaximumSizeInBytes=1052672L;LogMode='Circular';SecurityDescriptor='original'};$script:case='';$script:token='stable';$script:path='preserved';$script:reads=0;$script:writes=0
|
||||
$profile=Get-WelaNativeChannelProfile;$plans=@(Get-WelaNativeChannelPlan -Profile $profile -GrantEventLogReaders:$Grant|Where-Object {$_.Definition.channel -ceq $channel})
|
||||
$context=New-WelaConfigurationContext -Auto -BackupPath "$Dir/journal"
|
||||
Set-WelaNativeChannelControls $context $plans $profile.id
|
||||
$r=Complete-WelaConfiguration $context -Scope 'native-channel-settings-only'
|
||||
$r|Add-Member NoteProperty Action Configure;$r|Add-Member NoteProperty ChannelProfile $profile.id;$r|Add-Member NoteProperty GrantEventLogReadersRequested $Grant
|
||||
$r|ConvertTo-Json -Depth 20|Set-Content "$Dir/original.json" -Encoding UTF8
|
||||
Assert ($r.Results.Count -eq 1 -and $r.Results[0].Status -ceq 'Applied') 'Actual shared configuration callbacks produce original journal/result evidence.'
|
||||
}
|
||||
try {
|
||||
foreach($scenario in @('ok','no-grant','no-shrink','no-disable','no-revoke','hash','tamper','duplicate','drift','fresh-drift','source','native-fail','false-success','preservation','token','last-history','last-artifact')){
|
||||
$dir=Join-Path $root $scenario;$null=New-Item -ItemType Directory $dir;Original $dir ($scenario -ne 'no-grant')
|
||||
$plan=Invoke-WelaChannelRecovery -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Channel $channel -OutputPath "$dir/plan"
|
||||
Assert ($plan.Status -ceq 'ReviewRequired' -and $plan.ExitCode -eq 0) "Plan $scenario : $($plan.Diagnostic)"
|
||||
Assert ($script:writes -eq 0) 'Plan does not mutate.'
|
||||
$planPath="$dir/plan/plan.json";$hash=$plan.PlanHash
|
||||
if($scenario -eq 'hash'){$hash='f'*64}
|
||||
if($scenario -in @('tamper','duplicate')){$text=[IO.File]::ReadAllText($planPath);if($scenario -eq 'tamper'){$text=$text.Replace('1052672','2097152')}else{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')};[IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant()}
|
||||
if($scenario -eq 'source'){[IO.File]::AppendAllText("$dir/original.json",' ')}
|
||||
if($scenario -eq 'drift'){$script:settings.SecurityDescriptor='foreign'}
|
||||
$script:case=$scenario;$script:originalFile="$dir/original.json";$script:reads=0;$script:output="$dir/restore"
|
||||
$r=Invoke-WelaChannelRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath $script:output -AllowShrink:($scenario -ne 'no-shrink') -AllowDisable:($scenario -ne 'no-disable') -AllowRevoke:($scenario -notin @('no-revoke','no-grant'))
|
||||
Assert (($r.ExitCode -eq 0) -eq ($scenario -in @('ok','no-grant'))) "Restore $scenario : $($r.Diagnostic)"
|
||||
Assert ($r.ReadyRuleCredit -eq 0 -and (Test-Path "$dir/restore/manifest.json")) 'Outcome evidence is retained without Sigma credit.'
|
||||
if($scenario -in @('ok','no-grant')){
|
||||
Assert ($script:settings.SecurityDescriptor -ceq 'original' -and -not $script:settings.IsEnabled -and $script:settings.MaximumSizeInBytes -eq 1052672 -and $r.Status -ceq 'RestoredAndVerified') 'Original changed fields restored.'
|
||||
Assert ($r.ConfirmedFields.Count -eq $(if($scenario -eq 'ok'){3}else{2})) 'Only originally changed fields are written and confirmed.'
|
||||
$again=Invoke-WelaChannelRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/replay" -AllowShrink -AllowDisable -AllowRevoke
|
||||
Assert ($again.Status -ceq 'Refused') 'Completed old plan cannot be replayed.'
|
||||
}elseif($scenario -in @('native-fail','false-success','preservation','token','last-history','last-artifact')){
|
||||
Assert ($r.Status -ceq 'RestoreAttemptedUnverified' -and $script:writes -gt 0) 'Possible partial write is explicit; no rollback is inferred.'
|
||||
if($scenario -eq 'native-fail'){Assert ($r.ConfirmedFields.Count -eq 1 -and $script:settings.MaximumSizeInBytes -eq 1052672 -and $script:settings.SecurityDescriptor -ceq 'original+read' -and $script:settings.IsEnabled) 'Second-write failure retains one confirmed step and stops before disable.'}
|
||||
}else{Assert ($r.Status -ceq 'Refused' -and $script:writes -eq 0) 'Unreviewed or drifted input refuses before write.'}
|
||||
foreach($artifact in $r.Artifacts){$matches=(Get-FileHash (Join-Path $r.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256;Assert ($matches -eq (-not ($scenario -eq 'last-artifact' -and $artifact.Name -ceq 'pending-3-IsEnabled.json'))) 'Retained hashes expose the deliberately changed artifact; all other bytes match.'}
|
||||
}
|
||||
$dir=Join-Path $root 'history';$null=New-Item -ItemType Directory $dir;Original $dir
|
||||
$savedResult=[IO.File]::ReadAllText("$dir/original.json");$savedJournal=[IO.File]::ReadAllText("$dir/journal/before.jsonl")
|
||||
$cases=@('Status','Kind','Id','Action','Scope','ChannelProfile','Channel','Profile','Version','ComputerName','State','IsEnabled','MaximumSizeInBytes','LogMode','SecurityDescriptor','AfterDrift','DesiredDrift','ExtraAce','NoGrantAuthority','DuplicateJournal')
|
||||
foreach($bad in $cases){
|
||||
$r=ConvertFrom-WelaArrivalJson $savedResult;$e=ConvertFrom-WelaArrivalJson $savedJournal
|
||||
switch($bad){
|
||||
{$_ -in @('Status','Kind','Id')} {$r.Results[0].$bad=$true}
|
||||
{$_ -in @('Action','Scope','ChannelProfile')} {$r.$bad=$true}
|
||||
{$_ -in @('Channel','Profile')} {$e.Target.$bad=$true;$r.Results[0].Target.$bad=$true}
|
||||
{$_ -in @('Version','ComputerName')} {$e.$bad=$true}
|
||||
{$_ -in @('State','IsEnabled','MaximumSizeInBytes','LogMode','SecurityDescriptor')} {$e.Before.$bad=if($bad -eq 'IsEnabled'){'false'}else{$true};$r.Results[0].Before=Copy-State $e.Before}
|
||||
'AfterDrift' {$r.Results[0].After.MaximumSizeInBytes+=65536}
|
||||
'DesiredDrift' {$e.Desired.MaximumSizeInBytes+=65536;$r.Results[0].Desired=Copy-State $e.Desired}
|
||||
'ExtraAce' {$e.Desired.SecurityDescriptor='foreign';$r.Results[0].Desired=Copy-State $e.Desired;$r.Results[0].After.SecurityDescriptor='foreign'}
|
||||
'NoGrantAuthority' {$r.GrantEventLogReadersRequested=$false}
|
||||
}
|
||||
$r|ConvertTo-Json -Depth 20|Set-Content "$dir/original.json" -Encoding UTF8
|
||||
$text=$e|ConvertTo-Json -Depth 20 -Compress;if($bad -eq 'DuplicateJournal'){$text+="`n"+$text};[IO.File]::WriteAllText("$dir/journal/before.jsonl",$text)
|
||||
$p=Invoke-WelaChannelRecovery -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Channel $channel -OutputPath "$dir/reject-$bad"
|
||||
Assert ($p.Status -ceq 'Refused' -and -not $p.NativeWriteAttempted) "History $bad rejected before any write: $($p.Diagnostic)"
|
||||
}
|
||||
}finally{$env:COMPUTERNAME=$oldComputer;Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
Write-Host "PASS: $count channel recovery authority/order/partial-outcome assertions. Native descriptors require the Windows fixture."
|
||||
@@ -0,0 +1,108 @@
|
||||
param([switch]$AllowDisposableChannelWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows channel-write opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/EventLogSettings.psm1" -Force
|
||||
Import-Module "$repo/modules/NativeProviders.psm1" -Force
|
||||
Import-Module "$repo/modules/NativeChannelAccess.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/NativeChannelConfiguration.ps1"
|
||||
$count=0;$errors=@();$primary=$null
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Read-Raw([string]$Name){$r=Invoke-WelaNative wevtutil.exe @('gl',$Name,'/f:xml');$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.LoadXml(($r.Output -join "`n"));return ,$doc}
|
||||
function Guard-Raw($Xml){$copy=$Xml.CloneNode($true);$copy.DocumentElement.RemoveAttribute('enabled');$copy.DocumentElement.RemoveAttribute('channelAccess');foreach($node in @($copy.SelectNodes("/*/*[local-name()='logging']/*[local-name()='maxSize']"))){$null=$node.ParentNode.RemoveChild($node)};$copy.OuterXml}
|
||||
function Save($Name,$Value){$Value|ConvertTo-Json -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
|
||||
Add-Type -TypeDefinition @'
|
||||
using System; using System.IO; using System.Text; using System.Threading.Tasks;
|
||||
public static class WelaChannelRecoveryFixturePipe {
|
||||
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 exceeded 1Mi characters.");text.Append(buffer,0,n); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-channel-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){
|
||||
$all=@('-NoLogo','-NoProfile','-NonInteractive','-File',"$repo/WELA.ps1")+$Arguments
|
||||
foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Unsupported 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=[WelaChannelRecoveryFixturePipe]::Read($process.StandardOutput);$stderr=[WelaChannelRecoveryFixturePipe]::Read($process.StandardError)
|
||||
if(-not $process.WaitForExit(120000)){throw 'Public command exceeded two minutes.'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public command output drain timed out.'}
|
||||
$text=$stdout.Result+"`n"+$stderr.Result;[IO.File]::WriteAllText((Join-Path $root ($Name+'.txt')),$text)
|
||||
Assert ($process.ExitCode -eq $Expected) "Public $Name exit $($process.ExitCode) expected $Expected : $text"
|
||||
}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'}}
|
||||
try{$process.Dispose()}catch{$script:errors+=$_.Exception.Message}
|
||||
}
|
||||
}
|
||||
$profile=Get-WelaNativeChannelProfile;$before=@{};$raw=@{};$policies=Get-WelaEffectiveAuditPolicy
|
||||
foreach($control in $profile.controls){$name=$control.channel;$before[$name]=Get-WelaNativeChannel $name;if(Test-WelaNativeChannelSnapshot $before[$name]){$raw[$name]=Read-Raw $name}}
|
||||
$capi='Microsoft-Windows-CAPI2/Operational';$app='Microsoft-Windows-AppLocker/EXE and DLL'
|
||||
$expectedConfigure=if(@($before.Values|Where-Object State -eq 'Not installed').Count){1}else{0}
|
||||
Save 'before.json' $before;$rawText=@{};foreach($name in $raw.Keys){$rawText[$name]=$raw[$name].OuterXml};Save 'raw-before.json' $rawText
|
||||
try {
|
||||
$os=Get-CimInstance Win32_OperatingSystem
|
||||
Assert ($os.ProductType -eq 3 -and $os.BuildNumber -in @('20348','26100')) 'Reviewed disposable Server 2022/2025 required.'
|
||||
Assert ($raw.ContainsKey($capi) -and $raw.ContainsKey($app)) 'Readable CAPI2 and AppLocker channels required.'
|
||||
Assert (@($before.Values|Where-Object State -notin @('Enabled','Disabled','Not installed')).Count -eq 0) 'Unreadable original settings refuse fixture mutation.'
|
||||
# Owned disposable preparation removes only this group read ACE, preserving every
|
||||
# captured original byte for final cleanup. Product recovery never uses this shortcut.
|
||||
$descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($before[$capi].SecurityDescriptor)
|
||||
for($i=$descriptor.DiscretionaryAcl.Count-1;$i -ge 0;$i--){$ace=$descriptor.DiscretionaryAcl[$i];if($ace -is [Security.AccessControl.CommonAce] -and $ace.AceQualifier -eq 'AccessAllowed' -and $ace.SecurityIdentifier.Value -eq 'S-1-5-32-573' -and ($ace.AccessMask -band 1)){$descriptor.DiscretionaryAcl.RemoveAce($i)}}
|
||||
$withoutRead=$descriptor.GetSddlForm('All');Assert ((Get-WelaChannelAccessPlan $withoutRead).State -ceq 'GrantRequired') 'Actual descriptor permits one lossless read-only grant.'
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$app,'/ms:2147483648')
|
||||
foreach($scenario in @('grant','no-grant')){
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false','/ms:1048576',('/ca:'+$withoutRead))
|
||||
$prepared=Get-WelaNativeChannel $capi;Save ($scenario+'-prepared.json') $prepared
|
||||
$journal=Join-Path $root ($scenario+'-original-journal');$resultPath=Join-Path $root ($scenario+'-original.json')
|
||||
$options=@('channel-settings','-ChannelAction','Configure','-Auto','-BackupPath',$journal,'-ResultsPath',$resultPath);if($scenario -ceq 'grant'){$options+='-GrantEventLogReaders'}
|
||||
Public ($scenario+'-configure') $options $expectedConfigure
|
||||
$original=Get-Content $resultPath -Raw|ConvertFrom-Json;$selected=@($original.Results|Where-Object {$_.Target.Channel -ceq $capi})
|
||||
Assert ($selected.Count -eq 1 -and $selected[0].Status -ceq 'Applied') 'Public Configure supplies a genuinely Applied selected operation.'
|
||||
$configured=Get-WelaNativeChannel $capi;Assert ($configured.IsEnabled -and $configured.MaximumSizeInBytes -eq 102432768) 'Actual enable and size changes observed.'
|
||||
$others=@{};foreach($name in $raw.Keys){if($name -cne $capi){$others[$name]=(Read-Raw $name).OuterXml}}
|
||||
$planDir=Join-Path $root ($scenario+'-plan')
|
||||
Public ($scenario+'-plan') @('channel-recovery','-ChannelRecoveryJournalPath',"$journal/before.jsonl",'-ChannelRecoveryOriginalResultsPath',$resultPath,'-ChannelRecoveryChannel',$capi,'-ChannelRecoveryOutputPath',$planDir)
|
||||
$plan=Get-Content "$planDir/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($plan.Status -ceq 'ReviewRequired' -and -not $plan.NativeWriteAttempted -and (Get-FileHash "$planDir/plan.json").Hash.ToLowerInvariant() -ceq $plan.PlanHash) 'Public Plan is read-only with an independently checked exact hash.'
|
||||
$restoreArgs=@('channel-recovery','-ChannelRecoveryAction','Restore','-ChannelRecoveryPlanPath',"$planDir/plan.json",'-ChannelRecoveryPlanHash',$plan.PlanHash)
|
||||
$consents=@('-ChannelRecoveryAllowShrink','-ChannelRecoveryAllowDisable');if($scenario -ceq 'grant'){$consents+='-ChannelRecoveryAllowRevoke'}
|
||||
foreach($consent in $consents){
|
||||
$refuseDir=Join-Path $root ($scenario+'-missing-'+$consent.TrimStart('-'))
|
||||
Public ($scenario+'-missing-'+$consent.TrimStart('-')) ($restoreArgs+@('-ChannelRecoveryOutputPath',$refuseDir)+@($consents|Where-Object {$_ -cne $consent})) 1
|
||||
$r=Get-Content "$refuseDir/manifest.json" -Raw|ConvertFrom-Json;Assert ($r.Status -ceq 'Refused' -and -not $r.NativeWriteAttempted -and (Test-WelaNativeChannelSnapshotEqual $configured (Get-WelaNativeChannel $capi))) 'Each required consent refuses before native write.'
|
||||
}
|
||||
$whatIf=Join-Path $root ($scenario+'-whatif');Public ($scenario+'-whatif') ($restoreArgs+@('-ChannelRecoveryOutputPath',$whatIf,'-WhatIf')+$consents) 1
|
||||
Assert (-not (Test-Path $whatIf)) 'Unsupported preview option refuses before dispatch/output.'
|
||||
# Real native drift between reviewed plan and Restore must not be undone.
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$capi,('/ms:'+($configured.MaximumSizeInBytes+65536)))
|
||||
$drift=Join-Path $root ($scenario+'-drift');Public ($scenario+'-drift') ($restoreArgs+@('-ChannelRecoveryOutputPath',$drift)+$consents) 1
|
||||
$r=Get-Content "$drift/manifest.json" -Raw|ConvertFrom-Json;Assert ($r.Status -ceq 'Refused' -and -not $r.NativeWriteAttempted -and (Get-WelaNativeChannel $capi).MaximumSizeInBytes -eq ($configured.MaximumSizeInBytes+65536)) 'Actual native drift refuses without overwriting the later setting.'
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$capi,('/ms:'+$configured.MaximumSizeInBytes))
|
||||
$restoredDir=Join-Path $root ($scenario+'-restore');Public ($scenario+'-restore') ($restoreArgs+@('-ChannelRecoveryOutputPath',$restoredDir)+$consents)
|
||||
$r=Get-Content "$restoredDir/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($r.Status -ceq 'RestoredAndVerified' -and $r.NativeWriteAttempted -and $r.ConfirmedFields.Count -eq $(if($scenario -ceq 'grant'){3}else{2})) 'Every originally changed field has verified durable restoration.'
|
||||
Assert (Test-WelaNativeChannelSnapshotEqual $prepared (Get-WelaNativeChannel $capi)) 'Actual original enable/size/descriptor/retention tuple restored.'
|
||||
Assert ((Guard-Raw (Read-Raw $capi)) -ceq (Guard-Raw $raw[$capi])) 'All other raw selected-channel configuration fields preserved.'
|
||||
foreach($name in $others.Keys){Assert ((Read-Raw $name).OuterXml -ceq $others[$name]) 'Recovery does not touch another profile channel.'}
|
||||
foreach($artifact in $r.Artifacts){Assert ((Get-FileHash (Join-Path $restoredDir $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Restoration artifact hash matches actual bytes.'}
|
||||
$replay=Join-Path $root ($scenario+'-replay');Public ($scenario+'-replay') ($restoreArgs+@('-ChannelRecoveryOutputPath',$replay)+$consents) 1
|
||||
$r=Get-Content "$replay/manifest.json" -Raw|ConvertFrom-Json;Assert ($r.Status -ceq 'Refused' -and -not $r.NativeWriteAttempted) 'Restored old plan refuses replay.'
|
||||
}
|
||||
Write-Host "PASS: $count actual public channel Configure/Restore assertions."
|
||||
}catch{$primary=$_;Write-Host $_;Get-ChildItem -LiteralPath $root -Filter manifest.json -Recurse|ForEach-Object {Write-Host ([IO.File]::ReadAllText($_.FullName))}}
|
||||
finally {
|
||||
foreach($name in $raw.Keys){try{$s=$before[$name];$null=Invoke-WelaNative wevtutil.exe @('sl',$name,('/e:'+$s.IsEnabled.ToString().ToLowerInvariant()),('/ms:'+$s.MaximumSizeInBytes),('/ca:'+$s.SecurityDescriptor));if(-not (Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $name)) -or (Read-Raw $name).OuterXml -cne $raw[$name].OuterXml){throw 'Original full channel metadata differs after fixture cleanup'}}catch{$errors+=$name+': '+$_.Exception.Message}}
|
||||
try{$current=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($policies[$guid] -ne $current[$guid]){$errors+='Audit policy changed: '+$guid}}}catch{$errors+=$_.Exception.Message}
|
||||
Save 'cleanup.json' ([ordered]@{Complete=($errors.Count -eq 0);Errors=$errors;OriginalChannels=@($raw.Keys);AuditMasksCompared=$policies.Count;PrimaryError=[string]$primary;EventRecordsRestored=$false;Boundary='Fixture restores exact original configuration; shrinking may discard intervening records. No retention or forwarding proof.'})
|
||||
}
|
||||
$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}})
|
||||
Save 'acceptance.json' ([ordered]@{Status=$(if($primary -or $errors.Count){'Failed'}else{'Passed'});Commit=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$count;Artifacts=$artifacts;ReadyRuleCredit=0})
|
||||
if($errors.Count){throw ('Cleanup failed: '+($errors -join '; '))};if($primary){throw $primary};exit 0
|
||||
@@ -0,0 +1,70 @@
|
||||
# Public process-boundary regression: no mocked dispatcher or Windows writers.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = Split-Path $PSScriptRoot -Parent
|
||||
$engine = (Get-Process -Id $PID).Path
|
||||
$count = 0
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-cli-arguments-' + [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory -Path $root
|
||||
function Assert($Value, $Message) { if (-not $Value) { throw $Message }; $script:count++ }
|
||||
function Invoke-Case([string[]]$Arguments, [int]$Expected, [string]$Pattern) {
|
||||
$prior = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$output = & $engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1 | Out-String
|
||||
$code = $LASTEXITCODE
|
||||
} finally { $ErrorActionPreference = $prior }
|
||||
Assert ($code -eq $Expected -and $output -match $Pattern) "Unexpected public CLI exit/output [$code]: $output"
|
||||
}
|
||||
$isWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
|
||||
function Read-NativeState {
|
||||
$logs = @('Security','System','Application','ForwardedEvents','Microsoft-Windows-CAPI2/Operational')
|
||||
$state = [ordered]@{ Audit = Get-WelaEffectiveAuditPolicy; Channels = @() }
|
||||
foreach ($name in $logs) { $state.Channels += Get-WelaNativeChannel $name }
|
||||
return ($state | ConvertTo-Json -Depth 12 -Compress)
|
||||
}
|
||||
try {
|
||||
if ($isWindowsHost) {
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/NativeProviders.psm1" -Force
|
||||
$before = Read-NativeState
|
||||
}
|
||||
# These previously reached legacy writers, including the profile fast path.
|
||||
$commands = @(
|
||||
@('configure','-Auto'),
|
||||
@('configure','-Profile','wela-2.2.0','-Auto'),
|
||||
@('configure-eventlogs','-LogProfile','asd-collector-archive-2021-10','-ApplyLogMode','-Auto'),
|
||||
@('configure-sacl','-Auto'),
|
||||
@('channel-settings','-ChannelAction','Configure','-GrantEventLogReaders','-Auto'),
|
||||
@('powershell-transcription','-TranscriptionAction','Configure','-Auto'),
|
||||
@('firewall-logging','-FirewallAction','Configure','-Auto'),
|
||||
@('smb-auditing','-SmbAction','Configure','-Auto'),
|
||||
@('audit-integrity','-IntegrityAction','Configure','-Auto'),
|
||||
@('provider-packs','-ProviderAction','Configure','-Auto'),
|
||||
@('wec-collector','-WefAction','Configure','-Auto'),
|
||||
@('audit-settings','-Help')
|
||||
)
|
||||
foreach ($command in $commands) {
|
||||
foreach ($unknown in @('-WhatIf','-DryRnu')) {
|
||||
Invoke-Case ($command + @('-BackupPath',"$root/journal",'-ResultsPath',"$root/result.json",$unknown)) 1 'Unsupported trailing arguments'
|
||||
Assert (-not (Test-Path "$root/journal") -and -not (Test-Path "$root/result.json")) 'Rejected arguments must not create journals/results'
|
||||
}
|
||||
}
|
||||
# Unknown argument values are deliberately omitted from WELA's diagnostic.
|
||||
Invoke-Case @('configure','-Auto','-UnrecognizedOption','opaque-value') 1 'Unsupported trailing arguments'
|
||||
Invoke-Case @('configure','-Help','-WhatIf:$false') 1 'Unsupported trailing arguments'
|
||||
Invoke-Case @('-WhatIf','configure','-Auto') 1 'Unsupported trailing arguments'
|
||||
# Preserve documented named/positional binding, help, abbreviations and DryRun.
|
||||
Invoke-Case @('configure','-Help','-Auto','-DryRun') 0 'Read live state'
|
||||
Invoke-Case @('-Cmd','configure','-Help') 0 'Usage:'
|
||||
Invoke-Case @('configure','std','-Help') 0 'Usage:'
|
||||
Invoke-Case @('configure','-Hel') 0 'Usage:'
|
||||
Invoke-Case @('profiles') 0 'wela-2.2.0'
|
||||
Invoke-Case @('failed-logon-probe','-FailedLogonAction','Run','-WhatIf') 1 'only dedicated'
|
||||
if ($isWindowsHost) {
|
||||
Assert ((Read-NativeState) -ceq $before) 'Actual audit masks and native channel settings must remain unchanged'
|
||||
$evidence = [ordered]@{ Status='Passed'; Engine=$PSVersionTable.PSVersion.ToString(); OS=[Environment]::OSVersion.Version.ToString(); StateUnchanged=$true; Before=($before|ConvertFrom-Json); After=((Read-NativeState)|ConvertFrom-Json) }
|
||||
if ($env:RUNNER_TEMP) { $evidence | ConvertTo-Json -Depth 16 | Set-Content (Join-Path $env:RUNNER_TEMP 'wela-cli-arguments.json') -Encoding UTF8 }
|
||||
}
|
||||
Write-Host "PASS: $count public CLI argument assertions."
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force }
|
||||
$global:LASTEXITCODE = 0
|
||||
@@ -0,0 +1,16 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
|
||||
$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$cases=@(
|
||||
@{Args=@('eventlog-recovery','-EventRecoveryAction','Restore','-WhatIf');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('eventlog-recovery','-Help');Code=0;Pattern='AllowShrink'},
|
||||
@{Args=@('configure','-EventRecoveryAction','Restore','-Auto');Code=1;Pattern='require eventlog-recovery'},
|
||||
@{Args=@('eventlog-recovery','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('eventlog-recovery','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('eventlog-recovery','-Help','-Auto');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('eventlog-recovery','-Help','-DryRun');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('eventlog-recovery','-EventRecoveryAction','Restore','-EventRecoveryOutputPath','not-created');Code=1;Pattern='reviewed plan'},
|
||||
@{Args=@('eventlog-recovery','-EventRecoveryOutputPath','not-created');Code=1;Pattern='Plan requires'}
|
||||
)
|
||||
foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++}
|
||||
Write-Host "Event-log recovery CLI: $count checks passed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,79 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/EventLogSettings.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/AuditRecovery.ps1"
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/EventLogConfiguration.ps1"
|
||||
. "$repo/scripts/EventLogRecovery.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"}
|
||||
$sources=ConvertFrom-WelaArrivalJson (Get-WelaEventRecoverySources)
|
||||
Assert ($sources.'scripts/ControlApplicability.ps1' -ceq (Get-FileHash "$repo/scripts/ControlApplicability.ps1").Hash.ToLowerInvariant()) 'Actual host identity/context implementation is fingerprinted.'
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-event-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$oldComputer=$env:COMPUTERNAME;$env:COMPUTERNAME='TEST'
|
||||
function Get-WelaEventRecoveryContext {[pscustomobject][ordered]@{Host=[ordered]@{Computer='TEST';MachineGuid='1'};Reader='S-1-5-21-fixture'}}
|
||||
function Get-WelaEventLogState {param($Log);[pscustomobject]@{Log=$Log;ReadStatus='Available';MaximumSizeInBytes=$script:bytes;LogMode=$script:mode;FileSize=123;IsEnabled=$false;Diagnostic=''}}
|
||||
function Invoke-WelaNative {param($FilePath,$Arguments);foreach($arg in $Arguments){if($arg -like '/ms:*'){$script:bytes=[long]$arg.Substring(4)}};if($Arguments -contains '/ab:true'){$script:mode='AutoBackup'}}
|
||||
function Read-WelaEventRecoveryChannel {param($Log);$script:reads++;if($script:scenario -eq 'fresh-drift' -and $script:reads -eq 2){$script:bytes+=65536};[pscustomobject]@{Log=$Log;MaximumSizeInBytes=$script:bytes;LogMode=$script:mode;Guard=[ordered]@{IsEnabled=$false;Path='Original';SecurityDescriptor=$script:acl}}}
|
||||
function Set-WelaEventRecoveryChannel {param($Definition);Assert (Test-Path $script:pending) 'Pending receipt precedes write';$script:writes++;if($script:scenario -eq 'native-fail'){throw 'native failure'};if($script:scenario -ne 'false-success'){$script:bytes=$Definition.RecoverTo.MaximumSizeInBytes;$script:mode=$Definition.RecoverTo.LogMode};if($script:scenario -eq 'preservation'){$script:acl='changed'}}
|
||||
try {
|
||||
foreach($case in @('ok','no-shrink','no-mode','hash','tamper','duplicate','drift','fresh-drift','source','native-fail','false-success','preservation','historical-drift')){
|
||||
$script:scenario='';$script:bytes=33554432L;$script:mode='Retain';$script:acl='Original';$script:writes=0;$script:reads=0
|
||||
$dir=Join-Path $root $case;$null=New-Item -ItemType Directory $dir
|
||||
$context=New-WelaConfigurationContext -Auto -BackupPath "$dir/journal"
|
||||
Set-WelaEventLogProfileControls -Context $context -Profile 'asd-collector-archive-2021-10' -ApplyLogMode
|
||||
$result=Complete-WelaConfiguration -Context $context -Scope 'event-log-size-and-mode-only' -ResultsPath "$dir/original.json"
|
||||
Assert ($result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Applied') 'Genuine configuration callback creates completed evidence'
|
||||
$plan=Invoke-WelaEventLogRecovery Plan -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Log ForwardedEvents -OutputPath "$dir/plan"
|
||||
Assert ($plan.Status -eq 'ReviewRequired' -and $plan.ExitCode -eq 0) "Plan $case : $($plan.Diagnostic)"
|
||||
Assert ($script:writes -eq 0) 'Plan never restores'
|
||||
$planPath="$dir/plan/plan.json";$hash=$plan.PlanHash
|
||||
if($case -eq 'hash'){$hash='a'*64}
|
||||
if($case -in @('tamper','duplicate')){
|
||||
$text=[IO.File]::ReadAllText($planPath)
|
||||
if($case -eq 'tamper'){$text=$text.Replace('33554432','67108864')}else{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')}
|
||||
[IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant()
|
||||
}
|
||||
if($case -eq 'source'){[IO.File]::AppendAllText("$dir/original.json",' ')}
|
||||
if($case -eq 'historical-drift'){
|
||||
$original=Get-Content "$dir/original.json" -Raw|ConvertFrom-Json;$original.Results[0].After.MaximumSizeInBytes+=65536;$original|ConvertTo-Json -Depth 15|Set-Content "$dir/original.json"
|
||||
$bad=Invoke-WelaEventLogRecovery Plan -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Log ForwardedEvents -OutputPath "$dir/bad-plan"
|
||||
Assert ($bad.Status -eq 'Refused' -and $bad.Diagnostic -match 'unexplained drift') 'Independent postwrite buffer growth cannot be undone as WELA-owned change'
|
||||
}
|
||||
$script:scenario=$case;$script:reads=0;$script:pending="$dir/restore/before-restore.json"
|
||||
if($case -eq 'drift'){$script:bytes+=65536}
|
||||
$restore=Invoke-WelaEventLogRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/restore" -AllowShrink:($case -ne 'no-shrink') -AllowRetentionChange:($case -ne 'no-mode')
|
||||
Assert (($restore.ExitCode -eq 0) -eq ($case -eq 'ok')) "Restore $case : $($restore.Diagnostic)"
|
||||
Assert (Test-Path "$dir/restore/manifest.json") 'Manifest retained'
|
||||
if($case -eq 'ok'){
|
||||
Assert ($script:bytes -eq 33554432 -and $script:mode -eq 'Retain' -and $restore.Status -eq 'RestoredAndVerified' -and $restore.ReadyRuleCredit -eq 0) 'Original immediate-prewrite size/mode restored'
|
||||
$replay=Invoke-WelaEventLogRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/replay" -AllowShrink -AllowRetentionChange
|
||||
Assert ($replay.Status -eq 'Refused' -and $script:writes -eq 1) 'Old post-configuration plan is not replayed'
|
||||
}elseif($case -in @('native-fail','false-success','preservation')){Assert ($restore.Status -eq 'RestoreAttemptedUnverified' -and $script:writes -eq 1) 'Partial failure explicit'}
|
||||
else{Assert ($script:writes -eq 0 -and -not $restore.NativeWriteAttempted) 'Refusal occurs before write'}
|
||||
}
|
||||
# Reject PowerShell boolean-to-string comparison coercion in completed evidence.
|
||||
$goodResult=[IO.File]::ReadAllText("$root/ok/original.json");$goodJournal=[IO.File]::ReadAllText("$root/ok/journal/before.jsonl")
|
||||
foreach($field in @('Status','Kind','Id','Scope','ComputerName','Phase','StateLog','ReadStatus','TargetLog','DesiredMode')){
|
||||
$r=ConvertFrom-WelaArrivalJson $goodResult;$j=@($goodJournal -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_})
|
||||
switch($field){
|
||||
Status {$r.Results[0].Status=$true}
|
||||
Kind {$r.Results[0].Kind=$true}
|
||||
Id {$r.Results[0].Id=$true}
|
||||
Scope {$r.Scope=$true}
|
||||
ComputerName {$j[0].ComputerName=$true}
|
||||
Phase {$j[1].Phase=$true}
|
||||
StateLog {$r.Results[0].After.Log=$true}
|
||||
ReadStatus {$r.Results[0].After.ReadStatus=$true}
|
||||
TargetLog {$j[0].Target.Log=$true;$r.Results[0].Target.Log=$true}
|
||||
DesiredMode {$j[0].Desired.SizeMode=$true;$r.Results[0].Desired.SizeMode=$true}
|
||||
}
|
||||
$r|ConvertTo-Json -Depth 20|Set-Content "$root/typed-result.json"
|
||||
@($j|ForEach-Object {$_|ConvertTo-Json -Depth 20 -Compress})|Set-Content "$root/typed-journal.jsonl"
|
||||
Reject {Get-WelaEventRecoveryDefinition "$root/typed-journal.jsonl" "$root/typed-result.json" ForwardedEvents} 'mistyped recovery text|Exactly one result'
|
||||
}
|
||||
}finally{$env:COMPUTERNAME=$oldComputer;Remove-Item $root -Recurse -Force}
|
||||
Write-Host "Event-log recovery passed: $count assertions."
|
||||
@@ -0,0 +1,72 @@
|
||||
param([switch]$AllowDisposableChannelWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable hosted Windows opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/EventLogSettings.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/ControlApplicability.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/AuditRecovery.ps1"
|
||||
. "$repo/scripts/ChannelRead.ps1"
|
||||
. "$repo/scripts/EventLogRecovery.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
function Invoke-RecoveryFixtureCli {param([string[]]$Arguments,[int]$Expected=0)
|
||||
$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
if(($Expected -eq 0 -and $code -ne 0) -or ($Expected -ne 0 -and $code -eq 0)){throw "Public CLI $code : $($lines -join ' ')"}
|
||||
}
|
||||
$log='ForwardedEvents';$before=Read-WelaEventRecoveryChannel $log;$policies=Get-WelaEffectiveAuditPolicy
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-event-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$primary=$null
|
||||
try{
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:33554432','/rt:true','/ab:false')
|
||||
$prepared=Read-WelaEventRecoveryChannel $log
|
||||
Assert ((Get-WelaRecoveryKey $prepared.Guard) -ceq (Get-WelaRecoveryKey $before.Guard)) 'Preparation preserves enable/path/ACL/provider fields'
|
||||
Invoke-RecoveryFixtureCli @('configure-eventlogs','-LogProfile','asd-collector-archive-2021-10','-ApplyLogMode','-Auto','-BackupPath',"$root/journal",'-ResultsPath',"$root/original.json")
|
||||
$original=Get-Content "$root/original.json" -Raw|ConvertFrom-Json
|
||||
Assert ($original.Results.Count -eq 1 -and $original.Results[0].Status -eq 'Applied') 'Genuine public Configure evidence'
|
||||
$configured=Read-WelaEventRecoveryChannel $log
|
||||
Assert ($configured.MaximumSizeInBytes -eq 2147483648 -and $configured.LogMode -eq 'AutoBackup') 'Native configured size/mode observed'
|
||||
Invoke-RecoveryFixtureCli @('eventlog-recovery','-EventRecoveryJournalPath',"$root/journal/before.jsonl",'-EventRecoveryOriginalResultsPath',"$root/original.json",'-EventRecoveryLog',$log,'-EventRecoveryOutputPath',"$root/plan")
|
||||
$plan=Get-Content "$root/plan/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($plan.Status -eq 'ReviewRequired' -and (Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $configured)) 'Public Plan makes no channel changes'
|
||||
$apply=@('eventlog-recovery','-EventRecoveryAction','Restore','-EventRecoveryPlanPath',"$root/plan/plan.json",'-EventRecoveryPlanHash',$plan.PlanHash)
|
||||
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/unknown-option",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange','-WhatIf')) 1
|
||||
Assert (-not (Test-Path "$root/unknown-option") -and (Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $configured)) 'Unknown WhatIf refuses before output or native restoration'
|
||||
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/without-consent")) 1
|
||||
$refused=Get-Content "$root/without-consent/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($refused.Status -eq 'Refused' -and -not $refused.NativeWriteAttempted) 'Shrinking requires independent explicit consent'
|
||||
# Actual concurrent-size drift, then exact fixture restoration, exercises public refusal.
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:2147549184')
|
||||
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/drift",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) 1
|
||||
$drift=Get-Content "$root/drift/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($drift.Status -eq 'Refused' -and -not $drift.NativeWriteAttempted) 'Actual native size drift refuses restoration'
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:2147483648')
|
||||
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/restored",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange'))
|
||||
$restored=Get-Content "$root/restored/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($restored.Status -eq 'RestoredAndVerified' -and $restored.NativeWriteAttempted -and $restored.ReadyRuleCredit -eq 0) 'Native public restoration verified'
|
||||
Assert ((Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $prepared)) 'Exact prepared size/mode and all preserved fields restored'
|
||||
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/replay",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) 1
|
||||
$replay=Get-Content "$root/replay/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($replay.Status -eq 'Refused' -and -not $replay.NativeWriteAttempted) 'Consumed plan cannot overwrite recovered state'
|
||||
Write-Host "Native event-log recovery passed $count assertions; no record preservation or sustained retention claim."
|
||||
}catch{$primary=$_}
|
||||
finally{
|
||||
$errorText=''
|
||||
try{
|
||||
$arguments=@('sl',$log,('/ms:'+$before.MaximumSizeInBytes))
|
||||
switch($before.LogMode){'Circular'{$arguments+=@('/rt:false','/ab:false')};'Retain'{$arguments+=@('/rt:true','/ab:false')};'AutoBackup'{$arguments+=@('/rt:true','/ab:true')}}
|
||||
$null=Invoke-WelaNative wevtutil.exe $arguments
|
||||
if((Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -cne (Get-WelaRecoveryKey $before)){throw 'Original channel configuration differs after cleanup.'}
|
||||
$now=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($now[$guid] -ne $policies[$guid]){throw 'Original audit mask changed.'}}
|
||||
}catch{$errorText=$_.Exception.Message}
|
||||
$cleanup=[ordered]@{CleanupVerified=($errorText -eq '');Before=$before;After=(Read-WelaEventRecoveryChannel $log);AuditMasksCompared=$policies.Count;Diagnostic=$errorText}
|
||||
$cleanup|ConvertTo-Json -Depth 12|Set-Content "$root/cleanup.json" -Encoding UTF8
|
||||
if($errorText){throw "Cleanup failed: $errorText; primary: $primary"}
|
||||
Write-Host 'Original channel size/mode, enable/path/ACL/provider fields and all audit masks restored.'
|
||||
}
|
||||
if($primary){throw $primary}
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$cases=@(
|
||||
@{Args=@('failed-logon-probe','-FailedLogonAction','Run','-WhatIf');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('failed-logon-probe','-Help');Code=0;Pattern='nonexistent local account'},
|
||||
@{Args=@('configure','-FailedLogonAction','Run','-Auto');Code=1;Pattern='require failed-logon-probe'},
|
||||
@{Args=@('failed-logon-probe','-Help','-Auto');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('failed-logon-probe','-Help','-DryRun');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('failed-logon-probe','-Help','-ResultsPath','unused');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('failed-logon-probe','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('failed-logon-probe','-FailedLogonAction','Run');Code=1;Pattern='requires a new'},
|
||||
@{Args=@('failed-logon-probe','-FailedLogonOutputPath','unused');Code=1;Pattern='Plan creates no files'})
|
||||
foreach($case in $cases){$ErrorActionPreference='Continue';$output=& $engine -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $output -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') [$code] $output"};$count++}
|
||||
Write-Host "PASS: $count failed-logon public CLI checks."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,90 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/FailedLogonProbe.ps1"
|
||||
Add-Type -Path "$repo/scripts/FailedLogonProbeNative.cs" -ErrorAction Stop
|
||||
$script:count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject($Code,$Pattern){$message='';try{&$Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
|
||||
function Clone($Value){ConvertFrom-WelaArrivalJson ($Value|ConvertTo-Json -Depth 24 -Compress)}
|
||||
$token=[pscustomobject][ordered]@{UserSid='S-1-5-21-1-2-3-1001';AuthenticationId='0000000000000123';TokenId='0000000000001000';ModifiedId='0000000000001001';ElevatedAdministrator=$true;TokenType='Primary';Impersonation='Absent';GroupSids=@('S-1-1-0','S-1-5-32-544');GroupCount=2;PrivilegeCount=12;ProcessId=1234}
|
||||
$state=[pscustomobject][ordered]@{Host=[pscustomobject]@{Computer='LAB';DomainJoined=$false;Domain='WORKGROUP'};Token=$token;AuditPolicies=@{'0cce9215-69ae-11d9-bed3-505054503030'=2};Precedence=[pscustomobject]@{ValueExists=$true;Type='DWord';Value=1};Channel=[pscustomobject]@{Enabled=$true};Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';Sources='fixed-sources'}
|
||||
$nonce='abcdef0123456789abcdef0123456789ab';$worker=Clone $token;$worker.ProcessId=456;$worker.TokenId='0000000000002000'
|
||||
$operation=[pscustomobject]@{Nonce=$nonce;ProcessId=456;Executable=$state.Engine;BeforeToken=$worker;AfterToken=(Clone $worker);SecurityRecordIdBefore=100;Attempt=[pscustomobject]@{UserName=('WL'+$nonce.Substring(0,18));Domain='.';MissingAccountStatus=2221;LogonType=3;LogonProvider=2;Succeeded=$false;NativeError=1326;Clock='GetSystemTimePreciseAsFileTime';StartedUtc='2025-01-02T03:04:05.1234500Z';CompletedUtc='2025-01-02T03:04:06.1234500Z'}}
|
||||
$launch=[DateTimeOffset]'2025-01-02T03:04:05Z';$observed=[DateTimeOffset]'2025-01-02T03:04:07Z'
|
||||
Assert-WelaFailedLogonOperation $operation $state $nonce 456 $launch $observed
|
||||
Assert $true 'A fixed typed receipt under the same inherited authorization is accepted.'
|
||||
# PowerShell7 before DateKind support may materialize ISO UTC JSON as DateTime.
|
||||
$dated=Clone $operation;$dated.Attempt.StartedUtc=[datetime]::Parse('2025-01-02T03:04:05.1234500Z',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind);$dated.Attempt.CompletedUtc=[datetime]::Parse('2025-01-02T03:04:06.1234500Z',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind)
|
||||
Assert-WelaFailedLogonOperation $dated $state $nonce 456 $launch $observed
|
||||
Assert ($dated.Attempt.StartedUtc -ceq $operation.Attempt.StartedUtc) 'Supported UTC DateTime parsing preserves the exact native interval.'
|
||||
foreach($field in @('UserName','Domain','MissingAccountStatus','LogonType','LogonProvider','Succeeded','NativeError','Clock','StartedUtc','CompletedUtc','Nonce','ProcessId','Executable','Token','TokenType')){
|
||||
$bad=Clone $operation
|
||||
switch($field){
|
||||
UserName {$bad.Attempt.UserName='Administrator'}
|
||||
Domain {$bad.Attempt.Domain='example.test'}
|
||||
MissingAccountStatus {$bad.Attempt.MissingAccountStatus=0}
|
||||
LogonType {$bad.Attempt.LogonType=2}
|
||||
LogonProvider {$bad.Attempt.LogonProvider=0}
|
||||
Succeeded {$bad.Attempt.Succeeded=$true}
|
||||
NativeError {$bad.Attempt.NativeError='1326'}
|
||||
Clock {$bad.Attempt.Clock='DateTime.UtcNow'}
|
||||
StartedUtc {$bad.Attempt.StartedUtc='2025-01-02T03:04:04.9999999Z'}
|
||||
CompletedUtc {$bad.Attempt.CompletedUtc='2025-01-02T03:04:07.0000001Z'}
|
||||
Nonce {$bad.Nonce='f'*32}
|
||||
ProcessId {$bad.ProcessId=457}
|
||||
Executable {$bad.Executable='C:\other.exe'}
|
||||
Token {$bad.AfterToken.ModifiedId='0000000000001002'}
|
||||
TokenType {$bad.BeforeToken.GroupCount='2'}
|
||||
}
|
||||
Reject {Assert-WelaFailedLogonOperation $bad $state $nonce 456 $launch $observed} 'Unexpected|interval|token|observation'
|
||||
}
|
||||
foreach($field in @('Nonce','Executable','UserName','Domain','Clock','TokenType','Impersonation','ElevatedAdministrator')){
|
||||
$bad=Clone $operation
|
||||
if($field -in @('Nonce','Executable')){$bad.$field=$true}
|
||||
elseif($field -in @('TokenType','Impersonation')){$bad.BeforeToken.$field=$true}
|
||||
elseif($field -eq 'ElevatedAdministrator'){$bad.BeforeToken.ElevatedAdministrator='true'}
|
||||
else{$bad.Attempt.$field=$true}
|
||||
Reject {Assert-WelaFailedLogonOperation $bad $state $nonce 456 $launch $observed} 'receipt type|primary-token observation'
|
||||
}
|
||||
foreach($api in @('LogonUserW','NetUserGetInfo','GetSystemTimePreciseAsFileTime')){
|
||||
$import=[Wela.FailedLogonProbe.Native].GetMethod($api,[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0]
|
||||
Assert ($import.ExactSpelling -and $import.EntryPoint -ceq $api) ('Exact native binding '+$api)
|
||||
}
|
||||
Reject {[Wela.FailedLogonProbe.Native]::Run('Administrator')} 'GUID nonce'
|
||||
$xml='<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-a5ba-3e3b0328c30d}"/><EventID>4625</EventID><Version>0</Version><Keywords>0x8010000000000000</Keywords><EventRecordID>101</EventRecordID><Channel>Security</Channel><Computer>LAB</Computer><TimeCreated SystemTime="2025-01-02T03:04:05.5000000Z"/></System><EventData><Data Name="SubjectUserSid">S-1-5-21-1-2-3-1001</Data><Data Name="SubjectLogonId">0x123</Data><Data Name="TargetUserSid">S-1-0-0</Data><Data Name="TargetUserName">WLabcdef0123456789ab</Data><Data Name="TargetDomainName">LAB</Data><Data Name="Status">0xc000006d</Data><Data Name="SubStatus">0xc0000064</Data><Data Name="LogonType">3</Data><Data Name="AuthenticationPackageName">MICROSOFT_AUTHENTICATION_PACKAGE_V1_0</Data><Data Name="ProcessName">C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Data><Data Name="ProcessId">0x1c8</Data></EventData></Event>'
|
||||
Assert (Test-WelaFailedLogonEvent $xml $operation $state) 'Exact synthetic4625 matches.'
|
||||
foreach($edge in @(@('03:04:05.1234500Z',$true),@('03:04:06.1234500Z',$true),@('03:04:05.1234499Z',$false),@('03:04:06.1234501Z',$false))){Assert ((Test-WelaFailedLogonEvent $xml.Replace('03:04:05.5000000Z',$edge[0]) $operation $state) -eq $edge[1]) 'Exact100ns operation boundary.'}
|
||||
foreach($pair in @(@('4625','4624'),@('>0</Version>','>1</Version>'),@('WLabcdef0123456789ab','Administrator'),@('0xc0000064','0xc000006a'),@('0xc000006d','0x0'),@('>3</Data>','>2</Data>'),@('>MICROSOFT_AUTHENTICATION_PACKAGE_V1_0<','>Kerberos<'),@('>MICROSOFT_AUTHENTICATION_PACKAGE_V1_0<','>NTLM<'),@('0x1c8','0x1c9'),@('0x123','0x124'),@('S-1-5-21-1-2-3-1001','S-1-5-18'),@('>LAB<','>OTHER<'),@('S-1-0-0','S-1-5-18'),@('>101<','>100<'),@('0x8010000000000000','0x8020000000000000'),@('>Security<','>Application<'),@('powershell.exe','other.exe'),@('54849625-5478-4994-a5ba-3e3b0328c30d','54849625-5478-4994-a5ba-3e3b0328c30e'))){$bad=$xml.Replace($pair[0],$pair[1]);Assert ($bad -cne $xml) 'Mutation changes fixture';Assert (-not(Test-WelaFailedLogonEvent $bad $operation $state)) ('Mismatch refused '+$pair[0])}
|
||||
Assert (-not(Test-WelaFailedLogonEvent $xml.Replace('</EventData>','<Data Name="LogonType">3</Data></EventData>') $operation $state)) 'Duplicate payload refused.'
|
||||
Assert (-not(Test-WelaFailedLogonEvent $xml.Replace('</System>','<EventID>4625</EventID></System>') $operation $state)) 'Duplicate System field refused.'
|
||||
Assert (-not(Test-WelaFailedLogonEvent ('<!DOCTYPE Event [<!ENTITY x "LAB">]>'+$xml.Replace('>LAB<','>&x;<')) $operation $state)) 'DTD refused.'
|
||||
$null=Get-WelaFailedLogonStateKey $state
|
||||
foreach($mask in @(0,1,4,'2')){$bad=Clone $state;$bad.AuditPolicies=@{'0cce9215-69ae-11d9-bed3-505054503030'=$mask};Reject {Get-WelaFailedLogonStateKey $bad} 'failure auditing'}
|
||||
$bad=Clone $state;$bad.Precedence.Type='String';Reject {Get-WelaFailedLogonStateKey $bad} 'failure auditing'
|
||||
$bad=Clone $state;$bad.Channel.Enabled=$false;Reject {Get-WelaFailedLogonStateKey $bad} 'failure auditing'
|
||||
Reject {Invoke-WelaFailedLogonProbe -Action Run} 'requires a new'
|
||||
Reject {Invoke-WelaFailedLogonProbe -OutputPath 'unused'} 'Plan creates no files'
|
||||
# Production orchestration with only native boundaries mocked; no authentication here.
|
||||
$script:mode='Success';$script:reads=0;$script:attempts=0
|
||||
function Get-WelaFailedLogonState {$script:reads++;$copy=Clone $state;$copy.AuditPolicies=@{'0cce9215-69ae-11d9-bed3-505054503030'=2};if($script:mode -eq 'Blocked'){$copy.AuditPolicies['0cce9215-69ae-11d9-bed3-505054503030']=0};if($script:mode -eq 'Drift' -and $script:reads -gt 1){$copy.Sources='changed'};$copy}
|
||||
function Start-WelaFailedLogonAttempt {param($State,$OutputPath);$script:attempts++;$operation}
|
||||
function Read-WelaFailedLogonEvents {param($Operation);if($script:mode -eq 'ReadError'){throw 'native read failed'};$events=@($xml);if($script:mode -eq 'Duplicate'){$events+= $xml};[pscustomobject]@{Xml=$events;Capped=($script:mode -eq 'Cap')}}
|
||||
function Get-WelaFailedLogonWatermark {if($script:mode -eq 'Clear'){99}else{101}}
|
||||
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-failed-logon-fixtures-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
|
||||
try{
|
||||
$plan=Invoke-WelaFailedLogonProbe;Assert ($plan.Status -eq 'PrerequisitesObserved' -and $script:attempts -eq 0) 'Plan never authenticates.'
|
||||
foreach($mode in @('Success','Blocked','Cap','ReadError','Drift','Clear','Duplicate')){
|
||||
$script:mode=$mode;$script:reads=0;$script:attempts=0;$dir=Join-Path $temp $mode
|
||||
$result=Invoke-WelaFailedLogonProbe -Action Run -OutputPath $dir -TimeoutSeconds 1
|
||||
$manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $dir 'manifest.json')))
|
||||
Assert ($manifest.ReadyRuleCredit -eq 0 -and $manifest.PolicyChanges -eq 0 -and $manifest.AccountChanges -eq 0) 'No audit or readiness claim.'
|
||||
Assert ($null -ne $manifest.After) 'Final state retained.'
|
||||
foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $dir $artifact.Name)).Hash.ToLowerInvariant()) 'Exact artifact hash.'}
|
||||
if($mode -eq 'Success'){Assert ($result.Status -eq 'LocalFailedLogonObserved' -and $result.Matches -eq 1 -and $result.ExitCode -eq 0 -and $script:attempts -eq 1) 'Only one worker attempt.'}else{Assert ($result.Status -eq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) ('Unverified '+$mode)}
|
||||
if($mode -eq 'Blocked'){Assert ($script:attempts -eq 0) 'Missing prerequisites never attempt authentication.'}
|
||||
}
|
||||
}finally{Remove-Item -LiteralPath $temp -Recurse -Force}
|
||||
Write-Host "PASS: $script:count failed-logon fixtures; authentication was mocked."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,51 @@
|
||||
# Native public CLI only: fixture prepares auditing, product never changes it.
|
||||
param([switch]$AllowDisposableAuditWrite,[ValidateRange(1,3)][int]$ProbeRuns=2)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable GitHub-hosted Windows opt-in is required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/ControlApplicability.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/ChannelRead.ps1"
|
||||
. "$repo/scripts/FailedLogonProbe.ps1"
|
||||
$context=Get-WelaDefaultContext
|
||||
if(-not(Test-WelaDefaultContextComplete $context) -or $context.Build -notin @(20348,26100) -or $context.ProductType -ne 3 -or $context.DomainRole -ne 2 -or $context.DomainJoined){throw 'Only observed disposable workgroup Server2022/2025 is permitted.'}
|
||||
$script:count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function PolicyKey($Map){(@($Map.Keys|Sort-Object|ForEach-Object{"$_=$($Map[$_])"}) -join ';')}
|
||||
function AccountsKey {(@(Get-LocalUser -ErrorAction Stop|ForEach-Object {"$($_.SID.Value)=$($_.Name)=$($_.Enabled)"}|Sort-Object) -join ';')}
|
||||
$engine=(Get-Process -Id $PID).Path;$guid='0CCE9215-69AE-11D9-BED3-505054503030'
|
||||
$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$name='SCENoApplyLegacyAuditPolicy'
|
||||
$policies=Get-WelaEffectiveAuditPolicy;$precedence=Get-WelaRegistryState $path $name;$accounts=AccountsKey
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-failed-logon-native-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$failure=$null;$cleanupErrors=@()
|
||||
try{
|
||||
Set-ItemProperty -LiteralPath $path -Name $name -Type DWord -Value 1
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 2 -Mode minimum
|
||||
$preparedPolicies=PolicyKey (Get-WelaEffectiveAuditPolicy)
|
||||
for($trial=1;$trial -le $ProbeRuns;$trial++){
|
||||
$out=Join-Path $root ('probe-'+$trial)
|
||||
$ErrorActionPreference='Continue';$cli=&$engine -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$repo/WELA.ps1" failed-logon-probe -FailedLogonAction Run -FailedLogonOutputPath $out -FailedLogonTimeoutSeconds 20 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop'
|
||||
$manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $out 'manifest.json')))
|
||||
Write-Host ($manifest|ConvertTo-Json -Depth 18)
|
||||
foreach($file in @(Get-ChildItem -LiteralPath $out -Filter '*.xml')){Write-Host ([IO.File]::ReadAllText($file.FullName))}
|
||||
Assert ($code -eq 0 -and $manifest.Status -eq 'LocalFailedLogonObserved' -and $manifest.ExitCode -eq 0) ('Native public probe failed with exit '+$code+': '+$manifest.Diagnostic)
|
||||
Assert ($manifest.Matches -eq 1 -and $manifest.ReadyRuleCredit -eq 0 -and $manifest.PolicyChanges -eq 0 -and $manifest.AccountChanges -eq 0) 'One exact event, no mutation or Sigma credit.'
|
||||
Assert ($manifest.Operation.Attempt.NativeError -eq 1326 -and $manifest.Operation.Attempt.MissingAccountStatus -eq 2221 -and -not $manifest.Operation.Attempt.Succeeded) 'Actual local account absence and failed LogonUser receipt.'
|
||||
Assert (Test-WelaFailedLogonEvent ([IO.File]::ReadAllText((Join-Path $out 'event.xml'))) $manifest.Operation $manifest.Before) 'Actual4625 satisfies exact identity/process/type/status/time checks.'
|
||||
foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Native evidence hash verified.'}
|
||||
Assert ((PolicyKey (Get-WelaEffectiveAuditPolicy)) -ceq $preparedPolicies) 'Product leaves every audit mask unchanged.'
|
||||
Assert ((AccountsKey) -ceq $accounts) 'Local account names/SIDs/enabled states unchanged.'
|
||||
Assert ((Get-Acl -LiteralPath $out).AreAccessRulesProtected) 'Evidence directory inheritance is protected.'
|
||||
}
|
||||
}catch{$failure=$_}
|
||||
finally{
|
||||
try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $policies[$guid] -Mode exact}catch{$cleanupErrors+='Audit restoration: '+$_.Exception.Message}
|
||||
try{if($precedence.ValueExists){Set-ItemProperty -LiteralPath $path -Name $name -Type $precedence.Type -Value $precedence.Value}else{Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restoration: '+$_.Exception.Message}
|
||||
try{Assert ((PolicyKey (Get-WelaEffectiveAuditPolicy)) -ceq (PolicyKey $policies)) 'All59 original audit masks restored.';Assert (((Get-WelaRegistryState $path $name)|ConvertTo-Json -Compress) -ceq ($precedence|ConvertTo-Json -Compress)) 'Typed original precedence restored.';Assert ((AccountsKey) -ceq $accounts) 'No local accounts changed.'}catch{$cleanupErrors+='Verification: '+$_.Exception.Message}
|
||||
[ordered]@{CleanupVerified=($cleanupErrors.Count -eq 0);AuditMasksCompared=$policies.Count;ProbeRuns=$ProbeRuns;AssertionCount=$count;Failure=$(if($failure){$failure.Exception.Message}else{$null});CleanupErrors=$cleanupErrors}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root 'cleanup.json') -Encoding UTF8
|
||||
}
|
||||
if($failure){throw $failure};if($cleanupErrors.Count){throw ($cleanupErrors -join '; ')}
|
||||
Write-Host "PASS: $script:count actual native4625/public CLI assertions across $ProbeRuns independent runs; original policies restored, no local account changes."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path
|
||||
$cases=@(
|
||||
@{Args=@('file-access-probe','-Help');Code=0;Pattern='Reads one byte and discards it'},
|
||||
@{Args=@('file-access-probe','-FileProbeAction','Run','-WhatIf');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('file-access-probe','extra','-Help');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('file-access-probe','-Auto','-Help');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('file-access-probe','-DryRun','-Help');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('help','-FileProbeAction','Run');Code=1;Pattern='require file-access-probe'},
|
||||
@{Args=@('file-access-probe','-FileProbePath','\\host\share\file');Code=1;Pattern='exact ordinary'},
|
||||
@{Args=@('file-access-probe','-FileProbePath','C:\file.txt','-FileProbeAction','Run');Code=1;Pattern='Run requires'},
|
||||
@{Args=@('file-access-probe','-FileProbePath','C:\file.txt','-FileProbeOutputPath','never-created');Code=1;Pattern='Plan creates no output'}
|
||||
)
|
||||
foreach($case in $cases){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old};if($code -ne $case.Code -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI refusal failure: $($case.Args -join ' ') => $code / $($output -join ' ')"}}
|
||||
Write-Host "Passed $($cases.Count) public file-access CLI assertions.";$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,98 @@
|
||||
$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
|
||||
foreach($name in @('WefArrival','FileAccessProbe')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
|
||||
$script:checks=0
|
||||
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
|
||||
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'"}
|
||||
function Copy-Value($Value){(ConvertFrom-WelaArrivalJson (Get-WelaFileProbeKey ([pscustomobject]@{Data=$Value}))).Data}
|
||||
function New-Fixture {
|
||||
$script:token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='FIXTURE\Reader';AuthenticationId='0x1234';AuthenticationType='Negotiate';ImpersonationLevel='None';TokenSource='Process';Groups=@([pscustomobject]@{Sid='S-1-1-0';Attributes=7});Privileges=@([pscustomobject]@{Luid='0x8';Attributes=0})}
|
||||
$script:reader=[pscustomobject]@{Computer='FIXTURE';ProcessId=1234;UserSid=$script:token.Sid;UserName=$script:token.Name;TokenId='1';AuthenticationId='4660';ModifiedId='2';GroupSids=@('S-1-1-0');GroupCount=1;PrivilegeCount=1;ElevatedAdministrator=$true;TokenType='Primary';Impersonation='Absent'}
|
||||
$script:state=[pscustomobject]@{Computer='FIXTURE';Host=[pscustomobject]@{ProductType=3;Build=20348;DomainJoined=$false;Domain='WORKGROUP'};MachineGuid='01234567-89ab-cdef-0123-456789abcdef';Services=@([pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='RpcSs';Status='Running'},[pscustomobject]@{Name='Winmgmt';Status='Running'});Reader=($script:reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation);Token=(Copy-Value $script:token);File=[pscustomobject]@{Path='C:\Fixture\ReadCase.TxT';NativePath='\Device\HarddiskVolume5\Fixture\ReadCase.TxT';Identity='1:2:3:1339999';Size=32;LastWriteUtc='2026-09-20T00:00:00.0000000Z';DescriptorBase64='AA==';StateKey=('a'*64);Attributes=32;Links=1;SecurityInformation=511;Aces=@([pscustomobject]@{Type=2;Flags=64;Mask=1;Sid='S-1-1-0';Ordinary=$true;Binary='AA=='})};AuditPolicies=[pscustomobject]@{'0CCE921D-69AE-11D9-BED3-505054503030'=1};Precedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};Channel=[pscustomobject]@{Name='Security';Enabled=$true;SecurityDescriptor='O:SYG:SYD:'};Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';EngineHash=('b'*64);Sources=[pscustomobject]@{Source=('c'*64)}}
|
||||
$script:nonce='d'*32
|
||||
$script:operation=[pscustomobject]@{Kind='WelaOneByteFileRead';Nonce=$script:nonce;ProcessId=1234;Executable=$script:state.Engine;FilePath=$script:state.File.Path;BeforeReader=(Copy-Value $script:reader);AfterReader=(Copy-Value $script:reader);BeforeToken=(Copy-Value $script:token);AfterToken=(Copy-Value $script:token);Read=[pscustomobject]@{Clock='GetSystemTimePreciseAsFileTime';Phase='OneByteReadAndHeldIdentityReadback';Succeeded=$true;ReadCalls=1;BytesRead=1;HandleId='0x888';BeforeKey=('a'*64);AfterKey=('a'*64);StartedUtc='2026-09-21T00:00:00.0001000Z';ReadReturnedUtc='2026-09-21T00:00:00.0001600Z';CompletedUtc='2026-09-21T00:00:00.0002000Z'};RecordIdBefore=10}
|
||||
$script:reads=0;$script:batchMode='match';$script:afterDrift=$false;$script:workerFailure=$false;$script:failArtifact=$null
|
||||
}
|
||||
function Native-Xml {
|
||||
@"
|
||||
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-a5ba-3e3b0328c30d}"/><EventID>4663</EventID><Version>1</Version><Level>0</Level><Task>12800</Task><Opcode>0</Opcode><Keywords>0x8020000000000000</Keywords><TimeCreated SystemTime="2026-09-21T00:00:00.0001500Z"/><EventRecordID>11</EventRecordID><Channel>Security</Channel><Computer>FIXTURE</Computer></System><EventData><Data Name="SubjectUserSid">S-1-5-21-1-2-3-1001</Data><Data Name="SubjectUserName">Reader</Data><Data Name="SubjectDomainName">FIXTURE</Data><Data Name="SubjectLogonId">0x1234</Data><Data Name="ObjectServer">Security</Data><Data Name="ObjectType">File</Data><Data Name="ObjectName">C:\Fixture\ReadCase.TxT</Data><Data Name="HandleId">0x888</Data><Data Name="AccessList">%%4416</Data><Data Name="AccessMask">0x1</Data><Data Name="ProcessId">0x4d2</Data><Data Name="ProcessName">C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Data><Data Name="ResourceAttributes">-</Data></EventData></Event>
|
||||
"@
|
||||
}
|
||||
New-Fixture
|
||||
$null=Get-WelaFileProbeStateKey $script:state;Assert $true 'complete native prerequisites are accepted'
|
||||
Assert-WelaFileProbeOperation $script:operation $script:state $script:nonce 1234 ([datetimeoffset]'2026-09-21T00:00:00Z') ([datetimeoffset]'2026-09-21T00:00:01Z');Assert $true 'one precise same-token byte read is accepted'
|
||||
foreach($path in @('','relative.txt','\\host\share\file','C:\a:stream','C:\a\..\file','C:\a\file.','C:\a\file ','C:\a\file*','C:\a\','C:\a\\file','C:/file',('C:\'+('a'*240)))){Reject {Assert-WelaFileProbePath $path} 'exact ordinary'}
|
||||
$scopeSource=[pscustomobject]@{Path='C:\WELA\WELA.ps1'};$scopeEngine=[pscustomobject]@{Path='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'}
|
||||
foreach($path in @('C:\Data\ordinary.txt','C:\WELA-other\ordinary.txt')){$selected=[pscustomobject]@{Path=$path;Links=1;Attributes=32};Assert-WelaFileProbeScopeObservation $path $selected $scopeSource $scopeEngine;Assert $true 'ordinary targets outside the canonical implementation tree are allowed'}
|
||||
foreach($path in @('C:\WELA\WELA.ps1','c:\wela\scripts\FileAccessProbeNative.cs',$scopeEngine.Path)){$selected=[pscustomobject]@{Path=$path;Links=1;Attributes=32};Reject {Assert-WelaFileProbeScopeObservation $path $selected $scopeSource $scopeEngine} 'source tree|active PowerShell engine'}
|
||||
foreach($mode in @('alias','links','reparse','typed-links')){$selected=[pscustomobject]@{Path='C:\Data\ordinary.txt';Links=1;Attributes=32};$inputPath=$selected.Path;switch($mode){'alias' {$inputPath='C:\Alias\ordinary.txt'};'links' {$selected.Links=2};'reparse' {$selected.Attributes=1024};'typed-links' {$selected.Links=$true}};Reject {Assert-WelaFileProbeScopeObservation $inputPath $selected $scopeSource $scopeEngine} 'ordinary canonical single-link'}
|
||||
foreach($name in @('computer','machine','host-build','host-product','joined','service','token-source','token-sid','token-group','token-privilege','file-path','native-path','file-key','descriptor','size','links','sections','ace-ordinary','ace-type','ace-mask','ace-sid','channel-name','channel-enabled','precedence-type','precedence-value','mask','reader-type','reader-impersonation','engine','source')){
|
||||
New-Fixture
|
||||
switch($name){
|
||||
'computer' {$script:state.Computer=$true};'machine' {$script:state.MachineGuid=$true};'host-build' {$script:state.Host.Build=$true};'host-product' {$script:state.Host.ProductType=$true};'joined' {$script:state.Host.DomainJoined='false'}
|
||||
'service' {$script:state.Services[0].Status=$true};'token-source' {$script:state.Token.TokenSource=$true};'token-sid' {$script:state.Token.Sid=$true};'token-group' {$script:state.Token.Groups[0].Attributes=$true};'token-privilege' {$script:state.Token.Privileges[0].Attributes=$true}
|
||||
'file-path' {$script:state.File.Path=$true};'native-path' {$script:state.File.NativePath=$true};'file-key' {$script:state.File.StateKey=$true};'descriptor' {$script:state.File.DescriptorBase64=$true};'size' {$script:state.File.Size=$true};'links' {$script:state.File.Links=$true};'sections' {$script:state.File.SecurityInformation=$true}
|
||||
'ace-ordinary' {$script:state.File.Aces[0].Ordinary='true'};'ace-type' {$script:state.File.Aces[0].Type=$true};'ace-mask' {$script:state.File.Aces[0].Mask=$true};'ace-sid' {$script:state.File.Aces[0].Sid=$true}
|
||||
'channel-name' {$script:state.Channel.Name=$true};'channel-enabled' {$script:state.Channel.Enabled='true'};'precedence-type' {$script:state.Precedence.Type=$true};'precedence-value' {$script:state.Precedence.Value=$true};'mask' {$script:state.AuditPolicies.'0CCE921D-69AE-11D9-BED3-505054503030'=$true}
|
||||
'reader-type' {$script:state.Reader.TokenType=$true};'reader-impersonation' {$script:state.Reader.Impersonation=$true};'engine' {$script:state.Engine=$true};'source' {$script:state.Sources.Source=$true}
|
||||
}
|
||||
Reject {Get-WelaFileProbeStateKey $script:state} 'required|Incomplete|complete|ordinary|Unknown|Missing|Malformed|must already'
|
||||
}
|
||||
foreach($name in @('deny-only','disabled-group','inherit-only','failure-ace','callback','wrong-right','wrong-sid')){
|
||||
New-Fixture
|
||||
switch($name){'deny-only' {$script:state.Token.Groups[0].Attributes=16};'disabled-group' {$script:state.Token.Groups[0].Attributes=0};'inherit-only' {$script:state.File.Aces[0].Flags=72};'failure-ace' {$script:state.File.Aces[0].Flags=128};'callback' {$script:state.File.Aces[0].Ordinary=$false};'wrong-right' {$script:state.File.Aces[0].Mask=2};'wrong-sid' {$script:state.File.Aces[0].Sid='S-1-5-18'}}
|
||||
Reject {Get-WelaFileProbeStateKey $script:state} 'No existing ordinary success ReadData'
|
||||
}
|
||||
foreach($name in @('kind','nonce','pid','executable','path','clock','phase-type','phase-name','return-before','return-after','success','calls','bytes','handle','before','after','token-drift','reader-drift','pre-launch','post-observed','reverse')){
|
||||
New-Fixture
|
||||
switch($name){'kind' {$script:operation.Kind=$true};'nonce' {$script:operation.Nonce=$true};'pid' {$script:operation.ProcessId=$true};'executable' {$script:operation.Executable=$true};'path' {$script:operation.FilePath=$true};'clock' {$script:operation.Read.Clock=$true};'phase-type' {$script:operation.Read.Phase=$true};'phase-name' {$script:operation.Read.Phase='Other'};'return-before' {$script:operation.Read.ReadReturnedUtc='2026-09-21T00:00:00.0000999Z'};'return-after' {$script:operation.Read.ReadReturnedUtc='2026-09-21T00:00:00.0002001Z'};'success' {$script:operation.Read.Succeeded='true'};'calls' {$script:operation.Read.ReadCalls=$true};'bytes' {$script:operation.Read.BytesRead=$true};'handle' {$script:operation.Read.HandleId='0x0'};'before' {$script:operation.Read.BeforeKey=$true};'after' {$script:operation.Read.AfterKey='e'*64};'token-drift' {$script:operation.AfterToken.Privileges[0].Attributes=2};'reader-drift' {$script:operation.AfterReader.ModifiedId='999'};'pre-launch' {$script:operation.Read.StartedUtc='2026-09-20T23:59:59Z'};'post-observed' {$script:operation.Read.CompletedUtc='2026-09-21T00:00:02Z'};'reverse' {$script:operation.Read.CompletedUtc='2026-09-21T00:00:00Z'}}
|
||||
Reject {Assert-WelaFileProbeOperation $script:operation $script:state $script:nonce 1234 ([datetimeoffset]'2026-09-21T00:00:00Z') ([datetimeoffset]'2026-09-21T00:00:01Z')} 'authority|identity|receipt|Expected|token|interval'
|
||||
}
|
||||
New-Fixture;$xml=Native-Xml
|
||||
Assert (Test-WelaFileProbeEvent $xml $script:operation $script:state) 'actual-schema source fixture matches all attribution fields'
|
||||
foreach($change in @(@('4663','4662'),@('<Version>1','<Version>0'),@('0x8020000000000000','0x8010000000000000'),@('<Task>12800','<Task>1'),@('>FIXTURE</Computer>','>OTHER</Computer>'),@('>0x1</Data>','>0x2</Data>'),@('>0x888</Data>','>0x889</Data>'),@('>0x4d2</Data>','>0x4d3</Data>'),@('>0x1234</Data>','>0x1235</Data>'),@('>File</Data>','>Key</Data>'),@('ReadCase.TxT','Other.txt'),@('>%%4416</Data>','>%%4417</Data>'),@('0001500Z','0000999Z'),@('0001500Z','0002001Z'),@('<EventRecordID>11','<EventRecordID>10'),@('1001</Data>','1002</Data>'),@('v1.0\powershell.exe','v1.0\other.exe'))){Assert (-not(Test-WelaFileProbeEvent $xml.Replace($change[0],$change[1]) $script:operation $script:state)) "mismatched event $($change[0]) is refused"}
|
||||
Assert (Test-WelaFileProbeEvent $xml.Replace('ReadCase.TxT','readcase.txt').Replace('System32','SYSTEM32').Replace('%%4416',' %%4416 ') $script:operation $script:state) 'Windows path casing and native access-list whitespace do not change identity/right'
|
||||
Assert (Test-WelaFileProbeEvent $xml.Replace($script:state.File.Path,$script:state.File.NativePath.ToLowerInvariant()) $script:operation $script:state) 'the exact same-handle observed NT path is accepted case-insensitively'
|
||||
foreach($wrong in @('\Device\HarddiskVolume6\Fixture\ReadCase.TxT','\Device\HarddiskVolume5\Elsewhere\ReadCase.TxT','\Device\HarddiskVolume5\Fixture\Other.TxT')){Assert (-not(Test-WelaFileProbeEvent $xml.Replace($script:state.File.Path,$wrong) $script:operation $script:state)) 'other NT volumes and paths remain rejected'}
|
||||
Assert (Test-WelaFileProbeEvent $xml.Replace('0001500Z','0001800Z') $script:operation $script:state) 'an event after ReadFile returns but inside actual held-identity readback phase remains attributable'
|
||||
foreach($time in @('0001000Z','0002000Z')){Assert (Test-WelaFileProbeEvent $xml.Replace('0001500Z',$time) $script:operation $script:state) 'exact measured phase boundaries are inclusive'}
|
||||
Assert (-not(Test-WelaFileProbeEvent $xml.Replace('</EventData>','<Data Name="AccessMask">0x1</Data></EventData>') $script:operation $script:state)) 'duplicate XML authority is refused'
|
||||
Assert (-not(Test-WelaFileProbeEvent ('<!DOCTYPE Event [<!ENTITY x "x">]>'+$xml) $script:operation $script:state)) 'DTD evidence is refused'
|
||||
Assert (-not(Test-WelaFileProbeEvent ('<wrapper>'+$xml+'</wrapper>') $script:operation $script:state)) 'wrapped event is refused'
|
||||
$script:state.File.LastWriteUtc=[datetime]::SpecifyKind([datetime]'2026-09-20T00:00:00',[DateTimeKind]::Utc);$null=Get-WelaFileProbeStateKey $script:state
|
||||
$script:operation.Read.StartedUtc=[datetime]::SpecifyKind([datetime]'2026-09-21T00:00:00.0001000',[DateTimeKind]::Utc)
|
||||
Assert-WelaFileProbeOperation $script:operation $script:state $script:nonce 1234 ([datetimeoffset]'2026-09-21T00:00:00Z') ([datetimeoffset]'2026-09-21T00:00:01Z');Assert $true 'older PowerShell UTC DateTime observations remain valid'
|
||||
# Compile the exact retained bytes even on portable hosts; invoke no native API.
|
||||
function Initialize-WelaWmiProbeNative {}
|
||||
Initialize-WelaFileProbeNative
|
||||
Assert ([Wela.FileAccessProbe.FileHandle]::SourceSha256 -ceq (Get-FileHash (Join-Path $script:ScriptRoot 'scripts/FileAccessProbeNative.cs')).Hash.ToLowerInvariant()) 'compiled helper carries the SHA256 of the exact decoded source bytes'
|
||||
Initialize-WelaFileProbeNative;Assert $true 'identical compiled helper binding is reusable'
|
||||
Remove-Item Function:Initialize-WelaWmiProbeNative
|
||||
$sources=Get-WelaFileProbeSources
|
||||
foreach($name in @('scripts/CustomAuditProfiles.ps1','scripts/ChannelReadNative.cs','scripts/Configuration.ps1','scripts/IpsecPrerequisites.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')){Assert ($sources.$name -ceq (Get-FileHash (Join-Path $script:ScriptRoot $name)).Hash.ToLowerInvariant()) 'actual transitive dependency fingerprint is included'}
|
||||
|
||||
$script:writer=(Get-Command Write-WelaFileProbeArtifact).ScriptBlock
|
||||
function Get-WelaFileProbeOutputKey {param($Path) 'fixture-private-output'}
|
||||
function Write-WelaFileProbeArtifact {param($Root,$OutputKey,$Name,$Text) if($Name -eq $script:failArtifact){throw 'injected durable artifact failure'};& $script:writer $Root $OutputKey $Name $Text}
|
||||
function Get-WelaFileProbeState {param($Path) Copy-Value $script:state}
|
||||
function Start-WelaFileProbeRead {param($State,$RequestPath,$Nonce) $script:reads++;Assert (Test-Path (Join-Path (Split-Path $RequestPath) 'intent.json')) 'durable intent precedes each worker attempt';if($script:workerFailure){throw 'worker failed after a possible attempt'};$operation=Copy-Value $script:operation;$operation.Nonce=$Nonce;if($script:afterDrift){$script:state.Sources.Source='f'*64};$operation}
|
||||
function Read-WelaFileProbeEvents {param($Operation) $xml=Native-Xml;$items=if($script:batchMode -eq 'empty'){@()}elseif($script:batchMode -eq 'duplicate'){@($xml,$xml)}else{@($xml)};[pscustomobject]@{Xml=$items;Capped=($script:batchMode -eq 'capped');Query='fixture'}}
|
||||
function Get-WelaFileProbeWatermark {11}
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-file-probe-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
try {
|
||||
New-Fixture;$report=Invoke-WelaFileAccessProbe -FilePath $script:state.File.Path
|
||||
Assert ($report.Status -ceq 'PrerequisitesObserved' -and $script:reads -eq 0 -and -not $report.OutputPath) 'Plan observes prerequisites without files or byte reads'
|
||||
New-Fixture;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'success')
|
||||
Assert ($report.Status -ceq 'FileReadObserved' -and $script:reads -eq 1 -and $report.Matches -eq 1 -and $report.Artifacts.Count -eq 5 -and $report.RetainedContentBytes -eq 0 -and $report.SigmaEvtxCredit -eq 0) 'successful report retains five hashed metadata/XML artifacts and no byte content'
|
||||
foreach($mode in @('capped','duplicate','empty')){New-Fixture;$script:batchMode=$mode;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root $mode) -TimeoutSeconds 1;Assert ($report.Status -ceq 'Unverified' -and $report.ExitCode -eq 1) 'capped, duplicated or absent source evidence remains unverified'}
|
||||
New-Fixture;$script:afterDrift=$true;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'drift')
|
||||
Assert ($report.Status -ceq 'Unverified' -and $report.Diagnostic -match 'changed during the probe') 'late implementation drift prevents event readiness even after a matched record'
|
||||
New-Fixture;$script:workerFailure=$true;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'worker-failure')
|
||||
Assert ($report.Status -ceq 'Unverified' -and (Test-Path (Join-Path $root 'worker-failure/intent.json')) -and -not(Test-Path (Join-Path $root 'worker-failure/operation.json'))) 'uncertain worker attempt retains intent without fabricating completion'
|
||||
New-Fixture;$script:failArtifact='intent.json';$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'intent-failure')
|
||||
Assert ($report.ExitCode -eq 1 -and $script:reads -eq 0) 'failed durable intent prevents worker launch'
|
||||
New-Fixture;$script:failArtifact='manifest.json'
|
||||
Reject {Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'manifest-failure')} 'durable artifact failure'
|
||||
Assert ((Test-Path (Join-Path $root 'manifest-failure/operation.json')) -and (Test-Path (Join-Path $root 'manifest-failure/event.xml'))) 'manifest persistence failure fails outward while completed evidence remains'
|
||||
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
Write-Host "Passed $script:checks file-access probe assertions; no Windows settings or file data changed."
|
||||
@@ -0,0 +1,71 @@
|
||||
param([switch]$AllowDisposablePolicyWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Only an explicitly permitted disposable GitHub-hosted native Windows runner is supported.'}
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
|
||||
foreach($name in @('Configuration','WefArrival','ChannelRead','WmiProbe','FileAccessProbe','SelectedSaclConfiguration')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
|
||||
Initialize-WelaFileProbeNative;Initialize-WelaSelectedSaclNative
|
||||
$engine=(Get-Process -Id $PID).Path;$script:checks=0
|
||||
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
|
||||
function Policy-Key($Policy){$ordered=[ordered]@{};foreach($key in @($Policy.Keys|Sort-Object)){$ordered[$key]=$Policy[$key]};Get-WelaFileProbeKey $ordered}
|
||||
function Invoke-PublicFileProbe([string[]]$Arguments,[string]$Log,[bool]$Success=$true){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0};$text=$lines -join "`n";[IO.File]::WriteAllText($Log,$text,[Text.UTF8Encoding]::new($false));if(($Success -and $code -ne 0) -or (-not $Success -and $code -eq 0)){throw "Unexpected public CLI result $code : $text"};$start=$text.IndexOf('{');if($start -lt 0){throw 'Public CLI returned no JSON report.'};ConvertFrom-WelaArrivalJson $text.Substring($start)}
|
||||
function Add-OwnedReadSacl([string]$Path){$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null;try{$target=[Wela.SelectedSacl.Target]::new('FileSystem',$Path);$before=$target.Read();$null=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-1-0',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()}}
|
||||
$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-file-access-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot
|
||||
# The hosted runner's data volume can classify4663 as Removable Storage (Task12812).
|
||||
# Own an ordinary private system-volume directory for the strict File System Task12800 fixture.
|
||||
$targetRoot=New-WelaArrivalOutput (Join-Path (Join-Path $env:SystemRoot 'Temp') ('wela-file-access-targets-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot
|
||||
$file=Join-Path $targetRoot 'ReadCase.TxT';$plain=Join-Path $targetRoot 'WithoutAudit.txt'
|
||||
[IO.File]::WriteAllText($file,'WELA owned harmless file probe fixture.',[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllText($plain,'WELA owned file without a matching audit ACE.',[Text.UTF8Encoding]::new($false))
|
||||
$fileHash=(Get-FileHash $file).Hash;$beforePolicies=Get-WelaEffectiveAuditPolicy;$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$beforePrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy
|
||||
$originalToken=Get-WelaFileProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot());$changed=$false;$cleanupErrors=@()
|
||||
[IO.File]::WriteAllText((Join-Path $root 'original-audit-policy.json'),(Policy-Key $beforePolicies),[Text.UTF8Encoding]::new($false))
|
||||
[IO.File]::WriteAllText((Join-Path $root 'original-precedence.json'),(Get-WelaFileProbeKey $beforePrecedence),[Text.UTF8Encoding]::new($false))
|
||||
try {
|
||||
$changed=$true;Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type DWord -Value 1
|
||||
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 1 -Mode exact
|
||||
Add-OwnedReadSacl $file
|
||||
$before=Get-WelaFileProbeSnapshot $file;$beforeKey=$before.StateKey
|
||||
$plan=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$file.ToLowerInvariant()) (Join-Path $root 'plan.log')
|
||||
Assert ($plan.Status -ceq 'PrerequisitesObserved' -and $plan.Before.File.Path -ieq $file -and $plan.Before.File.StateKey -ceq $beforeKey) 'public Plan accepts Windows path casing and verifies the exact existing file SACL/policy'
|
||||
foreach($index in 1..2){
|
||||
$output=Join-Path $root ('run-'+$index)
|
||||
$report=Invoke-PublicFileProbe @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$file.ToLowerInvariant(),'-FileProbeOutputPath',$output) (Join-Path $root ('run-'+$index+'.log'))
|
||||
Assert ($report.Status -ceq 'FileReadObserved' -and $report.Matches -eq 1) 'public one-byte read produced exactly one attributable actual4663'
|
||||
Assert ($report.Operation.Read.Phase -ceq 'OneByteReadAndHeldIdentityReadback' -and (ConvertTo-WelaArrivalUtc $report.Operation.Read.StartedUtc) -le (ConvertTo-WelaArrivalUtc $report.Operation.Read.ReadReturnedUtc) -and (ConvertTo-WelaArrivalUtc $report.Operation.Read.ReadReturnedUtc) -le (ConvertTo-WelaArrivalUtc $report.Operation.Read.CompletedUtc)) 'actual phase retains separate ordered precise read-start, ReadFile-return and held-identity-readback completion timestamps'
|
||||
Assert ($report.Operation.Read.ReadCalls -eq 1 -and $report.Operation.Read.BytesRead -eq 1 -and $report.RetainedContentBytes -eq 0 -and $report.SigmaEvtxCredit -eq 0) 'one byte is read without retaining contents or granting Sigma credit'
|
||||
Assert ($report.Before.File.StateKey -ceq $beforeKey -and $report.After.File.StateKey -ceq $beforeKey -and (Get-FileHash $file).Hash -ceq $fileHash) 'existing file data, native identity and full descriptor remain unchanged'
|
||||
Assert (Test-WelaFileProbeEvent ([IO.File]::ReadAllText((Join-Path $output 'event.xml'))) $report.Operation $report.Before) 'retained native XML matches operation PID, handle, SID, logon, path, right and measured read/readback phase'
|
||||
foreach($artifact in $report.Artifacts){Assert ((Get-FileHash (Join-Path $output $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'retained artifact hash matches its public manifest'}
|
||||
}
|
||||
$missing=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$plain) (Join-Path $root 'missing-sacl.log') $false
|
||||
Assert ($missing.Status -ceq 'Unverified' -and $missing.Diagnostic -match 'No existing ordinary success ReadData' -and $null -eq $missing.Operation) 'real missing SACL refuses access without adding an ACE'
|
||||
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 0 -Mode exact
|
||||
$disabled=Invoke-PublicFileProbe @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$file,'-FileProbeOutputPath',(Join-Path $root 'disabled-policy')) (Join-Path $root 'disabled-policy.log') $false
|
||||
Assert ($disabled.Status -ceq 'Unverified' -and $disabled.Diagnostic -match 'File System success auditing' -and $null -eq $disabled.Operation) 'real disabled auditing refuses the byte read'
|
||||
Assert (-not(Test-Path (Join-Path $root 'disabled-policy/intent.json'))) 'failed prerequisites produce no pending read intent'
|
||||
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 1 -Mode exact
|
||||
foreach($target in @((Join-Path $script:ScriptRoot 'WELA.ps1'),$engine)){
|
||||
$refused=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$target) (Join-Path $root ('scope-refusal-'+[guid]::NewGuid().ToString('N')+'.log')) $false
|
||||
Assert ($refused.Status -ceq 'Unverified' -and $null -eq $refused.Before -and $refused.Diagnostic -match 'source tree|active PowerShell engine') 'actual implementation/engine targets are refused before prerequisite hashes'
|
||||
}
|
||||
$oldState=Get-WelaFileProbeState $file
|
||||
$replacement=Join-Path $targetRoot 'Replacement.txt';[IO.File]::WriteAllText($replacement,'WELA owned replacement.',[Text.UTF8Encoding]::new($false));Add-OwnedReadSacl $replacement
|
||||
Remove-Item -LiteralPath $file -Force;Move-Item -LiteralPath $replacement -Destination $file
|
||||
$message='';try{Start-WelaFileProbeRead $oldState (Join-Path $root 'not-used.json') ([guid]::NewGuid().ToString('N'))|Out-Null}catch{$message=$_.Exception.Message}
|
||||
Assert ($message -match 'drifted before worker launch') 'real replaced native file identity refuses a stale preflight before launching a worker'
|
||||
# Metadata-only desired access does not enforce data/delete sharing restrictions.
|
||||
# Acquire READ_DATA for this fixture lock check without issuing a ReadFile call.
|
||||
$held=[Wela.FileAccessProbe.FileHandle]::new($file,$true)
|
||||
try{$denied=$false;try{Remove-Item -LiteralPath $file -Force -ErrorAction Stop}catch{$denied=$true};Assert $denied 'held native data-capable handle prevents deletion of the selected file'}finally{$held.Dispose()}
|
||||
} finally {
|
||||
if($changed){try{Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask $beforePolicies['0CCE921D-69AE-11D9-BED3-505054503030'] -Mode exact;if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction Stop}}catch{$cleanupErrors+=$_.Exception.Message}}
|
||||
$auditRestored=(Policy-Key (Get-WelaEffectiveAuditPolicy)) -ceq (Policy-Key $beforePolicies)
|
||||
$precedenceRestored=(Get-WelaFileProbeKey (Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)) -ceq (Get-WelaFileProbeKey $beforePrecedence)
|
||||
$tokenRestored=(Get-WelaFileProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot())) -ceq $originalToken
|
||||
try{Remove-Item -LiteralPath $targetRoot -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+=$_.Exception.Message}
|
||||
$cleanup=[pscustomobject]@{Complete=($auditRestored -and $precedenceRestored -and $tokenRestored -and -not(Test-Path $targetRoot) -and $cleanupErrors.Count -eq 0);AuditPoliciesRestored=$auditRestored;PrecedenceRestored=$precedenceRestored;TokenRestored=$tokenRestored;OwnedTargetsRemoved=(-not(Test-Path $targetRoot));Errors=$cleanupErrors;Checks=$script:checks;Engine=$PSVersionTable.PSVersion.ToString();AfterAuditPolicies=(Get-WelaEffectiveAuditPolicy);AfterPrecedence=(Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)}
|
||||
[IO.File]::WriteAllText((Join-Path $root 'cleanup.json'),($cleanup|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false))
|
||||
if(-not $cleanup.Complete){throw "Native file-probe cleanup incomplete: $($cleanup|ConvertTo-Json -Compress -Depth 10)"}
|
||||
}
|
||||
Write-Host "Passed $script:checks actual native file-access assertions; all59 audit masks, typed precedence, privileges and owned-target cleanup verified. Evidence: $root"
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,178 @@
|
||||
# Mutating fixture only: public WELA never registers profiles or loads hives.
|
||||
param([switch]$AllowDisposableProfileWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableProfileWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Explicit disposable hosted native Windows fixture only.'}
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
|
||||
foreach($name in @('Configuration','WefArrival','WmiProbe','ChannelRead','ControlApplicability','TargetedSaclPlanning','SelectedSaclConfiguration','FileAccessProbe')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
|
||||
Initialize-WelaWmiProbeNative
|
||||
Add-Type -Path (Join-Path $PSScriptRoot 'RegistrySaclFixtureNative.cs') -ErrorAction Stop
|
||||
Add-Type -Path (Join-Path $PSScriptRoot 'FileSaclProfileFixture.cs') -ErrorAction Stop
|
||||
$nonce=[guid]::NewGuid().ToString('N')
|
||||
function Save([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $evidence $Name),(ConvertTo-Json -InputObject $Value -Depth 32),[Text.UTF8Encoding]::new($false))}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 32 -Compress}
|
||||
function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)}
|
||||
function Read-Receipt([string]$Name){ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $evidence $Name)))}
|
||||
function Read-PublicReport([string]$Name){$text=Read-Receipt ($Name+'-output.json');$start=$text.IndexOf('{');if($start -lt 0){throw 'Public probe JSON is missing.'};ConvertFrom-WelaArrivalJson $text.Substring($start)}
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;using System.IO;using System.Text;using System.Threading.Tasks;
|
||||
public static class WelaFileSaclLifecyclePipe {
|
||||
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]$Name,[string[]]$Arguments,[int]$Expected=0){
|
||||
$all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $script:ScriptRoot 'WELA.ps1'))+$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=[WelaFileSaclLifecyclePipe]::Read($process.StandardOutput);$stderr=[WelaFileSaclLifecyclePipe]::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.'}
|
||||
$text=$stdout.Result+"`n"+$stderr.Result;Save ($Name+'-output.json') $text
|
||||
Assert ($process.ExitCode -eq $Expected) ("Public $Name exited $($process.ExitCode), expected $Expected : "+$text)
|
||||
}finally{
|
||||
if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:cleanupErrors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:cleanupErrors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:cleanupErrors+=$_.Exception.Message}};if(-not $exited){$script:cleanupErrors+='Owned public process termination unconfirmed.'}}
|
||||
$process.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$script:assertions=0
|
||||
function Assert($Condition,[string]$Message){if(-not $Condition){throw $Message};$script:assertions++}
|
||||
$beforeProfiles=[Wela.FileSaclFixture.Profile]::Snapshot();$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot()
|
||||
$beforeMasks=Get-WelaEffectiveAuditPolicy;$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy';$beforePrecedence=Get-WelaRegistryState $precedencePath $precedenceName
|
||||
$evidence=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-filesystem-lifecycle-'+$nonce)) $script:ScriptRoot
|
||||
$targetRoot=New-WelaArrivalOutput (Join-Path (Join-Path $env:SystemRoot 'Temp') ('wela-filesystem-sacl-'+$nonce)) $script:ScriptRoot
|
||||
$files=Join-Path $evidence 'owned-hive-files';$null=New-Item -ItemType Directory $files
|
||||
Save 'before-profiles.json' $beforeProfiles;Save 'before-hives.json' $beforeHives;Save 'before-token.json' $beforeToken;Save 'before-masks.json' $beforeMasks;Save 'before-precedence.json' $beforePrecedence
|
||||
$hive=[Wela.RegistrySaclFixture.Hive]::new($nonce,(Join-Path $files 'owned.dat'));$profile=$null;$failure=$null;$cleanupErrors=@();$policyTouched=$false;$auditGuid='0CCE921D-69AE-11D9-BED3-505054503030'
|
||||
try {
|
||||
$hive.Prepare();$profile=[Wela.FileSaclFixture.Profile]::new($nonce,$hive.Sid,$targetRoot);$profile.Prepare()
|
||||
$signal=Join-Path $profile.AppDataPath 'Signal';$null=New-Item -ItemType Directory $signal
|
||||
$preparedProfiles=Key ([Wela.FileSaclFixture.Profile]::Snapshot())
|
||||
$collision=[Wela.FileSaclFixture.Profile]::new($nonce,$hive.Sid,$targetRoot);$refusal=''
|
||||
try{$collision.Prepare()}catch{$refusal=$_.Exception.Message}finally{$collision.Dispose()}
|
||||
Assert ($refusal -match 'already exists' -and -not $collision.Created -and (Key ([Wela.FileSaclFixture.Profile]::Snapshot())) -ceq $preparedProfiles) 'A real colliding ProfileList entry is never claimed, altered or removed by a new fixture owner.'
|
||||
$ownedProfilePath='Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\'+$hive.Sid
|
||||
try{
|
||||
Set-ItemProperty -LiteralPath $ownedProfilePath -Name ProfileImagePath -Type String -Value $profile.ProfilePath
|
||||
$refusal='';try{$profile.Dispose()}catch{$refusal=$_.Exception.Message}
|
||||
Assert ($refusal -match 'changed' -and $profile.Created -and (Test-Path -LiteralPath $ownedProfilePath)) 'Typed ownership drift refuses profile deletion despite identical text.'
|
||||
}finally{Set-ItemProperty -LiteralPath $ownedProfilePath -Name ProfileImagePath -Type ExpandString -Value $profile.ProfilePath}
|
||||
$profile.AssertOwned()
|
||||
Assert ((Key ([Wela.FileSaclFixture.Profile]::Snapshot())) -ceq $preparedProfiles) 'Fixture-only ownership refusal test restores its exact registered profile tuple.'
|
||||
|
||||
Public 'catalog' @('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-IncludeOptional','-ResultsPath',(Join-Path $evidence 'catalog.json'))
|
||||
$catalog=Read-Receipt 'catalog.json'
|
||||
Save 'owned-profile.json' ([pscustomobject]@{Sid=$hive.Sid;Nonce=$nonce;Root=$targetRoot;ProfilePath=$profile.ProfilePath;AppDataPath=$profile.AppDataPath;SelectedPath=$signal})
|
||||
$selectedRows=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Kind -ceq 'FileSystem' -and $_.Definition.Path -ieq $signal})
|
||||
Assert ($selectedRows.Count -eq 1 -and $selectedRows[0].Definition.Resolution -ceq 'Redirected') 'Real built-in catalog resolves exactly the owned redirected Signal folder.'
|
||||
Assert ($selectedRows[0].Definition.PrincipalSid -ceq 'S-1-1-0' -and @($selectedRows[0].Definition.Rights).Count -eq 1 -and $selectedRows[0].Definition.Rights[0] -ceq 'ReadData') 'Owned fixture uses the unchanged built-in read target.'
|
||||
Assert ((Key (Hives)) -ceq (Key (@($beforeHives)+$hive.Sid|Sort-Object))) 'Only the owned hive was mounted.'
|
||||
$profile.AssertOwned();$hive.AssertOwned()
|
||||
Assert (([Wela.FileSaclFixture.Profile]::Snapshot()).Children.Count -eq $beforeProfiles.Children.Count+1) 'Exactly one marker-owned profile entry was registered.'
|
||||
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Fixture profile/hive preparation restores full token state.'
|
||||
$selected=$selectedRows[0];Save 'selected.json' $selected
|
||||
$policyTouched=$true;Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type DWord -Value 1;Set-WelaEffectiveAuditPolicy -Guid $auditGuid -Mask 3 -Mode exact
|
||||
$preparedMasks=Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key);$preparedPrecedence=Key (Get-WelaRegistryState $precedencePath $precedenceName)
|
||||
$open=Join-Path $signal 'open';$protected=Join-Path $signal 'protected';$null=New-Item -ItemType Directory $open,$protected
|
||||
Add-Type -Path (Join-Path $PSScriptRoot 'SelectedSaclFixtureProtection.cs') -ErrorAction Stop
|
||||
Initialize-WelaSelectedSaclNative;$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null
|
||||
try{
|
||||
$protectedBefore=Get-WelaSelectedSaclSnapshot ([pscustomobject]@{Kind='FileSystem';Path=$protected;Resolution='Resolved'})
|
||||
[Wela.SelectedSaclFixture.Protection]::Protect('FileSystem',$protected,$protectedBefore.DescriptorBase64,$nonce)
|
||||
$target=[Wela.SelectedSacl.Target]::new('FileSystem',$signal);$before=$target.Read();$null=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-5-18',2,64)
|
||||
}finally{if($target){$target.Dispose()};$privilege.Dispose()}
|
||||
$leaf=Join-Path $open 'ReadLeaf.bin';$protectedLeaf=Join-Path $protected 'ReadLeaf.bin'
|
||||
foreach($path in @($leaf,$protectedLeaf)){[IO.File]::WriteAllBytes($path,[byte[]]@(87,69,76,65))}
|
||||
$contentBefore=@(foreach($path in @($leaf,$protectedLeaf)){[pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath $path).Hash.ToLowerInvariant()}})
|
||||
Save 'owned-content-before.json' $contentBefore
|
||||
$before=Get-WelaSelectedSaclSnapshot $selected.Definition;$children=Get-WelaSelectedSaclStableDescendants $selected.Definition $before
|
||||
Save 'before-public.json' $before;Save 'before-descendants.json' $children
|
||||
Assert ($before.Aces.Count -eq 1 -and $before.Aces[0].Sid -ceq 'S-1-5-18' -and $before.Aces[0].Mask -eq 2) 'Fixture seeds only its unrelated root audit ACE.'
|
||||
Assert ($children.Status -ceq 'Complete' -and $children.Entries.Count -eq 4 -and @($children.Entries|Where-Object ProtectedBarrier).Count -eq 2) 'Before-state captures exactly four owned descendants and the protected branch.'
|
||||
$selection=@('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$selected.Id,'-IncludeOptional')
|
||||
Public 'no-child-consent' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',(Join-Path $evidence 'no-child-plan.json')))
|
||||
Assert ((Read-Receipt 'no-child-plan.json').Rows[0].Status -ceq 'Blocked' -and (Read-Receipt 'no-child-plan.json').Rows[0].Diagnostic -match 'IncludeChildren') 'Actual public Plan refuses inherited scope without explicit child consent.'
|
||||
$selection+='-TargetSaclIncludeChildren'
|
||||
$planPath=Join-Path $evidence 'reviewed-plan.json';Public 'plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$planPath))
|
||||
$plan=Read-Receipt 'reviewed-plan.json';$row=$plan.Rows[0]
|
||||
Assert ($plan.Rows.Count -eq 1 -and $row.Status -is [string] -and $row.Status -ceq 'ChangeRequired' -and $row.Definition.Resolution -ceq 'Redirected' -and $row.Ace.Mask -eq 1 -and $row.Ace.Flags -eq 195) 'Public reviewed plan selects exactly the redirected catalog root and explicit ReadData inheritance.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey $row.Before) -ceq (Get-WelaSelectedSaclSnapshotKey $before) -and (Get-WelaSelectedSaclDescendantKey $row.DescendantsBefore) -ceq (Get-WelaSelectedSaclDescendantKey $children)) 'Public review binds independently observed full native root and descendants.'
|
||||
$dryBackup=Join-Path $evidence 'dry-journal'
|
||||
Public 'dry-run' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$planPath,'-DryRun','-BackupPath',$dryBackup,'-ResultsPath',(Join-Path $evidence 'dry-results.json')))
|
||||
$dry=Read-Receipt 'dry-results.json'
|
||||
Assert ($dry.DryRun -is [bool] -and $dry.DryRun -and $dry.Results[0].Status -ceq 'Skipped' -and -not(Test-Path $dryBackup)) 'Actual public DryRun writes no recovery directory or ACE.'
|
||||
Assert ((Get-WelaSelectedSaclDescendantKey (Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition))) -ceq (Get-WelaSelectedSaclDescendantKey $children)) 'Plan and DryRun preserve all native parent/child state.'
|
||||
$appeared=Join-Path $signal 'Appeared.bin';[IO.File]::WriteAllBytes($appeared,[byte[]]@(1))
|
||||
$staleBefore=Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition);Save 'stale-before.json' $staleBefore
|
||||
$staleBackup=Join-Path $evidence 'stale-journal'
|
||||
Public 'stale' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$planPath,'-BackupPath',$staleBackup,'-ResultsPath',(Join-Path $evidence 'stale-results.json'),'-Auto')) 1
|
||||
$staleAfter=Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition);Save 'stale-after.json' $staleAfter
|
||||
Assert (-not(Test-Path $staleBackup) -and (Read-Receipt 'stale-output.json') -match 'descendants changed' -and (Get-WelaSelectedSaclDescendantKey $staleBefore) -ceq (Get-WelaSelectedSaclDescendantKey $staleAfter)) 'A real unreviewed child refuses public Configure before journal/write and preserves all observed state.'
|
||||
Remove-Item -LiteralPath $appeared -Force -ErrorAction Stop
|
||||
$freshPlan=Join-Path $evidence 'fresh-plan.json';Public 'fresh-plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$freshPlan))
|
||||
$journal=Join-Path $evidence 'journal';$resultsPath=Join-Path $evidence 'results.json'
|
||||
Public 'configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$freshPlan,'-BackupPath',$journal,'-ResultsPath',$resultsPath,'-Auto'))
|
||||
$result=Read-Receipt 'results.json';$applied=$result.Results[0]
|
||||
Assert ($result.ExitCode -eq 0 -and $result.DryRun -is [bool] -and -not $result.DryRun -and $result.Results.Count -eq 1 -and $applied.Status -is [string] -and $applied.Status -ceq 'Applied') 'Actual public Configure reports exactly one completed selected root addition.'
|
||||
$after=Get-WelaSelectedSaclSnapshot $selected.Definition;$afterChildren=Get-WelaSelectedSaclStableDescendants $selected.Definition $after
|
||||
Save 'after-public.json' $after;Save 'after-descendants.json' $afterChildren
|
||||
Assert-WelaSelectedSaclPreserved $before $after $row.Ace
|
||||
Assert ($after.Aces.Count -eq $before.Aces.Count+1 -and $after.Aces[0].Binary -ceq $before.Aces[0].Binary) 'Independent native readback proves one appended root audit ACE and unchanged unrelated ACE.'
|
||||
$outcome=Test-WelaSelectedSaclDescendantOutcomes $children $afterChildren $row.Ace
|
||||
Save 'independent-descendant-outcomes.json' $outcome
|
||||
Assert ($outcome.Status -ceq 'Observed' -and @($outcome.Outcomes|Where-Object Status -CEQ 'InheritedAceObserved').Count -eq 2 -and @($outcome.Outcomes|Where-Object Status -CEQ 'ProtectedUnchanged').Count -eq 2) 'Actual propagation is observed on the open branch while both protected descendants retain exact security.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey $applied.After) -ceq (Get-WelaSelectedSaclSnapshotKey $after) -and (Get-WelaSelectedSaclDescendantKey $applied.DescendantsAfter) -ceq (Get-WelaSelectedSaclDescendantKey $afterChildren)) 'Public Applied evidence agrees with independent native parent and descendant readback.'
|
||||
$pending=Read-Receipt ('journal/'+$selected.Id+'.pending.json');$confirmed=Read-Receipt ('journal/'+$selected.Id+'.confirmed.json');$observed=Read-Receipt ('journal/'+$selected.Id+'.descendants-observed.json')
|
||||
Assert ($pending.State -is [string] -and $pending.State -ceq 'Pending' -and $null -eq $pending.After -and $confirmed.State -is [string] -and $confirmed.State -ceq 'Confirmed') 'Distinct original Pending and Confirmed receipts establish actual intent and completion.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey $pending.Before) -ceq (Get-WelaSelectedSaclSnapshotKey $before) -and (Get-WelaSelectedSaclSnapshotKey $confirmed.After) -ceq (Get-WelaSelectedSaclSnapshotKey $after) -and (Get-WelaSelectedSaclDescendantKey $observed.After) -ceq (Get-WelaSelectedSaclDescendantKey $afterChildren)) 'Retained original receipt bytes bind the exact actual root/child transition.'
|
||||
Assert ($result.GenerationReadiness -ceq 'Conditional' -and $result.UsableRuleCredit -eq 0) 'Root configuration remains conditional without a coverage or Sigma claim.'
|
||||
$againPlan=Join-Path $evidence 'idempotent-plan.json';Public 'idempotent-plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$againPlan))
|
||||
$againJournal=Join-Path $evidence 'idempotent-journal';Public 'idempotent-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$againPlan,'-BackupPath',$againJournal,'-ResultsPath',(Join-Path $evidence 'idempotent-results.json'),'-Auto'))
|
||||
$again=Read-Receipt 'idempotent-results.json'
|
||||
Assert ($again.Results[0].Status -ceq 'AlreadyCompliant' -and @(Get-ChildItem -LiteralPath $againJournal -Force).Count -eq 0 -and (Get-WelaSelectedSaclDescendantKey (Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition))) -ceq (Get-WelaSelectedSaclDescendantKey $afterChildren)) 'Public second Configure adds no duplicate ACE or receipt and preserves full native descendant state.'
|
||||
Public 'probe-plan' @('file-access-probe','-FileProbePath',$leaf.ToLowerInvariant())
|
||||
$probePlan=Read-PublicReport 'probe-plan';Save 'probe-plan.json' $probePlan
|
||||
Assert ($probePlan.Status -ceq 'PrerequisitesObserved' -and @($probePlan.Before.File.Aces|Where-Object {($_.Flags -band 16) -and $_.Sid -ceq 'S-1-1-0' -and ($_.Mask -band 1)}).Count -eq 1) 'Public read-probe Plan observes the actual inherited ReadData SACL on the owned leaf.'
|
||||
$probeOutput=Join-Path $evidence 'probe';Public 'probe' @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$leaf.ToLowerInvariant(),'-FileProbeOutputPath',$probeOutput)
|
||||
$probe=Read-PublicReport 'probe';Save 'probe-result.json' $probe
|
||||
Assert ($probe.Status -ceq 'FileReadObserved' -and $probe.Matches -eq 1 -and $probe.Operation.Read.ReadCalls -eq 1 -and $probe.Operation.Read.BytesRead -eq 1 -and $probe.RetainedContentBytes -eq 0) 'Actual one-byte public leaf read produces exactly one attributable4663 without retaining content.'
|
||||
Assert (Test-WelaFileProbeEvent ([IO.File]::ReadAllText((Join-Path $probeOutput 'event.xml'))) $probe.Operation $probe.Before) 'Retained native4663 matches exact worker PID/handle/token/path/right and measured operation phase.'
|
||||
foreach($artifact in $probe.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $probeOutput $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Public probe artifact hash matches retained bytes.'}
|
||||
Assert ($probe.ConfigurationChanges -eq 0 -and $probe.FileDataWrites -eq 0 -and $probe.SigmaEvtxCredit -eq 0) 'Observed read grants no configuration, file-write or Sigma credit.'
|
||||
Public 'protected-probe' @('file-access-probe','-FileProbePath',$protectedLeaf) 1
|
||||
$protectedProbe=Read-PublicReport 'protected-probe';Save 'protected-probe.json' $protectedProbe
|
||||
Assert ($protectedProbe.Status -ceq 'Unverified' -and $null -eq $protectedProbe.Operation -and $protectedProbe.Diagnostic -match 'No existing ordinary success ReadData') 'Protected leaf receives no inherited coverage and its public read probe is refused.'
|
||||
$contentAfter=@(foreach($path in @($leaf,$protectedLeaf)){[pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath $path).Hash.ToLowerInvariant()}});Save 'owned-content-after.json' $contentAfter
|
||||
Assert ((Key $contentAfter) -ceq (Key $contentBefore)) 'Fixture-owned content remains byte-identical.'
|
||||
$finalChildren=Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition);Save 'final-descendants.json' $finalChildren
|
||||
Assert ((Get-WelaSelectedSaclDescendantKey $finalChildren) -ceq (Get-WelaSelectedSaclDescendantKey $afterChildren)) 'Final public probe outcomes preserve full root/descendant security and membership.'
|
||||
$profile.AssertOwned();$hive.AssertOwned()
|
||||
Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Every public operation preserves prepared auditing and typed precedence.'
|
||||
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'All fixture and public operations restore full token groups/privileges.'
|
||||
|
||||
}catch{$failure=$_}finally{
|
||||
if($policyTouched){
|
||||
try{Set-WelaEffectiveAuditPolicy -Guid $auditGuid -Mask $beforeMasks[$auditGuid] -Mode exact}catch{$cleanupErrors+='Audit restore: '+$_.Exception.Message}
|
||||
try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restore: '+$_.Exception.Message}
|
||||
}
|
||||
try{if($profile){$profile.Dispose()}}catch{$cleanupErrors+='Profile removal: '+$_.Exception.Message}
|
||||
try{$hive.Dispose()}catch{$cleanupErrors+='Hive unload/seed removal: '+$_.Exception.Message}
|
||||
$afterProfiles=$null;$afterHives=$null;$afterToken=$null;$afterMasks=$null;$afterPrecedence=$null
|
||||
$profilesOk=$false;$hivesOk=$false;$tokenOk=$false;$masksOk=$false;$precedenceOk=$false
|
||||
try{$afterProfiles=[Wela.FileSaclFixture.Profile]::Snapshot();$profilesOk=(Key $afterProfiles) -ceq (Key $beforeProfiles)}catch{$cleanupErrors+='Profile verification: '+$_.Exception.Message}
|
||||
try{$afterHives=Hives;$hivesOk=(Key $afterHives) -ceq (Key $beforeHives)}catch{$cleanupErrors+='Hive verification: '+$_.Exception.Message}
|
||||
try{$afterToken=[Wela.WmiProbe.Native]::Snapshot();$tokenOk=(Key $afterToken) -ceq (Key $beforeToken)}catch{$cleanupErrors+='Token verification: '+$_.Exception.Message}
|
||||
try{$afterMasks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($afterMasks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key))}catch{$cleanupErrors+='Audit verification: '+$_.Exception.Message}
|
||||
try{$afterPrecedence=Get-WelaRegistryState $precedencePath $precedenceName;$precedenceOk=(Key $afterPrecedence) -ceq (Key $beforePrecedence)}catch{$cleanupErrors+='Precedence verification: '+$_.Exception.Message}
|
||||
if($profilesOk -and $hivesOk -and -not $hive.Loaded){try{Remove-Item -LiteralPath $targetRoot -Recurse -Force -ErrorAction Stop;Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+='Owned file removal: '+$_.Exception.Message}}
|
||||
$cleanup=[pscustomobject]@{Complete=($profilesOk -and $hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.Loaded -and -not $hive.SeedCreated -and -not(Test-Path $targetRoot) -and -not(Test-Path $files) -and $cleanupErrors.Count -eq 0);ProfilesRestored=$profilesOk;HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksCompared=$beforeMasks.Count;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path $targetRoot) -and -not(Test-Path $files));Errors=$cleanupErrors;Failure=$(if($failure){$failure.Exception.Message}else{$null});Assertions=$script:assertions;AfterProfiles=$afterProfiles;AfterHives=$afterHives;AfterToken=$afterToken;AfterMasks=$afterMasks;AfterPrecedence=$afterPrecedence}
|
||||
Save 'cleanup.json' $cleanup
|
||||
Save 'artifact-hashes.json' @(Get-ChildItem -LiteralPath $evidence -Recurse -File|Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object {[pscustomobject]@{Name=$_.FullName.Substring($evidence.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}})
|
||||
}
|
||||
if($failure){throw $failure};if(-not $cleanup.Complete){throw ('Owned profile fixture cleanup incomplete: '+(Key $cleanup))}
|
||||
Write-Host "Passed $script:assertions actual public filesystem SACL lifecycle assertions; cleanup confirmed. Evidence: $evidence"
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,86 @@
|
||||
// Disposable hosted-test setup only. Never imported by WELA product commands.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
namespace Wela.FileSaclFixture {
|
||||
public sealed class ValueState {public string Name,Kind;public object Value;}
|
||||
public sealed class KeyState {public string Name;public ValueState[] Values;public KeyState[] Children;}
|
||||
public sealed class Profile : IDisposable {
|
||||
const string ProfileList=@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList";
|
||||
const string ShellFolders=@"Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders";
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegCreateKeyExW(IntPtr root,string path,int reserved,string cls,uint options,uint access,IntPtr security,out IntPtr result,out uint disposition);
|
||||
[DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key);
|
||||
public readonly string Nonce,Sid,Root,ProfilePath,AppDataPath;
|
||||
public bool Created {get;private set;}
|
||||
public Profile(string nonce,string sid,string root) {
|
||||
if(Environment.OSVersion.Platform!=PlatformID.Win32NT||!Environment.Is64BitProcess||Environment.GetEnvironmentVariable("GITHUB_ACTIONS")!="true"||Environment.GetEnvironmentVariable("RUNNER_ENVIRONMENT")!="github-hosted")throw new InvalidOperationException("Disposable native hosted Windows fixture only.");
|
||||
if(!System.Text.RegularExpressions.Regex.IsMatch(nonce??"","^[a-f0-9]{32}$"))throw new InvalidOperationException("Exact owned nonce required.");
|
||||
string expectedSid="S-1-5-21-"+Convert.ToUInt32(nonce.Substring(0,8),16)+"-"+Convert.ToUInt32(nonce.Substring(8,8),16)+"-"+Convert.ToUInt32(nonce.Substring(16,8),16)+"-1001";
|
||||
if(sid!=expectedSid)throw new InvalidOperationException("Profile must use the matching owned hive SID.");
|
||||
string expectedRoot=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows),"Temp","wela-filesystem-sacl-"+nonce);
|
||||
if(!String.Equals(Path.GetFullPath(root),expectedRoot,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Only the nonce-owned system-volume fixture tree is supported.");
|
||||
AssertOrdinary(root);Nonce=nonce;Sid=sid;Root=Path.GetFullPath(root);ProfilePath=Path.Combine(Root,"Profile");AppDataPath=Path.Combine(Root,"RedirectedRoaming");
|
||||
}
|
||||
static void AssertOrdinary(string path) {
|
||||
for(DirectoryInfo directory=new DirectoryInfo(path);directory!=null;directory=directory.Parent)
|
||||
if(!directory.Exists||(directory.Attributes&FileAttributes.ReparsePoint)!=0)throw new InvalidOperationException("Fixture tree or ancestor is absent or a reparse point.");
|
||||
}
|
||||
public static KeyState Snapshot() {
|
||||
int count=0;using(RegistryKey machine=RegistryKey.OpenBaseKey(RegistryHive.LocalMachine,RegistryView.Registry64))
|
||||
using(RegistryKey root=machine.OpenSubKey(ProfileList,false)){if(root==null)throw new InvalidOperationException("Actual ProfileList is missing.");return Read(root,"ProfileList",0,ref count);}
|
||||
}
|
||||
static KeyState Read(RegistryKey key,string name,int depth,ref int count) {
|
||||
if(depth>8||++count>4096)throw new InvalidOperationException("Profile inventory exceeds its bounded scope.");
|
||||
string[] names=key.GetValueNames();Array.Sort(names,StringComparer.Ordinal);if(names.Length>256)throw new InvalidOperationException("Profile values exceed fixture bound.");
|
||||
var values=new List<ValueState>();foreach(string valueName in names){
|
||||
RegistryValueKind kind=key.GetValueKind(valueName);object value=key.GetValue(valueName,null,RegistryValueOptions.DoNotExpandEnvironmentNames);
|
||||
if(value==null||kind==RegistryValueKind.Unknown||kind==RegistryValueKind.None)throw new InvalidOperationException("Unknown typed profile value.");
|
||||
if(value is string&&((string)value).Length>1048576||value is byte[]&&((byte[])value).Length>1048576)throw new InvalidOperationException("Profile value exceeds fixture bound.");
|
||||
values.Add(new ValueState{Name=valueName,Kind=kind.ToString(),Value=value});
|
||||
}
|
||||
string[] children=key.GetSubKeyNames();Array.Sort(children,StringComparer.Ordinal);var result=new List<KeyState>();
|
||||
foreach(string child in children)using(RegistryKey opened=key.OpenSubKey(child,false)){if(opened==null)throw new InvalidOperationException("Profile inventory changed.");result.Add(Read(opened,child,depth+1,ref count));}
|
||||
return new KeyState{Name=name,Values=values.ToArray(),Children=result.ToArray()};
|
||||
}
|
||||
void AssertHive() {
|
||||
using(RegistryKey hive=Registry.Users.OpenSubKey(Sid,false))
|
||||
if(hive==null||hive.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(hive.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned hive marker differs.");
|
||||
}
|
||||
public void Prepare() {
|
||||
if(Created)throw new InvalidOperationException("Profile was already prepared.");AssertHive();AssertOrdinary(Root);
|
||||
Directory.CreateDirectory(ProfilePath);Directory.CreateDirectory(AppDataPath);
|
||||
IntPtr handle;uint disposition;int error=RegCreateKeyExW(new IntPtr(unchecked((int)0x80000002)),ProfileList+"\\"+Sid,0,null,0,0xF013F,IntPtr.Zero,out handle,out disposition);
|
||||
if(error!=0)throw new Win32Exception(error,"Create owned ProfileList entry");
|
||||
try{
|
||||
if(disposition!=1)throw new InvalidOperationException("ProfileList identity already exists.");Created=true;
|
||||
using(var safe=new SafeRegistryHandle(handle,false))using(RegistryKey key=RegistryKey.FromHandle(safe,RegistryView.Registry64)){
|
||||
key.SetValue("WelaFixtureOwner",Nonce,RegistryValueKind.String);
|
||||
key.SetValue("ProfileImagePath",ProfilePath,RegistryValueKind.ExpandString);key.Flush();
|
||||
}
|
||||
}finally{RegCloseKey(handle);}
|
||||
AssertHive();
|
||||
using(RegistryKey hive=Registry.Users.OpenSubKey(Sid,true))using(RegistryKey shell=hive.CreateSubKey(ShellFolders)){
|
||||
if(shell.ValueCount!=0||shell.SubKeyCount!=0)throw new InvalidOperationException("Owned known-folder key unexpectedly contains data.");
|
||||
shell.SetValue("AppData",AppDataPath,RegistryValueKind.ExpandString);
|
||||
shell.SetValue("Startup",@"%USERPROFILE%\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup",RegistryValueKind.ExpandString);shell.Flush();
|
||||
}
|
||||
AssertOwned();
|
||||
}
|
||||
public void AssertOwned() {
|
||||
AssertHive();
|
||||
using(RegistryKey machine=RegistryKey.OpenBaseKey(RegistryHive.LocalMachine,RegistryView.Registry64))
|
||||
using(RegistryKey key=machine.OpenSubKey(ProfileList+"\\"+Sid,false)){
|
||||
if(!Created||key==null||key.SubKeyCount!=0||key.ValueCount!=2||key.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal)||key.GetValueKind("ProfileImagePath")!=RegistryValueKind.ExpandString||!String.Equals(key.GetValue("ProfileImagePath",null,RegistryValueOptions.DoNotExpandEnvironmentNames) as string,ProfilePath,StringComparison.Ordinal))throw new InvalidOperationException("Owned ProfileList entry changed; removal is refused.");
|
||||
}
|
||||
}
|
||||
public void Dispose() {
|
||||
if(!Created)return;AssertOwned();
|
||||
using(RegistryKey machine=RegistryKey.OpenBaseKey(RegistryHive.LocalMachine,RegistryView.Registry64))
|
||||
using(RegistryKey root=machine.OpenSubKey(ProfileList,true)){root.DeleteSubKey(Sid,true);Created=false;}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$n=0
|
||||
function Check([string[]]$Arguments,[string]$Pattern,[int]$Expected=1){
|
||||
$old=$ErrorActionPreference;$ErrorActionPreference='Continue'
|
||||
try{$output=& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old}
|
||||
if(($Expected -eq 0 -and $code -ne 0) -or ($Expected -ne 0 -and $code -eq 0) -or ($output -join ' ') -notmatch $Pattern){throw "Unexpected CLI $($Arguments -join ' '): $code $output"};$script:n++
|
||||
}
|
||||
Check @('firewall-recovery','-Help') 'FirewallRecoveryPlanHash' 0
|
||||
Check @('configure','-FirewallRecoveryProfile','Domain') 'require firewall-recovery'
|
||||
Check @('firewall-recovery','-FirewallAction','Configure') 'dedicated'
|
||||
Check @('firewall-recovery','-RecoveryAction','Restore') 'dedicated|require audit-recovery'
|
||||
Check @('firewall-recovery','-FirewallRecoveryProfile','All') 'ValidateSet|does not belong'
|
||||
Check @('firewall-recovery','-FirewallRecoveryAction','Plan','-Auto') 'requires one profile'
|
||||
Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-DryRun') 'reviewed plan/hash'
|
||||
Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-WhatIf') 'dedicated options'
|
||||
Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-FirewallRecoveryPlanPath','missing','-FirewallRecoveryPlanHash',('a'*64),'-DryRun','-FirewallRecoveryOutputPath','must-not-exist') 'reviewed plan/hash'
|
||||
$global:LASTEXITCODE=0
|
||||
Write-Host "Firewall recovery public CLI: $n checks passed."
|
||||
@@ -0,0 +1,119 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
foreach($file in @('Configuration','FirewallLogging','AuditRecovery','WefArrival','WecUpdate','FirewallLoggingRecovery')){. (Join-Path $repo "scripts/$file.ps1")}
|
||||
$script:assertions=0;$script:writes=0;$script:mode='';$script:prompt=$null
|
||||
function Assert($Value,$Message){if(-not $Value){throw "FAIL: $Message"};$script:assertions++}
|
||||
function Throws($Action,$Pattern){$message='';try{& $Action | Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; received $message"}
|
||||
function Copy-Fixture($Value){Get-WelaFirewallRecoveryKey $Value | ConvertFrom-Json}
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-firewall-fixture-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory -Path $root
|
||||
$journal=Join-Path $root 'before.jsonl';$results=Join-Path $root 'original.json'
|
||||
# Only the platform output-ACL boundary and native state/setter are replaced.
|
||||
# Strict input parsing, durable artifact writes and all production orchestration run.
|
||||
function New-WelaArrivalOutput {param($Path,$SourcePath) if(Test-Path -LiteralPath $Path){throw 'Output exists'};$null=New-Item -ItemType Directory -Path $Path;[IO.Path]::GetFullPath($Path)}
|
||||
function Snapshot($Name,$Enabled='False',$Size=4096){[pscustomobject][ordered]@{Name=$Name;LogAllowed=$Enabled;LogBlocked=$Enabled;LogMaxSizeKilobytes=$Size;LogFileName="C:\Logs\$Name.log";Enabled='True'}}
|
||||
function Reset {
|
||||
$script:writes=0;$script:mode='';$script:prompt=$null
|
||||
$before=[pscustomobject]@{Local=Snapshot Domain;Effective=Snapshot Domain}
|
||||
$after=[pscustomobject]@{Local=Snapshot Domain True 16384;Effective=Snapshot Domain True 16384;Access=[pscustomobject]@{State='VerifiedExplicitGrant'}}
|
||||
$script:entry=[pscustomobject][ordered]@{Version=1;ComputerName='TEST';RecordedUtc=[DateTime]::UtcNow.ToString('o');Id='FirewallTextLog/Domain';Kind='FirewallTextLog';Target=[pscustomobject]@{Name='Domain';PolicyStore='PersistentStore'};Before=$before;Desired=[pscustomobject]@{LogAllowed='True';LogBlocked='True';MinimumSizeKiB=16384;LogFileName='C:\Logs\Domain.log';PathMode='Preserve'}}
|
||||
$script:row=[pscustomobject]@{Id=$entry.Id;Kind=$entry.Kind;Target=Copy-Fixture $entry.Target;Before=Copy-Fixture $entry.Before;Desired=Copy-Fixture $entry.Desired;After=$after;Status='Applied';Diagnostic=''}
|
||||
$stores=[ordered]@{}
|
||||
foreach($store in @('PersistentStore','ActiveStore')){
|
||||
$profiles=[ordered]@{}
|
||||
foreach($name in @('Domain','Private','Public')){$profiles[$name]=[pscustomobject]@{Logging=ConvertTo-WelaFirewallRecoveryTuple (Snapshot $name True 16384) -Snapshot;Preserved=[pscustomobject]@{Enabled=$true;DefaultInboundAction='Block';Other='unchanged'}}}
|
||||
$stores[$store]=[pscustomobject]$profiles
|
||||
}
|
||||
$script:state=[pscustomobject][ordered]@{Context=[pscustomobject]@{Computer='TEST';MachineGuid='actual-now';Reader='sid+logon';Engine='test'};Sources=[pscustomobject]@{Code='pinned'};NativeSources=[pscustomobject]@{Module='native'};Profiles=[pscustomobject]$stores;RuleConfiguration=@([pscustomobject]@{Store='PersistentStore';Sha256='rules'})}
|
||||
Save
|
||||
}
|
||||
function Save {
|
||||
[IO.File]::WriteAllText($journal,(Get-WelaFirewallRecoveryKey $entry),[Text.UTF8Encoding]::new($false))
|
||||
[IO.File]::WriteAllText($results,(Get-WelaFirewallRecoveryKey ([pscustomobject]@{DryRun=$false;Scope='firewall-text-logging-only';Results=@($row)})),[Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
function Get-WelaFirewallRecoveryState {Copy-Fixture $script:state}
|
||||
function Read-Host {param($Prompt) if($script:prompt){& $script:prompt};'y'}
|
||||
function Set-WelaFirewallRecoveryLogging {
|
||||
param($Profile,$Tuple)
|
||||
Assert ($Profile -ceq 'Domain') 'Setter receives only selected profile'
|
||||
$pending=Get-Content -LiteralPath (Join-Path $script:restoreOutput 'pending.json') -Raw | ConvertFrom-Json
|
||||
Assert ($pending.Status -ceq 'Pending' -and $pending.RecoverTo.LogAllowed -ceq 'False') 'Durable matching pending receipt precedes setter'
|
||||
$script:writes++
|
||||
$script:state.Profiles.PersistentStore.Domain.Logging=Copy-Fixture $Tuple
|
||||
if($script:mode -ne 'policy'){$script:state.Profiles.ActiveStore.Domain.Logging=Copy-Fixture $Tuple}
|
||||
if($script:mode -eq 'throw'){throw 'Injected partial native setter failure'}
|
||||
if($script:mode -eq 'enforcement'){$script:state.Profiles.PersistentStore.Domain.Preserved.Enabled=$false}
|
||||
}
|
||||
function Plan {
|
||||
$out=Join-Path $root ([guid]::NewGuid().ToString('N'))
|
||||
$r=Invoke-WelaFirewallLoggingRecovery -Profile Domain -JournalPath $journal -ResultsPath $results -OutputPath $out
|
||||
Assert ($r.Status -ceq 'Planned' -and $r.ExitCode -eq 0) "Plan accepted: $($r.Diagnostic)"
|
||||
$script:planPath=Join-Path $out 'plan.json';$script:planHash=$r.PlanSha256
|
||||
}
|
||||
function Restore([switch]$Prompt,[switch]$DryRun){
|
||||
$script:restoreOutput=Join-Path $root ([guid]::NewGuid().ToString('N'))
|
||||
$args=@{Action='Restore';PlanPath=$script:planPath;PlanHash=$script:planHash;Auto=(-not $Prompt);DryRun=$DryRun}
|
||||
if(-not $DryRun){$args.OutputPath=$script:restoreOutput}
|
||||
Invoke-WelaFirewallLoggingRecovery @args
|
||||
}
|
||||
try {
|
||||
Reset
|
||||
$e=Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST
|
||||
Assert ($e.RecoverTo.LogMaxSizeKilobytes -eq 4096 -and $e.Expected.LogAllowed -ceq 'True') 'Exact typed local recovery tuple'
|
||||
if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){
|
||||
$entry.Desired.PathMode='CisV4';$entry.Desired.LogFileName='%SystemRoot%\System32\LogFiles\Firewall\domainfw.log';$row.Desired=Copy-Fixture $entry.Desired
|
||||
$row.After.Local.LogFileName=$entry.Desired.LogFileName;$row.After.Effective.LogFileName=$entry.Desired.LogFileName;Save
|
||||
$migration=Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST
|
||||
Assert ($migration.RecoverTo.LogFileName -ceq 'C:\Logs\Domain.log' -and $migration.Expected.LogFileName -ceq $entry.Desired.LogFileName) 'CIS migration preserves the exact original local recovery path'
|
||||
Reset
|
||||
}
|
||||
foreach($bad in @('NotConfigured','true','1')){$v=Copy-Fixture $e.RecoverTo;$v.LogAllowed=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'True/False'}
|
||||
foreach($bad in @('4096',0,32768,$true,1.5)){$v=Copy-Fixture $e.RecoverTo;$v.LogMaxSizeKilobytes=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'integer'}
|
||||
foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\','C:\Logs\CON.log','C:\Logs\log.','C:\Logs\log ','C:\Logs\\log')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'}
|
||||
$v=Copy-Fixture $e.RecoverTo;$v|Add-Member Extra 1;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'Unexpected'
|
||||
foreach($change in @(
|
||||
{$script:row.Status='Failed'},{$script:row.Status=$true},{$script:row.After.Access.State=$true},
|
||||
{$script:entry.Target.Name=$true;$script:row.Target=Copy-Fixture $entry.Target},
|
||||
{$script:entry.Target.PolicyStore=$true;$script:row.Target=Copy-Fixture $entry.Target},
|
||||
{$script:entry.Desired.LogAllowed=$true;$script:row.Desired=Copy-Fixture $entry.Desired},
|
||||
{$script:entry.Target.PolicyStore='ActiveStore';$script:row.Target=Copy-Fixture $entry.Target},
|
||||
{$script:row.After.Local.LogMaxSizeKilobytes=20000},{$script:row.After.Local.LogFileName='C:\Other.log'},
|
||||
{$script:row.After.Access.State='Unknown'},{$script:entry.ComputerName='OTHER'},
|
||||
{$script:row.Before.Local.LogBlocked='True'},{$script:entry.Desired.MinimumSizeKiB='16384';$script:row.Desired=Copy-Fixture $entry.Desired}
|
||||
)){Reset;& $change;Save;Throws {Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST} 'required|Only|permitted|confirm|wrong-host|mismatch|Unsupported'}
|
||||
Reset;[IO.File]::AppendAllText($journal,"`n"+(Get-WelaFirewallRecoveryKey $entry));Throws {Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST} 'duplicate'
|
||||
Reset;[IO.File]::WriteAllText($journal,'{"Version":1,"version":1}');Throws {Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST} 'duplicate|Duplicate|collision'
|
||||
Reset;Plan;$r=Restore -DryRun;Assert ($r.Status -ceq 'WouldRestore' -and $writes -eq 0 -and -not (Test-Path $restoreOutput)) 'Dry run has no writes or output'
|
||||
$r=Restore;Assert ($r.Status -ceq 'LocalLoggingRestored' -and $r.ExitCode -eq 0 -and $writes -eq 1 -and $r.EffectiveMatchesLocal -and $r.ReadyRuleCredit -eq 0) "Exact restoration succeeded: $($r.Diagnostic)"
|
||||
$r=Restore;Assert ($r.Status -ceq 'AlreadyRestored' -and $writes -eq 1) 'Idempotence never calls setter'
|
||||
foreach($change in @(
|
||||
{$script:state.Profiles.PersistentStore.Domain.Logging.LogMaxSizeKilobytes=24576},
|
||||
{$script:state.Profiles.PersistentStore.Private.Logging.LogBlocked='False'},
|
||||
{$script:state.Profiles.PersistentStore.Domain.Preserved.Enabled=$false},
|
||||
{$script:state.RuleConfiguration[0].Sha256='drift'},{$script:state.Context.Reader='another-logon'},
|
||||
{$script:state.Sources.Code='changed'},{$script:state.NativeSources.Module='changed'}
|
||||
)){Reset;Plan;& $change;$r=Restore;Assert ($r.Status -ceq 'Refused' -and -not $r.WriteAttempted -and $writes -eq 0) 'Current drift blocks every setter'}
|
||||
Reset;Plan;$script:prompt={$script:state.Profiles.PersistentStore.Domain.Logging.LogBlocked='False'};$r=Restore -Prompt
|
||||
Assert ($r.Status -ceq 'Refused' -and $writes -eq 0 -and (Test-Path (Join-Path $restoreOutput 'pending.json'))) 'Fresh post-prompt guard preserves pending receipt without writing'
|
||||
Reset;Plan;$entry.Before.Local.LogMaxSizeKilobytes=2048;$row.Before=Copy-Fixture $entry.Before;Save;$r=Restore
|
||||
Assert ($r.Status -ceq 'Refused' -and $writes -eq 0) 'Changed original inputs block restore'
|
||||
Reset;Plan;[IO.File]::AppendAllText($planPath,' ');$r=Restore;Assert ($r.Status -ceq 'Refused' -and $writes -eq 0) 'Changed reviewed plan bytes block restore'
|
||||
Reset;Plan;$script:mode='throw';$r=Restore
|
||||
Assert ($r.Status -ceq 'WriteAttemptedUnverified' -and $r.ExitCode -eq 1 -and $r.WriteAttempted -and (Test-Path (Join-Path $restoreOutput 'pending.json')) -and -not (Test-Path (Join-Path $restoreOutput 'confirmed.json'))) 'Partial setter failure stays unverified with durable intent, never automatic rollback'
|
||||
Reset;Plan;$script:mode='enforcement';$r=Restore;Assert ($r.Status -ceq 'WriteAttemptedUnverified') 'Unexpected enforcement drift fails readback'
|
||||
Reset;Plan;$script:mode='policy';$r=Restore;Assert ($r.Status -ceq 'LocalLoggingRestored' -and -not $r.EffectiveMatchesLocal) 'Local restoration is separate from unchanged effective override'
|
||||
# CIM configuration hashes must retain enforcement/condition data and typed nulls.
|
||||
$cim=[pscustomobject]@{CimClass=[pscustomobject]@{CimClassName='MSFT_NetFirewallRule'};CimInstanceProperties=@([pscustomobject]@{Name='Enabled';Value=1;CimType='UInt16'},[pscustomobject]@{Name='Status';Value='volatile';CimType='String'})}
|
||||
$key=ConvertTo-WelaFirewallRecoveryCim $cim @('Status');Assert ($key.Enabled.Type -eq 'UInt16' -and -not $key.PSObject.Properties['Status']) 'Rule hash preserves typed configuration while excluding named diagnostics'
|
||||
$cim.CimInstanceProperties[0].Value=[DateTime]::UtcNow;Throws {ConvertTo-WelaFirewallRecoveryCim $cim} 'Unsupported native property type'
|
||||
# A prerequisite read must not connect to WMI while its services are stopped.
|
||||
$script:providerReads=0;$script:serviceStatus='Stopped'
|
||||
function Get-WelaChannelReader {[pscustomobject]@{ElevatedAdministrator=$true}}
|
||||
function Get-Service {param($Name,$ErrorAction) foreach($n in $Name){[pscustomobject]@{Name=$n;Status=$script:serviceStatus}}}
|
||||
function Get-CimInstance {$script:providerReads++;throw 'Native provider boundary reached'}
|
||||
Throws {Get-WelaFirewallRecoveryContext} 'must already be running'
|
||||
Assert ($providerReads -eq 0) 'Stopped services are refused before any native provider connection'
|
||||
$script:serviceStatus='Running';Throws {Get-WelaFirewallRecoveryContext} 'Native provider boundary reached'
|
||||
Assert ($providerReads -eq 1) 'Running services permit the first native provider read'
|
||||
} finally {Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
$global:LASTEXITCODE=0
|
||||
Write-Host "Firewall logging recovery: $script:assertions assertions passed."
|
||||
@@ -0,0 +1,81 @@
|
||||
param([switch]$AllowDisposableLoggingWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($env:OS -ne 'Windows_NT'){Write-Host 'Skipped: native Windows required.';exit 0}
|
||||
if(-not $AllowDisposableLoggingWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted logging-write opt-in is required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
foreach($file in @('Configuration','FirewallLogging','AuditRecovery','WefArrival','WecUpdate','ChannelRead','FirewallLoggingRecovery')){. (Join-Path $repo "scripts/$file.ps1")}
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-firewall-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory -Path $root
|
||||
$logDirectory=Join-Path $root 'owned-logs';$null=New-Item -ItemType Directory -Path $logDirectory
|
||||
$script:checks=0;$script:cliIndex=0;$before=$null;$cleanup=$false
|
||||
function Assert-Native($Value,$Message){if(-not $Value){throw $Message};$script:checks++;Write-Host "PASS: $Message"}
|
||||
function Save-Native($Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),(Get-WelaFirewallRecoveryKey $Value),[Text.UTF8Encoding]::new($false))}
|
||||
function Invoke-FixtureCli([string[]]$Arguments,[int]$Expected=0){
|
||||
$script:cliIndex++;$old=$ErrorActionPreference;$ErrorActionPreference='Continue'
|
||||
try{$output=& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old}
|
||||
$output | Out-File -LiteralPath (Join-Path $root ("cli-$script:cliIndex.txt")) -Encoding utf8
|
||||
if(-not (($Expected -eq 0 -and $code -eq 0) -or ($Expected -ne 0 -and $code -ne 0))){throw "Public $($Arguments[0]) exit $code (expected $Expected): $($output -join ' ')"}
|
||||
Assert-Native $true "Public $($Arguments[0]) exit $code (expected $Expected)"
|
||||
}
|
||||
try {
|
||||
$before=Get-WelaFirewallRecoveryState;Save-Native 'safety-before.json' $before
|
||||
# All test-only changes are the four logging fields and a new owned directory.
|
||||
# The product never changes destination ACLs, service state, enforcement or rules.
|
||||
$acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false)
|
||||
$owner=[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
try{$sid=$owner.User;$acl.SetOwner($sid)}finally{$owner.Dispose()}
|
||||
$service=([Security.Principal.NTAccount]::new('NT SERVICE\mpssvc')).Translate([Security.Principal.SecurityIdentifier])
|
||||
foreach($principal in @($sid,[Security.Principal.SecurityIdentifier]::new('S-1-5-18'),[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($principal,'FullControl','ContainerInherit,ObjectInherit','None','Allow'))}
|
||||
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($service,'Modify','ContainerInherit,ObjectInherit','None','Allow'))
|
||||
Set-Acl -LiteralPath $logDirectory -AclObject $acl
|
||||
foreach($profile in @('Domain','Private','Public')){
|
||||
NetSecurity\Set-NetFirewallProfile -Name $profile -PolicyStore PersistentStore -LogAllowed False -LogBlocked False -LogMaxSizeKilobytes 4096 -LogFileName (Join-Path $logDirectory "$profile.log") -Confirm:$false -ErrorAction Stop
|
||||
}
|
||||
$prepared=Get-WelaFirewallRecoveryState;Save-Native 'prepared.json' $prepared
|
||||
$original=Join-Path $root 'original.json';$backup=Join-Path $root 'configure-backup'
|
||||
Invoke-FixtureCli @('firewall-logging','-FirewallAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',$original)
|
||||
$originalReport=Get-Content -LiteralPath $original -Raw | ConvertFrom-Json
|
||||
Assert-Native (@($originalReport.Results | Where-Object Status -ceq 'Applied').Count -eq 3) 'Public Configure produced three actual completed Applied journals'
|
||||
$configured=Get-WelaFirewallRecoveryState;Save-Native 'configured.json' $configured
|
||||
$planDirectory=Join-Path $root 'plan'
|
||||
Invoke-FixtureCli @('firewall-recovery','-FirewallRecoveryProfile','Domain','-FirewallRecoveryJournalPath',(Join-Path $backup 'before.jsonl'),'-FirewallRecoveryResultsPath',$original,'-FirewallRecoveryOutputPath',$planDirectory)
|
||||
$planPath=Join-Path $planDirectory 'plan.json';$planHash=(Get-FileHash -LiteralPath $planPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$restoreArgs=@('firewall-recovery','-FirewallRecoveryAction','Restore','-FirewallRecoveryPlanPath',$planPath,'-FirewallRecoveryPlanHash',$planHash)
|
||||
Invoke-FixtureCli ($restoreArgs+@('-DryRun'))
|
||||
Assert-Native ((Get-WelaFirewallRecoveryKey (Get-WelaFirewallRecoveryState)) -ceq (Get-WelaFirewallRecoveryKey $configured)) 'Public dry run preserves complete native state'
|
||||
NetSecurity\Set-NetFirewallProfile -Name Domain -PolicyStore PersistentStore -LogMaxSizeKilobytes 24576 -Confirm:$false -ErrorAction Stop
|
||||
$drift=Get-WelaFirewallRecoveryState
|
||||
Invoke-FixtureCli ($restoreArgs+@('-DryRun')) 1
|
||||
Assert-Native ((Get-WelaFirewallRecoveryKey (Get-WelaFirewallRecoveryState)) -ceq (Get-WelaFirewallRecoveryKey $drift)) 'Changed confirmed After tuple is refused without mutation'
|
||||
NetSecurity\Set-NetFirewallProfile -Name Domain -PolicyStore PersistentStore -LogMaxSizeKilobytes 16384 -Confirm:$false -ErrorAction Stop
|
||||
$restoreDirectory=Join-Path $root 'restore'
|
||||
Invoke-FixtureCli ($restoreArgs+@('-Auto','-FirewallRecoveryOutputPath',$restoreDirectory))
|
||||
$result=Get-Content -LiteralPath (Join-Path $restoreDirectory 'result.json') -Raw | ConvertFrom-Json
|
||||
Assert-Native ($result.Status -ceq 'LocalLoggingRestored' -and $result.WriteAttempted -and $result.ReadyRuleCredit -eq 0) 'Public recovery confirms the actual local four-field tuple without Sigma credit'
|
||||
$recovered=Get-WelaFirewallRecoveryState;Save-Native 'recovered.json' $recovered
|
||||
Assert-Native ((Get-WelaFirewallRecoveryKey $recovered.Profiles.PersistentStore.Domain.Logging) -ceq (Get-WelaFirewallRecoveryKey $prepared.Profiles.PersistentStore.Domain.Logging)) 'Selected local logging tuple exactly matches its original before values'
|
||||
Assert-Native ((Get-WelaFirewallRecoveryInvariant $recovered Domain) -ceq (Get-WelaFirewallRecoveryInvariant $configured Domain)) 'Enforcement, other profiles and both-store rule/filter configurations remain unchanged'
|
||||
foreach($artifact in $result.Artifacts){Assert-Native ((Get-FileHash -LiteralPath (Join-Path $restoreDirectory $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) "Verified retained receipt $($artifact.Name)"}
|
||||
$again=Join-Path $root 'again';Invoke-FixtureCli ($restoreArgs+@('-Auto','-FirewallRecoveryOutputPath',$again))
|
||||
$againResult=Get-Content -LiteralPath (Join-Path $again 'result.json') -Raw | ConvertFrom-Json
|
||||
Assert-Native ($againResult.Status -ceq 'AlreadyRestored' -and -not $againResult.WriteAttempted) 'Public repeated recovery is idempotent without a setter'
|
||||
} finally {
|
||||
$errors=@()
|
||||
if($before){
|
||||
foreach($profile in @('Domain','Private','Public')){
|
||||
try{Set-WelaFirewallRecoveryLogging $profile $before.Profiles.PersistentStore.$profile.Logging}catch{$errors+="$profile cleanup: $($_.Exception.Message)"}
|
||||
}
|
||||
try{$final=Get-WelaFirewallRecoveryState;Save-Native 'safety-after.json' $final;if((Get-WelaFirewallRecoveryKey $final) -cne (Get-WelaFirewallRecoveryKey $before)){throw 'Complete final native firewall configuration differs from original safety snapshot.'}}catch{$errors+=$_.Exception.Message}
|
||||
}
|
||||
# Service handles may briefly retain the old owned path after restoring all profiles.
|
||||
if(-not $errors.Count){
|
||||
for($attempt=0;$attempt -lt 10;$attempt++){
|
||||
try{Remove-Item -LiteralPath $logDirectory -Recurse -Force -ErrorAction Stop;break}catch{if($attempt -eq 9){$errors+=$_.Exception.Message}else{Start-Sleep -Milliseconds 500}}
|
||||
}
|
||||
}
|
||||
$cleanup=-not $errors.Count
|
||||
Save-Native 'acceptance.json' ([pscustomobject]@{Build=$before.Context.Build;Engine=$PSVersionTable.PSVersion.ToString();Checks=$checks;CleanupVerified=$cleanup;CleanupErrors=$errors;Scope='Actual public Configure/Plan/Restore, drift refusal and idempotence; all original profile logging, enforcement and bounded native rule/filter configuration restored. No event/Sigma proof.'})
|
||||
if($errors.Count){throw "Fixture cleanup failed; evidence at $root : $($errors -join '; ')"}
|
||||
}
|
||||
$global:LASTEXITCODE=0
|
||||
Write-Host "PASS: $checks native firewall recovery checks; exact safety cleanup. Evidence: $root"
|
||||
@@ -0,0 +1,97 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = Split-Path $PSScriptRoot -Parent
|
||||
$script:ScriptRoot = $repo
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
. (Join-Path $repo 'scripts/Configuration.ps1')
|
||||
$script:checks=0
|
||||
function Assert($Condition,[string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:checks++ }
|
||||
function Rule([string]$Enabled='True',[string]$Inbound='Require',[string]$Outbound='Request',[string]$Health='OK') {
|
||||
[pscustomobject]@{Name='owned';Enabled=$Enabled;InboundSecurity=$Inbound;OutboundSecurity=$Outbound;PrimaryStatus=$Health}
|
||||
}
|
||||
$script:rule=Rule
|
||||
$positive=Get-WelaIpsecPrerequisite -ReadRules {$script:rule} -ReadAssociations {}
|
||||
Assert ($positive.Status -eq 'Applicable' -and $positive.Rules[0].Qualifies) 'healthy effective securing rule qualifies'
|
||||
$none=Get-WelaIpsecPrerequisite -ReadRules {} -ReadAssociations {}
|
||||
Assert ($none.Status -eq 'NotObservedWithinScope' -and $none.Limitations -match 'legacy IPsec') 'empty complete inventory is scope-limited absence'
|
||||
foreach ($candidate in @((Rule False),(Rule False Require Request Inactive),(Rule True None None))) {
|
||||
$script:rule=$candidate
|
||||
$evidence=Get-WelaIpsecPrerequisite -ReadRules {$script:rule} -ReadAssociations {}
|
||||
Assert ($evidence.Status -eq 'NotObservedWithinScope' -and -not $evidence.Rules[0].Qualifies) 'disabled and exemption-only policies do not qualify'
|
||||
}
|
||||
foreach ($candidate in @((Rule True Require Request Error),(Rule True Require Request Unknown),(Rule True Require Request Inactive),(Rule Maybe),([pscustomobject]@{Name='missing'}))) {
|
||||
$script:rule=$candidate
|
||||
Assert ((Get-WelaIpsecPrerequisite -ReadRules {$script:rule} -ReadAssociations {}).Status -eq 'Unknown') 'invalid or unhealthy policy stays unknown'
|
||||
}
|
||||
$script:rule=Rule
|
||||
Assert ((Get-WelaIpsecPrerequisite -ReadRules {$script:rule; throw 'denied midway'} -ReadAssociations {}).Status -eq 'Unknown') 'partial failed enumeration never qualifies'
|
||||
Assert ((Get-WelaIpsecPrerequisite -ReadRules {$script:rule} -ReadAssociations {throw 'denied'}).Status -eq 'Unknown') 'failed independent SA observation prevents complete positive evidence'
|
||||
Assert ((Get-WelaIpsecPrerequisite -ReadRules {$script:rule;$script:rule} -ReadAssociations {}).Status -eq 'Unknown') 'duplicate rule identities rejected'
|
||||
Assert ((Get-WelaIpsecPrerequisite -ReadRules {1..4097} -ReadAssociations {}).Status -eq 'Unknown') 'native inventory cap remains unknown'
|
||||
$sa=Get-WelaIpsecPrerequisite -ReadRules {} -ReadAssociations {[pscustomobject]@{Name='1';LocalEndpoint='192.0.2.1';RemoteEndpoint='192.0.2.2'}}
|
||||
Assert ($sa.Status -eq 'Applicable' -and $sa.MainModeAssociations.Count -eq 1) 'valid native SA is independently positive evidence'
|
||||
Assert ((Get-WelaIpsecPrerequisite -ReadRules {} -ReadAssociations {[pscustomobject]@{Name='1';LocalEndpoint='unknown';RemoteEndpoint='192.0.2.2'}}).Status -eq 'Unknown') 'malformed SA does not qualify'
|
||||
Assert ((Get-WelaIpsecPrerequisite -Offline -ReadRules {throw 'must not run'} -ReadAssociations {throw 'must not run'}).Status -eq 'Unknown') 'offline never queries this host'
|
||||
$script:zero=@{}; foreach ($policy in (Import-WelaAuditProfiles).catalog) {$script:zero[$policy.guid]=0}
|
||||
$profile='microsoft-stronger-reviewed-2026-09';$guid='0CCE9218-69AE-11D9-BED3-505054503030'
|
||||
function Plan([switch]$Optional,[switch]$Observe) { Get-WelaAuditProfilePlan -Profile $profile -Role MemberServer -Build 26100 -Current $script:zero -IncludeOptional:$Optional -ObserveIpsec:$Observe -ReadIpsec {$script:evidence} }
|
||||
$script:evidence=$positive
|
||||
$plan=Plan -Optional
|
||||
$row=@($plan.policies|Where-Object id -eq 'IPsec Main Mode')[0]
|
||||
Assert ($row.conditionalPrerequisite.Status -eq 'Unknown' -and $null -eq $row.targetMask) 'offline conditional plan has no applicable target'
|
||||
$plan=Plan -Observe
|
||||
Assert (($plan.policies|Where-Object id -eq 'IPsec Main Mode').action -eq 'Optional (not selected)') 'positive evidence never substitutes for explicit selection'
|
||||
$plan=Plan -Observe -Optional
|
||||
Assert (($plan.policies|Where-Object id -eq 'IPsec Main Mode').targetMask -eq 3) 'live selected positive plan retains exact SF mask'
|
||||
$script:evidence=$none;$plan=Plan -Observe -Optional
|
||||
Assert (($plan.policies|Where-Object id -eq 'IPsec Main Mode').action -like 'Preserve*') 'scope-limited absence explicitly preserves'
|
||||
function Single-Plan {
|
||||
$p=Plan -Optional -Observe
|
||||
$p.policies=@($p.policies|Where-Object id -eq 'IPsec Main Mode')
|
||||
$p
|
||||
}
|
||||
$script:evidence=$positive;$plan=Single-Plan
|
||||
$script:state=$script:zero.Clone();$script:writes=0;$script:reads=0
|
||||
$contextReader={ [pscustomobject]@{Role='MemberServer';Build=26100} }
|
||||
$writer={param($Guid,$Mask) $script:writes++;$script:state[$Guid]=$Mask}
|
||||
$reader={$script:state.Clone()}
|
||||
$result=Invoke-WelaAuditProfilePlan $plan -ReadContext $contextReader -ReadPolicy $reader -WritePolicy $writer -ReadIpsec {$positive} -Confirm:$false
|
||||
Assert ($result.success -and $script:writes -eq 1 -and $result.results[0].prerequisiteObservations.Count -eq 2) 'direct executor observes and rechecks before write'
|
||||
$script:state[$guid]=0;$script:writes=0
|
||||
$result=Invoke-WelaAuditProfilePlan $plan -ReadContext $contextReader -ReadPolicy $reader -WritePolicy $writer -ReadIpsec {$none} -Confirm:$false
|
||||
Assert ($result.success -and $script:writes -eq 0 -and $result.results[0].status -eq 'Skipped') 'unobserved condition never writes'
|
||||
$result=Invoke-WelaAuditProfilePlan $plan -ReadContext $contextReader -ReadPolicy $reader -WritePolicy $writer -ReadIpsec {$script:reads++;if($script:reads -eq 1){$positive}else{$none}} -Confirm:$false
|
||||
Assert (-not $result.success -and $script:writes -eq 0 -and $result.results[0].prerequisiteObservations.Count -eq 2) 'last-moment condition drift blocks direct executor'
|
||||
$plan.profile='microsoft-sct-server2025-2602'
|
||||
$result=Invoke-WelaAuditProfilePlan $plan -ReadContext $contextReader -ReadPolicy $reader -WritePolicy $writer -ReadIpsec {throw 'unrelated query'} -Confirm:$false
|
||||
Assert ($result.success -and $script:writes -eq 1) 'other profile intent is unaffected'
|
||||
$plan=Single-Plan;$plan|Add-Member NoteProperty CustomProfileSource ([pscustomobject]@{})
|
||||
Assert (-not (Test-WelaIpsecConditionalPolicy $plan $plan.policies[0])) 'custom profile intent is not reclassified by its id'
|
||||
|
||||
# Public configure adapter: real runner and durable journal, injected native boundaries.
|
||||
function Get-WelaRegistryState {param($Path,$Name) [pscustomobject]@{ValueExists=$true;Type='DWord';Value=1} }
|
||||
function Get-WelaAuditPrecedenceSource { $null }
|
||||
function Get-WelaNativeAuditPolicy {param($Guid) $script:state[$Guid] }
|
||||
function Invoke-WelaNative {param($FilePath,$Arguments)
|
||||
Assert (Test-Path -LiteralPath (Join-Path $script:backup 'before.jsonl')) 'journal precedes native write'
|
||||
$script:writes++;$script:state[$guid]=3
|
||||
}
|
||||
function Read-Host {param($Prompt) $script:evidence=$none; 'y' }
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ipsec-'+[guid]::NewGuid().ToString('N'))
|
||||
try {
|
||||
$script:evidence=$positive;$plan=Single-Plan;$script:state[$guid]=0;$script:writes=0
|
||||
$script:backup=Join-Path $root 'race';$ctx=New-WelaConfigurationContext -BackupPath $script:backup
|
||||
Set-WelaProfileAuditControls $ctx $plan -ReadIpsec {$script:evidence}
|
||||
$result=Complete-WelaConfiguration $ctx -Plan $plan
|
||||
$row=@($result.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0]
|
||||
Assert ($row.Status -eq 'Failed' -and $script:writes -eq 0 -and $row.PrerequisiteObservations[-1].Status -eq 'NotObservedWithinScope') 'public runner rechecks after prompt/journal and retains negative evidence'
|
||||
$script:evidence=$positive;$script:backup=Join-Path $root 'positive';$ctx=New-WelaConfigurationContext -Auto -BackupPath $script:backup
|
||||
Set-WelaProfileAuditControls $ctx $plan -ReadIpsec {$script:evidence}
|
||||
$result=Complete-WelaConfiguration $ctx -Plan $plan
|
||||
$row=@($result.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0]
|
||||
Assert ($row.Status -eq 'Applied' -and $script:writes -eq 1 -and $row.PrerequisiteObservations.Count -eq 5) 'public runner keeps plan/read/prewrite/readback/final native prerequisite observations'
|
||||
$script:evidence=$none;$script:backup=Join-Path $root 'negative';$ctx=New-WelaConfigurationContext -Auto -BackupPath $script:backup
|
||||
Set-WelaProfileAuditControls $ctx $plan -ReadIpsec {$script:evidence}
|
||||
$result=Complete-WelaConfiguration $ctx -Plan $plan
|
||||
Assert ($result.Skipped -eq 1 -and $script:writes -eq 1) 'negative public prerequisite is visible even when audit mask already matches'
|
||||
} finally {if(Test-Path $root){Remove-Item $root -Recurse -Force}}
|
||||
Write-Host "Passed $script:checks IPsec prerequisite assertions. No native mutations."
|
||||
@@ -0,0 +1,109 @@
|
||||
param([switch]$AllowDisposablePolicyWrite,[switch]$AllowDisposableIpsecRule)
|
||||
$ErrorActionPreference='Stop'
|
||||
if ($env:OS -ne 'Windows_NT') {throw 'Native Windows fixture required.'}
|
||||
if (-not $AllowDisposablePolicyWrite -or -not $AllowDisposableIpsecRule) {throw 'Disposable audit-policy and owned IPsec-rule opt-in are both required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
Import-Module NetSecurity -ErrorAction Stop
|
||||
. (Join-Path $repo 'scripts/Configuration.ps1')
|
||||
$script:checks=0
|
||||
function Assert($Condition,[string]$Message) {if(-not $Condition){throw "FAIL: $Message"};$script:checks++}
|
||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
Assert ([Security.Principal.WindowsPrincipal]::new($identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) 'fixture is elevated'
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-ipsec-'+[guid]::NewGuid().ToString('N'))
|
||||
$null=New-Item $root -ItemType Directory
|
||||
$name='wela-ipsec-'+[guid]::NewGuid().ToString('N')
|
||||
$guid='0CCE9218-69AE-11D9-BED3-505054503030'
|
||||
$before=Get-WelaEffectiveAuditPolicy
|
||||
$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
|
||||
$precedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy
|
||||
$beforeRules=@(Get-NetIPsecRule -PolicyStore ActiveStore -ErrorAction Stop|Select-Object Name,Enabled,InboundSecurity,OutboundSecurity,PrimaryStatus|Sort-Object Name|ConvertTo-Json -Depth 5 -Compress)
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$created=$false;$cleanup=$false
|
||||
try {
|
||||
$baseline=Get-WelaIpsecPrerequisite
|
||||
$baseline|ConvertTo-Json -Depth 10|Set-Content (Join-Path $root 'baseline.json') -Encoding UTF8
|
||||
Assert ($baseline.Status -ne 'Unknown') "both native sources are readable: $($baseline.Diagnostic)"
|
||||
# Both endpoints are documentation-only addresses; no packets or negotiations are generated.
|
||||
$null=New-NetIPsecRule -Name $name -DisplayName $name -PolicyStore PersistentStore -Profile Any -Enabled False -LocalAddress 192.0.2.250 -RemoteAddress 192.0.2.251 -InboundSecurity Request -OutboundSecurity Request -ErrorAction Stop
|
||||
$created=$true
|
||||
$evidence=Get-WelaIpsecPrerequisite
|
||||
$evidence|ConvertTo-Json -Depth 10|Set-Content (Join-Path $root 'disabled.json') -Encoding UTF8
|
||||
Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop|Select-Object *|Export-Clixml (Join-Path $root 'disabled-native.xml')
|
||||
Get-NetIPsecRule -PolicyStore ActiveStore -ErrorAction Stop|Select-Object *|Export-Clixml (Join-Path $root 'disabled-active-native.xml')
|
||||
$owned=@($evidence.Rules|Where-Object Name -eq $name)
|
||||
Assert ((Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop).Enabled -eq 'False') 'owned persistent rule is actually disabled'
|
||||
Assert ($evidence.Status -ne 'Unknown' -and @($owned|Where-Object Qualifies).Count -eq 0) "disabled rule does not qualify (ActiveStore may omit it): $($evidence.Diagnostic)"
|
||||
Set-NetIPsecRule -Name $name -PolicyStore PersistentStore -Enabled True -InboundSecurity None -OutboundSecurity None -ErrorAction Stop
|
||||
$evidence=Get-WelaIpsecPrerequisite
|
||||
$owned=@($evidence.Rules|Where-Object Name -eq $name)
|
||||
$evidence|ConvertTo-Json -Depth 10|Set-Content (Join-Path $root 'exemption.json') -Encoding UTF8
|
||||
Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop|Select-Object *|Export-Clixml (Join-Path $root 'exemption-native.xml')
|
||||
Assert ($evidence.Status -ne 'Unknown' -and @($owned|Where-Object Qualifies).Count -eq 0) "real exemption-only rule does not qualify: $($evidence.Diagnostic)"
|
||||
# Converting to an exemption clears its authentication-set references. Recreate
|
||||
# only this owned fixture so New-NetIPsecRule supplies valid native defaults.
|
||||
Remove-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop
|
||||
$created=$false
|
||||
$null=New-NetIPsecRule -Name $name -DisplayName $name -PolicyStore PersistentStore -Profile Any -Enabled True -LocalAddress 192.0.2.250 -RemoteAddress 192.0.2.251 -InboundSecurity Request -OutboundSecurity Request -ErrorAction Stop
|
||||
$created=$true
|
||||
$evidence=Get-WelaIpsecPrerequisite
|
||||
$evidence|ConvertTo-Json -Depth 10|Set-Content (Join-Path $root 'positive.json') -Encoding UTF8
|
||||
Get-NetIPsecRule -Name $name -PolicyStore ActiveStore -ErrorAction Stop|Select-Object *|Export-Clixml (Join-Path $root 'positive-native.xml')
|
||||
$owned=@($evidence.Rules|Where-Object Name -eq $name)
|
||||
Assert ($evidence.Status -eq 'Applicable' -and $owned.Count -eq 1 -and $owned[0].Qualifies) "real enabled securing ActiveStore rule establishes scoped applicability: $($evidence.Diagnostic)"
|
||||
$planPath=Join-Path $root 'plan.json'
|
||||
& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') plan -Profile microsoft-stronger-reviewed-2026-09 -IncludeOptional -SaclMode Skip -PlanPath $planPath
|
||||
Assert ($LASTEXITCODE -eq 0) 'public live plan succeeds'
|
||||
$plan=Get-Content $planPath -Raw|ConvertFrom-Json
|
||||
$row=@($plan.policies|Where-Object id -eq 'IPsec Main Mode')[0]
|
||||
Assert ($row.conditionalPrerequisite.Status -eq 'Applicable' -and $row.targetMask -eq 3) 'public plan contains native evidence and selected SF mask'
|
||||
$dryPath=Join-Path $root 'dry.json'
|
||||
& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') configure -Profile microsoft-stronger-reviewed-2026-09 -IncludeOptional -SaclMode Skip -DryRun -Auto -ResultsPath $dryPath
|
||||
Assert ($LASTEXITCODE -eq 0) 'public configure dry-run succeeds'
|
||||
$current=Get-WelaEffectiveAuditPolicy
|
||||
Assert (@($before.Keys|Where-Object {$before[$_] -ne $current[$_]}).Count -eq 0) 'dry-run preserves all59 effective masks'
|
||||
$dry=Get-Content $dryPath -Raw|ConvertFrom-Json
|
||||
$row=@($dry.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0]
|
||||
Assert ($row.PrerequisiteObservations.Count -ge 2 -and $row.Status -in @('Skipped','AlreadyCompliant')) 'public dry-run retains native prerequisite evidence'
|
||||
|
||||
# Actual public configure must produce a write for this control, then read it back.
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact
|
||||
$resultPath=Join-Path $root 'configure.json'
|
||||
& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') configure -Profile microsoft-stronger-reviewed-2026-09 -IncludeOptional -SaclMode Skip -Auto -BackupPath (Join-Path $root 'backup') -ResultsPath $resultPath
|
||||
Assert ($LASTEXITCODE -eq 0) 'actual public configure succeeds'
|
||||
$result=Get-Content $resultPath -Raw|ConvertFrom-Json
|
||||
$row=@($result.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0]
|
||||
Assert ($row.Status -eq 'Applied' -and $row.After -eq 3 -and $row.PrerequisiteObservations.Count -eq 5) 'actual gated policy write retains all five native observations'
|
||||
Assert (@($row.PrerequisiteObservations|Where-Object Status -ne Applicable).Count -eq 0) 'every configure boundary has positive native evidence'
|
||||
$journal=@(Get-Content (Join-Path $root 'backup/before.jsonl')|ConvertFrom-Json)
|
||||
Assert (@($journal|Where-Object {$_.Id -eq 'AuditPolicy/IPsec Main Mode' -and $_.Before -eq 0 -and $_.Desired.Mask -eq 3}).Count -eq 1) 'real public recovery journal retains exact policy transition'
|
||||
|
||||
# Native drift after prompt: exercise the real configuration callback and native reader.
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact
|
||||
$plan.policies=@($plan.policies|Where-Object id -eq 'IPsec Main Mode')
|
||||
function Read-Host {param($Prompt) Remove-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop; $script:created=$false; 'y'}
|
||||
$ctx=New-WelaConfigurationContext -BackupPath (Join-Path $root 'drift-backup')
|
||||
Set-WelaProfileAuditControls $ctx $plan
|
||||
$drift=Complete-WelaConfiguration $ctx -Plan $plan -ResultsPath (Join-Path $root 'drift.json')
|
||||
$row=@($drift.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0]
|
||||
if($baseline.Status -eq 'NotObservedWithinScope') {
|
||||
Assert ($row.Status -eq 'Failed' -and (Get-WelaEffectiveAuditPolicy)[$guid] -eq 0) 'real rule disappearance after prompt blocks auditpol write'
|
||||
} else {
|
||||
Assert ($row.Status -eq 'Applied') 'independent baseline prerequisite remains applicable after owned-rule removal'
|
||||
}
|
||||
} finally {
|
||||
if(@(Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction SilentlyContinue).Count){Remove-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop}
|
||||
foreach($id in $before.Keys){Set-WelaEffectiveAuditPolicy -Guid $id -Mask $before[$id] -Mode exact}
|
||||
if($precedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $precedence.Type -Value $precedence.Value -ErrorAction Stop}
|
||||
else {Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue}
|
||||
$after=Get-WelaEffectiveAuditPolicy
|
||||
Assert (@($before.Keys|Where-Object {$before[$_] -ne $after[$_]}).Count -eq 0) 'all59 original masks restored'
|
||||
$afterPrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy
|
||||
Assert (($precedence|ConvertTo-Json -Compress) -ceq ($afterPrecedence|ConvertTo-Json -Compress)) 'typed precedence/absence restored'
|
||||
$afterRules=@(Get-NetIPsecRule -PolicyStore ActiveStore -ErrorAction Stop|Select-Object Name,Enabled,InboundSecurity,OutboundSecurity,PrimaryStatus|Sort-Object Name|ConvertTo-Json -Depth 5 -Compress)
|
||||
Assert (($beforeRules -join '') -ceq ($afterRules -join '')) 'native rule inventory restored exactly'
|
||||
Assert (@(Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction SilentlyContinue).Count -eq 0) 'owned persistent rule removed'
|
||||
$cleanup=$true
|
||||
[pscustomobject]@{CleanupVerified=$cleanup;Checks=$script:checks;Engine=$PSVersionTable.PSVersion.ToString();Computer=$env:COMPUTERNAME;NoTrafficGenerated=$true}|ConvertTo-Json|Set-Content (Join-Path $root 'cleanup.json') -Encoding UTF8
|
||||
}
|
||||
Write-Host "Passed $script:checks native IPsec checks; artifacts: $root"
|
||||
@@ -0,0 +1,99 @@
|
||||
param([switch]$AllowDisposableChannelWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable hosted Windows opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/EventLogSettings.psm1" -Force
|
||||
Import-Module "$repo/modules/NativeProviders.psm1" -Force
|
||||
Import-Module "$repo/modules/NativeChannelAccess.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/NativeChannelConfiguration.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Binary($Value){$bytes=New-Object byte[] $Value.BinaryLength;$Value.GetBinaryForm($bytes,0);[Convert]::ToBase64String($bytes)}
|
||||
function Read-Raw([string]$Name){
|
||||
$r=Invoke-WelaNative wevtutil.exe @('gl',$Name,'/f:xml')
|
||||
$x=New-Object Xml.XmlDocument;$x.XmlResolver=$null;$x.LoadXml(($r.Output -join "`n"));return ,$x
|
||||
}
|
||||
function Guard-Raw($Xml){
|
||||
$x=$Xml.CloneNode($true);$x.DocumentElement.RemoveAttribute('enabled');$x.DocumentElement.RemoveAttribute('channelAccess')
|
||||
foreach($node in @($x.SelectNodes("/*/*[local-name()='logging']/*[local-name()='maxSize']"))){$null=$node.ParentNode.RemoveChild($node)}
|
||||
return $x.OuterXml
|
||||
}
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-channel-configure-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$profile=Get-WelaNativeChannelProfile
|
||||
$before=@{};$raw=@{};$policies=Get-WelaEffectiveAuditPolicy
|
||||
foreach($control in $profile.controls){$name=$control.channel;$before[$name]=Get-WelaNativeChannel $name;if(Test-WelaNativeChannelSnapshot $before[$name]){$raw[$name]=Read-Raw $name}}
|
||||
$rawEvidence=@{};foreach($name in $raw.Keys){$rawEvidence[$name]=$raw[$name].OuterXml};$rawEvidence|ConvertTo-Json -Depth 4|Set-Content "$root/raw-before.json" -Encoding UTF8
|
||||
$before|ConvertTo-Json -Depth 14|Set-Content "$root/before.json" -Encoding UTF8
|
||||
$missing=@($before.Values|Where-Object State -eq 'Not installed').Count
|
||||
$expected=if($missing){1}else{0}
|
||||
$capi='Microsoft-Windows-CAPI2/Operational';$app='Microsoft-Windows-AppLocker/EXE and DLL'
|
||||
$primary=$null
|
||||
function Run-Cli([string]$Name,[string[]]$Options){
|
||||
$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" channel-settings @Options -ResultsPath "$root/$Name.json" 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
$lines|Out-String|Set-Content "$root/$Name.txt" -Encoding UTF8
|
||||
Assert ($code -eq $expected) "Public $Name exit $code expected $expected : $($lines -join ' ')"
|
||||
$report=Get-Content "$root/$Name.json" -Raw|ConvertFrom-Json
|
||||
Assert ($report.ExitCode -eq $code -and $report.ForwardingReadiness -eq 'Not verified') 'Report agrees with native command exit and makes no forwarding claim'
|
||||
return $report
|
||||
}
|
||||
try{
|
||||
Assert ($raw.ContainsKey($capi) -and $raw.ContainsKey($app)) 'CAPI2 and AppLocker channels are required for this disposable fixture'
|
||||
Assert (@($before.Values|Where-Object { $_.State -notin @('Enabled','Disabled','Not installed') }).Count -eq 0) 'Unreadable original metadata refuses fixture writes'
|
||||
# Fixture-only remove this group read grant so the public opt-in must append it.
|
||||
$descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($before[$capi].SecurityDescriptor)
|
||||
for($i=$descriptor.DiscretionaryAcl.Count-1;$i -ge 0;$i--){
|
||||
$ace=$descriptor.DiscretionaryAcl[$i]
|
||||
if($ace -is [Security.AccessControl.CommonAce] -and $ace.AceQualifier -eq 'AccessAllowed' -and $ace.SecurityIdentifier.Value -eq 'S-1-5-32-573' -and ($ace.AccessMask -band 1)){$descriptor.DiscretionaryAcl.RemoveAce($i)}
|
||||
}
|
||||
$withoutRead=$descriptor.GetSddlForm('All');$access=Get-WelaChannelAccessPlan $withoutRead
|
||||
Assert ($access.State -eq 'GrantRequired') 'Prepared actual descriptor supports one lossless read-only grant'
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false','/ms:1048576',('/ca:'+$withoutRead))
|
||||
# Existing sizes above the signed 32-bit range must not be narrowed.
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$app,'/ms:2147483648')
|
||||
$prepared=@{};foreach($name in $raw.Keys){$prepared[$name]=Get-WelaNativeChannel $name}
|
||||
$null=Run-Cli 'plan' @('-ChannelAction','Plan','-GrantEventLogReaders')
|
||||
$null=Run-Cli 'dry-run' @('-ChannelAction','Configure','-GrantEventLogReaders','-DryRun','-Auto','-BackupPath',"$root/unused")
|
||||
Assert (-not (Test-Path "$root/unused")) 'DryRun creates no journal'
|
||||
foreach($name in $raw.Keys){Assert (Test-WelaNativeChannelSnapshotEqual $prepared[$name] (Get-WelaNativeChannel $name)) 'Plan and DryRun preserve actual native channel settings'}
|
||||
$plain=Run-Cli 'configure' @('-ChannelAction','Configure','-Auto','-BackupPath',"$root/plain-journal")
|
||||
$plainCapi=Get-WelaNativeChannel $capi
|
||||
Assert ($plainCapi.IsEnabled -and $plainCapi.MaximumSizeInBytes -eq 102432768 -and (Test-WelaChannelDescriptorEqual $plainCapi.SecurityDescriptor $withoutRead)) 'Public Configure enables/resizes CAPI2 and preserves its ACL without explicit grant'
|
||||
Assert ((Get-WelaNativeChannel $app).MaximumSizeInBytes -eq 2147483648) 'A larger existing 2GiB buffer is preserved'
|
||||
$granted=Run-Cli 'grant' @('-ChannelAction','Configure','-GrantEventLogReaders','-Auto','-BackupPath',"$root/grant-journal")
|
||||
$actual=Get-WelaNativeChannel $capi
|
||||
Assert (Test-WelaChannelDescriptorEqual $actual.SecurityDescriptor $access.ProposedDescriptor) 'Native readback matches the precise planned grant descriptor'
|
||||
$afterAcl=[Security.AccessControl.RawSecurityDescriptor]::new($actual.SecurityDescriptor)
|
||||
Assert ($afterAcl.DiscretionaryAcl.Count -eq $descriptor.DiscretionaryAcl.Count+1) 'Exactly one native DACL ACE is added'
|
||||
$newAce=$afterAcl.DiscretionaryAcl[$access.AddedAceIndex]
|
||||
Assert ($newAce.SecurityIdentifier.Value -eq 'S-1-5-32-573' -and $newAce.AccessMask -eq 1 -and $newAce.AceFlags -eq 0 -and -not $newAce.IsCallback) 'Added grant is unconditional read only'
|
||||
$afterAcl.DiscretionaryAcl.RemoveAce($access.AddedAceIndex)
|
||||
Assert ((Binary $afterAcl) -ceq (Binary $descriptor)) 'Owner/group/SACL/flags and every original ACE byte/order survive native application'
|
||||
$again=Run-Cli 'idempotent' @('-ChannelAction','Configure','-GrantEventLogReaders','-Auto','-BackupPath',"$root/repeat-journal")
|
||||
Assert (@($again.Results|Where-Object Status -eq 'Applied').Count -eq 0) 'Repeated native configuration does not apply another mutation'
|
||||
Assert (-not (Test-Path "$root/repeat-journal/before.jsonl")) 'Idempotent invocation journals no write'
|
||||
$journal=@(Get-Content "$root/grant-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json})
|
||||
Assert ($journal.Count -eq 1 -and $journal[0].Target.Channel -eq $capi -and (Test-WelaChannelDescriptorEqual $journal[0].Before.SecurityDescriptor $withoutRead)) 'Durable actual pre-grant journal preserves the original descriptor'
|
||||
$rawAfter=@{};foreach($name in $raw.Keys){$rawAfter[$name]=(Read-Raw $name).OuterXml};$rawAfter|ConvertTo-Json -Depth 4|Set-Content "$root/raw-configured.json" -Encoding UTF8
|
||||
foreach($name in $raw.Keys){Assert ((Guard-Raw (Read-Raw $name)) -ceq (Guard-Raw $raw[$name])) 'Native channel path/retention/provider metadata remain unchanged'}
|
||||
Write-Host "PASS: $count native public channel configuration assertions."
|
||||
}catch{$primary=$_}
|
||||
finally{
|
||||
$errors=@()
|
||||
foreach($name in $raw.Keys){
|
||||
try{$s=$before[$name];$null=Invoke-WelaNative wevtutil.exe @('sl',$name,('/e:'+$s.IsEnabled.ToString().ToLowerInvariant()),('/ms:'+$s.MaximumSizeInBytes),('/ca:'+$s.SecurityDescriptor))
|
||||
if(-not (Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $name)) -or (Read-Raw $name).OuterXml -cne $raw[$name].OuterXml){throw 'Original native channel configuration differs after cleanup'}
|
||||
}catch{$errors+="$name : $($_.Exception.Message)"}
|
||||
}
|
||||
$now=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($policies[$guid] -ne $now[$guid]){$errors+='Audit mask changed: '+$guid}}
|
||||
$after=@{};foreach($name in $before.Keys){$after[$name]=Get-WelaNativeChannel $name}
|
||||
[ordered]@{CleanupVerified=($errors.Count -eq 0);Before=$before;After=$after;AuditMasksCompared=$policies.Count;Diagnostic=$errors;Assertions=$count;PrimaryError=[string]$primary}|ConvertTo-Json -Depth 14|Set-Content "$root/cleanup.json" -Encoding UTF8
|
||||
if($errors.Count){throw "Cleanup failed: $($errors -join '; '); primary: $primary"}
|
||||
Write-Host 'Exact original channel metadata and all audit masks verified after cleanup; existing event records/retention duration not claimed.'
|
||||
}
|
||||
$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash}})
|
||||
$sources=@('WELA.ps1','scripts/Configuration.ps1','scripts/NativeChannelConfiguration.ps1','modules/NativeChannelAccess.psm1','modules/NativeProviders.psm1','modules/EventLogSettings.psm1','tests/NativeChannelConfigure.Windows.Tests.ps1')|ForEach-Object {[ordered]@{Path=$_;Sha256=(Get-FileHash -LiteralPath (Join-Path $repo $_) -Algorithm SHA256).Hash}}
|
||||
[ordered]@{Status=$(if($primary){'Failed'}else{'Passed'});Commit=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$count;Artifacts=$artifacts;Sources=@($sources);EventGenerationVerified=$false;ForwardingVerified=$false;ReadyRuleCredit=0}|ConvertTo-Json -Depth 8|Set-Content "$root/manifest.json" -Encoding UTF8
|
||||
if($primary){throw $primary};$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,174 @@
|
||||
param([switch]$AllowDisposableProviderWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableProviderWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows provider-write opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/EventLogSettings.psm1" -Force
|
||||
Import-Module "$repo/modules/NativeProviders.psm1" -Force
|
||||
Import-Module "$repo/modules/NativeChannelAccess.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/NativeChannelConfiguration.ps1"
|
||||
. "$repo/scripts/NativeProviderPacks.ps1"
|
||||
$count=0;$errors=@();$primary=$null;$mutated=@()
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 30|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
|
||||
function Read-Raw([string]$Name){$r=Invoke-WelaNative wevtutil.exe @('gl',$Name,'/f:xml');$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.LoadXml(($r.Output -join "`n"));return ,$doc}
|
||||
function Guard-Raw($Xml){$copy=$Xml.CloneNode($true);$copy.DocumentElement.RemoveAttribute('enabled');foreach($node in @($copy.SelectNodes("/*/*[local-name()='logging']/*[local-name()='maxSize']"))){$null=$node.ParentNode.RemoveChild($node)};$copy.OuterXml}
|
||||
function Services {
|
||||
foreach($name in @('EventLog','Winmgmt','WinRM','TermService','DNS')){
|
||||
$state=Get-WelaNativeService $name
|
||||
if($state.State -eq 'Unknown'){throw "Service $name is unreadable"}
|
||||
[pscustomobject][ordered]@{Name=$name;State=$state.State;Start=$(if($state.State -ne 'Not installed'){[string](Get-Service -Name $name -ErrorAction Stop).StartType}else{$null})}
|
||||
}
|
||||
}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 20 -Compress}
|
||||
Add-Type -TypeDefinition @'
|
||||
using System; using System.IO; using System.Text; using System.Threading.Tasks;
|
||||
public static class WelaProviderConfigureFixturePipe {
|
||||
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 exceeded 1Mi characters.");text.Append(buffer,0,n); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-provider-configure-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$wrapper=Join-Path $root 'public.ps1'
|
||||
@'
|
||||
param([string]$InputPath)
|
||||
$ErrorActionPreference='Stop';$global:LASTEXITCODE=0
|
||||
$p=Get-Content -LiteralPath $InputPath -Raw|ConvertFrom-Json
|
||||
$a=@{ProviderAction=[string]$p.Action;ProviderPack=[string[]]$p.Names;ResultsPath=[string]$p.ResultsPath}
|
||||
if($p.Action -ceq 'Configure'){$a.Auto=$true;$a.BackupPath=[string]$p.BackupPath}
|
||||
if($p.DryRun){$a.DryRun=$true}
|
||||
# Array-splatted strings are positional values, not named PowerShell switches.
|
||||
# Fixed literal branches exercise the public parameter parser exactly.
|
||||
if(@($p.Extra).Count -eq 0){& ([string]$p.Script) provider-packs @a}
|
||||
elseif(@($p.Extra).Count -eq 1 -and $p.Extra[0] -ceq '-WhatIf'){& ([string]$p.Script) provider-packs @a -WhatIf}
|
||||
elseif(@($p.Extra).Count -eq 1 -and $p.Extra[0] -ceq '-GrantEventLogReaders'){& ([string]$p.Script) provider-packs @a -GrantEventLogReaders}
|
||||
else{throw 'Unreviewed fixture option.'}
|
||||
exit $global:LASTEXITCODE
|
||||
'@ | Set-Content -LiteralPath $wrapper -Encoding UTF8
|
||||
function Public([string]$Name,[string]$Action,[string[]]$Names,[switch]$DryRun,[int]$Expected=0,[string[]]$Extra=@()){
|
||||
$inputPath=Join-Path $root ($Name+'-input.json');$resultPath=Join-Path $root ($Name+'.json');$backup=Join-Path $root ($Name+'-journal')
|
||||
Save ($Name+'-input.json') @{Script="$repo/WELA.ps1";Action=$Action;Names=$Names;DryRun=[bool]$DryRun;ResultsPath=$resultPath;BackupPath=$backup;Extra=$Extra}
|
||||
$all=@('-NoLogo','-NoProfile','-NonInteractive','-File',$wrapper,'-InputPath',$inputPath)
|
||||
foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Unsupported 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=[WelaProviderConfigureFixturePipe]::Read($process.StandardOutput);$stderr=[WelaProviderConfigureFixturePipe]::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 command output drain timed out.'}
|
||||
$text=$stdout.Result+"`n"+$stderr.Result;[IO.File]::WriteAllText((Join-Path $root ($Name+'.txt')),$text)
|
||||
Assert ($process.ExitCode -eq $Expected) "Public $Name exit $($process.ExitCode) expected $Expected : $text"
|
||||
}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'}}
|
||||
try{$process.Dispose()}catch{$script:errors+=$_.Exception.Message}
|
||||
}
|
||||
if(Test-Path $resultPath){$r=Get-Content $resultPath -Raw|ConvertFrom-Json;Assert ($r.ExitCode -eq $Expected -and $r.ReadyRules -eq 0 -and $r.UnverifiedEvidence.Count -eq 4) 'Public result agrees with process exit and grants no readiness credit.';return $r}
|
||||
Assert ($Expected -eq 1 -and -not(Test-Path $backup)) 'Invalid CLI refuses before report or recovery directory creation.'
|
||||
}
|
||||
$catalog=Get-WelaProviderPackCatalog
|
||||
$names=@('dns-client','capi2','winrm','rdp-client');$selected=@($catalog.packs|Where-Object {$names -contains $_.id})
|
||||
$channels=@(@($catalog.packs.channel)+@('Security','System','Application','Microsoft-Windows-AppLocker/EXE and DLL','Microsoft-Windows-DriverFrameworks-UserMode/Operational')|Sort-Object -Unique)
|
||||
$before=@{};$raw=@{};$prepared=@{};$preparedRaw=@{};$services=@(Services);$policies=Get-WelaEffectiveAuditPolicy
|
||||
foreach($channel in $channels){$before[$channel]=Get-WelaNativeChannel $channel;if(Test-WelaNativeChannelSnapshot $before[$channel]){$raw[$channel]=Read-Raw $channel}}
|
||||
$rawText=@{};foreach($channel in $raw.Keys){$rawText[$channel]=$raw[$channel].OuterXml}
|
||||
Save 'original.json' @{Channels=$before;RawXml=$rawText;Services=$services;AuditMasks=$policies;Engine=$PSVersionTable.PSVersion.ToString()}
|
||||
function Stable-Selected {foreach($channel in $selected.channel){Assert (Test-WelaNativeChannelSnapshotEqual $configured[$channel] (Get-WelaNativeChannel $channel)) 'Idempotent/refused/partial invocation preserves the expected complete selected-channel tuple.'}}
|
||||
function Preserved {
|
||||
foreach($channel in $channels){
|
||||
$now=Get-WelaNativeChannel $channel
|
||||
if($selected.channel -contains $channel){Assert ((Guard-Raw (Read-Raw $channel)) -ceq (Guard-Raw $preparedRaw[$channel])) 'Selected channel preserves complete descriptor, retention, path and publisher settings.'}
|
||||
elseif($raw.ContainsKey($channel)){Assert ((Read-Raw $channel).OuterXml -ceq $raw[$channel].OuterXml) 'Unselected registered channel retains every configuration field.'}
|
||||
else{Assert ((Key $now) -ceq (Key $before[$channel])) 'Uninstalled/unreadable nonselected channel observation remains unchanged.'}
|
||||
}
|
||||
Assert ((Key @(Services)) -ceq (Key $services)) 'EventLog, Winmgmt, WinRM, RDP and DNS service state/start types remain unchanged.'
|
||||
$nowMasks=Get-WelaEffectiveAuditPolicy;Assert ($nowMasks.Count -eq 59 -and $policies.Count -eq 59) 'All59 native audit masks are present.'
|
||||
foreach($guid in $policies.Keys){if($nowMasks[$guid] -ne $policies[$guid]){throw "Audit mask changed: $guid"}};$script:count++
|
||||
}
|
||||
try {
|
||||
Assert (@($services|Where-Object {$_.Name -in @('Winmgmt','EventLog') -and $_.State -ne 'Running'}).Count -eq 0) 'Metadata dependencies must already be running; fixture never starts services.'
|
||||
$os=Get-CimInstance Win32_OperatingSystem
|
||||
Assert ($os.ProductType -eq 3 -and $os.BuildNumber -in @('20348','26100')) 'Reviewed disposable Server2022/2025 required.'
|
||||
Assert (@($services|Where-Object {$_.Name -eq 'DNS' -and $_.State -eq 'Not installed'}).Count -eq 1) 'Fixture requires genuine DNS Server absence; no role is installed/removed for acceptance.'
|
||||
foreach($pack in $selected){Assert ($raw.ContainsKey($pack.channel)) "Actual selected channel required: $($pack.id)"}
|
||||
# First real public observation must support all four reviewed manifest gates.
|
||||
$initial=Public 'initial' 'Plan' $names
|
||||
foreach($entry in $initial.ControlsPlan){Assert ($entry.ProviderEvidence.CanConfigure -and $entry.ProviderEvidence.Schema.State -ceq 'Observed' -and $entry.ProviderEvidence.Schema.Provider -ceq $entry.Pack.provider) 'Exact actual provider/schema permits the selected pack.'}
|
||||
Assert ($initial.ControlsPlan.Count -eq 4) 'Exactly four explicit packs are observed.'
|
||||
foreach($channel in $raw.Keys){Assert ((Read-Raw $channel).OuterXml -ceq $raw[$channel].OuterXml) 'Initial public Plan preserves every registered channel configuration.'}
|
||||
foreach($pack in $selected){
|
||||
$channel=$pack.channel;$mutated+=,$channel
|
||||
$size=if($pack.id -ceq 'winrm'){2147483648L}else{1048576L}
|
||||
$nativeArguments=@('sl',$channel,'/e:false',('/ms:'+$size));if($pack.id -ceq 'capi2'){$nativeArguments+=@('/rt:true','/ab:false')}
|
||||
$null=Invoke-WelaNative wevtutil.exe $nativeArguments
|
||||
$prepared[$channel]=Get-WelaNativeChannel $channel;$preparedRaw[$channel]=Read-Raw $channel
|
||||
}
|
||||
$preparedText=@{};foreach($channel in $preparedRaw.Keys){$preparedText[$channel]=$preparedRaw[$channel].OuterXml}
|
||||
Save 'prepared.json' $prepared;Save 'prepared-xml.json' $preparedText
|
||||
$planned=Public 'plan' 'Plan' $names
|
||||
Assert (@($planned.ControlsPlan|Where-Object Status -cne 'ChangeRequired').Count -eq 0) 'Actual disabled/small prepared channels require change.'
|
||||
$dry=Public 'dry' 'Configure' $names -DryRun
|
||||
Assert ($dry.DryRun -and $dry.Results.Count -eq 4 -and @($dry.Results|Where-Object Status -cne 'Skipped').Count -eq 0 -and -not(Test-Path "$root/dry-journal")) 'Public DryRun skips all selected writes and creates no journal.'
|
||||
$null=Public 'whatif' 'Configure' $names -Expected 1 -Extra @('-WhatIf')
|
||||
$null=Public 'grant-option' 'Configure' $names -Expected 1 -Extra @('-GrantEventLogReaders')
|
||||
foreach($channel in $selected.channel){Assert (Test-WelaNativeChannelSnapshotEqual $prepared[$channel] (Get-WelaNativeChannel $channel)) 'Plan, DryRun and invalid options preserve prepared actual state.'}
|
||||
Preserved
|
||||
$configured=@{}
|
||||
$applied=Public 'configure' 'Configure' $names
|
||||
Assert ($applied.Action -ceq 'Configure' -and $applied.Scope -ceq 'native-channel-settings-only' -and $applied.Results.Count -eq 4 -and @($applied.Results|Where-Object Status -cne 'Applied').Count -eq 0) 'All four explicit configurations are actually Applied.'
|
||||
$journal=@(Get-Content "$root/configure-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json})
|
||||
Assert ($journal.Count -eq 4 -and @($journal|Where-Object {$selected.channel -notcontains $_.Target.Channel}).Count -eq 0) 'Exactly four selected changes have durable original journals.'
|
||||
foreach($pack in $selected){
|
||||
$channel=$pack.channel;$entry=@($applied.Results|Where-Object {$_.Target.Channel -ceq $channel});$j=@($journal|Where-Object {$_.Target.Channel -ceq $channel});$now=Get-WelaNativeChannel $channel;$configured[$channel]=$now
|
||||
$minimum=if($pack.id -ceq 'capi2'){102432768L}elseif($pack.id -ceq 'winrm'){2147483648L}else{33554432L}
|
||||
Assert ($entry.Count -eq 1 -and $j.Count -eq 1 -and (Test-WelaNativeChannelSnapshotEqual $j[0].Before $prepared[$channel]) -and (Test-WelaNativeChannelSnapshotEqual $entry[0].Before $prepared[$channel])) 'Native journal and result retain exact prepared before-state.'
|
||||
Assert ($now.IsEnabled -and $now.MaximumSizeInBytes -eq $minimum -and (Test-WelaNativeChannelSnapshotEqual $entry[0].After $now)) 'Exact native enable/floor/larger-buffer readback matches Applied after-state.'
|
||||
Assert ((Test-WelaChannelDescriptorEqual $now.SecurityDescriptor $prepared[$channel].SecurityDescriptor) -and $now.LogMode -ceq $prepared[$channel].LogMode -and -not $entry[0].Desired.AccessChangeRequested) 'Every descriptor byte and retention mode is preserved without a read grant.'
|
||||
}
|
||||
$configuredText=@{};foreach($channel in $selected.channel){$configuredText[$channel]=(Read-Raw $channel).OuterXml};Save 'configured-xml.json' $configuredText
|
||||
Assert ((Get-WelaNativeChannel 'Microsoft-Windows-CAPI2/Operational').LogMode -ceq 'Retain') 'An actual nondefault Retain setting survives provider configuration.'
|
||||
Preserved
|
||||
$repeat=Public 'repeat' 'Configure' $names
|
||||
Assert (@($repeat.Results|Where-Object Status -cne 'AlreadyCompliant').Count -eq 0 -and -not(Test-Path "$root/repeat-journal/before.jsonl")) 'Native repeat is idempotent and journals no write.'
|
||||
Stable-Selected
|
||||
$manual=Public 'manual' 'Configure' @('dns-server-analytical','dns-server-classic') -Expected 1
|
||||
Assert ($manual.Results.Count -eq 2 -and @($manual.Results|Where-Object Status -cne 'Failed').Count -eq 0 -and -not(Test-Path "$root/manual-journal/before.jsonl")) 'Both actual manual-only selections fail without channel mutation or journal.'
|
||||
Stable-Selected
|
||||
$missing=Public 'missing-dns' 'Configure' @('dns-server-audit') -Expected 1
|
||||
Assert ($missing.Results[0].Status -ceq 'Failed' -and $missing.ControlsPlan[0].ProviderEvidence.Service.State -ceq 'Not installed' -and -not(Test-Path "$root/missing-dns-journal/before.jsonl")) 'Missing actual DNS service cannot be replaced by an assumed server role.'
|
||||
Stable-Selected
|
||||
# A genuine partial public run must retain one success and one manual refusal.
|
||||
$capi='Microsoft-Windows-CAPI2/Operational';$null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false');$partialBefore=Get-WelaNativeChannel $capi
|
||||
$partial=Public 'partial' 'Configure' @('capi2','dns-server-analytical') -Expected 1
|
||||
Assert (@($partial.Results|Where-Object Status -ceq 'Applied').Count -eq 1 -and @($partial.Results|Where-Object Status -ceq 'Failed').Count -eq 1 -and (Get-WelaNativeChannel $capi).IsEnabled) 'Actual partial configuration retains one verified change and explicit nonzero failure.'
|
||||
$partialJournal=@(Get-Content "$root/partial-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json})
|
||||
Assert ($partialJournal.Count -eq 1 -and $partialJournal[0].Target.Channel -ceq $capi -and (Test-WelaNativeChannelSnapshotEqual $partialJournal[0].Before $partialBefore)) 'Partial run journals only its actual selected write.'
|
||||
Stable-Selected
|
||||
Preserved
|
||||
Save 'completed.json' @{Status='Passed';Assertions=$count;ActualAppliedControls=5;IdempotentControls=4;ManualRefusals=3;MissingServiceRefusals=1;ReadyRuleCredit=0}
|
||||
}catch{$primary=$_}
|
||||
finally {
|
||||
foreach($channel in $mutated){
|
||||
try {
|
||||
$s=$before[$channel];$retention=if($s.LogMode -ceq 'Circular'){'false'}else{'true'};$backup=if($s.LogMode -ceq 'AutoBackup'){'true'}else{'false'}
|
||||
$null=Invoke-WelaNative wevtutil.exe @('sl',$channel,('/e:'+$s.IsEnabled.ToString().ToLowerInvariant()),('/ms:'+$s.MaximumSizeInBytes),('/ca:'+$s.SecurityDescriptor),('/rt:'+$retention),('/ab:'+$backup))
|
||||
if(-not(Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $channel)) -or (Read-Raw $channel).OuterXml -cne $raw[$channel].OuterXml){throw 'Exact original channel configuration differs after cleanup.'}
|
||||
}catch{$errors+="$channel : $($_.Exception.Message)"}
|
||||
}
|
||||
$after=@{};$afterRaw=@{};foreach($channel in $channels){try{$after[$channel]=Get-WelaNativeChannel $channel;if($raw.ContainsKey($channel)){$afterRaw[$channel]=(Read-Raw $channel).OuterXml;if($afterRaw[$channel] -cne $raw[$channel].OuterXml){throw 'Original channel XML differs'}}elseif((Key $after[$channel]) -cne (Key $before[$channel])){throw 'Original unavailable observation differs'}}catch{$errors+="$channel : $($_.Exception.Message)"}}
|
||||
$serviceAfter=$null;try{$serviceAfter=@(Services);if((Key $serviceAfter) -cne (Key $services)){throw 'Service state/start type differs'}}catch{$errors+=$_.Exception.Message}
|
||||
$maskAfter=$null;try{$maskAfter=Get-WelaEffectiveAuditPolicy;if($maskAfter.Count -ne $policies.Count){throw 'Audit mask count differs'};foreach($guid in $policies.Keys){if($maskAfter[$guid] -ne $policies[$guid]){throw "Audit mask differs: $guid"}}}catch{$errors+=$_.Exception.Message}
|
||||
Save 'cleanup.json' @{CleanupVerified=($errors.Count -eq 0);Original=$before;After=$after;AfterRawXml=$afterRaw;ServicesBefore=$services;ServicesAfter=$serviceAfter;AuditMasksCompared=$policies.Count;AuditMasksAfter=$maskAfter;Errors=$errors;PrimaryError=[string]$primary;Assertions=$count}
|
||||
}
|
||||
$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash}})
|
||||
$sourcePaths=@('WELA.ps1','scripts/Configuration.ps1','scripts/NativeChannelConfiguration.ps1','scripts/NativeProviderPacks.ps1','modules/AuditProfiles.psm1','modules/EventLogSettings.psm1','modules/NativeProviders.psm1','modules/NativeChannelAccess.psm1','config/native_channel_profile.json','config/native_provider_packs.json','config/security_rules.json','tests/NativeProviderConfigure.Windows.Tests.ps1')+@($catalog.ruleReviews|ForEach-Object {'config/'+$_.localPath})
|
||||
$sources=@($sourcePaths|ForEach-Object {[ordered]@{Path=$_;Sha256=(Get-FileHash -LiteralPath (Join-Path $repo $_) -Algorithm SHA256).Hash}})
|
||||
Save 'manifest.json' @{Status=$(if($primary -or $errors.Count){'Failed'}else{'Passed'});Commit=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$count;Artifacts=$artifacts;Sources=$sources;EventGenerationVerified=$false;ForwardingVerified=$false;ReadyRuleCredit=0}
|
||||
if($errors.Count){throw "Fixture cleanup failed: $($errors -join '; '); primary=$primary"};if($primary){throw $primary}
|
||||
Write-Host "PASS: $count native public provider-pack assertions and exact channel/service/audit cleanup. No event generation or Sigma proof."
|
||||
exit 0
|
||||
@@ -43,6 +43,10 @@ function Get-WinEvent {
|
||||
$channel=if($f.TemplateMode -eq 'wrongchannel'){'Other/Operational'}else{$p.channel}
|
||||
$events+= [pscustomobject]@{Id=$e.id;Version=0;LogLink=[pscustomobject]@{LogName=$channel};Template=$template}
|
||||
}
|
||||
if($f.LargeIds){
|
||||
if($f.LargeOnly){$events=@()}
|
||||
foreach($large in @([long]3221734403,[long]4294967295)){$events+=[pscustomobject]@{Id=$large;Version=0;LogLink=[pscustomobject]@{LogName=$p.channel};Template='unselected template is never parsed'}}
|
||||
}
|
||||
[pscustomobject]@{Name=$ListProvider;Id='11111111-1111-1111-1111-111111111111';LogLinks=@([pscustomobject]@{LogName=$p.channel});Events=$events}
|
||||
}
|
||||
function Read-Host {param($Prompt) if($f.PromptSchemaDrift){$f.TemplateMode='missing'};$f.Prompt}
|
||||
@@ -95,6 +99,11 @@ try {
|
||||
Assert (@($entry.RuleReviews|Where-Object Eligibility -ne 'Conditional').Count -eq 0 -and $report.ReadyRules -eq 0) 'Provider settings never convert incomplete rule evidence into Ready.'
|
||||
Assert ($entry.ProviderEvidence.Schema.Events[0].Fields[0].InType -eq 'win:UnicodeString' -and $entry.ProviderEvidence.Schema.Events[0].TemplateSha256.Length -eq 64) 'Report retains runtime version, native field types and template fingerprint.'
|
||||
Assert ($f.Writes.Count -eq 0 -and -not(Test-Path $backup)) 'Read-only plan creates no journal and makes no channel changes.'
|
||||
Reset;$f.LargeIds=$true;$r=Invoke-WelaProviderPackCommand -Action Plan -Names winrm
|
||||
Assert ($r.ExitCode -eq 0 -and $r.ControlsPlan[0].ProviderEvidence.CanConfigure) 'Actual WinRM Int64 event IDs above Int32 do not invalidate unrelated selected event6.'
|
||||
Assert ($r.ControlsPlan[0].ProviderEvidence.Schema.Events.Count -eq 1 -and $r.ControlsPlan[0].ProviderEvidence.Schema.Events[0].Id -eq 6) 'Only the exact reviewed event6 enters schema evidence; large unselected IDs/templates are excluded.'
|
||||
Reset;$f.LargeIds=$true;$f.LargeOnly=$true;$r=Invoke-WelaProviderPackCommand -Action Configure -Names winrm -Auto -BackupPath $backup
|
||||
Assert ($r.ExitCode -eq 1 -and -not $r.ControlsPlan[0].ProviderEvidence.CanConfigure -and $f.Writes.Count -eq 0) 'Unrelated large native IDs cannot substitute for a missing selected event6.'
|
||||
Reset;$f.States['Microsoft-Windows-DNS-Client/Operational'].State='Not installed';$f.States['Microsoft-Windows-DNS-Client/Operational'].IsEnabled=$null
|
||||
$r=Invoke-WelaProviderPackCommand -Action Configure -Names dns-client -Auto -BackupPath $backup
|
||||
Assert ($r.ExitCode -eq 1 -and $f.Writes.Count -eq 0) 'Missing actual channel metadata cannot be replaced by provider-manifest availability.'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
$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=@('outgoing-ntlm','-Help');Code=0;Pattern='Changes only'},
|
||||
@{Args=@('configure','-NtlmAction','Configure');Code=1;Pattern='requires outgoing-ntlm'},
|
||||
@{Args=@('outgoing-ntlm','-OutgoingNtlmMode','Deny');Code=1;Pattern='enforcement is not accepted'},
|
||||
@{Args=@('outgoing-ntlm','-Role','Client');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('outgoing-ntlm','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('outgoing-ntlm','-Auto');Code=1;Pattern='require NtlmAction Configure'},
|
||||
@{Args=@('outgoing-ntlm','-DryRun');Code=1;Pattern='require NtlmAction Configure'},
|
||||
@{Args=@('outgoing-ntlm','-BackupPath',$root);Code=1;Pattern='require NtlmAction Configure'},
|
||||
@{Args=@('outgoing-ntlm','-Help','-ProviderAction','Configure');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('outgoing-ntlm','-NtlmAction','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 outgoing NTLM CLI guards."
|
||||
exit 0
|
||||
@@ -0,0 +1,58 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $repo 'scripts/Configuration.ps1')
|
||||
. (Join-Path $repo 'scripts/OutgoingNtlmAudit.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:writes=0;$script:reads=0;$script:failRead=$false;$script:failWrite=$false;$script:ignoreWrite=$false;$script:promptChange=$null;$script:onRead=$null
|
||||
}
|
||||
function Get-WelaOutgoingAuditSnapshot {
|
||||
$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)}
|
||||
}
|
||||
function Get-WelaOutgoingNtlmPolicySource {'Unknown (fixture has no RSoP ownership evidence)'}
|
||||
function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction)
|
||||
Assert ($LiteralPath -ceq 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' -and $Name -ceq 'RestrictSendingNTLMTraffic' -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 Read-Host {param($Prompt) if($script:promptChange){& $script:promptChange};return 'Y'}
|
||||
function Configure([string]$Mode='PreserveOrAudit',[switch]$DryRun,[switch]$Prompt){
|
||||
$script:sequence++;$script:backup=Join-Path $root ('case-'+$script:sequence)
|
||||
Invoke-WelaOutgoingAuditCommand -Action Configure -Mode $Mode -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 'outgoing-ntlm-audit-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/allow are audited; existing audit 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.'}
|
||||
}
|
||||
Reset 2;$r=Configure;Assert ($script:writes -eq 0 -and $r.Results[0].Status -ceq 'Skipped' -and $r.Plan.Status -ceq 'PreservedEnforcement' -and $script:policy.Value -eq 2) 'Default mode preserves and identifies authentication enforcement.'
|
||||
Reset 2;$r=Configure Audit;Assert ($script:writes -eq 1 -and $script:policy.Value -eq 1 -and $r.Results[0].Status -ceq 'Applied') 'Explicit audit mode authorizes replacing deny with auditing.'
|
||||
foreach($value in @(42,'1')){foreach($mode in @('PreserveOrAudit','Audit')){
|
||||
Reset $value $(if($value -is [string]){'String'}else{'DWord'});$r=Configure $mode
|
||||
Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0 -and $r.Results[0].Status -ceq 'Failed') 'Unknown values/types remain untouched even in explicit Audit mode.'
|
||||
}}
|
||||
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;$r=Invoke-WelaOutgoingAuditCommand -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-WelaOutgoingAuditCommand @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 outgoing NTLM assertions."
|
||||
exit 0
|
||||
@@ -0,0 +1,76 @@
|
||||
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/OutgoingNtlmAudit.ps1')
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force
|
||||
$engine=(Get-Process -Id $PID).Path;$count=0;$failure=$null;$errors=@()
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-outgoing-audit-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';$name='RestrictSendingNTLMTraffic'
|
||||
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 {
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$k=$null
|
||||
try{
|
||||
$k=$base.OpenSubKey('SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0');if(-not $k){throw 'Existing MSV1_0 key required.'}
|
||||
$values=@($k.GetValueNames()|Sort-Object|Where-Object {$_ -ine $name}|ForEach-Object{[pscustomobject][ordered]@{Name=$_;Type=$k.GetValueKind($_).ToString();Value=$k.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}})
|
||||
$children=@($k.GetSubKeyNames()|Sort-Object)
|
||||
$security=if($PSVersionTable.PSVersion.Major -ge 6){[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($k)}else{$k.GetAccessControl()}
|
||||
$acl=$security.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group)
|
||||
}finally{if($k){$k.Dispose()};$base.Dispose()}
|
||||
[pscustomobject][ordered]@{Values=$values;Children=$children;Access=$acl;DomainPolicy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' AuditNTLMInDomain;NtlmChannel=Get-WelaNativeChannel 'Microsoft-Windows-NTLM/Operational';SecurityChannel=Get-WelaNativeChannel Security;NetlogonService=[string](Get-Service Netlogon).Status}
|
||||
}
|
||||
function Public([string]$Label,[string[]]$Arguments){
|
||||
$prior=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') outgoing-ntlm @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
$output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8
|
||||
Assert ($code -eq 0) "Public $Label exited $code : $output"
|
||||
Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json
|
||||
}
|
||||
$original=Get-WelaOutgoingAuditSnapshot
|
||||
Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain) 'Actual unjoined disposable Server is required.'
|
||||
Assert (-not $original.Policy.ValueExists -or ($original.Policy.Type -ceq 'DWord' -and $original.Policy.Value -in @(0,1))) 'Fixture never replaces pre-existing enforcement or an unknown policy.'
|
||||
$other=Other;$masks=Masks
|
||||
Save 'original.json' @{Snapshot=$original;Unselected=$other;Masks=$masks;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();Commit=$env:GITHUB_SHA}
|
||||
try{
|
||||
foreach($case in @('absent','allow')){
|
||||
if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}
|
||||
if($case -eq 'allow'){$null=New-ItemProperty -LiteralPath $path -Name $name -PropertyType DWord -Value 0}
|
||||
$prepared=Get-WelaOutgoingAuditSnapshot
|
||||
$plan=Public ($case+'-plan') @('-NtlmAction','Plan','-ResultsPath',(Join-Path $root ($case+'-plan.json')))
|
||||
Assert ($plan.Plan.Status -ceq 'ChangeRequired' -and (Key $plan.Plan.Before) -ceq (Key $prepared)) 'Public plan retains the exact native absence/allow state and actual host.'
|
||||
$dryBackup=Join-Path $root ($case+'-dry-backup')
|
||||
$dry=Public ($case+'-dry') @('-NtlmAction','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-WelaOutgoingAuditSnapshot)) -ceq (Key $prepared)) 'Dry run preserves policy and creates no journal directory.'
|
||||
$backup=Join-Path $root ($case+'-backup')
|
||||
$report=Public $case @('-NtlmAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',(Join-Path $root ($case+'.json')))
|
||||
$after=Get-WelaOutgoingAuditSnapshot
|
||||
Assert ($report.Scope -ceq 'outgoing-ntlm-audit-policy-only' -and $report.Results.Count -eq 1 -and $report.Results[0].Status -ceq 'Applied') 'Exactly one native outgoing policy is applied through public CLI.'
|
||||
Assert ($after.Policy.Type -ceq 'DWord' -and $after.Policy.Value -eq 1 -and (Key $report.Results[0].After) -ceq (Key $after)) 'Native audit-only 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) -and $journal[0].Target.Path -ceq $path -and $journal[0].Target.Name -ceq $name) 'One original journal retains the actual typed policy and native context.'
|
||||
$repeatBackup=Join-Path $root ($case+'-repeat-backup')
|
||||
$repeat=Public ($case+'-repeat') @('-NtlmAction','Configure','-Auto','-BackupPath',$repeatBackup,'-ResultsPath',(Join-Path $root ($case+'-repeat.json')))
|
||||
Assert ($repeat.Results[0].Status -ceq 'AlreadyCompliant' -and -not(Test-Path (Join-Path $repeatBackup 'before.jsonl'))) 'Repeated configuration is idempotent without another original journal.'
|
||||
$audit=Public ($case+'-audit') @('-NtlmAction','Audit','-ResultsPath',(Join-Path $root ($case+'-audit.json')))
|
||||
Assert ($audit.Plan.Status -ceq 'AlreadyCompliant' -and $audit.ReadyRuleCredit -eq 0 -and $audit.EventGeneration -like 'Not verified*') 'Audit distinguishes registry compliance from event or authentication proof.'
|
||||
Assert ((Key (Other)) -ceq (Key $other) -and (Masks) -ceq $masks) 'Incoming/domain policies, siblings, access descriptor, channels, service and all59 masks remain unchanged.'
|
||||
}
|
||||
Save 'completed.json' @{Status='Passed';Assertions=$count;NativeWrites=2;Scope='Only outgoing audit DWORD1. No network authentication attempt, enforcement, event generation, GPO refresh or Sigma proof.'}
|
||||
}catch{$failure=$_.ToString();throw}finally{
|
||||
try{
|
||||
if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}
|
||||
if($original.Policy.ValueExists){$null=New-ItemProperty -LiteralPath $path -Name $name -Value $original.Policy.Value -PropertyType $original.Policy.Type}
|
||||
}catch{$errors+=$_.ToString()}
|
||||
$checks=[ordered]@{}
|
||||
foreach($pair in @(@('Policy',{(Key (Get-WelaOutgoingAuditSnapshot)) -ceq (Key $original)}),@('Unselected',{(Key (Other)) -ceq (Key $other)}),@('All59Masks',{(Masks) -ceq $masks}))){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}
|
||||
if(-not $complete){throw 'Outgoing NTLM native fixture cleanup failed.'}
|
||||
}
|
||||
Write-Host "PASS: $count native public outgoing NTLM assertions and exact cleanup."
|
||||
exit 0
|
||||
@@ -0,0 +1,113 @@
|
||||
param([switch]$AllowDisposablePolicyWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposablePolicyWrite -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
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force
|
||||
. (Join-Path $repo 'scripts/Configuration.ps1')
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-profile-configure-'+[guid]::NewGuid().ToString('N'))
|
||||
$null=New-Item -ItemType Directory -Path $root
|
||||
$count=0;$failure=$null;$cleanupErrors=@()
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 30 | Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 25 -Compress}
|
||||
function Masks($Value){@($Value.Keys|Sort-Object|ForEach-Object{"$_=$($Value[$_])"}) -join ';'}
|
||||
function Public([string]$Label,[string[]]$Arguments,[int]$Expected=0){
|
||||
$prior=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
$output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8
|
||||
Assert ($code -eq $Expected) "Public $Label exited $code, expected $Expected : $output"
|
||||
}
|
||||
function Channels { @(foreach($name in @('Security','System','Application','ForwardedEvents','Microsoft-Windows-CAPI2/Operational')){Get-WelaNativeChannel $name}) }
|
||||
function TypedPrecedence {Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy}
|
||||
$before=Get-WelaEffectiveAuditPolicy;$precedence=TypedPrecedence;$channels=Channels
|
||||
$hostState=Get-WelaHostContext
|
||||
$actualOs=Get-CimInstance Win32_OperatingSystem | Select-Object Version,BuildNumber,ProductType
|
||||
$actualComputer=Get-CimInstance Win32_ComputerSystem | Select-Object DomainRole,PartOfDomain
|
||||
$patch=Get-ItemPropertyValue -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name UBR
|
||||
Assert ($actualOs.ProductType -eq 3 -and $actualComputer.DomainRole -eq 2 -and -not $actualComputer.PartOfDomain) 'Fixture records an actual standalone server, without simulating domain membership.'
|
||||
Assert ($hostState.Role -eq 'MemberServer' -and $hostState.Build -in @(20348,26100)) 'Only the actual hosted server context is supported by this fixture.'
|
||||
$catalog=Join-Path $repo 'config/audit_profiles.json';$example=Join-Path $repo 'config/custom-audit-profile.example.json'
|
||||
$catalogHash=(Get-FileHash $catalog).Hash;$exampleHash=(Get-FileHash $example).Hash
|
||||
$profilePath=Join-Path $root 'profile.json'
|
||||
$custom=Get-Content $example -Raw|ConvertFrom-Json
|
||||
$custom.profiles[0].id='custom-native-acceptance'
|
||||
$custom.profiles[0].appliesTo=@([pscustomobject]@{roles=@($hostState.Role);minBuild=$hostState.Build;maxBuild=$hostState.Build})
|
||||
$custom.profiles[0].note='Disposable native acceptance fixture; no baseline or detection claim.'
|
||||
Save 'profile.json' $custom
|
||||
$sourceHash=(Get-FileHash $profilePath).Hash.ToLowerInvariant()
|
||||
$ids=@{Creation='0CCE922B-69AE-11D9-BED3-505054503030';Termination='0CCE922C-69AE-11D9-BED3-505054503030';Share='0CCE9244-69AE-11D9-BED3-505054503030';File='0CCE921D-69AE-11D9-BED3-505054503030'}
|
||||
$base=@('-Profile','custom-native-acceptance','-ProfileFile',$profilePath,'-SaclMode','Skip')
|
||||
Save 'original.json' @{Host=$hostState;NativeOS=$actualOs;NativeComputer=$actualComputer;UBR=$patch;Engine=$PSVersionTable.PSVersion.ToString();Masks=$before;Precedence=$precedence;Channels=$channels;Sources=@{Custom=$sourceHash;Catalog=$catalogHash;Example=$exampleHash}}
|
||||
try{
|
||||
# Fixture-only initial values distinguish exact, minimum, optional and NC semantics.
|
||||
$null=New-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -PropertyType DWord -Value 0 -Force
|
||||
Set-WelaEffectiveAuditPolicy -Guid $ids.Creation -Mask 2 -Mode exact
|
||||
Set-WelaEffectiveAuditPolicy -Guid $ids.Termination -Mask 3 -Mode exact
|
||||
Set-WelaEffectiveAuditPolicy -Guid $ids.Share -Mask 1 -Mode exact
|
||||
Set-WelaEffectiveAuditPolicy -Guid $ids.File -Mask 0 -Mode exact
|
||||
$seed=Get-WelaEffectiveAuditPolicy;$seedPrecedence=TypedPrecedence
|
||||
Save 'seeded.json' @{Masks=$seed;Precedence=$seedPrecedence}
|
||||
$planPath=Join-Path $root 'plan.json'
|
||||
Public 'plan' (@('plan')+$base+@('-PlanPath',$planPath))
|
||||
$plan=Get-Content $planPath -Raw|ConvertFrom-Json
|
||||
Assert ($plan.role -eq $hostState.Role -and $plan.build -eq $hostState.Build -and $plan.policies.Count -eq 59) 'Public Plan retains actual context and all59 controls.'
|
||||
Assert ($plan.CustomProfileSource.Sha256 -ceq $sourceHash) 'Plan binds the selected custom source bytes.'
|
||||
$dryPath=Join-Path $root 'dry.json';$dryBackup=Join-Path $root 'dry-backup'
|
||||
Public 'dry' (@('configure')+$base+@('-Auto','-DryRun','-BackupPath',$dryBackup,'-ResultsPath',$dryPath))
|
||||
$dry=Get-Content $dryPath -Raw|ConvertFrom-Json
|
||||
Assert ($dry.DryRun -and $dry.ExitCode -eq 0 -and -not(Test-Path $dryBackup)) 'DryRun returns explicit preview without creating a journal.'
|
||||
Assert ((Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $seed) -and (Key (TypedPrecedence)) -ceq (Key $seedPrecedence)) 'Plan/DryRun preserve all59 masks and typed precedence.'
|
||||
Public 'wrong-role' (@('configure')+$base+@('-Auto','-Role','Client','-Build',[string]$hostState.Build,'-BackupPath',(Join-Path $root 'wrong-backup'),'-ResultsPath',(Join-Path $root 'wrong.json'))) 1
|
||||
Assert (-not(Test-Path (Join-Path $root 'wrong-backup')) -and (Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $seed)) 'Mismatched actual role refuses before native writes or a journal.'
|
||||
$backup=Join-Path $root 'configure-backup';$resultPath=Join-Path $root 'configured.json'
|
||||
Public 'configure' (@('configure')+$base+@('-Auto','-BackupPath',$backup,'-ResultsPath',$resultPath))
|
||||
$result=Get-Content $resultPath -Raw|ConvertFrom-Json
|
||||
$expected=$seed.Clone();$expected[$ids.Creation]=3;$expected[$ids.Termination]=1
|
||||
Assert ((Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $expected)) 'Actual Configure enables minimum Success without clearing Failure, applies exact Success, and preserves optional/NC/omitted controls.'
|
||||
Assert ((TypedPrecedence).Type -eq 'DWord' -and (TypedPrecedence).Value -eq 1) 'Public Configure applies and verifies actual DWORD precedence before audit writes.'
|
||||
Assert ($result.ExitCode -eq 0 -and $result.Scope -ceq 'advanced-audit-policy-and-precedence' -and $result.ProfileScope -ceq 'advanced-audit-policy-only') 'Completed public results retain the narrow scope and success.'
|
||||
Assert ($result.CustomProfileSource.Sha256 -ceq $sourceHash -and $result.CustomProfileSource.CanonicalSha256 -ieq $catalogHash) 'Completed result retains source and canonical catalog fingerprints.'
|
||||
$journal=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json)
|
||||
Assert ($journal.Count -eq 3 -and $journal[0].Target.Name -ceq 'SCENoApplyLegacyAuditPolicy') 'Only precedence and the two changed subcategories are journaled, in prerequisite order.'
|
||||
foreach($entry in $journal){
|
||||
$row=@($result.Results|Where-Object Id -ceq $entry.Id)
|
||||
Assert ($row.Count -eq 1 -and $row[0].Status -ceq 'Applied' -and (Key $row[0].Before) -ceq (Key $entry.Before)) 'Every native write has matching original journal and Applied result.'
|
||||
}
|
||||
$repeatPath=Join-Path $root 'repeat.json';$repeatBackup=Join-Path $root 'repeat-backup'
|
||||
Public 'repeat' (@('configure')+$base+@('-Auto','-BackupPath',$repeatBackup,'-ResultsPath',$repeatPath))
|
||||
$repeat=Get-Content $repeatPath -Raw|ConvertFrom-Json
|
||||
Assert ($repeat.ExitCode -eq 0 -and @($repeat.Results|Where-Object Status -eq 'Applied').Count -eq 0 -and (Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $expected)) 'Repeated public Configure is idempotent with no native write.'
|
||||
$optionalPath=Join-Path $root 'optional.json'
|
||||
Public 'optional' (@('configure')+$base+@('-Auto','-IncludeOptional','-BackupPath',(Join-Path $root 'optional-backup'),'-ResultsPath',$optionalPath))
|
||||
$optional=Get-Content $optionalPath -Raw|ConvertFrom-Json;$expected[$ids.File]=3
|
||||
Assert ($optional.ExitCode -eq 0 -and (Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $expected)) 'Explicit IncludeOptional changes only File System; all other masks are preserved.'
|
||||
Assert (@($optional.Results|Where-Object Status -eq 'Applied').Count -eq 1) 'Optional second stage records exactly one applied control.'
|
||||
$auditPath=Join-Path $root 'audit.json'
|
||||
Public 'audit' (@('audit-settings')+$base+@('-IncludeOptional','-PlanPath',$auditPath))
|
||||
$audit=Get-Content $auditPath -Raw|ConvertFrom-Json
|
||||
Assert ($audit.policies.Count -eq 59 -and $audit.CustomProfileSource.Sha256 -ceq $sourceHash) 'Post-configure public Audit reads the same59 controls and source.'
|
||||
Assert ((Key (Channels)) -ceq (Key $channels)) 'Advanced-audit-only configuration preserves native channel configuration.'
|
||||
Assert ((Get-FileHash $profilePath).Hash -ieq $sourceHash -and (Get-FileHash $catalog).Hash -ceq $catalogHash -and (Get-FileHash $example).Hash -ceq $exampleHash) 'No input policy or canonical source file was changed.'
|
||||
Save 'completed.json' @{Status='Passed';Assertions=$count;ExpectedMasks=$expected;ObservedMasks=Get-WelaEffectiveAuditPolicy;Scope='Actual public custom-profile advanced policy/precedence only; no GPO refresh, event generation or Sigma claim.'}
|
||||
}catch{$failure=$_.ToString();throw}finally{
|
||||
try{
|
||||
$now=Get-WelaEffectiveAuditPolicy
|
||||
foreach($guid in $before.Keys){
|
||||
if($now[$guid] -ne $before[$guid]){
|
||||
try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $before[$guid] -Mode exact}catch{$cleanupErrors+="$guid : $($_.ToString())"}
|
||||
}
|
||||
}
|
||||
}catch{$cleanupErrors+=$_.ToString()}
|
||||
try{
|
||||
if($precedence.ValueExists){$null=New-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -PropertyType $precedence.Type -Value $precedence.Value -Force}
|
||||
else{Remove-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -ErrorAction Stop}
|
||||
}catch{$cleanupErrors+=$_.ToString()}
|
||||
$masksOk=$false;$precedenceOk=$false;$channelsOk=$false
|
||||
try{$masksOk=(Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $before);$precedenceOk=(Key (TypedPrecedence)) -ceq (Key $precedence);$channelsOk=(Key (Channels)) -ceq (Key $channels)}catch{$cleanupErrors+=$_.ToString()}
|
||||
Save 'cleanup.json' @{Failure=$failure;Errors=$cleanupErrors;All59MasksRestored=$masksOk;TypedPrecedenceRestored=$precedenceOk;ChannelsPreserved=$channelsOk;Complete=($masksOk -and $precedenceOk -and $channelsOk -and -not $cleanupErrors.Count)}
|
||||
if(-not $masksOk -or -not $precedenceOk -or -not $channelsOk -or $cleanupErrors.Count){throw 'Native profile fixture cleanup failed; inspect retained evidence.'}
|
||||
}
|
||||
Write-Host "PASS: $count public native profile configuration assertions and exact cleanup."
|
||||
exit 0
|
||||
@@ -0,0 +1,82 @@
|
||||
// Disposable CI fixture only. Never imported by WELA product code.
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32;
|
||||
namespace Wela.RegistrySaclFixture {
|
||||
sealed class Privilege : IDisposable {
|
||||
[StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;}
|
||||
[StructLayout(LayoutKind.Sequential)] struct Privileges {public uint Count;public Luid Id;public uint Attributes;}
|
||||
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
|
||||
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
|
||||
[DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref Privileges requested,uint size,out Privileges previous,out uint required);
|
||||
IntPtr token;Privileges previous;
|
||||
public Privilege(string name){
|
||||
if(name!="SeBackupPrivilege"&&name!="SeRestorePrivilege")throw new InvalidOperationException("Unreviewed fixture privilege.");
|
||||
IntPtr thread;if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated fixture refused.");}int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);
|
||||
if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try{Luid id;if(!LookupPrivilegeValue(null,name,out id))throw new Win32Exception(Marshal.GetLastWin32Error());Privileges request=new Privileges{Count=1,Id=id,Attributes=2};uint needed;bool ok=AdjustTokenPrivileges(token,false,ref request,(uint)Marshal.SizeOf(typeof(Privileges)),out previous,out needed);error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Existing fixture privilege is required: "+name);}catch{CloseHandle(token);token=IntPtr.Zero;throw;}
|
||||
}
|
||||
public void Dispose(){if(token==IntPtr.Zero)return;try{Privileges ignored;uint needed;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(Privileges)),out ignored,out needed);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Fixture privilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}}
|
||||
}
|
||||
public sealed class WriteReceipt {public string StartedUtc,ReturnedUtc,CompletedUtc,HandleId,ValueName,Value;public int Calls;public bool Success;}
|
||||
public sealed class Hive : IDisposable {
|
||||
[DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value);
|
||||
static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
|
||||
static readonly IntPtr HKCU=new IntPtr(unchecked((int)0x80000001)),HKU=new IntPtr(unchecked((int)0x80000003));
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegCreateKeyExW(IntPtr root,string path,int reserved,string cls,uint options,uint access,IntPtr security,out IntPtr result,out uint disposition);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegOpenKeyExW(IntPtr root,string path,uint options,uint access,out IntPtr key);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegSetValueExW(IntPtr key,string name,uint reserved,uint type,byte[] data,uint size);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryValueExW(IntPtr key,string name,IntPtr reserved,out uint type,byte[] data,ref uint size);
|
||||
[DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegSaveKeyExW(IntPtr key,string file,IntPtr security,uint flags);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegLoadKeyW(IntPtr root,string name,string file);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegUnLoadKeyW(IntPtr root,string name);
|
||||
public readonly string Nonce,Sid,SeedPath,FilePath;
|
||||
public bool SeedCreated{get;private set;} public bool Saved{get;private set;} public bool Loaded{get;private set;}
|
||||
public Hive(string nonce,string file){
|
||||
if(!System.Text.RegularExpressions.Regex.IsMatch(nonce??"","^[a-f0-9]{32}$"))throw new InvalidOperationException("Exact fixture nonce required.");
|
||||
Nonce=nonce;Sid="S-1-5-21-"+Convert.ToUInt32(nonce.Substring(0,8),16)+"-"+Convert.ToUInt32(nonce.Substring(8,8),16)+"-"+Convert.ToUInt32(nonce.Substring(16,8),16)+"-1001";
|
||||
SeedPath="Software\\WELARegistrySaclSeed_"+nonce;FilePath=Path.GetFullPath(file);
|
||||
if(File.Exists(FilePath))throw new InvalidOperationException("Fixture hive file must be new.");
|
||||
}
|
||||
static void Check(int status,string operation){if(status!=0)throw new Win32Exception(status,operation);}
|
||||
static bool Exists(RegistryKey root,string name){using(RegistryKey key=root.OpenSubKey(name)){return key!=null;}}
|
||||
public void Prepare(){
|
||||
if(SeedCreated||Saved||Loaded||Exists(Registry.Users,Sid))throw new InvalidOperationException("Fixture identity already exists.");
|
||||
IntPtr key;uint disposition;Check(RegCreateKeyExW(HKCU,SeedPath,0,null,0,0xF003F,IntPtr.Zero,out key,out disposition),"Create owned seed");
|
||||
try{if(disposition!=1)throw new InvalidOperationException("Seed collided with an existing key.");SeedCreated=true;
|
||||
using(RegistryKey seed=Registry.CurrentUser.OpenSubKey(SeedPath,true)){seed.SetValue("WelaFixtureOwner",Nonce,RegistryValueKind.String);seed.Flush();}
|
||||
using(new Privilege("SeBackupPrivilege")){Check(RegSaveKeyExW(key,FilePath,IntPtr.Zero,2),"Save owned seed to a new hive file");Saved=true;}
|
||||
}finally{RegCloseKey(key);}
|
||||
if(Exists(Registry.Users,Sid))throw new InvalidOperationException("Fixture HKU mount collided.");
|
||||
using(new Privilege("SeBackupPrivilege"))using(new Privilege("SeRestorePrivilege")){Check(RegLoadKeyW(HKU,Sid,FilePath),"Load owned hive under its fresh SID");Loaded=true;}
|
||||
AssertOwned();
|
||||
}
|
||||
public void AssertOwned(){using(RegistryKey key=Registry.Users.OpenSubKey(Sid)){if(!Loaded||key==null||key.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned hive marker changed.");}}
|
||||
public void CreateRunOnce(){AssertOwned();IntPtr key;uint disposition;Check(RegCreateKeyExW(HKU,Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",0,null,0,0xF003F,IntPtr.Zero,out key,out disposition),"Create owned catalog RunOnce target");try{if(disposition!=1)throw new InvalidOperationException("Owned target unexpectedly exists.");}finally{RegCloseKey(key);}
|
||||
using(RegistryKey target=Registry.Users.OpenSubKey(Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",true)){target.SetValue("KeepTypedDword",321,RegistryValueKind.DWord);}
|
||||
}
|
||||
public void AssertValues(bool probe){using(RegistryKey key=Registry.Users.OpenSubKey(Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce")){if(key==null||key.ValueCount!=(probe?2:1)||key.GetValueKind("KeepTypedDword")!=RegistryValueKind.DWord||!(key.GetValue("KeepTypedDword") is int)||(int)key.GetValue("KeepTypedDword")!=321)throw new InvalidOperationException("Unrelated owned typed values changed.");if(probe&&(key.GetValueKind("WelaProbe_"+Nonce)!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaProbe_"+Nonce) as string,Nonce,StringComparison.Ordinal)))throw new InvalidOperationException("Owned nonce value mismatch.");}}
|
||||
public WriteReceipt WriteProbe(){
|
||||
AssertOwned();AssertValues(false);string name="WelaProbe_"+Nonce;IntPtr key;
|
||||
Check(RegOpenKeyExW(HKU,Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",0,0x103,out key),"Open owned value writer");
|
||||
try{
|
||||
byte[] bytes=System.Text.Encoding.Unicode.GetBytes(Nonce+"\0");string handle="0x"+unchecked((ulong)key.ToInt64()).ToString("x");
|
||||
DateTime start=UtcNow();Check(RegSetValueExW(key,name,0,1,bytes,(uint)bytes.Length),"Write one owned nonce REG_SZ");DateTime returned=UtcNow();
|
||||
uint type,size=(uint)bytes.Length;byte[] actual=new byte[size];Check(RegQueryValueExW(key,name,IntPtr.Zero,out type,actual,ref size),"Read back same-handle owned nonce");
|
||||
if(type!=1||size!=bytes.Length||Convert.ToBase64String(actual)!=Convert.ToBase64String(bytes))throw new InvalidOperationException("Probe write readback failed.");DateTime completed=UtcNow();
|
||||
return new WriteReceipt{StartedUtc=start.ToString("o"),ReturnedUtc=returned.ToString("o"),CompletedUtc=completed.ToString("o"),HandleId=handle,ValueName=name,Value=Nonce,Calls=1,Success=true};
|
||||
}finally{RegCloseKey(key);}
|
||||
}
|
||||
public void Dispose(){
|
||||
if(Loaded){AssertOwned();using(new Privilege("SeBackupPrivilege"))using(new Privilege("SeRestorePrivilege")){Check(RegUnLoadKeyW(HKU,Sid),"Unload owned fixture hive");Loaded=false;}}
|
||||
if(SeedCreated){using(RegistryKey seed=Registry.CurrentUser.OpenSubKey(SeedPath)){if(seed==null||seed.SubKeyCount!=0||seed.ValueCount!=1||seed.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(seed.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned seed changed; refuse deletion.");}Registry.CurrentUser.DeleteSubKey(SeedPath,true);SeedCreated=false;}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
$ErrorActionPreference='Stop';$root=Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $root 'scripts/WefArrival.ps1');. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1')
|
||||
$operation=[pscustomobject]@{Computer='FIXTURE';ProcessId=1234;Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';NativePath='\REGISTRY\USER\S-1-5-21-1-2-3-1001\Software\Microsoft\Windows\CurrentVersion\RunOnce';RecordIdBefore=10;Token=[pscustomobject]@{Sid='S-1-5-21-4-5-6-500';AuthenticationId='0x1234'};Write=[pscustomobject]@{ValueName='WelaProbe_nonce';Value='nonce';HandleId='0x456';StartedUtc='2026-09-22T00:00:00.0001000Z';ReturnedUtc='2026-09-22T00:00:00.0001500Z';CompletedUtc='2026-09-22T00:00:00.0002000Z'}}
|
||||
$xml=@"
|
||||
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-a5ba-3e3b0328c30d}"/><EventID>4657</EventID><Version>0</Version><Task>12801</Task><Keywords>0x8020000000000000</Keywords><Channel>Security</Channel><Computer>FIXTURE</Computer><EventRecordID>11</EventRecordID><TimeCreated SystemTime="2026-09-22T00:00:00.0001800Z"/></System><EventData><Data Name="SubjectUserSid">S-1-5-21-4-5-6-500</Data><Data Name="SubjectUserName">Reader</Data><Data Name="SubjectDomainName">FIXTURE</Data><Data Name="SubjectLogonId">0x1234</Data><Data Name="ObjectName">$($operation.NativePath)</Data><Data Name="ObjectValueName">WelaProbe_nonce</Data><Data Name="HandleId">0x456</Data><Data Name="OperationType">%%1904</Data><Data Name="OldValueType">-</Data><Data Name="OldValue">-</Data><Data Name="NewValueType">%%1873</Data><Data Name="NewValue">nonce</Data><Data Name="ProcessId">0x4d2</Data><Data Name="ProcessName">$($operation.Engine)</Data></EventData></Event>
|
||||
"@
|
||||
$count=0
|
||||
function Assert($value,$message){if(-not $value){throw $message};$script:count++}
|
||||
Assert (Test-WelaRegistrySaclFixtureEvent $xml $operation) 'Exact native-schema creation must match the measured write/readback phase.'
|
||||
foreach($change in @(@('4657','4663'),@('%%1904','%%1905'),@('%%1873','%%1874'),@('0x1234','0x1235'),@('0x456','0x457'),@('0x4d2','0x4d3'),@('6-500','6-501'),@('RunOnce','Other'),@('>nonce<','>other<'),@('0001800Z','0000999Z'),@('0001800Z','0002001Z'),@('EventRecordID>11','EventRecordID>10'),@('12801','12800'),@('8020000000000000','8010000000000000'))){Assert (-not(Test-WelaRegistrySaclFixtureEvent $xml.Replace($change[0],$change[1]) $operation)) ('Changed attribution must fail: '+$change[0])}
|
||||
Assert (-not(Test-WelaRegistrySaclFixtureEvent $xml.Replace('</EventData>','<Data Name="ProcessId">0x4d2</Data></EventData>') $operation)) 'Duplicate identity fields must fail.'
|
||||
Assert (-not(Test-WelaRegistrySaclFixtureEvent ('<!DOCTYPE Event [<!ENTITY x "x">]>'+$xml) $operation)) 'DTD input must fail.'
|
||||
Write-Host "Passed $count registry SACL fixture evidence assertions."
|
||||
@@ -0,0 +1,147 @@
|
||||
# Mutating test fixture only: public WELA never loads hives or prepares audit policy.
|
||||
param([switch]$AllowDisposableHiveWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableHiveWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Explicit disposable GitHub-hosted native Windows fixture only.'}
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
|
||||
foreach($name in @('Configuration','WefArrival','WmiProbe','ChannelRead','SelectedSaclConfiguration')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
|
||||
. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1')
|
||||
Initialize-WelaWmiProbeNative
|
||||
Add-Type -Path (Join-Path $PSScriptRoot 'RegistrySaclFixtureNative.cs') -ErrorAction Stop
|
||||
$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-registry-sacl-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot
|
||||
$files=Join-Path $root 'owned-hive-files';$null=New-Item -ItemType Directory $files
|
||||
function Save([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),($Value|ConvertTo-Json -Depth 28),[Text.UTF8Encoding]::new($false))}
|
||||
function Read-Receipt([string]$Name){ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $root $Name)))}
|
||||
function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
|
||||
$script:assertions=0
|
||||
function Assert($Condition,[string]$Message){if(-not $Condition){throw $Message};$script:assertions++}
|
||||
function Invoke-PublicFixture([string]$Name,[string[]]$Arguments,[int]$ExpectedExit=0,[string]$Diagnostic=''){
|
||||
$old=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') targeted-sacl @Arguments -ResultsPath (Join-Path $root ($Name+'.json')) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0}
|
||||
Save ($Name+'-output.json') @($output|ForEach-Object {[string]$_})
|
||||
Assert ($code -eq $ExpectedExit) ("Public $Name exited $code : "+($output -join ' '))
|
||||
if($Diagnostic){Assert (($output -join ' ') -match $Diagnostic) ("Public $Name did not report the expected refusal: "+($output -join ' '))}
|
||||
if($ExpectedExit -eq 0){Read-Receipt ($Name+'.json')}
|
||||
}
|
||||
function Assert-SelectedRow($Plan,[string]$Status){
|
||||
Assert ($Plan.Kind -is [string] -and $Plan.Kind -ceq 'WelaSelectedSaclPlan' -and @($Plan.Rows).Count -eq 1 -and $Plan.Rows[0].Id -is [string] -and $Plan.Rows[0].Id -ceq $selected.Id -and $Plan.Rows[0].Status -is [string] -and $Plan.Rows[0].Status -ceq $Status) ('Exact public selected row must be '+$Status)
|
||||
Assert ($Plan.Rows[0].Definition.UserSid -ceq $hive.Sid -and $Plan.Rows[0].Definition.Path -ieq $providerPath -and $Plan.GenerationReadiness -ceq 'Conditional' -and $Plan.UsableRuleCredit -eq 0) 'Public plan must remain bound to the owned target without generation/Sigma credit.'
|
||||
}
|
||||
function Assert-PreparedState {
|
||||
Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks) 'Public selected-SACL calls must preserve all 59 prepared audit masks.'
|
||||
Assert ((Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Public selected-SACL calls must preserve typed precedence.'
|
||||
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Current process token groups and privilege attributes must remain exact.'
|
||||
$hive.AssertValues($false)
|
||||
}
|
||||
$engine=(Get-Process -Id $PID).Path;$guid='0CCE921E-69AE-11D9-BED3-505054503030'
|
||||
$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy'
|
||||
$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$beforeMasks=Get-WelaEffectiveAuditPolicy;$beforePrecedence=Get-WelaRegistryState $precedencePath $precedenceName
|
||||
Save 'before-hives.json' $beforeHives;Save 'before-token.json' $beforeToken;Save 'before-masks.json' $beforeMasks;Save 'before-precedence.json' $beforePrecedence
|
||||
$hive=[Wela.RegistrySaclFixture.Hive]::new([guid]::NewGuid().ToString('N'),(Join-Path $files 'owned.dat'));$failure=$null;$cleanupErrors=@();$policyTouched=$false
|
||||
try {
|
||||
Assert ($beforeMasks.Count -eq 59) 'All 59 native audit subcategories must be observed before fixture mutation.'
|
||||
$hive.Prepare();$hive.CreateRunOnce();$hive.AssertOwned();$hive.AssertValues($false)
|
||||
Assert ((Key (Hives)) -ceq (Key (@($beforeHives)+$hive.Sid|Sort-Object))) 'Only the fresh owned SID hive may appear in HKU.'
|
||||
$providerPath='Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce'
|
||||
Save 'mounted.json' ([pscustomobject]@{Sid=$hive.Sid;File=$hive.FilePath;Seed=$hive.SeedPath;Loaded=$hive.Loaded;Target=$providerPath})
|
||||
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Fixture save/load must restore existing backup/restore privilege attributes.'
|
||||
$catalog=Invoke-PublicFixture 'catalog' @('-TargetSaclProfile','asd-native-2021-10','-IncludeOptional')
|
||||
$selectedRows=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq $providerPath})
|
||||
Assert ($selectedRows.Count -eq 1 -and $selectedRows[0].Id -cmatch '^sacl-[a-f0-9]{24}$' -and $selectedRows[0].Definition.Resolution -ceq 'Resolved') 'Actual public catalog must resolve exactly one owned registry target.'
|
||||
$selected=$selectedRows[0];Save 'selected.json' $selected
|
||||
# Seed a distinct explicit SYSTEM QueryValue audit ACE to prove additive preservation.
|
||||
Initialize-WelaSelectedSaclNative;$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null
|
||||
try{$target=[Wela.SelectedSacl.Target]::new('Registry',(Resolve-WelaSelectedSaclNativePath $selected.Definition));$original=$target.Read();$seeded=$target.Add($original.Identity,$original.DescriptorBase64,'S-1-5-18',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()}
|
||||
Save 'before-public-snapshot.json' $seeded
|
||||
$policyTouched=$true
|
||||
Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type DWord -Value 1
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact
|
||||
$preparedMasks=Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key);$preparedPrecedence=Key (Get-WelaRegistryState $precedencePath $precedenceName)
|
||||
$selection=@('-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$selected.Id,'-IncludeOptional')
|
||||
$noConsent=Invoke-PublicFixture 'no-consent-plan' ($selection+@('-TargetSaclAction','Plan'))
|
||||
Assert-SelectedRow $noConsent 'Blocked'
|
||||
$refusedBackup=Join-Path $root 'refused-no-consent'
|
||||
Invoke-PublicFixture 'no-consent-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'no-consent-plan.json'),'-BackupPath',$refusedBackup,'-Auto')) 1 'inheritance requires explicit'
|
||||
Assert (-not(Test-Path -LiteralPath $refusedBackup)) 'Missing inheritance consent must fail before journal creation.'
|
||||
$selection+=@('-TargetSaclIncludeChildren')
|
||||
$plan=Invoke-PublicFixture 'plan' ($selection+@('-TargetSaclAction','Plan'))
|
||||
Assert-SelectedRow $plan 'ChangeRequired'
|
||||
Assert ($plan.Rows[0].DescendantsBefore.Status -ceq 'Complete' -and @($plan.Rows[0].DescendantsBefore.Entries).Count -eq 0) 'Owned RunOnce must have a complete empty descendant capture.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey $plan.Rows[0].Before) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded)) 'Read-only planning must preserve the entire native target descriptor/identity.'
|
||||
$configure=$selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'plan.json'),'-Auto')
|
||||
$dry=Invoke-PublicFixture 'dry-run' ($configure+@('-DryRun'))
|
||||
Assert ($dry.Kind -ceq 'WelaSelectedSaclResult' -and $dry.DryRun -is [bool] -and $dry.DryRun -and $dry.Results[0].Status -is [string] -and $dry.Results[0].Status -ceq 'Skipped' -and $null -eq $dry.BackupPath) 'Public DryRun must skip mutation and journal creation.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded)) 'DryRun must leave the exact native descriptor unchanged.'
|
||||
Assert-PreparedState
|
||||
$backup=Join-Path $root 'applied-journal'
|
||||
$applied=Invoke-PublicFixture 'applied' ($configure+@('-BackupPath',$backup))
|
||||
Assert ($applied.Results.Count -eq 1 -and $applied.Results[0].Status -is [string] -and $applied.Results[0].Status -ceq 'Applied' -and $applied.GenerationReadiness -ceq 'Conditional' -and $applied.UsableRuleCredit -eq 0) 'Exactly the owned target must be Applied without event or rule credit.'
|
||||
$after=Get-WelaSelectedSaclSnapshot $selected.Definition;Save 'after-public-snapshot.json' $after
|
||||
Assert-WelaSelectedSaclPreserved $seeded $after $plan.Rows[0].Ace
|
||||
Assert ($after.Aces.Count -eq $seeded.Aces.Count+1) 'Public Configure must append exactly one ACE and preserve the unrelated explicit audit ACE.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey $after) -ceq (Get-WelaSelectedSaclSnapshotKey $applied.Results[0].After)) 'Public result must match independent native final readback.'
|
||||
$pending=Read-Receipt ('applied-journal/'+$selected.Id+'.pending.json');$confirmed=Read-Receipt ('applied-journal/'+$selected.Id+'.confirmed.json');$desc=Read-Receipt ('applied-journal/'+$selected.Id+'.descendants-observed.json')
|
||||
Assert ($pending.State -is [string] -and $pending.State -ceq 'Pending' -and $null -eq $pending.After -and $confirmed.State -is [string] -and $confirmed.State -ceq 'Confirmed' -and $pending.Id -ceq $selected.Id -and $confirmed.Id -ceq $selected.Id -and $desc.Verification.Status -ceq 'Observed') 'Durable pending/confirmed and descendant receipts must name the exact selected target.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey $pending.Before) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded) -and (Get-WelaSelectedSaclSnapshotKey $confirmed.After) -ceq (Get-WelaSelectedSaclSnapshotKey $after)) 'Journal snapshots must agree with both independent native observations.'
|
||||
Assert-PreparedState
|
||||
$staleBackup=Join-Path $root 'refused-stale'
|
||||
Invoke-PublicFixture 'stale-configure' ($configure+@('-BackupPath',$staleBackup)) 1 'changed; review a new plan'
|
||||
Assert (-not(Test-Path -LiteralPath $staleBackup)) 'Replaying the old descriptor must fail before another journal.'
|
||||
$fresh=Invoke-PublicFixture 'idempotent-plan' ($selection+@('-TargetSaclAction','Plan'))
|
||||
Assert-SelectedRow $fresh 'AlreadyCompliant'
|
||||
$idemBackup=Join-Path $root 'idempotent-journal'
|
||||
$idempotent=Invoke-PublicFixture 'idempotent' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'idempotent-plan.json'),'-BackupPath',$idemBackup,'-Auto'))
|
||||
Assert ($idempotent.Results[0].Status -is [string] -and $idempotent.Results[0].Status -ceq 'AlreadyCompliant' -and @(Get-ChildItem -LiteralPath $idemBackup -Force).Count -eq 0) 'Fresh idempotent public Configure must make no write receipts.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $after)) 'Stale refusal and idempotent Configure must preserve exact native state.'
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact
|
||||
$blocked=Invoke-PublicFixture 'missing-policy-plan' ($selection+@('-TargetSaclAction','Plan'))
|
||||
Assert-SelectedRow $blocked 'Blocked'
|
||||
$policyBackup=Join-Path $root 'refused-policy'
|
||||
Invoke-PublicFixture 'missing-policy-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'idempotent-plan.json'),'-BackupPath',$policyBackup,'-Auto')) 1 'outcomes are not already effective'
|
||||
Assert (-not(Test-Path -LiteralPath $policyBackup) -and (Get-WelaEffectiveAuditPolicy)[$guid] -eq 0) 'Public Configure must refuse ineffective auditing without preparing policy or a journal.'
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact
|
||||
Assert-PreparedState
|
||||
$boundary=Read-WelaChannelLatest 'Security';Save 'security-boundary.json' $boundary
|
||||
Assert ($boundary.Status -ceq 'EventObserved' -and $boundary.Event.RecordId -gt 0) 'Actual Security watermark must be observed before the single value write.'
|
||||
$operation=[pscustomobject]@{Phase='OneRegSetValueAndSameHandleTypedReadback';Computer=$boundary.Event.Computer;ProcessId=$PID;Engine=$engine;NativePath=('\REGISTRY\USER\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce');RecordIdBefore=$boundary.Event.RecordId;Token=[Wela.WmiProbe.Native]::Snapshot();Write=$hive.WriteProbe();ObservedUtc=[Wela.WmiProbe.Native]::UtcNow().ToString('o')}
|
||||
Save 'operation.json' $operation
|
||||
Assert ($operation.Write.Calls -eq 1 -and $operation.Write.Success -is [bool] -and $operation.Write.Success -and (ConvertTo-WelaArrivalUtc $operation.Write.StartedUtc) -le (ConvertTo-WelaArrivalUtc $operation.Write.ReturnedUtc) -and (ConvertTo-WelaArrivalUtc $operation.Write.ReturnedUtc) -le (ConvertTo-WelaArrivalUtc $operation.Write.CompletedUtc) -and (ConvertTo-WelaArrivalUtc $operation.Write.CompletedUtc) -le (ConvertTo-WelaArrivalUtc $operation.ObservedUtc)) 'Exactly one native write and its same-handle typed readback must have ordered measured times.'
|
||||
$hive.AssertValues($true)
|
||||
$found=@{};$candidates=@{};$deadline=[DateTime]::UtcNow.AddSeconds(20)
|
||||
$xpath="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4657 and EventRecordID > $($boundary.Event.RecordId)]] and *[EventData[Data[@Name='ObjectName']='$($operation.NativePath)']]"
|
||||
do {
|
||||
$events=@();try{$events=@(Get-WinEvent -LogName Security -FilterXPath $xpath -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notmatch 'NoMatchingEventsFound'){throw}}
|
||||
try {
|
||||
if($events.Count -ge 256){throw 'Owned-target native event query reached its bound.'}
|
||||
foreach($event in $events){$xml=$event.ToXml();if($xml.Length -gt 131072){throw 'Owned-target event exceeds its XML bound.'};$candidates[[string]$event.RecordId]=$xml;if(Test-WelaRegistrySaclFixtureEvent $xml $operation){$found[[string]$event.RecordId]=$xml}}
|
||||
}finally{foreach($event in $events){if($event -is [IDisposable]){$event.Dispose()}}}
|
||||
if($found.Count -eq 0){Start-Sleep -Milliseconds 250}
|
||||
}while($found.Count -eq 0 -and [DateTime]::UtcNow -lt $deadline)
|
||||
Save 'event-candidates.json' $candidates
|
||||
Assert ($found.Count -eq 1) ('Expected exactly one attributable native4657; candidates='+$candidates.Count+' matches='+$found.Count)
|
||||
[IO.File]::WriteAllText((Join-Path $root 'event.xml'),[string]@($found.Values)[0],[Text.UTF8Encoding]::new($false))
|
||||
Assert ((Get-WelaSelectedSaclSnapshot $selected.Definition).DescriptorBase64 -ceq $after.DescriptorBase64) 'The value operation must preserve every native descriptor section.'
|
||||
Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Observed event delivery must not alter prepared auditing.'
|
||||
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'The actual native value operation must preserve the full primary token.'
|
||||
}catch{$failure=$_}finally {
|
||||
if($policyTouched){
|
||||
try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforeMasks[$guid] -Mode exact}catch{$cleanupErrors+='Audit restore: '+$_.Exception.Message}
|
||||
try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restore: '+$_.Exception.Message}
|
||||
}
|
||||
try{$hive.Dispose()}catch{$cleanupErrors+='Hive unload/seed removal: '+$_.Exception.Message}
|
||||
$hivesOk=$false;$tokenOk=$false;$masksOk=$false;$precedenceOk=$false
|
||||
$afterHives=$null;$afterToken=$null;$masks=$null;$afterPrecedence=$null
|
||||
try{$afterHives=Hives;$hivesOk=(Key $afterHives) -ceq (Key $beforeHives)}catch{$cleanupErrors+='HKU verification: '+$_.Exception.Message}
|
||||
try{$afterToken=[Wela.WmiProbe.Native]::Snapshot();$tokenOk=(Key $afterToken) -ceq (Key $beforeToken)}catch{$cleanupErrors+='Token verification: '+$_.Exception.Message}
|
||||
try{$masks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($masks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key))}catch{$cleanupErrors+='Audit verification: '+$_.Exception.Message}
|
||||
try{$afterPrecedence=Get-WelaRegistryState $precedencePath $precedenceName;$precedenceOk=(Key $afterPrecedence) -ceq (Key $beforePrecedence)}catch{$cleanupErrors+='Precedence verification: '+$_.Exception.Message}
|
||||
if(-not $hive.Loaded -and $hivesOk){try{Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+='Owned file removal: '+$_.Exception.Message}}
|
||||
$cleanup=[pscustomobject]@{Complete=($hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.SeedCreated -and -not(Test-Path -LiteralPath $files) -and $cleanupErrors.Count -eq 0);HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksCompared=$beforeMasks.Count;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path -LiteralPath $files));Errors=$cleanupErrors;Failure=$(if($failure){$failure.Exception.Message}else{$null});Assertions=$script:assertions;AfterHives=$afterHives;AfterToken=$afterToken;AfterMasks=$masks;AfterPrecedence=$afterPrecedence}
|
||||
Save 'cleanup.json' $cleanup
|
||||
$artifacts=@(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 -Algorithm SHA256).Hash.ToLowerInvariant()}})
|
||||
Save 'artifact-hashes.json' $artifacts
|
||||
}
|
||||
if($failure){throw $failure};if(-not $cleanup.Complete){throw ('Owned registry fixture cleanup incomplete: '+(Key $cleanup))}
|
||||
Write-Host "Passed $script:assertions actual public registry SACL lifecycle assertions and one exact4657; all cleanup confirmed. Evidence: $root"
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,21 @@
|
||||
# Test-only attribution for the single fixture-owned REG_SZ creation.
|
||||
function Test-WelaRegistrySaclFixtureEvent {
|
||||
param([string]$Xml,$Operation)
|
||||
$reader=$null
|
||||
try {
|
||||
if($Xml.Length -gt 131072){return $false}
|
||||
$settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072
|
||||
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader)
|
||||
$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
|
||||
if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1){return $false}
|
||||
$system=@{};foreach($name in @('Provider','EventID','Version','Task','Keywords','Channel','Computer','EventRecordID','TimeCreated')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
|
||||
if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4657' -or $system.Version.InnerText -cne '0' -or $system.Task.InnerText -cne '12801' -or $system.Keywords.InnerText -ine '0x8020000000000000' -or $system.Channel.InnerText -cne 'Security' -or $system.Computer.InnerText -ine $Operation.Computer -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false}
|
||||
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Write.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Write.CompletedUtc)){return $false}
|
||||
$fields=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $fields.ContainsKey($name)){return $false};$fields[$name]=$node.InnerText}
|
||||
if($fields.Count -ne 14){return $false};foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectName','ObjectValueName','HandleId','OperationType','OldValueType','OldValue','NewValueType','NewValue','ProcessId','ProcessName')){if(-not $fields.ContainsKey($name)){return $false}}
|
||||
if($fields.SubjectUserSid -cne $Operation.Token.Sid -or $fields.ObjectName -ine $Operation.NativePath -or $fields.ObjectValueName -cne $Operation.Write.ValueName -or $fields.OperationType -cne '%%1904' -or $fields.NewValueType -cne '%%1873' -or $fields.NewValue -cne $Operation.Write.Value -or $fields.ProcessName -ine $Operation.Engine){return $false}
|
||||
foreach($name in @('SubjectLogonId','ProcessId','HandleId')){if($fields[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}}
|
||||
if([Convert]::ToUInt64($fields.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Token.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($fields.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($fields.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Write.HandleId.Substring(2),16)){return $false}
|
||||
$true
|
||||
}catch{$false}finally{if($reader){$reader.Dispose()}}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path
|
||||
$cases=@(
|
||||
@{Args=@('registry-sacl-recovery','-Help');Code=0;Pattern='One proven registry-root audit ACE'},
|
||||
@{Args=@('registry-sacl-recovery','unexpected','-Help');Code=1;Pattern='arguments|dedicated options'},
|
||||
@{Args=@('registry-sacl-recovery','-WhatIf','-Help');Code=1;Pattern='arguments|dedicated options'},
|
||||
@{Args=@('registry-sacl-recovery','-DryRun','-Help');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('registry-sacl-recovery','-Auto','-Help');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('help','-RegistryRecoveryAction','Restore');Code=1;Pattern='require registry-sacl-recovery'},
|
||||
@{Args=@('registry-sacl-recovery');Code=1;Pattern='four original evidence paths'},
|
||||
@{Args=@('registry-sacl-recovery','-RegistryRecoveryAction','Restore');Code=1;Pattern='reviewed plan path/hash'},
|
||||
@{Args=@('registry-sacl-recovery','-RegistryRecoveryAllowAuditReduction');Code=1;Pattern='four original evidence paths'}
|
||||
)
|
||||
foreach($case in $cases){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old};if($code -ne $case.Code -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI refusal failure: $($case.Args -join ' ') => $code / $($output -join ' ')"}}
|
||||
Write-Host "Passed $($cases.Count) public registry recovery CLI assertions.";$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,127 @@
|
||||
$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -Force
|
||||
foreach($name in @('WefArrival','EvtxRecovery','WecUpdate','TargetedSaclPlanning','SelectedSaclConfiguration','RegistrySaclRecovery')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
|
||||
$script:count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Throws($Action,$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
|
||||
function Clone($Value){ConvertFrom-WelaEvtxJson (ConvertTo-Json -InputObject $Value -Depth 32)}
|
||||
function Save($Path,$Value){[IO.File]::WriteAllText($Path,(ConvertTo-Json -InputObject $Value -Depth 32),[Text.UTF8Encoding]::new($false))}
|
||||
function Get-WelaSelectedSaclContext {[pscustomobject]@{Computer='fixture';Role='Client';Build=26100;Detail=[pscustomobject]@{UBR=1};Key='fixture-context'}}
|
||||
$script:policies=@{};foreach($row in (Import-WelaAuditProfiles).catalog){$script:policies[$row.guid]=3}
|
||||
function Get-WelaEffectiveAuditPolicy {$script:policies}
|
||||
function Get-WelaAuditPrecedenceState {[pscustomobject]@{Registry=[pscustomobject]@{ValueExists=$true;Type='DWord';Value=1}}}
|
||||
function Get-WelaSaclUserInventory {[pscustomobject]@{Users=@();Complete=$true;Diagnostics=@()}}
|
||||
function Get-WelaSaclTargetObservation {throw 'Unselected targets must not be observed.'}
|
||||
$catalog=Get-WelaSelectedSaclCatalog -Profile wela-2.2.0 -IncludeOptional -Context (Get-WelaSelectedSaclContext)
|
||||
$fixtureSelection=@($catalog.Rows|Where-Object {$_.Definition.Scope -ceq 'registry'})[0]
|
||||
$nativePath=Resolve-WelaSelectedSaclNativePath $fixtureSelection.Definition
|
||||
$script:before=[pscustomobject]@{Path=$nativePath;Kind='Registry';Identity=($nativePath+':1000');IsDirectory=$false;DescriptorBase64='YmVmb3Jl';Owner='S-1-5-18';Group='S-1-5-18';DaclBase64='ZGFjbA==';ControlFlags=32788;SecurityInformation=511;DescriptorScope='WinSDK-defined sections 0x1ff; future sections unobserved';Aces=@([pscustomobject]@{Binary='b3RoZXI=';Type=2;Flags=64;Mask=1;Sid='S-1-5-18';Ordinary=$true})}
|
||||
$script:after=$null;$script:current=$null;$script:scenario='';$script:mutations=0
|
||||
function Get-WelaSelectedSaclSnapshot {param($Definition) if($Definition.Path -cne $fixtureSelection.Definition.Path){throw 'Unselected target read.'};Clone $script:current}
|
||||
function Get-WelaSelectedSaclChildNames {param($Definition,$Snapshot,$Maximum) [pscustomobject]@{Names=@();Truncated=$false}}
|
||||
function Write-WelaSelectedSaclNative {
|
||||
param($Definition,$Before,$Ace)
|
||||
$script:current=Clone $Before;$script:current.Identity=$nativePath+':1001';$script:current.DescriptorBase64='YWZ0ZXI='
|
||||
$script:current.Aces+=@([pscustomobject]@{Binary='YWRkZWQ=';Type=2;Flags=$Ace.Flags;Mask=$Ace.Mask;Sid=$Ace.Sid;Ordinary=$true})
|
||||
$script:after=Clone $script:current;Clone $script:current
|
||||
}
|
||||
function Get-WelaRegistryRecoveryDescriptorObservation {
|
||||
param($Snapshot)
|
||||
$known=if($Snapshot.DescriptorBase64 -ceq $script:before.DescriptorBase64){Clone $script:before}elseif($Snapshot.DescriptorBase64 -ceq $script:after.DescriptorBase64){Clone $script:after}else{throw 'Unknown mocked native descriptor bytes.'}
|
||||
$known.Identity=$Snapshot.Identity;$known
|
||||
}
|
||||
function Get-WelaRegistryRecoveryAddition {param($Before,$After,$Ace) if($Before.DescriptorBase64 -cne $script:before.DescriptorBase64 -or $After.DescriptorBase64 -cne $script:after.DescriptorBase64){throw 'Native descriptor append proof differs.'};'YWRkZWQ='}
|
||||
function Get-WelaRegistryRecoverySnapshot {param($Definition) if($script:scenario -ceq 'children'){throw 'Recovery requires empty registry descendants.'};Clone $script:current}
|
||||
$script:sourceReader=(Get-Command Get-WelaRegistryRecoverySources).ScriptBlock
|
||||
function Get-WelaRegistryRecoverySources {$sources=&$script:sourceReader;if(($script:scenario -ceq 'source-after-pending' -and (Test-Path (Join-Path $script:out 'pending.json'))) -or ($script:scenario -ceq 'source-after-write' -and $script:mutations -gt 0)){$sources.'WELA.ps1'='0'*64};if($script:scenario -ceq 'plan-after-pending' -and (Test-Path (Join-Path $script:out 'pending.json'))){[IO.File]::AppendAllText($script:planPath,' ')};$sources}
|
||||
function Get-WelaRegistryRecoveryContext {
|
||||
$machine=if($script:scenario -ceq 'host-after-write' -and $script:mutations -gt 0){'00000000-0000-0000-0000-000000000002'}else{'00000000-0000-0000-0000-000000000001'}
|
||||
$token=if($script:scenario -ceq 'token-after-write' -and $script:mutations -gt 0){'different-token'}else{'fixture-token'}
|
||||
[pscustomobject][ordered]@{Host=[pscustomobject]@{Computer='fixture';MachineGuid=$machine};Selected=(Get-WelaSelectedSaclContext);Token=$token;AuditMasks='fixture59';Precedence='fixtureDWORD1'}
|
||||
}
|
||||
function Open-WelaRegistryRecoveryTarget {
|
||||
param($Definition)
|
||||
$object=[pscustomobject]@{WriteAttempted=$false;AfterObservation=$null}
|
||||
$object|Add-Member ScriptMethod Remove {
|
||||
param($Identity,$Descriptor,$Added)
|
||||
Assert ((Test-Path (Join-Path $script:out 'pending.json')) -and $Identity -ceq $script:current.Identity -and $Descriptor -ceq $script:current.DescriptorBase64 -and $Added -ceq 'YWRkZWQ=') 'Durable intent and exact current removal arguments precede native adapter.'
|
||||
if($script:scenario -ceq 'native-refusal'){throw 'Native prewrite refusal.'}
|
||||
$this.WriteAttempted=$true;$script:mutations++;$script:current=Clone $script:before;$script:current.Identity=$nativePath+':1002';$this.AfterObservation=Clone $script:current
|
||||
if($script:scenario -ceq 'native-partial'){throw 'Native write completed but after-state is unverified.'}
|
||||
if($script:scenario -ceq 'original-after-write'){[IO.File]::AppendAllText($script:originalPath,' ')}
|
||||
if($script:scenario -ceq 'artifact-after-write'){[IO.File]::AppendAllText((Join-Path $script:out 'pending.json'),' ')}
|
||||
Clone $script:current
|
||||
}
|
||||
$object|Add-Member ScriptMethod Dispose {if($script:scenario -ceq 'dispose-failure'){throw 'Native privilege restore failed.'}}
|
||||
$object
|
||||
}
|
||||
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-registry-recovery-unit-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
|
||||
function New-Original {
|
||||
$script:scenario='';$script:mutations=0;$script:current=Clone $script:before
|
||||
$script:caseRoot=Join-Path $temp ([guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $script:caseRoot
|
||||
$script:originalPath=Join-Path $script:caseRoot 'original.json';$script:journal=Join-Path $script:caseRoot 'journal';$script:resultPath=Join-Path $script:caseRoot 'result.json'
|
||||
$null=Invoke-WelaSelectedSacl -Action Plan -Profile wela-2.2.0 -Ids $fixtureSelection.Id -IncludeOptional -IncludeChildren -ResultsPath $script:originalPath
|
||||
$result=Invoke-WelaSelectedSacl -Action Configure -Profile wela-2.2.0 -Ids $fixtureSelection.Id -IncludeOptional -IncludeChildren -PlanPath $script:originalPath -BackupPath $script:journal -ResultsPath $script:resultPath -Auto
|
||||
Assert ($result.Results[0].Status -ceq 'Applied') 'Original portable history comes from the real shared selected-SACL executor with only native boundaries replaced.'
|
||||
$script:pendingPath=Join-Path $script:journal ($fixtureSelection.Id+'.pending.json');$script:confirmedPath=Join-Path $script:journal ($fixtureSelection.Id+'.confirmed.json')
|
||||
}
|
||||
function Build-Plan {New-WelaRegistryRecoveryPlan $script:originalPath $script:pendingPath $script:confirmedPath $script:resultPath}
|
||||
function Prepare-Recovery {
|
||||
New-Original
|
||||
$script:review=Join-Path $script:caseRoot 'review'
|
||||
$report=Invoke-WelaRegistrySaclRecovery -OriginalPlanPath $script:originalPath -PendingPath $script:pendingPath -ConfirmedPath $script:confirmedPath -OriginalResultsPath $script:resultPath -OutputPath $script:review
|
||||
Assert ($report.Status -ceq 'ReviewRequired' -and -not $report.WriteAttempted) ('Plan failed: '+$report.Diagnostic)
|
||||
$script:planPath=Join-Path $script:review 'plan.json';$script:hash=$report.PlanHash;$script:out=Join-Path $script:caseRoot 'restore'
|
||||
}
|
||||
function Restore-Review {param([switch]$OmitReduction,[switch]$OmitInheritance) Invoke-WelaRegistrySaclRecovery -Action Restore -PlanPath $script:planPath -PlanHash $script:hash -OutputPath $script:out -AllowAuditReduction:(-not $OmitReduction) -AllowInheritance:(-not $OmitInheritance)}
|
||||
try{
|
||||
foreach($empty in @($null,@(),[pscustomobject]@{})){Assert-WelaRegistryRecoveryEmptyCatalog $empty;Assert $true 'Known empty catalogue representations are accepted.'}
|
||||
foreach($invalid in @($true,'',1,@('target'),[pscustomobject]@{Path='target'})){Throws {Assert-WelaRegistryRecoveryEmptyCatalog $invalid} 'must be empty'}
|
||||
Prepare-Recovery;$result=Restore-Review
|
||||
Assert ($result.Status -ceq 'AddedAceRemoved' -and $result.WriteAttempted -and $script:mutations -eq 1 -and $result.ReadyRuleCredit -eq 0) ('Exact recovery failed: '+$result.Diagnostic)
|
||||
Assert ((Test-Path (Join-Path $script:out 'pending.json')) -and (Test-Path (Join-Path $script:out 'confirmed.json'))) 'Separate durable intent and completion exist.'
|
||||
foreach($artifact in $result.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $script:out $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Retained recovery hash matches real bytes.'}
|
||||
$script:out=Join-Path $script:caseRoot 'replay';$replay=Restore-Review;Assert ($replay.Status -ceq 'Refused' -and -not $replay.WriteAttempted -and $script:mutations -eq 1) 'Recovered original plan cannot remove another ACE.'
|
||||
foreach($case in @('reduction','inheritance')){Prepare-Recovery;$result=Restore-Review -OmitReduction:($case -ceq 'reduction') -OmitInheritance:($case -ceq 'inheritance');Assert ($result.Status -ceq 'Refused' -and -not $result.WriteAttempted -and -not(Test-Path (Join-Path $script:out 'pending.json'))) 'Each consent refuses before intent and mutation.'}
|
||||
$mutations=@(
|
||||
@{File='originalPath';Change={$args[0].Kind=$true};Pattern='mistyped'},
|
||||
@{File='originalPath';Change={$args[0].IncludeChildren=$false};Pattern='child consent'},
|
||||
@{File='originalPath';Change={$args[0].Rows[0].Status=$true};Pattern='mistyped'},
|
||||
@{File='originalPath';Change={$args[0].Rows[0].Definition.Kind=$true};Pattern='mistyped'},
|
||||
@{File='originalPath';Change={$args[0].Rows[0].Before.Kind=$true};Pattern='metadata|registry|catalog'},
|
||||
@{File='originalPath';Change={$args[0].Sources[0].Sha256=$true};Pattern='mistyped'},
|
||||
@{File='originalPath';Change={$args[0].Sources[0].Path=$true};Pattern='mistyped'},
|
||||
@{File='originalPath';Change={$args[0].Rows[0].Ace.Flags='194'};Pattern='integer'},
|
||||
@{File='originalPath';Change={$args[0].Rows[0].Before.Owner='S-1-1-0'};Pattern='metadata'},
|
||||
@{File='originalPath';Change={$args[0].Rows[0].DescendantsBefore.Status=$true};Pattern='mistyped'},
|
||||
@{File='originalPath';Change={$args[0].Rows[0].DescendantsBefore.Entries=@('child')};Pattern='empty'},
|
||||
@{File='originalPath';Change={$args[0].Rows[0].DescendantsBefore.Maximum=129};Pattern='empty'},
|
||||
@{File='pendingPath';Change={$args[0].State=$true};Pattern='mistyped'},
|
||||
@{File='pendingPath';Change={$args[0].ContextKey='other'};Pattern='scope'},
|
||||
@{File='pendingPath';Change={$args[0].After=$args[0].Before};Pattern='Pending'},
|
||||
@{File='confirmedPath';Change={$args[0].Kind=$true};Pattern='mistyped'},
|
||||
@{File='confirmedPath';Change={$args[0].Before.DaclBase64='changed'};Pattern='metadata'},
|
||||
@{File='confirmedPath';Change={$args[0].DescendantsAfter.Diagnostics=@('incomplete')};Pattern='empty'},
|
||||
@{File='confirmedPath';Change={$args[0].DescendantVerification.Status=$true};Pattern='mistyped'},
|
||||
@{File='resultPath';Change={$args[0].Results[0].Status=$true};Pattern='mistyped'},
|
||||
@{File='resultPath';Change={$args[0].DryRun=$true};Pattern='non-dry-run'},
|
||||
@{File='resultPath';Change={$args[0].ExitCode=$true};Pattern='integer'},
|
||||
@{File='resultPath';Change={$args[0].Plan.SchemaVersion=$true};Pattern='integer'},
|
||||
@{File='resultPath';Change={$args[0].BackupPath='somewhere-else'};Pattern='Receipt paths'},
|
||||
@{File='originalPath';Change={$args[0].CapturedUtc=[DateTime]::UtcNow.AddDays(1).ToString('o')};Pattern='timestamps'}
|
||||
)
|
||||
foreach($test in $mutations){New-Original;$path=Get-Variable -Name $test.File -ValueOnly;$data=ConvertFrom-WelaEvtxJson ([IO.File]::ReadAllText($path));&$test.Change $data;Save $path $data;Throws {Build-Plan} $test.Pattern;Assert ($script:mutations -eq 0) 'Invalid original history cannot reach a native writer.'}
|
||||
foreach($case in @('descriptor','lastwrite','children')){Prepare-Recovery;if($case -ceq 'descriptor'){$script:current.DescriptorBase64='ZGlmZmVyZW50'}elseif($case -ceq 'lastwrite'){$script:current.Identity=$nativePath+':9999'}else{$script:scenario='children'};$result=Restore-Review;Assert ($result.Status -ceq 'Refused' -and -not $result.WriteAttempted) 'Current descriptor, benign-value last-write and child drift refuse recovery.'}
|
||||
foreach($case in @('source-after-pending','plan-after-pending','native-refusal','native-partial','token-after-write','host-after-write','source-after-write','original-after-write','artifact-after-write','dispose-failure')){
|
||||
Prepare-Recovery;$script:scenario=$case;$result=Restore-Review
|
||||
$attempted=$case -in @('native-partial','token-after-write','host-after-write','source-after-write','original-after-write','artifact-after-write','dispose-failure')
|
||||
Assert ($result.ExitCode -eq 1 -and $result.WriteAttempted -eq $attempted -and $result.Status -ceq $(if($attempted){'WriteAttemptedUnverified'}else{'Refused'})) ("Failure state $case : "+$result.Diagnostic)
|
||||
Assert (-not(Test-Path (Join-Path $script:out 'confirmed.json')) -and (Test-Path (Join-Path $script:out 'pending.json'))) 'Unverified operations retain intent but never confirmed completion.'
|
||||
}
|
||||
Prepare-Recovery;$forged=ConvertFrom-WelaEvtxJson ([IO.File]::ReadAllText($script:planPath));$forged.AddedAce=$true;Save $script:planPath $forged;$script:hash=(Get-FileHash -LiteralPath $script:planPath).Hash.ToLowerInvariant();$result=Restore-Review;Assert ($result.Status -ceq 'Refused' -and -not $result.WriteAttempted) 'A freshly hashed forged instruction cannot replace the independently rebuilt plan.'
|
||||
Prepare-Recovery;[IO.File]::AppendAllText($script:planPath,' ');$result=Restore-Review;Assert ($result.Status -ceq 'Refused' -and -not $result.WriteAttempted) 'Exact reviewed file hash refuses byte drift.'
|
||||
New-Original;$text=[IO.File]::ReadAllText($script:originalPath);[IO.File]::WriteAllText($script:originalPath,($text -replace '"Kind"\s*:\s*"WelaSelectedSaclPlan"','"Kind": "WelaSelectedSaclPlan", "Kind": true'));Throws {Build-Plan} 'Duplicate|duplicate'
|
||||
New-Original;$plan=ConvertFrom-WelaEvtxJson ([IO.File]::ReadAllText($script:originalPath));$plan.CapturedUtc=([DateTimeOffset]::Parse([string]$plan.CapturedUtc)).UtcDateTime;Save $script:originalPath $plan;$null=Build-Plan;Assert $true 'Canonical UTC DateTime materialization remains supported.'
|
||||
}finally{if(Test-Path -LiteralPath $temp){Remove-Item -LiteralPath $temp -Recurse -Force}}
|
||||
Write-Host "Passed $script:count registry recovery assertions; only native/context boundaries mocked."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,145 @@
|
||||
# Mutating test fixture only: public WELA never loads hives or prepares audit policy.
|
||||
param([switch]$AllowDisposableHiveWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableHiveWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Explicit disposable GitHub-hosted native Windows fixture only.'}
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
|
||||
foreach($name in @('Configuration','WefArrival','WmiProbe','ChannelRead','SelectedSaclConfiguration','RegistrySaclRecovery')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
|
||||
. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1')
|
||||
Initialize-WelaWmiProbeNative
|
||||
Add-Type -Path (Join-Path $PSScriptRoot 'RegistrySaclFixtureNative.cs') -ErrorAction Stop
|
||||
$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-registry-recovery-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot
|
||||
$files=Join-Path $root 'owned-hive-files';$null=New-Item -ItemType Directory $files
|
||||
function Save([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),($Value|ConvertTo-Json -Depth 28),[Text.UTF8Encoding]::new($false))}
|
||||
function Read-Receipt([string]$Name){ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $root $Name)))}
|
||||
function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
|
||||
$script:assertions=0
|
||||
function Assert($Condition,[string]$Message){if(-not $Condition){throw $Message};$script:assertions++}
|
||||
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;using System.IO;using System.Text;using System.Threading.Tasks;
|
||||
public static class WelaRegistryRecoveryFixturePipe {
|
||||
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]$Name,[string[]]$Arguments,[int]$Expected=0){
|
||||
$all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $script:ScriptRoot 'WELA.ps1'))+$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=[WelaRegistryRecoveryFixturePipe]::Read($process.StandardOutput);$stderr=[WelaRegistryRecoveryFixturePipe]::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.'}
|
||||
$text=$stdout.Result+"`n"+$stderr.Result;Save ($Name+'-output.json') $text
|
||||
Assert ($process.ExitCode -eq $Expected) ("Public $Name exited $($process.ExitCode), expected $Expected : "+$text)
|
||||
}finally{
|
||||
if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:cleanupErrors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:cleanupErrors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:cleanupErrors+=$_.Exception.Message}};if(-not $exited){$script:cleanupErrors+='Owned public process termination unconfirmed.'}}
|
||||
$process.Dispose()
|
||||
}
|
||||
}
|
||||
$engine=(Get-Process -Id $PID).Path;$guid='0CCE921E-69AE-11D9-BED3-505054503030'
|
||||
$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy'
|
||||
$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$beforeMasks=Get-WelaEffectiveAuditPolicy;$beforePrecedence=Get-WelaRegistryState $precedencePath $precedenceName
|
||||
Save 'before-hives.json' $beforeHives;Save 'before-token.json' $beforeToken;Save 'before-masks.json' $beforeMasks;Save 'before-precedence.json' $beforePrecedence
|
||||
$hive=[Wela.RegistrySaclFixture.Hive]::new([guid]::NewGuid().ToString('N'),(Join-Path $files 'owned.dat'));$failure=$null;$cleanupErrors=@();$policyTouched=$false
|
||||
try {
|
||||
Assert ($beforeMasks.Count -eq 59) 'All 59 original audit masks observed.'
|
||||
$hive.Prepare();$hive.CreateRunOnce();$hive.AssertOwned();$hive.AssertValues($false)
|
||||
$providerPath='Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce'
|
||||
Save 'mounted.json' ([pscustomobject]@{Sid=$hive.Sid;File=$hive.FilePath;Seed=$hive.SeedPath;Target=$providerPath})
|
||||
Assert ((Key (Hives)) -ceq (Key (@($beforeHives)+$hive.Sid|Sort-Object))) 'Only fixture-owned hive was mounted.'
|
||||
$policyTouched=$true;Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type DWord -Value 1;Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact
|
||||
$preparedMasks=Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key);$preparedPrecedence=Key (Get-WelaRegistryState $precedencePath $precedenceName)
|
||||
Public 'catalog' @('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-IncludeOptional','-ResultsPath',(Join-Path $root 'catalog.json'))
|
||||
$catalog=Read-Receipt 'catalog.json';$selectedRows=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq $providerPath})
|
||||
Assert ($selectedRows.Count -eq 1 -and $selectedRows[0].Definition.Kind -ceq 'Registry') 'Exactly one real catalog target in owned HKU hive.'
|
||||
$selected=$selectedRows[0];Save 'selected.json' $selected
|
||||
Initialize-WelaSelectedSaclNative;$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null
|
||||
try{$target=[Wela.SelectedSacl.Target]::new('Registry',(Resolve-WelaSelectedSaclNativePath $selected.Definition));$before=$target.Read();$seeded=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-5-18',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()}
|
||||
Save 'before-public.json' $seeded
|
||||
$originalPlan=Join-Path $root 'original-plan.json';$backup=Join-Path $root 'original-journal';$originalResults=Join-Path $root 'original-results.json'
|
||||
$selection=@('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$selected.Id,'-IncludeOptional','-TargetSaclIncludeChildren')
|
||||
Public 'original-plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$originalPlan))
|
||||
Public 'original-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$originalPlan,'-BackupPath',$backup,'-ResultsPath',$originalResults,'-Auto'))
|
||||
$result=Read-Receipt 'original-results.json';Assert ($result.Results[0].Status -is [string] -and $result.Results[0].Status -ceq 'Applied') 'Recovery starts from actual completed public Configure.'
|
||||
$applied=Get-WelaSelectedSaclSnapshot $selected.Definition;Save 'applied-native.json' $applied;$hive.AssertValues($false)
|
||||
$review=Join-Path $root 'review';$pending=Join-Path $backup ($selected.Id+'.pending.json');$confirmed=Join-Path $backup ($selected.Id+'.confirmed.json')
|
||||
$reviewArgs=@('registry-sacl-recovery','-RegistryRecoveryOriginalPlanPath',$originalPlan,'-RegistryRecoveryPendingPath',$pending,'-RegistryRecoveryConfirmedPath',$confirmed,'-RegistryRecoveryOriginalResultsPath',$originalResults)
|
||||
Public 'recovery-plan' ($reviewArgs+@('-RegistryRecoveryOutputPath',$review))
|
||||
$manifest=Read-Receipt 'review/manifest.json';Assert ($manifest.Status -ceq 'ReviewRequired' -and -not $manifest.WriteAttempted -and $manifest.PlanHash -ceq (Get-FileHash -LiteralPath (Join-Path $review 'plan.json')).Hash.ToLowerInvariant()) 'Actual recovery Plan binds independently checked exact bytes without writes.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $applied)) 'Recovery planning preserves exact native state.'
|
||||
$restoreArgs=@('registry-sacl-recovery','-RegistryRecoveryAction','Restore','-RegistryRecoveryPlanPath',(Join-Path $review 'plan.json'),'-RegistryRecoveryPlanHash',$manifest.PlanHash)
|
||||
foreach($missing in @('AuditReduction','Inheritance')){
|
||||
$out=Join-Path $root ('missing-'+$missing);$consent=if($missing -ceq 'AuditReduction'){'-RegistryRecoveryAllowInheritance'}else{'-RegistryRecoveryAllowAuditReduction'}
|
||||
Public ('missing-'+$missing) ($restoreArgs+@('-RegistryRecoveryOutputPath',$out,$consent)) 1
|
||||
$refusal=Read-Receipt ('missing-'+$missing+'/manifest.json');Assert ($refusal.Status -ceq 'Refused' -and -not $refusal.WriteAttempted -and -not(Test-Path -LiteralPath (Join-Path $out 'pending.json'))) 'Each explicit reduction/inheritance consent is required before durable intent or removal.'
|
||||
}
|
||||
$restoredDir=Join-Path $root 'restored'
|
||||
Public 'restore' ($restoreArgs+@('-RegistryRecoveryOutputPath',$restoredDir,'-RegistryRecoveryAllowAuditReduction','-RegistryRecoveryAllowInheritance'))
|
||||
$restored=Read-Receipt 'restored/manifest.json';Save 'restored-native.json' (Get-WelaSelectedSaclSnapshot $selected.Definition)
|
||||
Assert ($restored.Status -ceq 'AddedAceRemoved' -and $restored.WriteAttempted -and $restored.PolicyChanges -eq 0 -and $restored.ReadyRuleCredit -eq 0) 'Actual public recovery removes one proven ACE with no audit-policy or rule credit.'
|
||||
$native=Get-WelaSelectedSaclSnapshot $selected.Definition;Initialize-WelaRegistryRecoveryNative
|
||||
[Wela.RegistrySaclRecovery.Descriptor]::Removed($applied.DescriptorBase64,$native.DescriptorBase64,(Read-Receipt 'review/plan.json').AddedAce)
|
||||
Assert ($native.Aces.Count -eq $seeded.Aces.Count -and $native.Aces[0].Binary -ceq $seeded.Aces[0].Binary -and $native.Owner -ceq $seeded.Owner -and $native.Group -ceq $seeded.Group -and $native.DaclBase64 -ceq $seeded.DaclBase64) 'Independent native observation retains unrelated audit ACE, owner/group/DACL.'
|
||||
$hive.AssertValues($false)
|
||||
foreach($artifact in $restored.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $restoredDir $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Durable actual recovery artifact hash verified.'}
|
||||
Assert ((Read-Receipt 'restored/pending.json').State -ceq 'Pending' -and (Read-Receipt 'restored/confirmed.json').State -ceq 'Confirmed') 'Distinct durable intent and verified completion receipts exist.'
|
||||
$replay=Join-Path $root 'replay';Public 'replay' ($restoreArgs+@('-RegistryRecoveryOutputPath',$replay,'-RegistryRecoveryAllowAuditReduction','-RegistryRecoveryAllowInheritance')) 1
|
||||
Assert ((Read-Receipt 'replay/manifest.json').Status -ceq 'Refused' -and -not (Read-Receipt 'replay/manifest.json').WriteAttempted) 'Old reviewed restore refuses replay.'
|
||||
# A second genuine public addition creates fresh history before a benign value edit.
|
||||
$secondPlan=Join-Path $root 'value-plan.json';$secondJournal=Join-Path $root 'value-journal';$secondResults=Join-Path $root 'value-results.json'
|
||||
Public 'value-plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$secondPlan))
|
||||
Public 'value-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$secondPlan,'-BackupPath',$secondJournal,'-ResultsPath',$secondResults,'-Auto'))
|
||||
Assert ((Read-Receipt 'value-results.json').Results[0].Status -ceq 'Applied') 'Fresh recovery scenario starts from another genuine Applied addition.'
|
||||
$valueAfter=Get-WelaSelectedSaclSnapshot $selected.Definition;$write=$hive.WriteProbe();Save 'benign-value-write.json' $write;$hive.AssertValues($true)
|
||||
$valueNow=Get-WelaSelectedSaclSnapshot $selected.Definition
|
||||
Assert ($valueNow.Identity -cne $valueAfter.Identity -and $valueNow.DescriptorBase64 -ceq $valueAfter.DescriptorBase64) 'One benign fixture value edit changes actual last-write identity without changing the SACL.'
|
||||
$valueReview=Join-Path $root 'value-drift'
|
||||
Public 'value-drift' @('registry-sacl-recovery','-RegistryRecoveryOriginalPlanPath',$secondPlan,'-RegistryRecoveryPendingPath',(Join-Path $secondJournal ($selected.Id+'.pending.json')),'-RegistryRecoveryConfirmedPath',(Join-Path $secondJournal ($selected.Id+'.confirmed.json')),'-RegistryRecoveryOriginalResultsPath',$secondResults,'-RegistryRecoveryOutputPath',$valueReview) 1
|
||||
$refusal=Read-Receipt 'value-drift/manifest.json';Assert ($refusal.Status -ceq 'Refused' -and -not $refusal.WriteAttempted -and $refusal.Diagnostic -match 'last-write identity') 'Benign value drift is refused without artificial historical-identity relaxation.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $valueNow)) 'Value-drift refusal preserves exact current native state.'
|
||||
# Start a separate owned hive for child drift; never rewrite historical timestamps.
|
||||
$firstSid=$hive.Sid;$hive.Dispose();Assert ((Key (Hives)) -ceq (Key $beforeHives)) 'First owned hive is unloaded before the next isolated scenario.'
|
||||
Save 'first-hive-unloaded.json' ([pscustomobject]@{Sid=$firstSid;Loaded=$hive.Loaded;SeedCreated=$hive.SeedCreated})
|
||||
$hive=[Wela.RegistrySaclFixture.Hive]::new([guid]::NewGuid().ToString('N'),(Join-Path $files 'second-owned.dat'));$hive.Prepare();$hive.CreateRunOnce();$hive.AssertValues($false)
|
||||
$providerPath='Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce'
|
||||
Public 'child-catalog' @('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-IncludeOptional','-ResultsPath',(Join-Path $root 'child-catalog.json'))
|
||||
$childCatalog=Read-Receipt 'child-catalog.json';$childRows=@($childCatalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq $providerPath});Assert ($childRows.Count -eq 1) 'Child scenario resolves only its separate owned catalog target.'
|
||||
$childSelected=$childRows[0];$childPlan=Join-Path $root 'child-plan.json';$childJournal=Join-Path $root 'child-journal';$childResults=Join-Path $root 'child-results.json'
|
||||
$childSelection=@('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$childSelected.Id,'-IncludeOptional','-TargetSaclIncludeChildren')
|
||||
Public 'child-plan' ($childSelection+@('-TargetSaclAction','Plan','-ResultsPath',$childPlan))
|
||||
Public 'child-configure' ($childSelection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$childPlan,'-BackupPath',$childJournal,'-ResultsPath',$childResults,'-Auto'))
|
||||
Assert ((Read-Receipt 'child-results.json').Results[0].Status -ceq 'Applied') 'Child scenario also uses a genuine public Apply with empty historical descendants.'
|
||||
$childKey=[Microsoft.Win32.Registry]::Users.CreateSubKey($hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce\OwnedChild');$childKey.Dispose()
|
||||
$childAfter=Get-WelaSelectedSaclSnapshot $childSelected.Definition;Save 'child-drift-native.json' $childAfter
|
||||
$childReview=Join-Path $root 'child-drift'
|
||||
Public 'child-drift' @('registry-sacl-recovery','-RegistryRecoveryOriginalPlanPath',$childPlan,'-RegistryRecoveryPendingPath',(Join-Path $childJournal ($childSelected.Id+'.pending.json')),'-RegistryRecoveryConfirmedPath',(Join-Path $childJournal ($childSelected.Id+'.confirmed.json')),'-RegistryRecoveryOriginalResultsPath',$childResults,'-RegistryRecoveryOutputPath',$childReview) 1
|
||||
$refusal=Read-Receipt 'child-drift/manifest.json';Assert ($refusal.Status -ceq 'Refused' -and -not $refusal.WriteAttempted -and $refusal.Diagnostic -match 'empty registry descendant') 'A real new child refuses recovery before any write.'
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $childSelected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $childAfter)) 'Child-drift refusal preserves exact current parent security state.'
|
||||
$hive.AssertValues($false)
|
||||
Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'All public operations preserve prepared auditing.'
|
||||
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'All native fixture security/backup/restore privilege attributes restored.'
|
||||
}catch{$failure=$_}finally {
|
||||
if($policyTouched){
|
||||
try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforeMasks[$guid] -Mode exact}catch{$cleanupErrors+='Audit restore: '+$_.Exception.Message}
|
||||
try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restore: '+$_.Exception.Message}
|
||||
}
|
||||
try{$hive.Dispose()}catch{$cleanupErrors+='Hive unload/seed removal: '+$_.Exception.Message}
|
||||
$hivesOk=$false;$tokenOk=$false;$masksOk=$false;$precedenceOk=$false
|
||||
$afterHives=$null;$afterToken=$null;$masks=$null;$afterPrecedence=$null
|
||||
try{$afterHives=Hives;$hivesOk=(Key $afterHives) -ceq (Key $beforeHives)}catch{$cleanupErrors+='HKU verification: '+$_.Exception.Message}
|
||||
try{$afterToken=[Wela.WmiProbe.Native]::Snapshot();$tokenOk=(Key $afterToken) -ceq (Key $beforeToken)}catch{$cleanupErrors+='Token verification: '+$_.Exception.Message}
|
||||
try{$masks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($masks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key))}catch{$cleanupErrors+='Audit verification: '+$_.Exception.Message}
|
||||
try{$afterPrecedence=Get-WelaRegistryState $precedencePath $precedenceName;$precedenceOk=(Key $afterPrecedence) -ceq (Key $beforePrecedence)}catch{$cleanupErrors+='Precedence verification: '+$_.Exception.Message}
|
||||
if(-not $hive.Loaded -and $hivesOk){try{Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+='Owned file removal: '+$_.Exception.Message}}
|
||||
$cleanup=[pscustomobject]@{Complete=($hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.SeedCreated -and -not(Test-Path -LiteralPath $files) -and $cleanupErrors.Count -eq 0);HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksCompared=$beforeMasks.Count;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path -LiteralPath $files));Errors=$cleanupErrors;Failure=$(if($failure){$failure.Exception.Message}else{$null});Assertions=$script:assertions;AfterHives=$afterHives;AfterToken=$afterToken;AfterMasks=$masks;AfterPrecedence=$afterPrecedence}
|
||||
Save 'cleanup.json' $cleanup
|
||||
$artifacts=@(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 -Algorithm SHA256).Hash.ToLowerInvariant()}})
|
||||
Save 'artifact-hashes.json' $artifacts
|
||||
}
|
||||
if($failure){throw $failure};if(-not $cleanup.Complete){throw ('Owned registry fixture cleanup incomplete: '+(Key $cleanup))}
|
||||
Write-Host "Passed $script:assertions actual public registry SACL recovery assertions; all cleanup confirmed. Evidence: $root"
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,101 @@
|
||||
param([switch]$AllowDisposableWarningWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableWarningWrite -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
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force
|
||||
. (Join-Path $repo 'scripts/Configuration.ps1')
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-security-warning-'+[guid]::NewGuid().ToString('N'))
|
||||
$null=New-Item -ItemType Directory -Path $root
|
||||
$path='HKLM:\SYSTEM\CurrentControlSet\Services\Eventlog\Security';$name='WarningLevel'
|
||||
$count=0;$failure=$null;$cleanupErrors=@()
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 25|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 25 -Compress}
|
||||
function Masks($Value){@($Value.Keys|Sort-Object|ForEach-Object{"$_=$($Value[$_])"}) -join ';'}
|
||||
function Warning {Get-WelaRegistryState $path $name}
|
||||
function Unselected {
|
||||
# Own the native registry view and read its descriptor without Get-Acl LiteralPath provider conversion.
|
||||
$baseKey=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
|
||||
$key=$null
|
||||
try{
|
||||
$key=$baseKey.OpenSubKey('SYSTEM\CurrentControlSet\Services\Eventlog\Security')
|
||||
if(-not $key){throw 'Existing Security registry key is unavailable.'}
|
||||
$values=@(foreach($n in @($key.GetValueNames()|Sort-Object)){
|
||||
if($n -ine $name){[pscustomobject][ordered]@{Name=$n;Type=[string]$key.GetValueKind($n);Value=$key.GetValue($n,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}}
|
||||
})
|
||||
$subkeys=@($key.GetSubKeyNames()|Sort-Object)
|
||||
$security=if($PSVersionTable.PSVersion.Major -ge 6){[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($key)}else{$key.GetAccessControl()}
|
||||
$acl=$security.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group)
|
||||
}finally{if($key){$key.Dispose()};$baseKey.Dispose()}
|
||||
[pscustomobject][ordered]@{OtherSecurityValues=$values;SecuritySubkeys=$subkeys;SecurityAcl=$acl;SecurityChannel=Get-WelaNativeChannel Security;ApplicationChannel=Get-WelaNativeChannel Application;OneSettings=Get-WelaRegistryState 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' EnableOneSettingsAuditing;CrashOnAuditFail=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' CrashOnAuditFail;EventLogService=[string](Get-Service EventLog).Status}
|
||||
}
|
||||
function Public([string]$Label,[string[]]$Arguments,[int]$Expected=0){
|
||||
$prior=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') audit-notifications @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
$output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8
|
||||
Assert ($code -eq $Expected) "Public $Label exited $code, expected $Expected : $output"
|
||||
}
|
||||
$before=Warning;$unselected=Unselected;$masks=Get-WelaEffectiveAuditPolicy
|
||||
Assert $before.KeyExists 'Existing Security registry key is required; fixture never creates/removes it.'
|
||||
Save 'original.json' @{Warning=$before;Unselected=$unselected;Masks=$masks;Engine=$PSVersionTable.PSVersion.ToString();OS=[Environment]::OSVersion.VersionString}
|
||||
$base=@('-NotificationControl','SecurityWarning')
|
||||
try{
|
||||
# Remove only the selected value to exercise absence rather than a fabricated default.
|
||||
if((Warning).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}
|
||||
$seed=Warning
|
||||
Public 'plan' ($base+@('-NotificationAction','Plan','-WarningPercent','90','-ResultsPath',(Join-Path $root 'plan.json')))
|
||||
$plan=Get-Content (Join-Path $root 'plan.json') -Raw|ConvertFrom-Json
|
||||
Assert ($plan.Plan.Count -eq 1 -and $plan.Plan[0].Definition.Id -ceq 'SecurityWarning' -and $plan.Plan[0].Desired -eq 90 -and -not $plan.Plan[0].Before.Policy.ValueExists) 'Public Plan retains actual absence and exactly one selected control.'
|
||||
$dryBackup=Join-Path $root 'dry-backup'
|
||||
Public 'dry' ($base+@('-NotificationAction','Configure','-WarningPercent','90','-Auto','-DryRun','-BackupPath',$dryBackup,'-ResultsPath',(Join-Path $root 'dry.json')))
|
||||
$dry=Get-Content (Join-Path $root 'dry.json') -Raw|ConvertFrom-Json
|
||||
Assert ($dry.ExitCode -eq 0 -and $dry.DryRun -and $dry.Results[0].Status -ceq 'Skipped' -and -not(Test-Path $dryBackup)) 'Public dry run reports a skipped proposal and creates no journal.'
|
||||
Assert ((Key (Warning)) -ceq (Key $seed) -and (Key (Unselected)) -ceq (Key $unselected) -and (Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $masks)) 'Plan/DryRun preserve the selected absence, unrelated native state and all59 masks.'
|
||||
$cases=@(
|
||||
@{Id='absent';Before=$null;Maximum=90;Desired=90;Status='Applied'},
|
||||
@{Id='zero';Before=0;Maximum=80;Desired=80;Status='Applied'},
|
||||
@{Id='higher';Before=95;Maximum=70;Desired=70;Status='Applied'},
|
||||
@{Id='earlier';Before=25;Maximum=90;Desired=25;Status='AlreadyCompliant'}
|
||||
)
|
||||
foreach($case in $cases){
|
||||
if($null -ne $case.Before){$null=New-ItemProperty -LiteralPath $path -Name $name -Value $case.Before -PropertyType DWord -Force}
|
||||
$prior=Warning;$backup=Join-Path $root ($case.Id+'-backup');$results=Join-Path $root ($case.Id+'.json')
|
||||
Public $case.Id ($base+@('-NotificationAction','Configure','-WarningPercent',[string]$case.Maximum,'-Auto','-BackupPath',$backup,'-ResultsPath',$results))
|
||||
$report=Get-Content $results -Raw|ConvertFrom-Json;$after=Warning
|
||||
Assert ($report.ExitCode -eq 0 -and $report.Scope -ceq 'audit-notifications' -and $report.Results.Count -eq 1 -and $report.Results[0].Status -ceq $case.Status) 'Each selected native case has one accurate result and narrow scope.'
|
||||
Assert ($after.Type -ceq 'DWord' -and $after.Value -eq $case.Desired -and $report.Current[0].Before.Policy.Value -eq $case.Desired) 'Native DWORD readback and public current state match the exact intended threshold.'
|
||||
Assert ($report.PrivacyChannelPlan.Count -eq 0 -and $report.EventGeneration -match 'Not verified') 'No privacy-channel operation or warning event claim is implied.'
|
||||
$journalPath=Join-Path $backup 'before.jsonl'
|
||||
if($case.Status -eq 'Applied'){
|
||||
$journal=@(Get-Content $journalPath|ConvertFrom-Json)
|
||||
Assert ($journal.Count -eq 1 -and $journal[0].Target.Name -ceq $name -and $journal[0].Target.Path -ceq $path -and (Key $journal[0].Before.Policy) -ceq (Key $prior)) 'The one native change has exact typed original journal evidence.'
|
||||
Assert ((Key $report.Results[0].Before.Policy) -ceq (Key $prior) -and (Key $report.Results[0].After.Policy) -ceq (Key $after)) 'Applied result binds exact native before and after policy.'
|
||||
}else{Assert (-not(Test-Path $journalPath)) 'An earlier existing warning is preserved without a write journal.'}
|
||||
Assert ((Key (Unselected)) -ceq (Key $unselected) -and (Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $masks)) 'Each public Configure preserves siblings, ACL, channels, service, audit masks, OneSettings and CrashOnAuditFail.'
|
||||
}
|
||||
$repeatPath=Join-Path $root 'repeat.json';$repeatBackup=Join-Path $root 'repeat-backup'
|
||||
Public 'repeat' ($base+@('-NotificationAction','Configure','-WarningPercent','90','-Auto','-BackupPath',$repeatBackup,'-ResultsPath',$repeatPath))
|
||||
$repeat=Get-Content $repeatPath -Raw|ConvertFrom-Json
|
||||
Assert ($repeat.Results[0].Status -ceq 'AlreadyCompliant' -and (Warning).Value -eq 25 -and -not(Test-Path (Join-Path $repeatBackup 'before.jsonl'))) 'Repeated Configure is idempotent and preserves the earlier threshold.'
|
||||
# Fixture-owned wrong type must remain wrong rather than being coerced and overwritten.
|
||||
Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop
|
||||
$null=New-ItemProperty -LiteralPath $path -Name $name -Value 'fixture-not-a-dword' -PropertyType String
|
||||
$invalid=Warning;$invalidPath=Join-Path $root 'invalid.json';$invalidBackup=Join-Path $root 'invalid-backup'
|
||||
Public 'invalid' ($base+@('-NotificationAction','Configure','-WarningPercent','90','-Auto','-BackupPath',$invalidBackup,'-ResultsPath',$invalidPath)) 1
|
||||
$refused=Get-Content $invalidPath -Raw|ConvertFrom-Json
|
||||
Assert ($refused.ExitCode -eq 1 -and $refused.Results[0].Status -ceq 'Failed' -and (Key (Warning)) -ceq (Key $invalid) -and -not(Test-Path (Join-Path $invalidBackup 'before.jsonl'))) 'Actual wrong type yields failure and is preserved without a native write journal.'
|
||||
Save 'completed.json' @{Status='Passed';Assertions=$count;Scope='Actual named policy configuration only. No warning generation, log exhaustion, retention changes, GPO refresh, ingestion or Sigma proof.'}
|
||||
}catch{$failure=$_.ToString();throw}finally{
|
||||
try{
|
||||
if((Warning).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}
|
||||
if($before.ValueExists){$null=New-ItemProperty -LiteralPath $path -Name $name -Value $before.Value -PropertyType $before.Type}
|
||||
}catch{$cleanupErrors+=$_.ToString()}
|
||||
$warningOk=$false;$otherOk=$false;$masksOk=$false
|
||||
try{$warningOk=(Key (Warning)) -ceq (Key $before);$otherOk=(Key (Unselected)) -ceq (Key $unselected);$masksOk=(Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $masks)}catch{$cleanupErrors+=$_.ToString()}
|
||||
Save 'cleanup.json' @{Failure=$failure;Errors=$cleanupErrors;WarningRestored=$warningOk;UnselectedPreserved=$otherOk;All59MasksPreserved=$masksOk;Complete=($warningOk -and $otherOk -and $masksOk -and -not $cleanupErrors.Count)}
|
||||
if(-not $warningOk -or -not $otherOk -or -not $masksOk -or $cleanupErrors.Count){throw 'Native warning fixture cleanup failed; inspect retained evidence.'}
|
||||
}
|
||||
Write-Host "PASS: $count native public Security warning assertions and exact cleanup."
|
||||
exit 0
|
||||
@@ -0,0 +1,113 @@
|
||||
param([switch]$AllowDisposablePolicyWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposablePolicyWrite -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/SmbAuditing.ps1')
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
$os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_ComputerSystem
|
||||
if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.DomainRole -ne 2 -or $computer.PartOfDomain){throw 'An unjoined disposable Server 2022/2025 is required.'}
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-smb-policy-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$engine=(Get-Process -Id $PID).Path;$definitions=@(Get-WelaSmbAuditDefinitions);$count=0;$failure=$null;$errors=@()
|
||||
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 Runtime {
|
||||
foreach($side in @('Server','Client')){
|
||||
$cmd="Get-Smb${side}Configuration";$c=& $cmd -ErrorAction Stop
|
||||
[pscustomobject][ordered]@{Side=$side;Properties=@($c.CimInstanceProperties|Sort-Object Name|ForEach-Object{[pscustomobject][ordered]@{Name=$_.Name;Type=$_.CimType.ToString();Value=$_.Value}})}
|
||||
}
|
||||
}
|
||||
function UnselectedRuntime($Snapshot) {
|
||||
foreach($side in $Snapshot){
|
||||
$component=if($side.Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'}
|
||||
$selected=@($definitions|Where-Object Component -eq $component|ForEach-Object Name)
|
||||
[pscustomobject][ordered]@{Side=$side.Side;Properties=@($side.Properties|Where-Object Name -NotIn $selected)}
|
||||
}
|
||||
}
|
||||
function Policies {foreach($d in $definitions){[pscustomobject]@{Definition=$d;Policy=Get-WelaRegistryState $d.Path $d.Name}}}
|
||||
function Keys {
|
||||
foreach($component in @('LanmanServer','LanmanWorkstation')){
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$k=$null
|
||||
try{
|
||||
$k=$base.OpenSubKey("SOFTWARE\Policies\Microsoft\Windows\$component")
|
||||
if(-not $k){[pscustomobject][ordered]@{Component=$component;Exists=$false;Values=@();Children=@();Access=$null};continue}
|
||||
$acl=if($PSVersionTable.PSVersion.Major -ge 6){[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($k)}else{$k.GetAccessControl()}
|
||||
[pscustomobject][ordered]@{Component=$component;Exists=$true;Values=@($k.GetValueNames()|Sort-Object|ForEach-Object{[pscustomobject][ordered]@{Name=$_;Type=$k.GetValueKind($_).ToString();Value=$k.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}});Children=@($k.GetSubKeyNames()|Sort-Object);Access=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group)}
|
||||
}finally{if($k){$k.Dispose()};$base.Dispose()}
|
||||
}
|
||||
}
|
||||
function OtherKeys {
|
||||
$all=@(Keys)
|
||||
foreach($k in $all){$names=@($definitions|Where-Object Component -eq $k.Component|ForEach-Object Name);$k.Values=@($k.Values|Where-Object Name -NotIn $names)}
|
||||
return $all
|
||||
}
|
||||
function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){
|
||||
$prior=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') smb-auditing @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
$output|Set-Content -LiteralPath (Join-Path $root ($Name+'.txt')) -Encoding UTF8
|
||||
Assert ($code -eq $Expected) "Public $Name exited $code : $output"
|
||||
Get-Content -Raw -LiteralPath (Join-Path $root ($Name+'.json'))|ConvertFrom-Json
|
||||
}
|
||||
$before=@(Policies);$keys=@(Keys);$runtime=@(Runtime);$masks=Masks
|
||||
$services=@(Get-Service LanmanServer,LanmanWorkstation|Sort-Object Name|Select-Object Name,Status)
|
||||
Save 'original.json' @{Policies=$before;Keys=$keys;Runtime=$runtime;Masks=$masks;Services=$services;Build=[int]$os.BuildNumber;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();PartOfDomain=$computer.PartOfDomain;DomainRole=$computer.DomainRole}
|
||||
try{
|
||||
$initial=@(Get-WelaSmbAuditPlan)
|
||||
if([int]$os.BuildNumber -eq 20348){Assert (@($initial|Where-Object Status -ne NotApplicable).Count -eq 0) 'All six policies are genuinely not applicable on Server 2022.'}
|
||||
else{
|
||||
Assert (@($initial|Where-Object {$_.Status -notin @('ChangeRequired','PolicyConfigured')}).Count -eq 0) 'All six policies require exact local ADMX and readable native runtime before fixture writes.'
|
||||
foreach($d in $definitions){New-WelaRegistryKey $d.Path;$null=New-ItemProperty -LiteralPath $d.Path -Name $d.Name -Value 0 -PropertyType DWord -Force}
|
||||
}
|
||||
$prepared=@(Policies);$other=@(OtherKeys);$preparedRuntime=@(Runtime);Save 'prepared.json' $prepared;Save 'prepared-runtime.json' $preparedRuntime
|
||||
$plan=Public plan @('-SmbAction','Plan','-ResultsPath',(Join-Path $root 'plan.json'))
|
||||
Assert ($plan.Controls.Count -eq 6) 'Public Plan accounts for exactly six controls.'
|
||||
$dry=Public dry @('-SmbAction','Configure','-DryRun','-BackupPath',(Join-Path $root 'dry-backup'),'-ResultsPath',(Join-Path $root 'dry.json'))
|
||||
Assert ($dry.DryRun -and @($dry.Results|Where-Object Status -eq Applied).Count -eq 0 -and -not(Test-Path (Join-Path $root 'dry-backup'))) 'Dry run does not change policy or create original journals.'
|
||||
Assert ((Key @(Policies)) -ceq (Key $prepared) -and (Key @(Runtime)) -ceq (Key $preparedRuntime)) 'Plan and DryRun preserve exact typed policy and full native runtime.'
|
||||
$applied=Public apply @('-SmbAction','Configure','-Auto','-BackupPath',(Join-Path $root 'apply-backup'),'-ResultsPath',(Join-Path $root 'apply.json'))
|
||||
Assert ($applied.Scope -ceq 'smb-audit-policies-only' -and $applied.Results.Count -eq 6) 'Public Configure retains narrow scope and all six outcomes.'
|
||||
if([int]$os.BuildNumber -eq 20348){
|
||||
Assert (@($applied.Results|Where-Object Status -ne Skipped).Count -eq 0 -and -not(Test-Path (Join-Path $root 'apply-backup/before.jsonl'))) 'Unsupported Server 2022 has six skipped controls and no policy writes.'
|
||||
}else{
|
||||
Assert (@($applied.Results|Where-Object Status -ne Applied).Count -eq 0) 'Server 2025 actually applied all six policy DWORDs.'
|
||||
$journal=@(Get-Content (Join-Path $root 'apply-backup/before.jsonl')|ConvertFrom-Json);Assert ($journal.Count -eq 6) 'Every actual write has an original journal entry.'
|
||||
foreach($row in $applied.Results){
|
||||
$j=@($journal|Where-Object Id -eq $row.Id);$p=@($prepared|Where-Object {$_.Definition.Path -ceq $row.Target.Path -and $_.Definition.Name -ceq $row.Target.Name})
|
||||
Assert ($j.Count -eq 1 -and $p.Count -eq 1 -and (Key $j[0].Before.Policy) -ceq (Key $p[0].Policy)) 'Each journal matches the actual typed original policy.'
|
||||
Assert ($row.After.Policy.Type -ceq 'DWord' -and $row.After.Policy.Value -eq 1 -and $row.After.PolicyRegistryConfigured) 'Actual native readback verifies each DWORD without inferring runtime state.'
|
||||
}
|
||||
$repeat=Public repeat @('-SmbAction','Configure','-Auto','-BackupPath',(Join-Path $root 'repeat-backup'),'-ResultsPath',(Join-Path $root 'repeat.json'))
|
||||
Assert (@($repeat.Results|Where-Object Status -ne AlreadyCompliant).Count -eq 0 -and -not(Test-Path (Join-Path $root 'repeat-backup/before.jsonl'))) 'Repeated public Configure is idempotent without another journal.'
|
||||
}
|
||||
$afterRuntime=@(Runtime);$afterOther=@(OtherKeys);Save 'after-runtime.json' $afterRuntime;Save 'after-other-keys.json' $afterOther;Save 'prepared-other-keys.json' $other
|
||||
Assert ((Key $afterOther) -ceq (Key $other)) 'Sibling values, access descriptors and child keys are preserved.'
|
||||
Assert ((Key @(UnselectedRuntime $afterRuntime)) -ceq (Key @(UnselectedRuntime $runtime))) 'Every unrelated native SMB runtime property is preserved.'
|
||||
Assert ((Masks) -ceq $masks) 'All59 audit masks are preserved.'
|
||||
if([int]$os.BuildNumber -eq 26100){
|
||||
foreach($row in $applied.Results){
|
||||
$side=if($row.Target.Path -like '*LanmanServer'){'Server'}else{'Client'}
|
||||
$observed=@(($afterRuntime|Where-Object Side -eq $side).Properties|Where-Object Name -eq $row.Target.Name)
|
||||
Assert ($observed.Count -eq 1 -and $observed[0].Type -ceq 'Boolean' -and $row.After.Runtime.Value -ceq $observed[0].Value) 'Reported audit runtime observation matches a separate native getter; activation is observed, not assumed.'
|
||||
}
|
||||
}
|
||||
Save 'completed.json' @{Status='Passed';Assertions=$count;ActualPolicyWrites=$(if([int]$os.BuildNumber -eq 26100){6}else{0});Scope='Policy registry only; no SMB traffic, activation, GPO refresh, event generation or Sigma proof.'}
|
||||
}catch{$failure=$_.ToString();throw}finally{
|
||||
foreach($row in $before){try{
|
||||
$d=$row.Definition;$old=$row.Policy;$now=Get-WelaRegistryState $d.Path $d.Name
|
||||
if($old.ValueExists){$null=New-ItemProperty -LiteralPath $d.Path -Name $d.Name -Value $old.Value -PropertyType $old.Type -Force}
|
||||
elseif($now.ValueExists){Remove-ItemProperty -LiteralPath $d.Path -Name $d.Name -ErrorAction Stop}
|
||||
}catch{$errors+=$_.ToString()}}
|
||||
foreach($k in $keys|Where-Object {-not $_.Exists}){try{
|
||||
$path="HKLM:\SOFTWARE\Policies\Microsoft\Windows\$($k.Component)"
|
||||
if(Test-Path -LiteralPath $path){$item=Get-Item -LiteralPath $path;if($item.ValueCount -ne 0 -or $item.SubKeyCount -ne 0){throw 'A fixture-created key is not empty; it was preserved.'};Remove-Item -LiteralPath $path -ErrorAction Stop}
|
||||
}catch{$errors+=$_.ToString()}}
|
||||
$checks=[ordered]@{}
|
||||
foreach($pair in @(@('Policies',{(Key @(Policies)) -ceq (Key $before)}),@('Keys',{(Key @(Keys)) -ceq (Key $keys)}),@('Runtime',{(Key @(Runtime)) -ceq (Key $runtime)}),@('AuditMasks',{(Masks) -ceq $masks}),@('Services',{(Key @(Get-Service LanmanServer,LanmanWorkstation|Sort-Object Name|Select-Object Name,Status)) -ceq (Key $services)}))){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}
|
||||
if(-not $complete){throw 'SMB native policy fixture cleanup failed; inspect retained receipts.'}
|
||||
}
|
||||
Write-Host "PASS: $count native public SMB policy assertions and exact cleanup."
|
||||
exit 0
|
||||
@@ -0,0 +1,21 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$script:checks=0
|
||||
function Check-Cli {
|
||||
param([string[]]$Arguments,[bool]$Success,[string]$Match)
|
||||
$old=$ErrorActionPreference;$ErrorActionPreference='Continue'
|
||||
try{$output=(& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1 | Out-String);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old}
|
||||
if(($Success -and $code -ne 0) -or (-not $Success -and $code -eq 0) -or $output -notmatch $Match){throw "CLI guard failed: $($Arguments -join ' '), exit $code : $output"}
|
||||
$script:checks++
|
||||
}
|
||||
Check-Cli @('smb-runtime','-Help') $true 'smb-runtime'
|
||||
Check-Cli @('smb-runtime','-Profile','test','-Help') $false 'dedicated options'
|
||||
Check-Cli @('help','-SmbRuntimeAction','Activate') $false 'SmbRuntime options require'
|
||||
Check-Cli @('smb-runtime','-SmbAction','Configure','-Help') $false 'dedicated options'
|
||||
Check-Cli @('smb-runtime','-DryRun') $false 'DryRun is supported only'
|
||||
Check-Cli @('smb-runtime','-SmbRuntimeAction','Activate','-DryRun','-Help') $true 'smb-runtime'
|
||||
Check-Cli @('smb-runtime','-BackupPath','unused','-Help') $false 'dedicated options'
|
||||
Write-Host "PASS: $script:checks public SMB runtime CLI guards"
|
||||
# Expected child failures are assertions, not the enclosing Actions step result.
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,130 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/SmbAuditing.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/SmbRuntimeActivation.ps1')
|
||||
$script:checks=0
|
||||
function Assert($Condition,[string]$Message){if(-not $Condition){throw "FAIL: $Message"};$script:checks++}
|
||||
function Reject([scriptblock]$Code,[string]$Message){$failed=$false;try{& $Code}catch{$failed=$true};Assert $failed $Message}
|
||||
Reject {Set-WelaSmbRuntimeFlag 'LanmanWorkstation/EnableInsecureGuestLogons'} 'security parameter refused by actual setter adapter'
|
||||
Reject {Set-WelaSmbRuntimeFlag 'LanmanServer/auditinsecureguestlogon'} 'mis-cased control refused'
|
||||
$nativeModuleBase=[IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$command=[pscustomobject]@{Name='Set-SmbServerConfiguration';ModuleName='SmbServerConfiguration';CommandType='Function';Module=[pscustomobject]@{ModuleBase=$nativeModuleBase};Parameters=@{}}
|
||||
foreach($definition in @(Get-WelaSmbAuditDefinitions | Where-Object Component -eq LanmanServer)){$command.Parameters[$definition.Name]=[pscustomobject]@{ParameterType=[bool]}}
|
||||
Assert-WelaSmbRuntimeCommand $command Server Set $nativeModuleBase
|
||||
Assert $true 'actual nested native CDXML module metadata accepted'
|
||||
$command.ModuleName='Other'
|
||||
Reject {Assert-WelaSmbRuntimeCommand $command Server Set $nativeModuleBase} 'foreign module refused'
|
||||
$command.ModuleName='SmbServerConfiguration'
|
||||
Reject {Assert-WelaSmbRuntimeCommand $command Server Set ($nativeModuleBase+'other')} 'unexpected module directory refused'
|
||||
$command.Parameters.AuditInsecureGuestLogon.ParameterType=[string]
|
||||
Reject {Assert-WelaSmbRuntimeCommand $command Server Set $nativeModuleBase} 'mistyped native parameter refused'
|
||||
$command.Parameters.Remove('AuditInsecureGuestLogon')
|
||||
Reject {Assert-WelaSmbRuntimeCommand $command Server Set $nativeModuleBase} 'missing native parameter refused'
|
||||
function FixtureConfiguration {
|
||||
param([string]$Side='Server')
|
||||
$component=if($Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'}
|
||||
$properties=@(Get-WelaSmbAuditDefinitions | Where-Object Component -eq $component | ForEach-Object {[pscustomobject]@{Name=$_.Name;Value=$false;CimType='Boolean'}})
|
||||
$properties+=[pscustomobject]@{Name='RequireSecuritySignature';Value=$true;CimType='Boolean'}
|
||||
[pscustomobject]@{CimClass=[pscustomobject]@{CimClassName="MSFT_Smb${Side}Configuration"};CimInstanceProperties=$properties}
|
||||
}
|
||||
$native=FixtureConfiguration
|
||||
$config=ConvertTo-WelaSmbRuntimeConfiguration $native Server
|
||||
Assert ($config.RequireSecuritySignature.Value -eq $true -and $config.AuditInsecureGuestLogon.Value -eq $false) 'native typed security and audit properties retained'
|
||||
$native.CimInstanceProperties[0].Value='False'
|
||||
Reject {ConvertTo-WelaSmbRuntimeConfiguration $native Server} 'string audit Boolean rejected'
|
||||
$native=FixtureConfiguration;$native.CimInstanceProperties[0].CimType='String'
|
||||
Reject {ConvertTo-WelaSmbRuntimeConfiguration $native Server} 'wrong native CIM type rejected'
|
||||
$native=FixtureConfiguration;$native.CimClass.CimClassName='MSFT_AnotherConfiguration'
|
||||
Reject {ConvertTo-WelaSmbRuntimeConfiguration $native Server} 'wrong native class rejected'
|
||||
$native=FixtureConfiguration;$native.CimInstanceProperties+=[pscustomobject]@{Name='Mystery';Value=[pscustomobject]@{a=1};CimType='Instance'}
|
||||
Reject {ConvertTo-WelaSmbRuntimeConfiguration $native Server} 'unknown unrelated configuration remains unverified'
|
||||
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-smb-activation-'+[guid]::NewGuid().ToString('N'))
|
||||
$null=New-Item -ItemType Directory -Path $root
|
||||
$script:receiptWriter=${function:Write-WelaSmbRuntimeReceipt}
|
||||
function Reset-Fixture {
|
||||
$policies=[ordered]@{}
|
||||
foreach($definition in Get-WelaSmbAuditDefinitions){$policies["$($definition.Component)/$($definition.Name)"]=[pscustomobject]@{Policy=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Type=$null;Value=$null}}}
|
||||
$script:fixture=[pscustomobject][ordered]@{Computer='fixture';Host=[pscustomobject]@{Build=26100};Commands='native';Sources='hash';Policies=[pscustomobject]$policies;Configurations=[pscustomobject]@{Server=(ConvertTo-WelaSmbRuntimeConfiguration (FixtureConfiguration Server) Server);Client=(ConvertTo-WelaSmbRuntimeConfiguration (FixtureConfiguration Client) Client)}}
|
||||
$script:writes=0;$script:reads=0;$script:driftRead=0;$script:failWrite=0;$script:securityDrift=$false;$script:receiptFail=$false;$script:promptDrift=$false
|
||||
$script:out=Join-Path $root ([guid]::NewGuid().ToString('N'))
|
||||
}
|
||||
function Get-WelaSmbRuntimeState {
|
||||
$script:reads++
|
||||
if($script:reads -eq $script:driftRead){$script:fixture.Sources='changed'}
|
||||
Get-WelaSmbRuntimeKey $script:fixture | ConvertFrom-Json
|
||||
}
|
||||
function Write-WelaSmbRuntimeReceipt {
|
||||
param($Root,$Name,$Value)
|
||||
if($script:receiptFail -and $Name -eq '1-pending.json'){throw 'Injected durable-write failure'}
|
||||
& $script:receiptWriter $Root $Name $Value
|
||||
}
|
||||
function Set-WelaSmbRuntimeFlag {
|
||||
param($Id)
|
||||
$script:writes++
|
||||
Assert (Test-Path (Join-Path $script:out "$($script:writes)-pending.json")) 'pending receipt exists before setter'
|
||||
if($script:writes -eq $script:failWrite){throw 'Injected native setter failure'}
|
||||
$parts=$Id.Split('/');$side=if($parts[0] -eq 'LanmanServer'){'Server'}else{'Client'}
|
||||
$script:fixture.Configurations.$side.($parts[1]).Value=$true
|
||||
if($script:securityDrift){$script:fixture.Configurations.Server.RequireSecuritySignature.Value=$false}
|
||||
}
|
||||
function Read-Host {param($Prompt) if($script:promptDrift){$script:fixture.Sources='changed at prompt'};'y'}
|
||||
try {
|
||||
Reset-Fixture
|
||||
$plan=Invoke-WelaSmbRuntimeActivation
|
||||
Assert ($plan.Status -eq 'Planned' -and $plan.Controls.Count -eq 6 -and $script:writes -eq 0) 'default Plan is six read-only audit controls'
|
||||
Assert (-not (Test-Path $script:out)) 'Plan creates no evidence directory'
|
||||
$dry=Invoke-WelaSmbRuntimeActivation -Action Activate -DryRun -OutputPath $script:out
|
||||
Assert ($dry.Status -eq 'DryRun' -and $script:writes -eq 0 -and -not (Test-Path $script:out)) 'DryRun does not write'
|
||||
Reject {Invoke-WelaSmbRuntimeActivation -Action Plan -Auto} 'irrelevant Plan consent rejected'
|
||||
Reject {Invoke-WelaSmbRuntimeActivation -Action Plan -DryRun} 'invalid dry run action rejected'
|
||||
$id='LanmanServer/AuditInsecureGuestLogon'
|
||||
foreach($value in @(0,'1',2)) {
|
||||
Reset-Fixture;$script:fixture.Policies.$id.Policy=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Type='DWord';Value=$value}
|
||||
$report=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out
|
||||
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0 -and -not (Test-Path $script:out)) 'conflicting or mistyped policy stops all mutations'
|
||||
}
|
||||
Reset-Fixture;$script:fixture.Policies.$id.Policy=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Type='String';Value=1}
|
||||
Assert ((Invoke-WelaSmbRuntimeActivation).ExitCode -eq 1) 'wrong registry kind blocks'
|
||||
Reset-Fixture;$script:fixture.Policies.$id.Policy=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Type='DWord';Value=1}
|
||||
Assert ((Invoke-WelaSmbRuntimeActivation).ExitCode -eq 0) 'existing enabled policy is compatible'
|
||||
|
||||
Reset-Fixture
|
||||
$report=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out
|
||||
Assert ($report.ExitCode -eq 0 -and $report.Status -eq 'RuntimeAuditingActive' -and $script:writes -eq 6) "six native activations succeed: $($report.Diagnostic)"
|
||||
Assert (@($report.Results | Where-Object Status -eq Activated).Count -eq 6) 'all six report confirmed activation'
|
||||
Assert ((Get-ChildItem -LiteralPath $script:out -File).Count -eq 14) 'plan, six pending, six confirmed, final result retained'
|
||||
Assert ($report.After.Configurations.Server.RequireSecuritySignature.Value -eq $true) 'security property preserved'
|
||||
Assert ($report.ReadyRuleCredit -eq 0 -and $report.EventGeneration -eq 'Not tested') 'activation grants no event or rule proof'
|
||||
$prior=Get-Content -Raw -LiteralPath (Join-Path $script:out 'result.json')
|
||||
$second=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out
|
||||
Assert ($second.ExitCode -eq 1 -and (Get-Content -Raw -LiteralPath (Join-Path $script:out 'result.json')) -ceq $prior) 'existing evidence is never overwritten'
|
||||
$script:out=Join-Path $root ([guid]::NewGuid().ToString('N'));$script:writes=0
|
||||
$repeat=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out
|
||||
Assert ($repeat.ExitCode -eq 0 -and $script:writes -eq 0 -and @($repeat.Results | Where-Object Status -eq AlreadyActive).Count -eq 6) 'idempotence requires no setters'
|
||||
|
||||
Reset-Fixture;$script:failWrite=2
|
||||
$partial=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out
|
||||
Assert ($partial.ExitCode -eq 1 -and $script:writes -eq 2) 'partial native failure stops remaining writes'
|
||||
Assert ($partial.Results[0].Status -eq 'Activated' -and $partial.Results[1].Status -eq 'Failed' -and $partial.Results[2].Status -eq 'Skipped') 'partial outcomes preserved'
|
||||
Assert ((Test-Path (Join-Path $script:out '1-confirmed.json')) -and -not (Test-Path (Join-Path $script:out '2-confirmed.json'))) 'failed operation is never confirmed'
|
||||
foreach($read in @(2,3,20)) {
|
||||
Reset-Fixture;$script:driftRead=$read
|
||||
$drift=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out
|
||||
Assert ($drift.ExitCode -eq 1) 'fresh/prewrite/final source drift fails closed'
|
||||
if($read -lt 4){Assert ($script:writes -eq 0) 'prewrite drift performs no setter'}
|
||||
}
|
||||
Reset-Fixture;$script:promptDrift=$true
|
||||
$drift=Invoke-WelaSmbRuntimeActivation -Action Activate -OutputPath $script:out
|
||||
Assert ($drift.ExitCode -eq 1 -and $script:writes -eq 0) 'prompt-time drift refused'
|
||||
Reset-Fixture;$script:securityDrift=$true
|
||||
$drift=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out
|
||||
Assert ($drift.ExitCode -eq 1 -and $script:writes -eq 1 -and -not (Test-Path (Join-Path $script:out '1-confirmed.json'))) 'unrelated security delta prevents confirmation'
|
||||
Reset-Fixture;$script:receiptFail=$true
|
||||
$failed=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out
|
||||
Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 0) 'failed durable intent blocks setter'
|
||||
Assert (Test-Path (Join-Path $script:out 'result.json')) 'partial diagnostic survives pending-write failure'
|
||||
Write-Host "PASS: $script:checks SMB runtime activation assertions"
|
||||
}finally{Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Mutates only six audit flags on disposable GitHub-hosted Windows VMs. Never run on production.
|
||||
$ErrorActionPreference='Stop'
|
||||
if($env:OS -ne 'Windows_NT' -or $env:GITHUB_ACTIONS -ne 'true' -or $env:WELA_DISPOSABLE_SMB_ACTIVATION -ne 'true') {throw 'Explicit disposable GitHub Windows test opt-in is required.'}
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/SmbAuditing.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/SmbRuntimeActivation.ps1')
|
||||
$computer=Get-CimInstance Win32_ComputerSystem
|
||||
$os=Get-CimInstance Win32_OperatingSystem
|
||||
if($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100)){throw 'Fixture requires an isolated member-class Server 2022/2025 host.'}
|
||||
$evidence=Join-Path $env:RUNNER_TEMP ('wela-smb-runtime-'+[guid]::NewGuid().ToString('N'))
|
||||
$null=New-Item -ItemType Directory -Path $evidence
|
||||
$reportPath=Join-Path $evidence 'activation'
|
||||
$cleanup=[ordered]@{Build=[int]$os.BuildNumber;Engine=$PSVersionTable.PSVersion.ToString();OriginalCaptured=$false;AuditFlagsRestored=$false;FullContextRestored=$false;NativeActivation=$false;UnsupportedRefusal=$false}
|
||||
$original=$null
|
||||
try {
|
||||
if([int]$os.BuildNumber -eq 20348) {
|
||||
$report=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $reportPath
|
||||
if($report.ExitCode -ne 1 -or $report.Diagnostic -notlike '*NotApplicable*' -or (Test-Path $reportPath)){throw 'Server 2022 activation was not refused before writes.'}
|
||||
$report | ConvertTo-Json -Depth 24 | Set-Content -LiteralPath (Join-Path $evidence 'refusal.json') -Encoding UTF8
|
||||
$global:LASTEXITCODE=0
|
||||
$null=& (Join-Path $script:ScriptRoot 'WELA.ps1') smb-runtime -SmbRuntimeAction Activate -SmbRuntimeOutputPath $reportPath -Auto
|
||||
if($LASTEXITCODE -ne 1 -or (Test-Path $reportPath)){throw 'Public CLI did not refuse unsupported Server 2022.'}
|
||||
$cleanup.UnsupportedRefusal=$true
|
||||
Write-Host 'PASS: actual Server 2022 native and public-CLI refusal, no output or setters.'
|
||||
}else{
|
||||
$original=Get-WelaSmbRuntimeState
|
||||
if(@(Get-WelaSmbRuntimePlan $original | Where-Object Status -eq BlockedPolicy).Count){throw 'Fixture will not overwrite a conflicting policy.'}
|
||||
$cleanup.OriginalCaptured=$true
|
||||
$original | ConvertTo-Json -Depth 24 | Set-Content -LiteralPath (Join-Path $evidence 'original.json') -Encoding UTF8
|
||||
foreach($definition in Get-WelaSmbAuditDefinitions) {
|
||||
$side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'}
|
||||
$command="SmbShare\Set-Smb${side}Configuration"
|
||||
$parameters=@{Force=$true;Confirm=$false;ErrorAction='Stop'};$parameters[$definition.Name]=$false
|
||||
$null=& $command @parameters
|
||||
}
|
||||
$prepared=Get-WelaSmbRuntimeState
|
||||
$expected=Get-WelaSmbRuntimeKey $original | ConvertFrom-Json
|
||||
foreach($definition in Get-WelaSmbAuditDefinitions) {
|
||||
$side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'}
|
||||
$expected.Configurations.$side.($definition.Name).Value=$false
|
||||
}
|
||||
if((Get-WelaSmbRuntimeKey $prepared) -cne (Get-WelaSmbRuntimeKey $expected)){throw 'Fixture preparation changed other settings or did not make audit flags False.'}
|
||||
$dry=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -DryRun -OutputPath $reportPath
|
||||
if($dry.ExitCode -ne 0 -or (Test-Path $reportPath) -or (Get-WelaSmbRuntimeKey (Get-WelaSmbRuntimeState)) -cne (Get-WelaSmbRuntimeKey $prepared)){throw 'Native dry-run changed context or wrote output.'}
|
||||
$global:LASTEXITCODE=0
|
||||
$cli=@(& (Join-Path $script:ScriptRoot 'WELA.ps1') smb-runtime -SmbRuntimeAction Activate -SmbRuntimeOutputPath $reportPath -Auto)
|
||||
if($LASTEXITCODE -ne 0){throw "Public CLI exited $LASTEXITCODE"}
|
||||
$report=Get-Content -Raw -LiteralPath (Join-Path $reportPath 'result.json') | ConvertFrom-Json
|
||||
if($report.ExitCode -ne 0 -or $report.Status -ne 'RuntimeAuditingActive' -or @($report.Results | Where-Object Status -eq Activated).Count -ne 6){throw "Native six-flag activation failed: $($report.Diagnostic)"}
|
||||
$active=Get-WelaSmbRuntimeState
|
||||
foreach($definition in Get-WelaSmbAuditDefinitions) {
|
||||
$side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'}
|
||||
$expected.Configurations.$side.($definition.Name).Value=$true
|
||||
}
|
||||
if((Get-WelaSmbRuntimeKey $active) -cne (Get-WelaSmbRuntimeKey $expected)){throw 'Activation did not preserve every unrelated configuration field and policy tuple.'}
|
||||
$repeat=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath (Join-Path $evidence 'idempotent')
|
||||
if($repeat.ExitCode -ne 0 -or @($repeat.Results | Where-Object Status -eq AlreadyActive).Count -ne 6){throw 'Native idempotence failed.'}
|
||||
if(@(Get-ChildItem -LiteralPath $repeat.OutputPath -Filter '*-pending.json').Count){throw 'Idempotent run unexpectedly journaled a setter.'}
|
||||
$cleanup.NativeActivation=$true
|
||||
Write-Host 'PASS: actual Server 2025 public-CLI activation of all six native Boolean audit flags, dry-run, idempotence and preservation of all unrelated native configuration.'
|
||||
}
|
||||
}finally{
|
||||
if($original) {
|
||||
$failures=@()
|
||||
foreach($definition in Get-WelaSmbAuditDefinitions) {
|
||||
try {
|
||||
$side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'}
|
||||
$command="SmbShare\Set-Smb${side}Configuration"
|
||||
$parameters=@{Force=$true;Confirm=$false;ErrorAction='Stop'};$parameters[$definition.Name]=[bool]$original.Configurations.$side.($definition.Name).Value
|
||||
$null=& $command @parameters
|
||||
}catch{$failures+=$_.Exception.Message}
|
||||
}
|
||||
$restored=Get-WelaSmbRuntimeState
|
||||
$restored | ConvertTo-Json -Depth 24 | Set-Content -LiteralPath (Join-Path $evidence 'restored.json') -Encoding UTF8
|
||||
$cleanup.AuditFlagsRestored=$failures.Count -eq 0
|
||||
$cleanup.FullContextRestored=(Get-WelaSmbRuntimeKey $restored) -ceq (Get-WelaSmbRuntimeKey $original)
|
||||
$cleanup | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $evidence 'acceptance.json') -Encoding UTF8
|
||||
if(-not $cleanup.AuditFlagsRestored -or -not $cleanup.FullContextRestored){throw "Native SMB fixture cleanup mismatch: $($failures -join '; ')"}
|
||||
Write-Host 'PASS: exact native audit flags and full configuration/policy/source context restored.'
|
||||
}else{$cleanup | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $evidence 'acceptance.json') -Encoding UTF8}
|
||||
Write-Host "Native SMB evidence: $evidence"
|
||||
}
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$cases=@(
|
||||
@{Args=@('transcription-recovery','-Help');Code=0;Pattern='Usage: transcription-recovery'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-Help');Code=0;Pattern='TranscriptRecoveryPlanHash'},
|
||||
@{Args=@('transcription-recovery','-Profile','CisV4L2');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('help','-TranscriptRecoveryAction','Plan');Code=1;Pattern='require transcription-recovery'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-TranscriptRecoveryPlanHash','bad');Code=1;Pattern='Restore consumes'},
|
||||
@{Args=@('transcription-recovery','-Auto');Code=1;Pattern='Plan takes'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAllowTemporarySuspension');Code=1;Pattern='Plan takes'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-WhatIf');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-DryRnu');Code=1;Pattern='dedicated options'}
|
||||
)
|
||||
foreach($case in $cases) {
|
||||
$prior=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}
|
||||
finally{$ErrorActionPreference=$prior}
|
||||
if($code -ne $case.Code -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"}
|
||||
}
|
||||
Write-Host "Passed $($cases.Count) public transcription recovery CLI checks."
|
||||
# Expected child refusals must not become the enclosing Actions step result.
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,191 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/AuditRecovery.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/TranscriptionRecovery.ps1')
|
||||
$script:artifactWriter=(Get-Command Write-WelaRecoveryArtifact).ScriptBlock
|
||||
$script:jsonReader=(Get-Command ConvertFrom-WelaRecoveryJson).ScriptBlock
|
||||
function ConvertFrom-WelaRecoveryJson {
|
||||
param($Text)
|
||||
$value=& $script:jsonReader $Text
|
||||
# Older PowerShell 7 JSON readers materialize an explicit UTC timestamp.
|
||||
if($script:legacyJsonDate -and $value.RecordedUtc -is [string]){$value.RecordedUtc=[datetime]::Parse($value.RecordedUtc,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind)}
|
||||
$value
|
||||
}
|
||||
function Write-WelaRecoveryArtifact {
|
||||
param($Path,$Value)
|
||||
if($script:failArtifact -and [IO.Path]::GetFileName($Path) -eq $script:failArtifact){throw 'injected durable artifact failure'}
|
||||
& $script:artifactWriter $Path $Value
|
||||
}
|
||||
$script:checks=0;$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-transcript-recovery-test-'+[guid]::NewGuid().ToString('N'))
|
||||
$null=New-Item -ItemType Directory $root
|
||||
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
|
||||
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'"}
|
||||
function Copy-Value($Value){Copy-WelaTranscriptRecoveryValue $Value}
|
||||
function Typed($Value,$Type='DWord'){[pscustomobject]@{KeyExists=$true;ValueExists=($null -ne $Value);Value=$Value;Type=$(if($null -ne $Value){$Type}else{$null})}}
|
||||
function Get-WelaTranscriptRecoveryContext {[pscustomobject]@{Host=[pscustomobject]@{Computer='fixture';MachineGuid=$script:machine};Reader='fixture-reader'}}
|
||||
function Get-WelaTranscriptRecoverySources {[pscustomobject]@{Code=$script:code}}
|
||||
function Assert-WelaTranscriptRecoveryLocalPath {param($Path) if(-not $Path -or $Path.StartsWith('\\')){throw 'local path fixture refusal'}}
|
||||
function Get-WelaTranscriptRecoveryProtectedPolicy {return ,$script:protected}
|
||||
function Get-WelaTranscriptCapability {[pscustomobject]@{Status='Supported';Views=@('Registry64','Registry32')}}
|
||||
function Get-WelaTranscriptPolicy {param($Views) Copy-Value $script:policy}
|
||||
function Get-WelaTranscriptDestination {param($Path) [pscustomobject]@{RequestedPath=$Path;Path=$Path;Status='Observed';ConfigureAllowed=$true;CreationTimeUtc='fixture';Acl=$script:acl}}
|
||||
function Get-WelaTranscriptState {param($OutputDirectory) [pscustomobject]@{Capability=(Get-WelaTranscriptCapability);Policy=(Get-WelaTranscriptPolicy);Destination=(Get-WelaTranscriptDestination $OutputDirectory)}}
|
||||
function Set-WelaTranscriptRecoveryValue {
|
||||
param($Name,$Value)
|
||||
$script:writes++
|
||||
Assert (Test-Path (Join-Path $script:restoreOutput ('{0:d3}-pending.json' -f $script:writes))) 'each actual write has a durable pending receipt first'
|
||||
if($script:writes -eq $script:failWrite){throw 'injected write failure'}
|
||||
foreach($view in $script:policy){$view.Machine.$Name=Copy-Value $Value}
|
||||
if($script:writes -eq $script:driftWrite){$script:protected=@('changed independent module policy')}
|
||||
}
|
||||
function Read-Host {param($Prompt) if($script:promptDrift){$script:policy[0].Machine.EnableInvocationHeader=Typed 1;$script:policy[1].Machine.EnableInvocationHeader=Typed 1};'y'}
|
||||
function New-Fixture($Enable=1,$Directory='C:\Old') {
|
||||
$script:machine='stable';$script:code='stable';$script:acl='private';$script:protected=@('module','script-block','unrelated');$script:writes=0;$script:failWrite=-1;$script:driftWrite=-1;$script:promptDrift=$false;$script:failArtifact=$null;$script:legacyJsonDate=$false
|
||||
$script:fixture=Join-Path $root ([guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $script:fixture
|
||||
$beforePolicy=@(foreach($view in @('Registry64','Registry32')){[pscustomobject]@{View=$view;Machine=[pscustomobject]@{EnableTranscripting=(Typed $Enable);OutputDirectory=(Typed $Directory String);EnableInvocationHeader=(Typed 0)};CurrentUser=[pscustomobject]@{EnableTranscripting=(Typed $null);OutputDirectory=(Typed $null);EnableInvocationHeader=(Typed $null)}}})
|
||||
$script:policy=Copy-Value $beforePolicy
|
||||
foreach($view in $script:policy){$view.Machine.EnableTranscripting=Typed 1;$view.Machine.OutputDirectory=Typed 'C:\New' String}
|
||||
$before=[pscustomobject]@{Capability=(Get-WelaTranscriptCapability);Policy=$beforePolicy;Destination=(Get-WelaTranscriptDestination 'C:\New')}
|
||||
$after=Get-WelaTranscriptState 'C:\New'
|
||||
$target=[pscustomobject]@{Hive='LocalMachine';SubKey='SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription';OutputDirectory='C:\New'}
|
||||
$desired=[pscustomobject]@{EnableTranscripting=[pscustomobject]@{Type='DWord';Value=1};OutputDirectory=[pscustomobject]@{Type='String';Value='C:\New'};EnableInvocationHeader='Preserve'}
|
||||
$script:entry=[pscustomobject]@{Version=1;ComputerName='fixture';RecordedUtc=[datetime]::UtcNow.ToString('o');Id='PowerShellTranscription/CisV4L2';Kind='PowerShellTranscription';Before=$before;Target=$target;Desired=$desired}
|
||||
$script:original=[pscustomobject]@{ExitCode=0;Failed=0;Skipped=0;DryRun=$false;Action='Configure';Scope='windows-powershell-transcription-policy-only';Results=@([pscustomobject]@{Id=$script:entry.Id;Kind=$script:entry.Kind;Before=$before;After=$after;Target=$target;Desired=$desired;Status='Applied'})}
|
||||
Save-History
|
||||
$script:restoreOutput=Join-Path $script:fixture 'restore'
|
||||
}
|
||||
function Save-History {
|
||||
$script:journal=Join-Path $script:fixture 'before.jsonl';$script:originalPath=Join-Path $script:fixture 'original.json'
|
||||
Get-WelaRecoveryKey $script:entry|Set-Content -LiteralPath $script:journal -Encoding UTF8
|
||||
Get-WelaRecoveryKey $script:original|Set-Content -LiteralPath $script:originalPath -Encoding UTF8
|
||||
}
|
||||
function Plan-Fixture {
|
||||
$script:planResult=Invoke-WelaTranscriptRecovery -JournalPath $script:journal -OriginalResultsPath $script:originalPath -OutputPath (Join-Path $script:fixture 'plan')
|
||||
$script:planPath=Join-Path $script:planResult.OutputPath 'plan.json'
|
||||
$script:restoreParameters=@{Action='Restore';PlanPath=$script:planPath;PlanHash=$script:planResult.PlanSha256;OutputPath=$script:restoreOutput;Auto=$true}
|
||||
}
|
||||
try {
|
||||
New-Fixture;Plan-Fixture
|
||||
Assert ($script:planResult.RequiresTemporarySuspension -and $script:writes -eq 0) 'plan exposes a required temporary suspension without changes'
|
||||
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters} 'explicit.*TemporarySuspension'
|
||||
Assert (-not (Test-Path $script:restoreOutput)) 'missing suspension consent creates no recovery output'
|
||||
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
||||
Assert ($report.Status -eq 'Restored' -and $report.ExitCode -eq 0 -and $script:writes -eq 3) 'enabled destination recovery completes three verified writes'
|
||||
$confirmed=@(Get-ChildItem $script:restoreOutput '*-confirmed.json'|ForEach-Object {ConvertFrom-WelaRecoveryJson (Get-Content $_.FullName -Raw)})
|
||||
Assert ($confirmed[0].Step.Name -eq 'EnableTranscripting' -and $confirmed[0].Step.Value.Value -eq 0 -and $confirmed[1].Step.Name -eq 'OutputDirectory' -and $confirmed[2].Step.Value.Value -eq 1) 'explicit suspension precedes destination and original enablement comes last'
|
||||
Assert ($confirmed[0].Before[0].Machine.EnableTranscripting.Value -eq 1 -and $confirmed[0].After[0].Machine.EnableTranscripting.Value -eq 0) 'confirmed receipts retain distinct before/after step snapshots'
|
||||
Assert ($script:policy[0].Machine.OutputDirectory.Value -eq 'C:\Old' -and $script:policy[0].Machine.EnableInvocationHeader.Value -eq 0) 'original directory restored and header retained'
|
||||
Assert ($report.SigmaEvtxCredit -eq 0) 'recovery grants no EVTX credit'
|
||||
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'Current policy/destination differs'
|
||||
foreach($before in @(0,$null)) {
|
||||
New-Fixture $before;Plan-Fixture
|
||||
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
||||
Assert ($report.Status -eq 'Restored' -and $script:policy[0].Machine.EnableTranscripting.Value -eq $before) 'disabled or absent original enablement is recovered exactly'
|
||||
Assert ($script:writes -eq $(if($null -eq $before){3}else{2})) 'only required ordered steps are written'
|
||||
}
|
||||
New-Fixture 0 $null;Plan-Fixture
|
||||
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters
|
||||
Assert ($report.Status -eq 'Restored' -and -not $script:policy[0].Machine.OutputDirectory.ValueExists -and $script:policy[0].Machine.OutputDirectory.KeyExists) 'absent output value restored with key retained'
|
||||
New-Fixture 1 $null
|
||||
Reject {Plan-Fixture} 'absent output directory requires'
|
||||
New-Fixture 0 'C:\New';Plan-Fixture
|
||||
$preview=$script:restoreParameters.Clone();$preview.Remove('OutputPath')
|
||||
$report=Invoke-WelaTranscriptRecovery @preview -DryRun
|
||||
Assert ($report.Status -eq 'WouldRestore' -and $script:writes -eq 0 -and -not (Test-Path $script:restoreOutput)) 'preview is read-only'
|
||||
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters
|
||||
Assert ($report.Status -eq 'Restored' -and $script:writes -eq 1) 'unchanged destination restores enablement only'
|
||||
foreach($alter in @('wrong-host','failed','mismatch','type','duplicate','shared-view')) {
|
||||
New-Fixture
|
||||
switch($alter){
|
||||
'wrong-host' {$script:entry.ComputerName='other'}
|
||||
'failed' {$script:original.Results[0].Status='Failed'}
|
||||
'mismatch' {$script:original.Results[0].Desired=Copy-Value $script:original.Results[0].Desired;$script:original.Results[0].Desired.EnableTranscripting.Value=0}
|
||||
'type' {$script:entry.Before.Policy[0].Machine.EnableTranscripting=Typed '1' String;$script:entry.Before.Policy[1].Machine.EnableTranscripting=Typed '1' String}
|
||||
'duplicate' {$script:original.Results += $script:original.Results[0]}
|
||||
'shared-view' {$script:entry.Before.Policy[1].Machine.EnableTranscripting=Typed 0}
|
||||
}
|
||||
Save-History
|
||||
Reject {Plan-Fixture} 'history|Applied|differs|DWORD|shared|Shared'
|
||||
Assert ($script:writes -eq 0) 'unsupported or inconsistent source evidence never mutates'
|
||||
}
|
||||
foreach($alter in @('status','action','scope','id','kind','result-id','result-kind','version','exit','failed-count','skipped-count','hive','subkey','target-directory','desired-enable-type','desired-enable-value','desired-output-type','desired-output-value','header-intent','capability','view64','view32','before-enable-type','before-output-type')) {
|
||||
New-Fixture
|
||||
switch($alter){
|
||||
'status' {$script:original.Results[0].Status=$true}
|
||||
'action' {$script:original.Action=$true}
|
||||
'scope' {$script:original.Scope=$true}
|
||||
'id' {$script:entry.Id=$true;$script:original.Results[0].Id=$true}
|
||||
'kind' {$script:entry.Kind=$true;$script:original.Results[0].Kind=$true}
|
||||
'result-id' {$script:original.Results[0].Id=$true}
|
||||
'result-kind' {$script:original.Results[0].Kind=$true}
|
||||
'version' {$script:entry.Version=$true}
|
||||
'exit' {$script:original.ExitCode=$false}
|
||||
'failed-count' {$script:original.Failed=$false}
|
||||
'skipped-count' {$script:original.Skipped=$false}
|
||||
'hive' {$script:entry.Target.Hive=$true}
|
||||
'subkey' {$script:entry.Target.SubKey=$true}
|
||||
'target-directory' {$script:entry.Target.OutputDirectory=$true}
|
||||
'desired-enable-type' {$script:entry.Desired.EnableTranscripting.Type=$true}
|
||||
'desired-enable-value' {$script:entry.Desired.EnableTranscripting.Value=$true}
|
||||
'desired-output-type' {$script:entry.Desired.OutputDirectory.Type=$true}
|
||||
'desired-output-value' {$script:entry.Desired.OutputDirectory.Value=$true}
|
||||
'header-intent' {$script:entry.Desired.EnableInvocationHeader=$true}
|
||||
'capability' {$script:entry.Before.Capability.Status=$true}
|
||||
'view64' {$script:entry.Before.Policy[0].View=$true}
|
||||
'view32' {$script:entry.Before.Policy[1].View=$true}
|
||||
'before-enable-type' {foreach($view in $script:entry.Before.Policy){$view.Machine.EnableTranscripting.Type=$true}}
|
||||
'before-output-type' {foreach($view in $script:entry.Before.Policy){$view.Machine.OutputDirectory.Type=$true}}
|
||||
}
|
||||
Save-History
|
||||
Reject {Plan-Fixture} 'history|Applied|Unsupported|registry views|DWORD|REG_SZ'
|
||||
Assert ($script:writes -eq 0 -and -not (Test-Path (Join-Path $script:fixture 'plan'))) "Boolean $alter evidence is rejected before plan creation or mutation"
|
||||
}
|
||||
New-Fixture;$script:legacyJsonDate=$true;Plan-Fixture
|
||||
Assert ($script:planResult.Status -eq 'Planned' -and $script:writes -eq 0) 'explicit UTC DateTime from older PowerShell JSON readers remains valid history'
|
||||
Reject {ConvertTo-WelaArrivalUtc ([datetime]::SpecifyKind([datetime]::Now,[DateTimeKind]::Unspecified))} 'explicit UTC'
|
||||
foreach($alter in @('Kind','SchemaVersion')) {
|
||||
New-Fixture;Plan-Fixture
|
||||
$tampered=ConvertFrom-WelaRecoveryJson (Get-Content -LiteralPath $script:planPath -Raw);$tampered.$alter=$true
|
||||
Get-WelaRecoveryKey $tampered|Set-Content -LiteralPath $script:planPath -Encoding UTF8
|
||||
$script:restoreParameters.PlanHash=(Get-FileHash -LiteralPath $script:planPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'Unsupported transcription recovery plan'
|
||||
Assert ($script:writes -eq 0 -and -not (Test-Path $script:restoreOutput)) "Boolean reviewed plan $alter is rejected before output or mutation"
|
||||
}
|
||||
foreach($alter in @('source','host','policy','directory','protected','plan')) {
|
||||
New-Fixture;Plan-Fixture
|
||||
switch($alter){
|
||||
'source' {$script:code='changed'}
|
||||
'host' {$script:machine='changed'}
|
||||
'policy' {foreach($view in $script:policy){$view.Machine.EnableTranscripting=Typed 0}}
|
||||
'directory' {$script:acl='changed'}
|
||||
'protected' {$script:protected=@('changed')}
|
||||
'plan' {Add-Content -LiteralPath $script:planPath ' '}
|
||||
}
|
||||
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'differs|different'
|
||||
Assert ($script:writes -eq 0) 'drift before restore causes no mutation'
|
||||
}
|
||||
New-Fixture;Plan-Fixture;$script:promptDrift=$true;$script:restoreParameters.Auto=$false
|
||||
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
||||
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0) 'prompt-time typed policy drift blocks the first write'
|
||||
New-Fixture;Plan-Fixture;$script:failWrite=2
|
||||
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
||||
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 2 -and $script:policy[0].Machine.EnableTranscripting.Value -eq 0 -and $script:policy[0].Machine.OutputDirectory.Value -eq 'C:\New') 'partial failure stops and reports the actual suspended state'
|
||||
Assert ((Test-Path (Join-Path $script:restoreOutput '001-confirmed.json')) -and (Test-Path (Join-Path $script:restoreOutput '002-pending.json')) -and -not (Test-Path (Join-Path $script:restoreOutput '003-pending.json'))) 'partial receipts preserve confirmed versus uncertain steps'
|
||||
New-Fixture;Plan-Fixture;$script:driftWrite=1
|
||||
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
||||
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 1 -and $report.Diagnostic -match 'Preserved PowerShell policy changed') 'independent policy drift after a write stops all later writes'
|
||||
foreach($name in @('001-pending.json','001-confirmed.json')) {
|
||||
New-Fixture;Plan-Fixture;$script:failArtifact=$name
|
||||
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
||||
Assert ($report.ExitCode -eq 1 -and $script:writes -eq $(if($name -like '*pending*'){0}else{1})) 'durable receipt failure stops before any further native writes'
|
||||
}
|
||||
New-Fixture;Plan-Fixture;$script:failArtifact='result.json'
|
||||
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'durable artifact failure'
|
||||
Assert ($script:writes -eq 3 -and (Test-Path (Join-Path $script:restoreOutput '003-confirmed.json'))) 'result persistence failure fails outward while durable final confirmation remains'
|
||||
Reject {ConvertFrom-WelaRecoveryJson '{"x":1,"X":2}'} 'Duplicate'
|
||||
Reject {ConvertFrom-WelaRecoveryJson '{x:1}'} 'strict JSON'
|
||||
Write-Host "Passed $script:checks transcription recovery assertions; no Windows policy changes."
|
||||
} finally {Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
@@ -0,0 +1,116 @@
|
||||
param([switch]$AllowDisposablePolicyWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($env:OS -ne 'Windows_NT'){Write-Host 'Skipped: actual Windows transcription recovery requires Windows.';exit 0}
|
||||
if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'This native mutation fixture requires explicit consent on a disposable GitHub-hosted runner.'}
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
foreach($file in @('Configuration','AuditRecovery','PowerShellTranscription','TranscriptionRecovery')){. (Join-Path $script:ScriptRoot ('scripts/'+$file+'.ps1'))}
|
||||
$script:checks=0
|
||||
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
|
||||
$root=New-WelaRecoveryOutput (Join-Path $env:RUNNER_TEMP ('wela-transcription-recovery-'+[guid]::NewGuid().ToString('N')))
|
||||
$before=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))
|
||||
$protectedBefore=Get-WelaTranscriptRecoveryProtectedPolicy
|
||||
Write-WelaRecoveryArtifact (Join-Path $root 'original-policy.json') $before
|
||||
Write-WelaRecoveryArtifact (Join-Path $root 'original-protected-policy.json') $protectedBefore
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
|
||||
$policyRoot='SOFTWARE\Policies\Microsoft\Windows\PowerShell'
|
||||
$originalParents=@{}
|
||||
foreach($path in @($policyRoot,($policyRoot+'\Transcription'))){$key=$base.OpenSubKey($path);$originalParents[$path]=($null -ne $key);if($key){$key.Dispose()}}
|
||||
$base.Dispose()
|
||||
$hostExe=Join-Path $PSHOME $(if($PSVersionTable.PSEdition -eq 'Desktop'){'powershell.exe'}else{'pwsh.exe'})
|
||||
$native51=Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$restored=$false;$touched=$false
|
||||
function Set-FixtureValue([string]$Name,$Value,[string]$Type='DWord') {
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
|
||||
$key=$base.CreateSubKey($policyRoot+'\Transcription')
|
||||
try{if($null -eq $Value){$key.DeleteValue($Name,$false)}else{$key.SetValue($Name,$Value,[Microsoft.Win32.RegistryValueKind]$Type)};$key.Flush()}finally{$key.Dispose();$base.Dispose()}
|
||||
}
|
||||
function Invoke-WelaTranscriptFixtureCli {
|
||||
param([string[]]$Parameters,[string]$Log,[switch]$ExpectFailure)
|
||||
$global:LASTEXITCODE=$null
|
||||
$priorPreference=$ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference='Continue'
|
||||
& $hostExe -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $script:ScriptRoot 'WELA.ps1') @Parameters *> $Log
|
||||
$code=$global:LASTEXITCODE
|
||||
} finally {$ErrorActionPreference=$priorPreference}
|
||||
if($ExpectFailure){Assert ($null -ne $code -and $code -ne 0) 'native public CLI refuses unsupported or stale recovery'}
|
||||
elseif($code -ne 0){throw "Public CLI failed ($code): $(Get-Content $Log -Raw)"}
|
||||
$global:LASTEXITCODE=0
|
||||
}
|
||||
try {
|
||||
foreach($scenario in @('Enabled','DisabledAbsentDirectory','AbsentEnablement','Drift')) {
|
||||
$case=New-WelaRecoveryOutput (Join-Path $root $scenario)
|
||||
$old=New-WelaRecoveryOutput (Join-Path $case 'old-transcripts')
|
||||
$new=New-WelaRecoveryOutput (Join-Path $case 'new-transcripts')
|
||||
$touched=$true
|
||||
Set-FixtureValue EnableTranscripting 0
|
||||
Set-FixtureValue OutputDirectory $(if($scenario -eq 'DisabledAbsentDirectory'){$null}else{$old}) String
|
||||
Set-FixtureValue EnableTranscripting $(if($scenario -eq 'AbsentEnablement'){$null}elseif($scenario -eq 'DisabledAbsentDirectory'){0}else{1})
|
||||
$caseBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))
|
||||
$preserved=Get-WelaTranscriptRecoveryProtectedPolicy
|
||||
Write-WelaRecoveryArtifact (Join-Path $case 'fixture-before.json') $caseBefore
|
||||
$backup=Join-Path $case 'configure-backup';$original=Join-Path $case 'configure-result.json'
|
||||
Invoke-WelaTranscriptFixtureCli @('powershell-transcription','-TranscriptionAction','Configure','-TranscriptDirectory',$new,'-Auto','-BackupPath',$backup,'-ResultsPath',$original) (Join-Path $case 'configure.log')
|
||||
$configured=ConvertFrom-WelaRecoveryJson (Get-Content $original -Raw)
|
||||
Assert ($configured.Results.Count -eq 1 -and $configured.Results[0].Status -eq 'Applied') 'actual public Configure creates the exact completed composite history'
|
||||
$planDirectory=Join-Path $case 'plan';$planPath=Join-Path $planDirectory 'plan.json'
|
||||
Invoke-WelaTranscriptFixtureCli @('transcription-recovery','-TranscriptRecoveryJournalPath',(Join-Path $backup 'before.jsonl'),'-TranscriptRecoveryOriginalResultsPath',$original,'-TranscriptRecoveryOutputPath',$planDirectory) (Join-Path $case 'plan.log')
|
||||
$plan=ConvertFrom-WelaRecoveryJson (Get-Content $planPath -Raw)
|
||||
$planHash=(Get-FileHash $planPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Assert ($plan.Context.Reader.UserSid -and $plan.Sources.'scripts/TranscriptionRecovery.ps1' -and $plan.SigmaEvtxCredit -eq 0) 'native plan binds reader/code and grants no EVTX credit'
|
||||
$restoreDirectory=Join-Path $case 'restore'
|
||||
$restoreArguments=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-TranscriptRecoveryPlanPath',$planPath,'-TranscriptRecoveryPlanHash',$planHash,'-TranscriptRecoveryOutputPath',$restoreDirectory,'-Auto')
|
||||
if($scenario -eq 'Drift') {
|
||||
Set-FixtureValue EnableTranscripting 0
|
||||
Invoke-WelaTranscriptFixtureCli ($restoreArguments+@('-TranscriptRecoveryAllowTemporarySuspension')) (Join-Path $case 'drift-refusal.log') -ExpectFailure
|
||||
Assert (-not (Test-Path $restoreDirectory) -and (Get-WelaTranscriptRegistryValue -Name EnableTranscripting).Value -eq 0) 'actual changed native policy is preserved before any output/write'
|
||||
continue
|
||||
}
|
||||
if($plan.RequiresTemporarySuspension) {
|
||||
Invoke-WelaTranscriptFixtureCli $restoreArguments (Join-Path $case 'consent-refusal.log') -ExpectFailure
|
||||
Assert (-not (Test-Path $restoreDirectory) -and (Get-WelaTranscriptRegistryValue -Name EnableTranscripting).Value -eq 1) 'no suspension consent preserves the enabled policy'
|
||||
$restoreArguments += '-TranscriptRecoveryAllowTemporarySuspension'
|
||||
}
|
||||
$previewArguments=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-TranscriptRecoveryPlanPath',$planPath,'-TranscriptRecoveryPlanHash',$planHash,'-DryRun')
|
||||
if($plan.RequiresTemporarySuspension){$previewArguments += '-TranscriptRecoveryAllowTemporarySuspension'}
|
||||
Invoke-WelaTranscriptFixtureCli $previewArguments (Join-Path $case 'preview.log')
|
||||
Assert ((Get-WelaRecoveryKey @(Get-WelaTranscriptPolicy @('Registry64','Registry32'))) -ceq (Get-WelaRecoveryKey $plan.ExpectedPolicy)) 'actual public preview leaves both native registry views unchanged'
|
||||
Invoke-WelaTranscriptFixtureCli $restoreArguments (Join-Path $case 'restore.log')
|
||||
$report=ConvertFrom-WelaRecoveryJson (Get-Content (Join-Path $restoreDirectory 'result.json') -Raw)
|
||||
Assert ($report.Status -eq 'Restored' -and $report.ExitCode -eq 0) 'actual public Restore completes'
|
||||
Assert ((Get-WelaRecoveryKey @(Get-WelaTranscriptPolicy @('Registry64','Registry32'))) -ceq (Get-WelaRecoveryKey $caseBefore)) 'native restore matches original typed policy including value absence in both views'
|
||||
Assert ((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -ceq (Get-WelaRecoveryKey $preserved)) 'all other machine/user PowerShell policy remains exact'
|
||||
$pending=@(Get-ChildItem $restoreDirectory '*-pending.json');$confirmed=@(Get-ChildItem $restoreDirectory '*-confirmed.json')
|
||||
Assert ($pending.Count -eq $plan.Steps.Count -and $confirmed.Count -eq $plan.Steps.Count) 'every actual native write has separate durable pending and confirmed receipts'
|
||||
if($scenario -eq 'Enabled') {
|
||||
$marker='WELA_RECOVERED_TRANSCRIPT_'+[guid]::NewGuid().ToString('N')
|
||||
& $native51 -NoLogo -NoProfile -Command "Write-Output '$marker'" *> (Join-Path $case 'benign-session.log')
|
||||
Assert ($LASTEXITCODE -eq 0) 'fresh built-in Windows PowerShell session completes after recovery'
|
||||
$matching=@(Get-ChildItem -LiteralPath $old -Recurse -File -Filter '*.txt'|Where-Object {(Get-Content $_.FullName -Raw).Contains($marker)})
|
||||
Assert ($matching.Count -eq 1) 'one real fresh Windows PowerShell transcript contains the benign marker at the restored destination'
|
||||
Write-WelaRecoveryArtifact (Join-Path $case 'transcript-marker.json') ([pscustomobject]@{Marker=$marker;Path=$matching[0].FullName;Sha256=(Get-FileHash $matching[0].FullName).Hash;Scope='Disposable local fixture only; no production/central assertion'})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if($touched) {
|
||||
Set-FixtureValue EnableTranscripting 0
|
||||
foreach($name in @('OutputDirectory','EnableInvocationHeader','EnableTranscripting')) {
|
||||
$value=$before[0].Machine.$name
|
||||
Set-FixtureValue $name $(if($value.ValueExists){$value.Value}else{$null}) $(if($value.ValueExists){$value.Type}else{'DWord'})
|
||||
}
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
foreach($path in @(($policyRoot+'\Transcription'),$policyRoot)) {
|
||||
if($originalParents[$path]){continue}
|
||||
$key=$base.OpenSubKey($path)
|
||||
$empty=$null -ne $key -and $key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0
|
||||
if($key){$key.Dispose()};if($empty){$base.DeleteSubKey($path,$false)}
|
||||
}
|
||||
} finally {$base.Dispose()}
|
||||
}
|
||||
$after=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))
|
||||
$restored=(Get-WelaRecoveryKey $after) -ceq (Get-WelaRecoveryKey $before) -and (Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -ceq (Get-WelaRecoveryKey $protectedBefore)
|
||||
Write-WelaRecoveryArtifact (Join-Path $root 'cleanup.json') ([pscustomobject]@{CleanupVerified=$restored;Checks=$script:checks;Engine=$PSVersionTable.PSVersion.ToString();Computer=$env:COMPUTERNAME;After=$after})
|
||||
if(-not $restored){throw "Exact native policy cleanup failed; retained private evidence at $root"}
|
||||
}
|
||||
Write-Host "Passed $script:checks actual native transcription recovery assertions; exact policy cleanup verified. Evidence: $root"
|
||||
@@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-auth-cli-'+[guid]::NewGuid().ToString('N'))
|
||||
$cases=@(
|
||||
@{Args=@('wec-authorization','-Help');Code=0;Pattern='already disabled'},
|
||||
@{Args=@('configure','-WecAuthorizationAction','Apply','-Auto');Code=1;Pattern='require wec-authorization'},
|
||||
@{Args=@('wec-authorization','-Auto');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-DryRun');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-WhatIf');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-Typo');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-Help','-WecStateDesired','Enabled');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-ResultsPath',$root);Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-WecAuthorizationOutputPath',$root);Code=1;Pattern='Plan requires'},
|
||||
@{Args=@('wec-authorization','-WecAuthorizationId','test','-WecAuthorizationSourceSid','S-1-5-21-1-2-3-4','-WecAuthorizationPlanPath','missing','-WecAuthorizationOutputPath',$root);Code=1;Pattern='Plan requires'},
|
||||
@{Args=@('wec-authorization','-WecAuthorizationAction','Apply','-WecAuthorizationOutputPath',$root);Code=1;Pattern='reviewed plan'},
|
||||
@{Args=@('wec-authorization','-WecAuthorizationAction','Apply','-WecAuthorizationPlanPath','missing','-WecAuthorizationPlanHash',('a'*64),'-WecAuthorizationId','test','-WecAuthorizationOutputPath',$root);Code=1;Pattern='only'}
|
||||
)
|
||||
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 unexpectedly created output.'}
|
||||
Write-Host "PASS: $count WEC authorization public CLI guards."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,108 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/WecAuthorization.ps1"
|
||||
Initialize-WelaWecAuthorizationNative
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action,[string]$Pattern='.'){$m='';try{&$Action|Out-Null}catch{$m=$_.Exception.Message};Assert ($m -match $Pattern) "Expected $Pattern; got $m; value=$bad; action=$Action"}
|
||||
function Copy-Auth($Value){Get-WelaWecAuthorizationKey $Value|ConvertFrom-Json}
|
||||
$sidA='S-1-5-21-11-22-33-1001';$sidB='S-1-5-21-11-22-33-1002';$id='WELA Native Security Example'
|
||||
$authA=Get-WelaWefAuthorization @($sidA);$authB=Get-WelaWefAuthorization @($sidB);$authAB=Get-WelaWefAuthorization @($sidA,$sidB)
|
||||
$base=[IO.File]::ReadAllText("$repo/config/wef-examples/native-security.xml").Replace('<Enabled>true</Enabled>','<Enabled>false</Enabled>').Replace('<AllowedSourceDomainComputers></AllowedSourceDomainComputers>',('<AllowedSourceDomainComputers>'+$authA+'</AllowedSourceDomainComputers>'))
|
||||
foreach($bad in @('S-1-1-0','S-1-5-20','s-1-5-21-11-22-33-1001','S-1-5-21-011-22-33-1001','S-1-5-21-4294967296-22-33-1001','S-1-5-21-11-22-33','S-1-5-21-11-22-33-1001 ',1,$true,$null)){Reject {Get-WelaWecAuthorizationSids @($bad)}}
|
||||
Reject {Get-WelaWecAuthorizationSids @()};Reject {Get-WelaWecAuthorizationSids @($sidA,$sidA)} 'Duplicate';Reject {Get-WelaWecAuthorizationSids @($sidA*33)}
|
||||
Assert ((Get-WelaWecAuthorizationKey @(Get-WelaWecAuthorizationSids @($sidB,$sidA))) -ceq (Get-WelaWecAuthorizationKey @($sidA,$sidB))) 'SID order is canonical'
|
||||
foreach($good in @($authA,$authAB)){[Wela.WecAuthorization.Edit]::ValidateAuthorization($good);$count++}
|
||||
foreach($bad in @('',$authA.Replace('GA','GR'),$authA.Replace('NSG:NS','SYG:SY'),($authA+$authA),$authA.Replace('11-22','011-22'),$authA.Replace('11-22','4294967296-22'),$authAB.Replace($sidB,$sidA),('O:NSG:NSD:(A;;GA;;;'+$sidB+')(A;;GA;;;'+$sidA+')'),($authA+'S:(AU;SA;GA;;;WD)'))){Reject {[Wela.WecAuthorization.Edit]::ValidateAuthorization($bad)}}
|
||||
Assert ([Wela.WecAuthorization.Edit]::SourceSha256 -ceq (Get-WelaArrivalHash ([IO.File]::ReadAllBytes("$repo/scripts/WecAuthorizationNative.cs")))) 'Compiled native helper binds the exact source bytes'
|
||||
# Allocated EC_VARIANT buffers exercise the WEC ABI rather than EVT_VARIANT values.
|
||||
$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(64)
|
||||
try {
|
||||
for($i=0;$i -lt 64;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)}
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,2)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,27) -eq [uint32]0) 'EcVarTypeUInt32 is 2 and source-initiated value is zero'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,1)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,27) -eq [uint32]1) 'Collector-initiated scalar remains distinguishable'
|
||||
foreach($type in @(0,1,4,8,130)){[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,$type);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,27)} 'UInt32'}
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,1)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,0) -eq $true) 'Native scalar Boolean decodes true'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,0)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,0) -eq $false) 'Native scalar Boolean decodes false'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,2);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,0)} 'Boolean'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,4)
|
||||
[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,16));[Runtime.InteropServices.Marshal]::WriteInt16($buffer,16,65)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,20,31) -ceq 'A') 'String data is decoded within returned bounds'
|
||||
Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,18,31)} 'Unterminated'
|
||||
[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,64));Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,20,31)} 'outside'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,132);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,20,31)} 'scalar'
|
||||
Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,15,31)} 'buffer';Reject {[Wela.WecAuthorization.Edit]::Decode([IntPtr]::Zero,16,31)} 'buffer'
|
||||
Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,1)} 'selection'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,0)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,6) -ceq '') 'Absent description normalizes to empty'
|
||||
Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,31)} 'scalar'
|
||||
}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)}
|
||||
$before=Get-WelaWecAuthorizationDefinition $base;$after=Get-WelaWecAuthorizationDefinition ($base.Replace($authA,$authAB))
|
||||
Assert ($before.SourceSids.Count -eq 1 -and $after.SourceSids.Count -eq 2 -and $before.PreservedKey -ceq $after.PreservedKey -and $before.WholeKey -cne $after.WholeKey) 'Only the explicit allow list is excluded from preserved XML'
|
||||
foreach($bad in @($base.Replace('SourceInitiated','CollectorInitiated'),$base.Replace('>false</Enabled>','>true</Enabled>'),$base.Replace($authA,'D:(A;;GA;;;WD)'),$base.Replace($authA,''),$base.Replace($authA,$authAB.Replace($sidB,$sidA)),$base.Replace('Path="Security"','Path="Microsoft-Windows-Sysmon/Operational"'),$base.Replace('</Subscription>','<AllowedSourceDomainComputers>bad</AllowedSourceDomainComputers></Subscription>'))){Reject {Get-WelaWecAuthorizationDefinition $bad}}
|
||||
$reader=[pscustomobject][ordered]@{ProcessId=10;TokenId='1';ModifiedId='2';UserSid=$sidA;AuthenticationId='3';ElevatedAdministrator=$true;GroupSids=@('S-1-5-32-544')}
|
||||
$context=[pscustomobject][ordered]@{Host='TEST';Reader=$reader;Services='Running';Destination='unchanged'}
|
||||
$copy=Copy-Auth $context;$copy.Reader.ProcessId=11;$copy.Reader.TokenId='4';$copy.Reader.ModifiedId='5';Assert ((Get-WelaWecAuthorizationReviewKey $copy) -ceq (Get-WelaWecAuthorizationReviewKey $context)) 'Separate same-logon CLI processes can use a reviewed plan'
|
||||
$copy.Reader.AuthenticationId='6';Assert ((Get-WelaWecAuthorizationReviewKey $copy) -cne (Get-WelaWecAuthorizationReviewKey $context)) 'Different logon is not accepted'
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-auth-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$script:xml=$base;$script:mode='ok';$script:reads=0;$script:contextReads=0;$script:saves=0;$script:pending=''
|
||||
function Get-WelaWecAuthorizationContext {$script:contextReads++;$v=Copy-Auth $context;if($script:mode -eq 'token-drift' -and $script:contextReads -gt 1){$v.Reader.ModifiedId='drift'};$v}
|
||||
function Read-WelaWecAuthorizationDefinition {param($Id);$script:reads++;if($script:mode -eq 'drift' -and $script:reads -eq 2){$script:xml=$script:xml.Replace('MinLatency','Normal')};if($script:mode -eq 'denied'){throw 'Native access denied'};Get-WelaWecAuthorizationDefinition $script:xml}
|
||||
function Read-WelaWecSubscriptionXml {param($Id);$script:xml}
|
||||
function New-WelaWecAuthorizationEdit {
|
||||
param($Before)
|
||||
$edit=[pscustomobject]@{SaveAttempted=$false}
|
||||
$edit|Add-Member ScriptMethod Save {param($Authorization)
|
||||
Assert (Test-Path $script:pending) 'Pending artifact exists before native call'
|
||||
$pending=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($script:pending));Assert ($pending.Status -ceq 'Pending' -and $pending.DesiredAuthorization -ceq $Authorization) 'Durable intent states exact allow list'
|
||||
if($script:mode -eq 'native-refusal'){throw 'Native current view differs'}
|
||||
$this.SaveAttempted=$true;$script:saves++
|
||||
if($script:mode -eq 'native-error'){throw 'Native save failed'}
|
||||
if($script:mode -eq 'false-success'){return}
|
||||
$doc=Read-WelaWefXml $script:xml;$doc.Subscription.AllowedSourceDomainComputers=$Authorization
|
||||
if($script:mode -eq 'preservation'){$doc.Subscription.ReadExistingEvents='true'}
|
||||
if($script:mode -eq 'unexpected-enabled'){$doc.Subscription.Enabled='true'}
|
||||
$script:xml=$doc.OuterXml
|
||||
if($script:mode -eq 'artifact-drift'){[IO.File]::AppendAllText($script:pending,' ')}
|
||||
}
|
||||
$edit|Add-Member ScriptMethod Dispose {if($script:mode -eq 'cleanup-error'){throw 'Handle cleanup failed'}}
|
||||
$edit
|
||||
}
|
||||
try {
|
||||
Reject {Invoke-WelaWecAuthorization -Id $id -SourceSids @($sidA) -PlanHash ('a'*64) -OutputPath (Join-Path $root 'bad')} 'Plan requires'
|
||||
Reject {Invoke-WelaWecAuthorization Apply -PlanPath missing -PlanHash ('a'*64) -Id '' -OutputPath (Join-Path $root 'bad')} 'only'
|
||||
Reject {Invoke-WelaWecAuthorization -Id $id -SourceSids @($sidA) -WhatIf -OutputPath (Join-Path $root 'bad')} 'Unknown'
|
||||
foreach($scenario in @('ok','no-op','hash','schema','kind','duplicate-json','context','source','stale','enabled','denied','drift','token-drift','native-refusal','native-error','false-success','preservation','unexpected-enabled','artifact-drift','cleanup-error')){
|
||||
$script:mode='ok';$script:xml=$base;$script:reads=0;$script:contextReads=0;$script:saves=0
|
||||
$desired=if($scenario -eq 'no-op'){@($sidA)}else{@($sidA,$sidB)}
|
||||
$planned=Invoke-WelaWecAuthorization -Id $id -SourceSids $desired -OutputPath (Join-Path $root ($scenario+'-plan'))
|
||||
Assert ($planned.Status -ceq 'ReviewRequired' -and $planned.ExitCode -eq 0 -and -not $planned.NativeSaveAttempted -and $script:saves -eq 0) "Plan: $($planned.Diagnostic)"
|
||||
$path=Join-Path $planned.OutputPath 'plan.json';$hash=$planned.PlanHash
|
||||
if($scenario -eq 'hash'){$hash='f'*64}
|
||||
if($scenario -in @('schema','kind','duplicate-json','context','source')){
|
||||
$text=[IO.File]::ReadAllText($path)
|
||||
switch($scenario){schema{$text=$text -replace '"SchemaVersion"\s*:\s*1','"SchemaVersion":true'}kind{$text=$text -replace '"Kind"\s*:\s*"WelaWecAuthorizationPlan"','"Kind":true'}duplicate-json{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')}context{$text=$text.Replace('TEST','OTHER')}source{$text=$text.Replace('scripts/WecAuthorization.ps1','scripts/other.ps1')}}
|
||||
[IO.File]::WriteAllText($path,$text);$hash=(Get-FileHash $path).Hash.ToLowerInvariant()
|
||||
}
|
||||
if($scenario -eq 'stale'){$script:xml=$base.Replace('MinLatency','Normal')};if($scenario -eq 'enabled'){$script:xml=$base.Replace('>false</Enabled>','>true</Enabled>')}
|
||||
$script:mode=$scenario;$script:reads=0;$script:contextReads=0;$out=Join-Path $root ($scenario+'-apply');$script:pending=Join-Path $out 'before-save.json'
|
||||
$applied=Invoke-WelaWecAuthorization Apply -PlanPath $path -PlanHash $hash -OutputPath $out
|
||||
Assert (($applied.ExitCode -eq 0) -eq ($scenario -in @('ok','no-op'))) "Scenario $scenario : $($applied.Diagnostic)"
|
||||
Assert ($applied.ReadyRuleCredit -eq 0 -and (Test-Path (Join-Path $out 'manifest.json'))) 'No readiness credit and final result retained'
|
||||
if($scenario -in @('native-error','false-success','preservation','unexpected-enabled','artifact-drift','cleanup-error')){Assert ($applied.NativeSaveAttempted -and $applied.Status -ceq 'SaveAttemptedUnverified') 'Possible persistent change remains unverified'}elseif($scenario -notin @('ok','no-op')){Assert (-not $applied.NativeSaveAttempted -and $script:saves -eq 0 -and $applied.Status -ceq 'Refused') 'Rejected before native save'}
|
||||
if($scenario -eq 'no-op'){Assert ($applied.Status -ceq 'AlreadyMatches' -and -not $applied.NativeSaveAttempted -and $script:saves -eq 0) 'No-op never saves'}
|
||||
if($scenario -eq 'ok'){
|
||||
Assert ($applied.Status -ceq 'AuthorizationChangedAndVerified' -and $applied.NativeSaveAttempted -and $applied.After.PreservedKey -ceq $before.PreservedKey) 'Successful update changes only explicit authorization'
|
||||
$again=Invoke-WelaWecAuthorization Apply -PlanPath $path -PlanHash $hash -OutputPath (Join-Path $root 'replay');Assert ($again.Status -ceq 'Refused' -and -not $again.NativeSaveAttempted -and $script:saves -eq 1) 'Changed pre-state refuses stale plan'
|
||||
}
|
||||
if($scenario -ne 'artifact-drift'){foreach($artifact in $applied.Artifacts){Assert ((Get-FileHash (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Retained artifact hash matches bytes'}}
|
||||
}
|
||||
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
Write-Host "PASS: $count focused WEC authorization assertions; no native delivery proof."
|
||||
@@ -0,0 +1,107 @@
|
||||
param([switch]$AllowDisposableSubscription)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableSubscription -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows subscription opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/ChannelRead.ps1"
|
||||
. "$repo/scripts/WecAuthorization.ps1"
|
||||
$count=0;$engine=(Get-Process -Id $PID).Path
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Key($Value){Get-WelaWecAuthorizationKey $Value}
|
||||
function Services {@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog' OR Name='WinRM'"|Sort-Object Name|Select-Object Name,State,StartMode)}
|
||||
function Channel {$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{[pscustomobject]@{Name=$c.LogName;Enabled=$c.IsEnabled;Mode=[string]$c.LogMode;MaximumBytes=$c.MaximumSizeInBytes;Path=$c.LogFilePath;SecurityDescriptor=$c.SecurityDescriptor}}finally{$c.Dispose()}}
|
||||
function EnableChannel([bool]$Value){$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{$c.IsEnabled=$Value;$c.SaveChanges()}finally{$c.Dispose()}}
|
||||
Add-Type -Path (Join-Path $PSScriptRoot 'WecAuthorizationFixtureNative.cs')
|
||||
function Ids {[Wela.WecAuthorizationFixture.Inventory]::Read()|Sort-Object}
|
||||
function Inventory {$ids=@(Ids);Save 'last-observed-ids.json' $ids;@($ids|ForEach-Object {[pscustomobject]@{Id=$_;Xml=Read-WelaWecSubscriptionXml $_}})}
|
||||
$nonce=[guid]::NewGuid().ToString('N');$id='WELA-Authorization-'+$nonce;$description='Owned authorization '+$nonce+' '+[char]0x65e5+[char]0x672c
|
||||
$sidA='S-1-5-21-111111111-222222222-333333333-1234';$sidB='S-1-5-21-111111111-222222222-333333333-1235'
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-wec-authorization-'+$nonce);$null=New-Item -ItemType Directory $root
|
||||
function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
|
||||
# Native -File argument binding cannot portably carry a string[] on both engines.
|
||||
# This fixture wrapper supplies the selected array to the actual public script.
|
||||
$wrapper=Join-Path $root 'invoke-public.ps1'
|
||||
@'
|
||||
param([string]$WelaPath,[string]$Action,[string]$Id,[string]$Sids,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath)
|
||||
$ErrorActionPreference='Stop'
|
||||
$p=@{Cmd='wec-authorization';WecAuthorizationAction=$Action;WecAuthorizationOutputPath=$OutputPath}
|
||||
if($PSBoundParameters.ContainsKey('Id')){$p.WecAuthorizationId=$Id}
|
||||
if($PSBoundParameters.ContainsKey('Sids')){$p.WecAuthorizationSourceSid=@($Sids.Split(';'))}
|
||||
if($PSBoundParameters.ContainsKey('PlanPath')){$p.WecAuthorizationPlanPath=$PlanPath}
|
||||
if($PSBoundParameters.ContainsKey('PlanHash')){$p.WecAuthorizationPlanHash=$PlanHash}
|
||||
$global:LASTEXITCODE=0
|
||||
& $WelaPath @p
|
||||
exit $LASTEXITCODE
|
||||
'@|Set-Content -LiteralPath $wrapper -Encoding UTF8
|
||||
function Public([string[]]$Arguments,[string]$Output,[bool]$Success=$true){
|
||||
$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File $wrapper -WelaPath "$repo/WELA.ps1" @Arguments -OutputPath $Output 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
if(($code -eq 0) -ne $Success){Write-Host ($text -join "`n");Get-ChildItem $root -Filter manifest.json -Recurse|ForEach-Object {Write-Host (Get-Content $_.FullName -Raw)};throw "Public command returned $code"};$script:count++
|
||||
$result=Get-Content -LiteralPath (Join-Path $Output 'manifest.json') -Raw|ConvertFrom-Json
|
||||
foreach($a in $result.Artifacts){Assert ((Get-FileHash (Join-Path $Output $a.Name)).Hash.ToLowerInvariant() -ceq $a.Sha256) 'Public evidence hash matches actual bytes'}
|
||||
$result
|
||||
}
|
||||
$beforeServices=Services;$beforeChannel=Channel;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart
|
||||
$original=$null;$created=$false;$failure=$null;$cleanupErrors=@();$inventoryOk=$false;$servicesOk=$false;$channelOk=$false;$endServices=$null;$endChannel=$null;$endDelayed=$null
|
||||
Save 'before-fixture.json' @{Services=$beforeServices;Channel=$beforeChannel;DelayedAutoStart=$beforeDelayed}
|
||||
try {
|
||||
$wec=@($beforeServices|Where-Object Name -eq Wecsvc);Assert ($wec.Count -eq 1 -and $wec[0].State -in @('Running','Stopped') -and $wec[0].StartMode -in @('Auto','Manual','Disabled')) 'Stable original service state required'
|
||||
if($wec[0].StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual};if($wec[0].State -eq 'Stopped'){Start-Service Wecsvc}
|
||||
if(-not $beforeChannel.Enabled){EnableChannel $true}
|
||||
Save 'original-console-enumeration.json' (Invoke-WelaNative 'wecutil.exe' @('es'))
|
||||
$original=@(Inventory);Save 'original-inventory.json' $original;Assert (@(Ids) -notcontains $id) 'Unique owned subscription is initially absent'
|
||||
$query='<QueryList><Query Id="0" Path="Application"><Select Path="Application">*[System[(EventID=1)]]</Select></Query></QueryList>'
|
||||
$xml=@"
|
||||
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription"><SubscriptionId>$id</SubscriptionId><SubscriptionType>SourceInitiated</SubscriptionType><Description>$description</Description><Enabled>false</Enabled><Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri><ConfigurationMode>Normal</ConfigurationMode><Query><![CDATA[$query]]></Query><ReadExistingEvents>false</ReadExistingEvents><TransportName>HTTP</TransportName><ContentFormat>Events</ContentFormat><Locale Language="en-US"/><LogFile>ForwardedEvents</LogFile><AllowedSourceDomainComputers>$(Get-WelaWefAuthorization @($sidA))</AllowedSourceDomainComputers></Subscription>
|
||||
"@
|
||||
$path=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false));$created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$path)
|
||||
$before=Read-WelaWecAuthorizationDefinition $id;[IO.File]::WriteAllText((Join-Path $root 'original.xml'),$before.Xml,[Text.UTF8Encoding]::new($false));$duringServices=Services;$duringChannel=Channel
|
||||
Initialize-WelaWecAuthorizationNative
|
||||
$missing=$false;try{$e=[Wela.WecAuthorization.Edit]::new($id+'-missing');$e.Dispose()}catch{$missing=$true};Assert ($missing -and @(Ids) -notcontains ($id+'-missing')) 'Native existing-only handle never creates a missing ID'
|
||||
$index=0
|
||||
foreach($desired in @(@($sidA),@($sidA,$sidB),@($sidA,$sidB),@($sidB),@($sidA))){
|
||||
$index++;$prior=Read-WelaWecAuthorizationDefinition $id;$changed=$prior.Authorization -cne (Get-WelaWefAuthorization $desired)
|
||||
$plan=Public @('-Action','Plan','-Id',$id,'-Sids',($desired -join ';')) (Join-Path $root "plan-$index")
|
||||
Assert ($plan.Status -ceq 'ReviewRequired' -and -not $plan.NativeSaveAttempted -and (Read-WelaWecAuthorizationDefinition $id).WholeKey -ceq $prior.WholeKey) 'Actual public Plan preserved original native definition'
|
||||
$planPath=Join-Path $plan.OutputPath 'plan.json'
|
||||
if($index -eq 2){$bad=Public @('-Action','Apply','-PlanPath',$planPath,'-PlanHash',('f'*64)) (Join-Path $root 'bad-hash') $false;Assert ($bad.Status -ceq 'Refused' -and -not $bad.NativeSaveAttempted) 'Wrong hash refuses before native save'}
|
||||
$apply=Public @('-Action','Apply','-PlanPath',$planPath,'-PlanHash',$plan.PlanHash) (Join-Path $root "apply-$index")
|
||||
Assert ($apply.NativeSaveAttempted -eq $changed -and $apply.Status -ceq $(if($changed){'AuthorizationChangedAndVerified'}else{'AlreadyMatches'})) 'Only a changed allow list saves'
|
||||
$after=Read-WelaWecAuthorizationDefinition $id
|
||||
Assert ($after.Authorization -ceq (Get-WelaWefAuthorization $desired) -and $after.PreservedKey -ceq $before.PreservedKey -and $apply.ReadyRuleCredit -eq 0) 'Actual disabled definition changes only selected authorization; no readiness credit'
|
||||
if($index -eq 2){$stale=Public @('-Action','Apply','-PlanPath',$planPath,'-PlanHash',$plan.PlanHash) (Join-Path $root 'stale') $false;Assert ($stale.Status -ceq 'Refused' -and -not $stale.NativeSaveAttempted) 'Stale pre-state plan refuses replay'}
|
||||
}
|
||||
Assert ((Read-WelaWecAuthorizationDefinition $id).WholeKey -ceq $before.WholeKey) 'Fresh public plan restored complete original subscription'
|
||||
# Direct native fresh-handle guard checks a concurrently changed description.
|
||||
$edit=New-WelaWecAuthorizationEdit $before
|
||||
try{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+($description+' drift')));$refused=$false;try{$edit.Save((Get-WelaWefAuthorization @($sidB)))}catch{$refused=$true};Assert ($refused -and -not $edit.SaveAttempted) 'Native guard refuses a changed current definition before save'}finally{$edit.Dispose();$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+$description))}
|
||||
try{
|
||||
$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,'/e:true')
|
||||
$enabled=Public @('-Action','Plan','-Id',$id,'-Sids',$sidB) (Join-Path $root 'enabled-refusal') $false
|
||||
Assert ($enabled.Status -ceq 'Refused' -and -not $enabled.NativeSaveAttempted) 'Public command refuses actual enabled subscription'
|
||||
}finally{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,'/e:false')}
|
||||
Assert ((Read-WelaWecAuthorizationDefinition $id).WholeKey -ceq $before.WholeKey) 'Fixture drift and enabled-state probes restored full original XML'
|
||||
Assert ((Key (Services)) -ceq (Key $duringServices) -and (Key (Channel)) -ceq (Key $duringChannel)) 'Product preserves services and complete channel configuration'
|
||||
[IO.File]::WriteAllText((Join-Path $root 'restored-owned.xml'),(Read-WelaWecSubscriptionXml $id),[Text.UTF8Encoding]::new($false))
|
||||
Write-Host "PASS: $count actual WEC authorization assertions on $($PSVersionTable.PSVersion). No real source or forwarding proof."
|
||||
}catch{$failure=$_.ToString();Write-Host $failure}finally{
|
||||
try{
|
||||
if($created -and @(Ids) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;if($doc.Subscription.Description -cne $description -and $doc.Subscription.Description -cne ($description+' drift')){throw 'Fixture ownership differs; do not delete subscription.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)}
|
||||
$restored=@(Inventory);Save 'restored-inventory.json' $restored;$inventoryOk=$null -ne $original -and (Key $restored) -ceq (Key $original)
|
||||
}catch{$cleanupErrors+=$_.ToString()}
|
||||
try{if((Channel).Enabled -ne $beforeChannel.Enabled){EnableChannel $beforeChannel.Enabled};$endChannel=Channel;$channelOk=(Key $endChannel) -ceq (Key $beforeChannel)}catch{$cleanupErrors+=$_.ToString()}
|
||||
try{
|
||||
$wec=@($beforeServices|Where-Object Name -eq Wecsvc)[0]
|
||||
if($wec.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc}
|
||||
if($wec.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled}
|
||||
if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}}
|
||||
$endServices=Services;$endDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart;$servicesOk=(Key $endServices) -ceq (Key $beforeServices) -and (Key $endDelayed) -ceq (Key $beforeDelayed)
|
||||
}catch{$cleanupErrors+=$_.ToString()}
|
||||
Save 'after-fixture.json' @{Services=$endServices;Channel=$endChannel;DelayedAutoStart=$endDelayed}
|
||||
Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$cleanupErrors;SubscriptionsRestored=$inventoryOk;ServicesRestored=$servicesOk;ChannelRestored=$channelOk;Complete=($inventoryOk -and $servicesOk -and $channelOk -and -not $cleanupErrors.Count);Assertions=$count;Computer=[Environment]::MachineName;Engine=$PSVersionTable.PSVersion.ToString();Scope='Owned disabled subscription authorization only; inert SIDs are not resolved or authenticated.'}
|
||||
}
|
||||
if($failure -or -not $inventoryOk -or -not $servicesOk -or -not $channelOk -or $cleanupErrors.Count){throw "Native authorization or fixture cleanup failed; inspect $root"}
|
||||
exit 0
|
||||
@@ -0,0 +1,25 @@
|
||||
// Read-only disposable-fixture inventory; bypasses console/native text decoding.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
namespace Wela.WecAuthorizationFixture {
|
||||
public static class Inventory {
|
||||
[DllImport("wecapi.dll",SetLastError=true)] static extern IntPtr EcOpenSubscriptionEnum(uint flags);
|
||||
[DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcEnumNextSubscription(IntPtr enumeration,uint size,StringBuilder name,out uint used);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle);
|
||||
public static string[] Read() {
|
||||
IntPtr handle=EcOpenSubscriptionEnum(0);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try {
|
||||
var names=new List<string>();
|
||||
while(true) {
|
||||
var name=new StringBuilder(4096);uint used;
|
||||
if(!EcEnumNextSubscription(handle,4096,name,out used)) {int error=Marshal.GetLastWin32Error();if(error==259)return names.ToArray();throw new Win32Exception(error);}
|
||||
if(used<2||used>4096||name.Length==0||name.Length+1!=used||names.Count>=64||names.Contains(name.ToString()))throw new InvalidOperationException("Disposable native subscription inventory is invalid, duplicated or exceeds its bound.");
|
||||
names.Add(name.ToString());
|
||||
}
|
||||
}finally {EcClose(handle);}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
param([switch]$AllowDisposableSubscription)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableSubscription -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows subscription opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/ChannelRead.ps1"
|
||||
$count=0;$engine=(Get-Process -Id $PID).Path
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
|
||||
function Services {@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog' OR Name='WinRM'"|Sort-Object Name|Select-Object Name,State,StartMode)}
|
||||
function Channel {$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{[pscustomobject]@{Name=$c.LogName;Enabled=$c.IsEnabled;Mode=[string]$c.LogMode;MaximumBytes=$c.MaximumSizeInBytes;Path=$c.LogFilePath;SecurityDescriptor=$c.SecurityDescriptor}}finally{$c.Dispose()}}
|
||||
function Inventory {$ids=@(Get-WelaWecSubscriptionIds);if($ids.Count -gt 64){throw 'Disposable fixture inventory exceeds 64 entries.'};@($ids|ForEach-Object {[pscustomobject]@{Id=$_;Xml=Read-WelaWecSubscriptionXml $_}})}
|
||||
$hostState=Get-WelaChannelReadHost
|
||||
Assert ($hostState.Build -in @(20348,26100) -and $hostState.UBR -gt 0 -and $hostState.ProductType -eq 3 -and $hostState.DomainRole -eq 2 -and -not $hostState.DomainJoined) 'Actual patched standalone Server2022/2025 fixture; no invented domain identity'
|
||||
$nonce=[guid]::NewGuid().ToString('N');$id='WELA-Observe-'+$nonce;$unicode=([string][char]0x65e5)+([string][char]0x672c)
|
||||
$description='Owned observation '+$nonce+' '+$unicode;$sid='S-1-5-21-111111111-222222222-333333333-1234'
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-wec-observation-'+$nonce);$null=New-Item -ItemType Directory $root
|
||||
function Save($Name,$Value){$text=ConvertTo-Json -InputObject $Value -Depth 30;[IO.File]::WriteAllText((Join-Path $root $Name),$text,[Text.UTF8Encoding]::new($false))}
|
||||
$beforeServices=Services;$beforeChannel=Channel;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart
|
||||
$original=$null;$created=$false;$failure=$null;$errors=@();$inventoryOk=$false;$servicesOk=$false;$channelOk=$false;$reports=@();$afterServices=$null;$afterChannel=$null;$afterDelayed=$null
|
||||
$sources=[ordered]@{};foreach($p in @('WELA.ps1','scripts/WefDeployment.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionInventory.cs','modules/WecSubscriptionXml.cs')){$sources[$p]=(Get-FileHash (Join-Path $repo $p)).Hash.ToLowerInvariant()}
|
||||
Save 'before-fixture.json' @{Host=$hostState;Services=$beforeServices;Channel=$beforeChannel;DelayedAutoStart=$beforeDelayed;Sources=$sources}
|
||||
$config=Get-Content "$repo/config/wef-examples/collector.json" -Raw|ConvertFrom-Json
|
||||
# Existing Audit/Plan deliberately remain incomplete on this real standalone runner.
|
||||
# The example collector identity is never resolved, contacted or asserted as local.
|
||||
$config.SourceSids=@($sid);$config.SubscriptionFiles=@('requested.xml');$config.IngressRuleName='WELA-Absent-'+$nonce
|
||||
$path=Join-Path $root 'requested.xml';$configPath=Join-Path $root 'collector.json';Save 'collector.json' $config
|
||||
$query='<QueryList><Query Id="0" Path="Application"><Select Path="Application">*[System[(EventID=1)] and EventData[Data='''+$unicode+''']]</Select></Query></QueryList>'
|
||||
$xml=@"
|
||||
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription"><SubscriptionId>$id</SubscriptionId><SubscriptionType>SourceInitiated</SubscriptionType><Description>$description</Description><Enabled>false</Enabled><Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri><ConfigurationMode>Normal</ConfigurationMode><Query><![CDATA[$query]]></Query><ReadExistingEvents>false</ReadExistingEvents><TransportName>HTTP</TransportName><ContentFormat>Events</ContentFormat><Locale Language="en-US"/><LogFile>ForwardedEvents</LogFile><AllowedSourceDomainComputers></AllowedSourceDomainComputers></Subscription>
|
||||
"@
|
||||
[IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false))
|
||||
function Public([string]$Action,[string]$Name){
|
||||
$out=Join-Path $root ($Name+'.json');$prior=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" wec-collector -WefAction $Action -WefConfigPath $configPath -ResultsPath $out 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
[IO.File]::WriteAllText((Join-Path $root ($Name+'.log')),($text -join "`n"),[Text.UTF8Encoding]::new($false))
|
||||
Assert ($code -eq 1 -and (Test-Path $out)) 'Public collector reports incomplete real standalone prerequisites with exit1'
|
||||
$r=Get-Content -LiteralPath $out -Raw -Encoding UTF8|ConvertFrom-Json
|
||||
Assert ($r.Action -ceq $Action -and $r.Role -ceq 'Collector' -and $r.LocalConfigurationStatus -ceq 'Incomplete' -and -not $r.HostIdentity.DomainJoined) 'Public report preserves actual role and incomplete domain prerequisites'
|
||||
Assert ($r.Subscriptions.Count -eq 1 -and $r.Subscriptions[0].Id -ceq $id -and $r.Subscriptions[0].EventArrival -ceq 'Not tested' -and $r.Subscriptions[0].ForwardedSigmaCoverage -ceq 'Not assessed' -and $r.Subscriptions[0].ChannelObservationLocation -like 'Collector only*') 'No source authentication, remote channel or forwarded coverage claim'
|
||||
$script:reports+=($Name+'.json');$r
|
||||
}
|
||||
function SubscriptionControl($Report){@($Report.Controls|Where-Object Kind -eq Subscription)[0]}
|
||||
try {
|
||||
$wec=@($beforeServices|Where-Object Name -eq Wecsvc);Assert ($wec.Count -eq 1 -and $wec[0].State -in @('Running','Stopped') -and $wec[0].StartMode -in @('Auto','Manual','Disabled')) 'Stable original collector service state required'
|
||||
if($wec[0].StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual};if($wec[0].State -eq 'Stopped'){Start-Service Wecsvc}
|
||||
$original=@(Inventory);Save 'original-inventory.json' $original;Assert (@(Get-WelaWecSubscriptionIds) -notcontains $id) 'Unique owned subscription initially absent'
|
||||
Save 'console-enumeration-before.json' (Invoke-WelaNative 'wecutil.exe' @('es'))
|
||||
$duringServices=Services
|
||||
$absent=Public Audit 'absent-before'
|
||||
Assert ($absent.Subscriptions[0].ObservedSubscription.Exists -eq $false -and $null -eq $absent.Subscriptions[0].ObservedEnabled -and -not $absent.Subscriptions[0].ObservationError -and (SubscriptionControl $absent).Status -ceq 'ChangeRequired') 'Complete native enumeration establishes selected absence'
|
||||
$model=Import-WelaWefConfig $configPath Collector;$ownedPath=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($ownedPath,$model.Subscriptions[0].Xml,[Text.UTF8Encoding]::new($false))
|
||||
$created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$ownedPath)
|
||||
$before=Read-WelaWecSubscriptionXml $id;[IO.File]::WriteAllText((Join-Path $root 'original-owned.xml'),$before,[Text.UTF8Encoding]::new($false))
|
||||
Assert (@(Get-WelaWecSubscriptionIds) -ccontains $id) 'Actual native enumeration returns exact owned ID'
|
||||
foreach($action in @('Audit','Plan')){
|
||||
$r=Public $action ('present-'+$action.ToLowerInvariant());$o=$r.Subscriptions[0]
|
||||
Assert ($o.ObservedSubscription.Exists -and $o.ObservedEnabled -eq $false -and -not $o.ObservationError -and (SubscriptionControl $r).Status -ceq 'RequestedSettingsMatch') 'Existing disabled native definition is observed and matched'
|
||||
Assert ($o.ObservedSubscription.Xml -ceq $before -and $o.ObservedSubscription.Definition.Description -ceq $description -and $o.Filters[0].XPath -ceq ('*[System[(EventID=1)] and EventData[Data='''+$unicode+''']]')) 'Public JSON preserves exact Unicode native XML, description and selected XPath'
|
||||
Assert ((Read-WelaWecSubscriptionXml $id) -ceq $before) 'Public Audit/Plan does not save or alter existing subscription'
|
||||
}
|
||||
[IO.File]::WriteAllText($path,$xml.Replace('<Enabled>false</Enabled>','<Enabled>true</Enabled>'),[Text.UTF8Encoding]::new($false))
|
||||
$r=Public Plan 'requested-enabled'
|
||||
Assert ($r.Subscriptions[0].RequestedEnabled -and $r.Subscriptions[0].ObservedEnabled -eq $false -and (SubscriptionControl $r).Status -ceq 'ManualReview') 'Actual disabled state is not replaced with requested enabled state'
|
||||
[IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false))
|
||||
try {
|
||||
$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+($description+' drift')))
|
||||
$r=Public Audit 'description-drift'
|
||||
Assert ((SubscriptionControl $r).Status -ceq 'ManualReview' -and $r.Subscriptions[0].ObservedSubscription.Definition.Description -ceq ($description+' drift')) 'Native Unicode drift is retained and does not become a match'
|
||||
}finally{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+$description))}
|
||||
$config.SourceSids=@($sid.Replace('-1234','-1235'));Save 'collector.json' $config
|
||||
$r=Public Audit 'authorization-mismatch'
|
||||
Assert ((SubscriptionControl $r).Status -ceq 'Unknown' -and $null -eq $r.Subscriptions[0].ObservedSubscription -and $null -eq $r.Subscriptions[0].ObservedEnabled -and $r.Subscriptions[0].ObservationError) 'Unsupported observed authorization remains unknown, never absent'
|
||||
$config.SourceSids=@($sid);Save 'collector.json' $config
|
||||
Assert ((Read-WelaWecSubscriptionXml $id) -ceq $before) 'All public observations and fixture drift restoration preserve original raw XML'
|
||||
[IO.File]::WriteAllText((Join-Path $root 'restored-owned.xml'),(Read-WelaWecSubscriptionXml $id),[Text.UTF8Encoding]::new($false))
|
||||
$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id);$created=$false
|
||||
$r=Public Audit 'absent-after';Assert ($r.Subscriptions[0].ObservedSubscription.Exists -eq $false -and -not $r.Subscriptions[0].ObservationError) 'Actual removed owned subscription returns confirmed absence'
|
||||
Assert ((Key (Services)) -ceq (Key $duringServices) -and (Key (Channel)) -ceq (Key $beforeChannel)) 'Read-only public commands preserve services and complete destination configuration'
|
||||
Write-Host "PASS: $count actual collector observation assertions on $($PSVersionTable.PSVersion). No domain/forwarding proof."
|
||||
}catch{$failure=$_.ToString();Write-Host $failure}finally{
|
||||
try {
|
||||
if($created -and @(Get-WelaWecSubscriptionIds) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;if($doc.Subscription.Description -cne $description -and $doc.Subscription.Description -cne ($description+' drift')){throw 'Fixture ownership differs; do not delete subscription.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)}
|
||||
$restored=@(Inventory);Save 'restored-inventory.json' $restored;$inventoryOk=$null -ne $original -and (Key $restored) -ceq (Key $original)
|
||||
}catch{$errors+=$_.ToString()}
|
||||
try{$afterChannel=Channel;$channelOk=(Key $afterChannel) -ceq (Key $beforeChannel)}catch{$errors+=$_.ToString()}
|
||||
try {
|
||||
$wec=@($beforeServices|Where-Object Name -eq Wecsvc)[0]
|
||||
if($wec.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc}
|
||||
if($wec.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled}
|
||||
if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}}
|
||||
$afterServices=Services;$afterDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart
|
||||
$servicesOk=(Key $afterServices) -ceq (Key $beforeServices) -and (Key $afterDelayed) -ceq (Key $beforeDelayed)
|
||||
}catch{$errors+=$_.ToString()}
|
||||
Save 'after-fixture.json' @{Services=$afterServices;Channel=$afterChannel;DelayedAutoStart=$afterDelayed}
|
||||
$artifacts=@(Get-ChildItem $root -File|ForEach-Object {[pscustomobject]@{Name=$_.Name;Bytes=$_.Length;Sha256=(Get-FileHash $_.FullName).Hash.ToLowerInvariant()}})
|
||||
Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$errors;SubscriptionsRestored=$inventoryOk;ServicesRestored=$servicesOk;ChannelPreserved=$channelOk;Complete=($inventoryOk -and $servicesOk -and $channelOk -and -not $errors.Count);Assertions=$count;Engine=$PSVersionTable.PSVersion.ToString();Host=$hostState;Sources=$sources;Artifacts=$artifacts;PublicReports=$reports;Scope='Native local collector observation only; real standalone prerequisites remain incomplete.'}
|
||||
}
|
||||
if($failure -or -not $inventoryOk -or -not $servicesOk -or -not $channelOk -or $errors.Count){throw "Native observation or fixture cleanup failed; inspect $root"}
|
||||
exit 0
|
||||
@@ -0,0 +1,15 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
|
||||
$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$cases=@(
|
||||
@{Args=@('wec-ingress','-Help');Code=0;Pattern='TCP5985'},
|
||||
@{Args=@('configure','-WecIngressAction','Apply','-Auto');Code=1;Pattern='require wec-ingress'},
|
||||
@{Args=@('wec-ingress','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-ingress','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-ingress','-Help','-Auto');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-ingress','-Help','-DryRun');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-ingress','-WecIngressAction','Apply','-WecIngressOutputPath','not-created');Code=1;Pattern='reviewed plan'},
|
||||
@{Args=@('wec-ingress','-WecIngressOutputPath','not-created');Code=1;Pattern='Plan requires'}
|
||||
)
|
||||
foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++}
|
||||
Write-Host "WEC ingress CLI: $count checks passed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,62 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/WecIngress.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"}
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ingress-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$script:mode='ok';$script:reads=0;$script:creates=0;$script:exists=$false
|
||||
function Get-WelaIngressContext {[pscustomobject][ordered]@{Computer='TEST';Addresses=@('10.10.10.10');Reader='LOGON'}}
|
||||
function Assert-WelaIngressAbsent {param($Name);$script:reads++;if($script:exists -or ($script:mode -eq 'race' -and $script:reads -eq 2)){throw 'already exists'}}
|
||||
function New-WelaIngressNativeRule {param($Selection);Assert (Test-Path $script:journal) 'Pending receipt precedes creation';$script:creates++;if($script:mode -eq 'failure'){throw 'native failure'};$script:exists=$true}
|
||||
function Read-WelaIngressRule {
|
||||
param($Store,$Name)
|
||||
$r=[pscustomobject]@{Name=$Name;DisplayName=$Name;Description='WELA reviewed collector ingress; TCP 5985, Domain profile, explicit IPv4 scopes.';Group='WELA reviewed collector ingress';Enabled='True';Profile='Domain';Direction='Inbound';Action='Allow';EdgeTraversalPolicy='Block';LooseSourceMapping=$false;LocalOnlyMapping=$false;PolicyStoreSourceType='Local';Owner='';Platform=@()}
|
||||
$f=[ordered]@{Port=[pscustomobject]@{Protocol='TCP';LocalPort='5985';RemotePort='Any';IcmpType='Any';DynamicTarget='Any'};Address=[pscustomobject]@{LocalAddress=@('10.10.10.10');RemoteAddress=@('192.0.2.0/255.255.255.0')};Application=[pscustomobject]@{Program='Any';Package='Any'};Service=[pscustomobject]@{Service='Any'};Interface=[pscustomobject]@{InterfaceAlias='Any'};InterfaceType=[pscustomobject]@{InterfaceType='Any'};Security=[pscustomobject]@{Authentication='NotRequired';Encryption='NotRequired';OverrideBlockRules=$false;LocalUser='Any';RemoteUser='Any';RemoteMachine='Any'}}
|
||||
if($script:mode -eq 'broader'){$f.Address.RemoteAddress=@('Any')}
|
||||
if($script:mode -eq 'wrong-port'){$f.Port.LocalPort='Any'}
|
||||
if($script:mode -eq 'wrong-store' -and $Store -eq 'ActiveStore'){$r.PolicyStoreSourceType='GroupPolicy'}
|
||||
[pscustomobject]@{Store=$Store;Rule=$r;Filters=$f}
|
||||
}
|
||||
try {
|
||||
foreach($bad in @('Any','10.1','010.0.0.1','127.0.0.1','0.0.0.0','224.0.0.1','255.255.255.255','10.0.0.1/24','10.0.0.0/16','192.0.2.0/33','192.0.2.0/024','192.0.2.0/255.255.255.0','example.org','192.0.2.1-192.0.2.4','::1')){Reject {ConvertTo-WelaIngressAddress $bad -Remote} '.'}
|
||||
Assert ((ConvertTo-WelaIngressAddress '192.0.2.0/255.255.255.0' -Remote -Observed) -eq '192.0.2.0/24') 'Observed mask canonicalized'
|
||||
Reject {ConvertTo-WelaIngressAddress '192.0.2.0/255.0.255.0' -Remote -Observed} 'Noncontiguous'
|
||||
Reject {Get-WelaIngressSelection 'WELA-WEC-Test' @('10.10.10.10') @('192.0.2.1','192.0.2.1/32')} 'Duplicate'
|
||||
Reject {Get-WelaIngressSelection '*' @('10.10.10.10') @('192.0.2.1')} 'name'
|
||||
$selection=Get-WelaIngressSelection 'WELA-WEC-Test' @('10.10.10.10') @('192.0.2.0/24')
|
||||
$observation=Read-WelaIngressRule PersistentStore $selection.Name;Assert-WelaIngressReadback $observation $selection
|
||||
foreach($package in @($null,'')){$observation.Filters.Application.Package=$package;Assert-WelaIngressReadback $observation $selection;$count++}
|
||||
$observation.Filters.Application.Package='S-1-15-2-1';Reject {Assert-WelaIngressReadback $observation $selection} 'Package'
|
||||
$observation.Filters.Application.PSObject.Properties.Remove('Package');Reject {Assert-WelaIngressReadback $observation $selection} 'Package'
|
||||
$observation.Filters.Application|Add-Member NoteProperty Package 'Any'
|
||||
$evidence=ConvertTo-WelaIngressEvidence $observation;Assert ($evidence.Application.Package.Present -and $evidence.Application.Package.Value -eq 'Any') 'Evidence preserves explicit inspected fields'
|
||||
foreach($field in @('Enabled','Direction','Profile','Action','EdgeTraversalPolicy','LooseSourceMapping','LocalOnlyMapping','PolicyStoreSourceType','Description','Group','DisplayName','Name')){
|
||||
$saved=$observation.Rule.$field;$observation.Rule.$field='unexpected';Reject {Assert-WelaIngressReadback $observation $selection} 'differs';$observation.Rule.$field=$saved
|
||||
}
|
||||
foreach($scenario in @('ok','hash','context','duplicate','race','failure','broader','wrong-port','wrong-store','unassigned','replay')){
|
||||
$script:mode='ok';$script:reads=0;$script:creates=0;$script:exists=$false
|
||||
$result=Invoke-WelaWecIngress Plan -Name $selection.Name -LocalAddress $selection.LocalAddresses -RemoteAddress $selection.RemoteAddresses -OutputPath (Join-Path $root ($scenario+'-plan'))
|
||||
Assert ($result.Status -eq 'ReviewRequired' -and $script:creates -eq 0) "Read-only plan: $($result.Diagnostic)"
|
||||
$path=Join-Path $result.OutputPath 'plan.json';$hash=$result.PlanHash
|
||||
if($scenario -eq 'hash'){$hash='b'*64}
|
||||
if($scenario -in @('context','duplicate','unassigned')){
|
||||
$text=[IO.File]::ReadAllText($path)
|
||||
if($scenario -eq 'context'){$text=$text.Replace('TEST','OTHER')}
|
||||
if($scenario -eq 'duplicate'){$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')}
|
||||
if($scenario -eq 'unassigned'){$text=$text.Replace('"10.10.10.10"','"10.10.10.11"')}
|
||||
[IO.File]::WriteAllText($path,$text);$hash=(Get-FileHash $path).Hash.ToLowerInvariant()
|
||||
}
|
||||
$script:mode=$scenario;$script:reads=0;$out=Join-Path $root ($scenario+'-apply');$script:journal=Join-Path $out 'before-create.json'
|
||||
$applied=Invoke-WelaWecIngress Apply -PlanPath $path -PlanHash $hash -OutputPath $out
|
||||
Assert (($applied.ExitCode -eq 0) -eq ($scenario -in @('ok','replay'))) "Scenario $scenario : $($applied.Diagnostic)"
|
||||
Assert ($applied.ReadyRuleCredit -eq 0 -and (Test-Path (Join-Path $out 'manifest.json'))) 'No detection credit; durable result'
|
||||
if($scenario -in @('hash','context','duplicate','race','unassigned')){Assert ($script:creates -eq 0) 'Refused before mutation'}
|
||||
if($scenario -in @('failure','broader','wrong-port','wrong-store')){Assert ($applied.Status -eq 'CreateAttemptedUnverified' -and $script:creates -eq 1) 'Unverified possible creation retained'}
|
||||
if($scenario -eq 'replay'){$again=Invoke-WelaWecIngress Apply -PlanPath $path -PlanHash $hash -OutputPath (Join-Path $root 'replay-again');Assert ($again.Status -eq 'Refused' -and $script:creates -eq 1) 'Existing rule never overwritten'}
|
||||
}
|
||||
}finally{Remove-Item $root -Recurse -Force}
|
||||
Write-Host "WEC ingress tests passed: $count assertions."
|
||||
@@ -0,0 +1,75 @@
|
||||
param([switch]$AllowDisposableFirewallRule)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableFirewallRule -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
. "$repo/scripts/WefDeployment.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/ChannelRead.ps1"
|
||||
. "$repo/scripts/WecIngress.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 16 -Compress}
|
||||
function RulesKey {param([string]$Exclude);Key @(Read-WelaIngressRules PersistentStore|Where-Object Name -ne $Exclude|Sort-Object Name|Select-Object Name,DisplayName,Description,Group,Enabled,Profile,Direction,Action,EdgeTraversalPolicy,LooseSourceMapping,LocalOnlyMapping,Owner)}
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
function Invoke-IngressFixtureCli {param([string[]]$Arguments,[int]$Expected=0)
|
||||
$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old}
|
||||
if(($Expected -eq 0 -and $code -ne 0) -or ($Expected -ne 0 -and $code -eq 0)){throw "CLI $code : $($lines -join ' ')"}
|
||||
}
|
||||
$context=Get-WelaIngressContext;$contextKey=Key $context;$beforeRules=RulesKey ''
|
||||
$local=@($context.Addresses|Where-Object {$_ -notlike '127.*' -and $_ -notlike '169.254.*'})[0]
|
||||
if(-not $local){throw 'An assigned nonloopback IPv4 address is required.'}
|
||||
$name='WELA-WEC-Test-'+[guid]::NewGuid().ToString('N');$selection=Get-WelaIngressSelection $name @($local) @('192.0.2.0/24')
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-ingress-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$primary=$null;$attempted=$false
|
||||
try {
|
||||
Assert-WelaIngressAbsent $name
|
||||
Invoke-IngressFixtureCli @('wec-ingress','-WecIngressName',$name,'-WecIngressLocalAddress',$local,'-WecIngressRemoteAddress','192.0.2.0/24','-WecIngressOutputPath',"$root/plan")
|
||||
$plan=Get-Content "$root/plan/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($plan.Status -eq 'ReviewRequired' -and -not $plan.NativeCreateAttempted) 'Public Plan is read only'
|
||||
Assert ((RulesKey '') -ceq $beforeRules) 'Planning preserves persistent rule inventory and properties'
|
||||
$attempted=$true
|
||||
Invoke-IngressFixtureCli @('wec-ingress','-WecIngressAction','Apply','-WecIngressPlanPath',"$root/plan/plan.json",'-WecIngressPlanHash',$plan.PlanHash,'-WecIngressOutputPath',"$root/apply")
|
||||
$apply=Get-Content "$root/apply/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($apply.Status -eq 'CreatedAndVerified' -and $apply.NativeCreateAttempted -and $apply.ReadyRuleCredit -eq 0) 'Actual public creation and readback'
|
||||
foreach($store in @('PersistentStore','ActiveStore')){Assert-WelaIngressReadback (Read-WelaIngressRule $store $name) $selection;$count++}
|
||||
Assert ((RulesKey $name) -ceq $beforeRules) 'Other persistent rule properties preserved'
|
||||
# Exercise the real existing collector prerequisite against the new native rule.
|
||||
# Other prerequisites may be unmet on this standalone fixture; inspect only ingress.
|
||||
$collectorConfig=[pscustomobject]@{CollectorFqdn=(Get-WelaWefHost).Fqdn;ListenerAddress='*';IngressRuleName=$name;IngressLocalAddresses=@($local);IngressRemoteAddresses=@('192.0.2.0/24')}
|
||||
$collectorChecks=@(Get-WelaWefCollectorPrerequisites $collectorConfig)
|
||||
$ingress=@($collectorChecks|Where-Object Name -eq 'Existing scoped domain ingress rule')
|
||||
Assert ($ingress.Count -eq 1 -and $ingress[0].Verified) 'Existing collector prerequisite accepts the same reviewed CIDR after native dotted-netmask readback'
|
||||
$null=Write-WelaWecUpdateArtifact $root 'collector-ingress-check.json' ($ingress[0]|ConvertTo-Json -Depth 8)
|
||||
$collectorConfig.IngressRemoteAddresses=@('192.0.2.0/25')
|
||||
$mismatch=@(Get-WelaWefCollectorPrerequisites $collectorConfig|Where-Object Name -eq 'Existing scoped domain ingress rule')
|
||||
Assert ($mismatch.Count -eq 1 -and -not $mismatch[0].Verified) 'Collector prerequisite refuses a genuinely different approved network'
|
||||
$null=Write-WelaWecUpdateArtifact $root 'collector-ingress-mismatch.json' ($mismatch[0]|ConvertTo-Json -Depth 8)
|
||||
# Native New must not replace an existing name, even if a creator races our last absence check.
|
||||
$collision=$false;try{New-WelaIngressNativeRule $selection}catch{$collision=$true}
|
||||
Assert $collision 'Native duplicate-name creation refuses replacement'
|
||||
Assert-WelaIngressReadback (Read-WelaIngressRule PersistentStore $name) $selection
|
||||
Invoke-IngressFixtureCli @('wec-ingress','-WecIngressAction','Apply','-WecIngressPlanPath',"$root/plan/plan.json",'-WecIngressPlanHash',$plan.PlanHash,'-WecIngressOutputPath',"$root/replay") 1
|
||||
$replay=Get-Content "$root/replay/manifest.json" -Raw|ConvertFrom-Json
|
||||
Assert ($replay.Status -eq 'Refused' -and -not $replay.NativeCreateAttempted) 'Plan replay refuses an existing rule'
|
||||
Assert ((Key (Get-WelaIngressContext)) -ceq $contextKey) 'Profiles, services, host and address context unchanged'
|
||||
Write-Host "Native WEC ingress passed $count assertions on $([Environment]::OSVersion.Version), PowerShell $($PSVersionTable.PSVersion). No listener or traffic created."
|
||||
}catch{
|
||||
$primary=$_
|
||||
foreach($store in @('PersistentStore','ActiveStore')){try{$snapshot=ConvertTo-WelaIngressEvidence (Read-WelaIngressRule $store $name);Write-Host ($snapshot|ConvertTo-Json -Depth 8)}catch{Write-Host "Diagnostic read $store : $($_.Exception.Message)"}}
|
||||
}
|
||||
finally {
|
||||
$errors=@()
|
||||
try {
|
||||
$owned=@(Read-WelaIngressRules PersistentStore|Where-Object Name -eq $name)
|
||||
if($owned.Count){if(-not $attempted -or $owned.Count -ne 1 -or $owned[0].Group -cne 'WELA reviewed collector ingress' -or $owned[0].DisplayName -cne $name){throw 'Fixture ownership is ambiguous; refusing removal.'};$owned[0]|NetSecurity\Remove-NetFirewallRule -ErrorAction Stop}
|
||||
Assert-WelaIngressAbsent $name
|
||||
if((RulesKey '') -cne $beforeRules -or (Key (Get-WelaIngressContext)) -cne $contextKey){throw 'Original rule inventory, service or profile state differs after cleanup.'}
|
||||
}catch{$errors+=$_.Exception.Message}
|
||||
if($errors.Count){throw "Fixture cleanup failed: $($errors -join '; '); primary: $primary; evidence: $root"}
|
||||
Write-Host 'Owned rule removed; original persistent rules, firewall profiles and services preserved.'
|
||||
}
|
||||
if($primary){throw $primary}
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,94 @@
|
||||
param([switch]$AllowDisposableListenerReplacement)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableListenerReplacement -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows listener replacement opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/NativeChannelConfiguration.ps1"
|
||||
. "$repo/scripts/WefDeployment.ps1"
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-listener-checkpoint-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
function Save($Name,$Value){$Value|ConvertTo-Json -Depth 20|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
|
||||
function ReadListeners {
|
||||
@(Microsoft.WSMan.Management\Get-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -Enumerate -ErrorAction Stop|ForEach-Object {
|
||||
[pscustomobject][ordered]@{Address=[string]$_.Address;Transport=[string]$_.Transport;Port=[string]$_.Port;Hostname=[string]$_.Hostname;Enabled=[string]$_.Enabled;URLPrefix=[string]$_.URLPrefix;CertificateThumbprint=[string]$_.CertificateThumbprint;ListeningOn=@($_.ListeningOn|ForEach-Object {[string]$_}|Sort-Object);RawXml=$_.OuterXml}
|
||||
}|Sort-Object Address,Transport)
|
||||
}
|
||||
function Key($Value){ConvertTo-Json -InputObject @($Value|Select-Object Address,Transport,Port,Hostname,Enabled,URLPrefix,CertificateThumbprint,ListeningOn) -Depth 10 -Compress}
|
||||
function ReadServices {@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='MpsSvc' OR Name='BFE'"|Sort-Object Name|Select-Object Name,StartMode,State)}
|
||||
function ReadFirewall {@(NetSecurity\Get-NetFirewallRule -PolicyStore ActiveStore|Sort-Object Name|Select-Object Name,Enabled,Profile,Direction,Action,PolicyStoreSourceType)}
|
||||
$adapter=Join-Path $root 'checkpoint-native51.ps1'
|
||||
@'
|
||||
param([string]$ListenerAddress,[string]$PayloadPath)
|
||||
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()}
|
||||
$r=[ordered]@{EngineMajor=$PSVersionTable.PSVersion.Major;Engine=$PSVersionTable.PSVersion.ToString();ProcessId=$PID;UserSid=$sid;Status='Failed';Xml='';Diagnostic=''}
|
||||
try {
|
||||
if($PSVersionTable.PSVersion.Major -ne 5 -or $ListenerAddress -notmatch '^(\*|IP:[0-9.]+)$'){throw 'Only fixture native5.1 HTTP selectors are supported.'}
|
||||
$held=[IO.File]::Open($PayloadPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
|
||||
try {$v=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$ListenerAddress;Transport='HTTP'} -FilePath $PayloadPath -ErrorAction Stop;$r.Xml=[string]$v.OuterXml;$r.Status='Created'}finally{$held.Dispose()}
|
||||
}catch{$r.Diagnostic=$_.ToString()}
|
||||
$r|ConvertTo-Json -Compress
|
||||
if($r.Status -ne 'Created'){exit 1}
|
||||
'@|Set-Content -LiteralPath $adapter -Encoding UTF8
|
||||
function NewCheckpointListener($Selector,$Values) {
|
||||
$doc=[Xml.XmlDocument]::new();$element=$doc.CreateElement('cfg','Listener','http://schemas.microsoft.com/wbem/wsman/1/config/listener');$null=$doc.AppendChild($element)
|
||||
foreach($name in @('Port','Hostname','Enabled','URLPrefix','CertificateThumbprint')){$child=$doc.CreateElement('cfg',$name,$element.NamespaceURI);$child.InnerText=[string]$Values[$name];$null=$element.AppendChild($child)}
|
||||
$payload=Join-Path $root ('native-listener-'+[guid]::NewGuid().ToString('N')+'.xml');[IO.File]::WriteAllText($payload,$doc.OuterXml,[Text.UTF8Encoding]::new($false))
|
||||
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe'
|
||||
$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$adapter+'" -ListenerAddress "'+$Selector.Address+'" -PayloadPath "'+$payload+'"'
|
||||
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false)
|
||||
$process=[Diagnostics.Process]::new();$process.StartInfo=$info
|
||||
try {
|
||||
if(-not $process.Start()){throw 'Native5.1 adapter did not start.'};$childId=$process.Id;$stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync()
|
||||
if(-not $process.WaitForExit(20000)){throw 'Native5.1 adapter timed out.'};$text=$stdout.Result;$errorText=$stderr.Result
|
||||
if($errorText -or $text.Length -gt 65536){throw 'Unexpected native adapter output.'}
|
||||
$receipt=$text|ConvertFrom-Json;Save ('adapter-'+[guid]::NewGuid().ToString('N')+'.json') $receipt
|
||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()}
|
||||
Assert ($receipt.EngineMajor -eq 5 -and $receipt.ProcessId -eq $childId -and $receipt.UserSid -ceq $sid) 'Actual native5.1 child identity must match the invoking account and observed PID.'
|
||||
if($process.ExitCode -ne 0 -or $receipt.Status -cne 'Created'){throw $receipt.Diagnostic}
|
||||
[string]$receipt.Xml
|
||||
}finally{if($process.Id -and -not $process.HasExited){$process.Kill();$null=$process.WaitForExit(5000)};$process.Dispose()}
|
||||
}
|
||||
|
||||
$services=ReadServices;$firewall=ReadFirewall;$original=$null;$removed=@();$created=$false;$failure=$null;$cleanupErrors=@();$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
try {
|
||||
$os=Get-CimInstance Win32_OperatingSystem;Assert ($os.BuildNumber -in @('20348','26100') -and $os.ProductType -eq 3) 'Standalone Server 2022/2025 fixture required.'
|
||||
$s=@($services|Where-Object Name -eq 'WinRM');Assert ($s.Count -eq 1 -and $s[0].StartMode -in @('Auto','Manual') -and $s[0].State -in @('Running','Stopped')) 'Stable non-disabled WinRM required.'
|
||||
if($s[0].State -ne 'Running'){Start-Service WinRM -ErrorAction Stop}
|
||||
$original=ReadListeners;Save 'listeners-original.json' $original;Save 'services-original.json' $services;Save 'firewall-original.json' $firewall
|
||||
# Replacement is a fixture-only, disposable-VM operation. Product must refuse overlaps.
|
||||
foreach($listener in @($original|Where-Object Transport -eq 'HTTP')){
|
||||
Assert ($listener.Address -match '^(\*|IP:[0-9.]+)$' -and $listener.Port -eq '5985' -and $listener.URLPrefix -eq 'wsman' -and $listener.Enabled -in @('true','false') -and -not $listener.CertificateThumbprint -and $listener.RawXml -notmatch 'Source="GPO"') 'Only ordinary local HTTP fixture listeners can be temporarily replaced.'
|
||||
Microsoft.WSMan.Management\Remove-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$listener.Address;Transport='HTTP'} -ErrorAction Stop
|
||||
$removed+=$listener
|
||||
}
|
||||
$ip=@(NetTCPIP\Get-NetIPAddress -AddressFamily IPv4|Where-Object {$_.AddressState -eq 'Preferred' -and $_.IPAddress -notmatch '^(127\.|169\.254\.|0\.)'}|Sort-Object IPAddress|Select-Object -First 1).IPAddress
|
||||
Assert ([bool]$ip) 'An assigned preferred IPv4 address is required.';$selector=@{Address='IP:'+$ip;Transport='HTTP'}
|
||||
$values=@{Port='5985';Hostname='';Enabled='true';URLPrefix='wsman';CertificateThumbprint=''}
|
||||
Save 'selection.json' @{Selector=$selector;Values=$values}
|
||||
$created=$true
|
||||
$result=NewCheckpointListener $selector $values
|
||||
Save 'native-create.json' @{Xml=$result}
|
||||
$after=ReadListeners;Save 'listeners-created.json' $after;$chosen=@($after|Where-Object {$_.Address -ceq $selector.Address -and $_.Transport -ceq 'HTTP'})
|
||||
Assert ($chosen.Count -eq 1) 'Exactly one assigned-IP listener must exist.'
|
||||
Assert ($chosen[0].Port -ceq '5985' -and $chosen[0].Enabled -ceq 'true' -and $chosen[0].URLPrefix -ceq 'wsman' -and -not $chosen[0].CertificateThumbprint -and -not $chosen[0].Hostname) 'Every fixed listener property must match.'
|
||||
Assert ($chosen[0].ListeningOn.Count -eq 1 -and $chosen[0].ListeningOn[0] -ceq $ip) 'Actual ListeningOn must contain exactly the selected IPv4 address.'
|
||||
$duplicateRejected=$false;$duplicateError=''
|
||||
try {$null=NewCheckpointListener $selector $values}catch{$duplicateRejected=$true;$duplicateError=$_.ToString()}
|
||||
Save 'collision.json' @{Rejected=$duplicateRejected;Diagnostic=$duplicateError};Assert $duplicateRejected 'Windows must reject creating the same listener selector twice.'
|
||||
Assert ((Key (ReadListeners)) -ceq (Key $after)) 'Rejected collision must preserve the listener definition.'
|
||||
$prereq=@(Get-WelaWefCollectorPrerequisites ([pscustomobject]@{CollectorFqdn='fixture.invalid';ListenerAddress=$selector.Address;IngressRuleName='WELA-checkpoint-does-not-exist';IngressLocalAddresses=@($ip);IngressRemoteAddresses=@('192.0.2.0/24')}))
|
||||
Save 'collector-prerequisite.json' $prereq;$field=@($prereq|Where-Object Name -eq 'Existing matching HTTP listener');Assert ($field.Count -eq 1 -and $field[0].Verified) 'Existing collector prerequisite must recognize the actual exact-IP listener.'
|
||||
Write-Host "PASS: $count native listener checkpoint assertions. No WEF delivery proof."
|
||||
}catch{$failure=$_.ToString();Write-Host $failure;throw}finally{
|
||||
if($created){try {Microsoft.WSMan.Management\Remove-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $selector -ErrorAction Stop}catch{$cleanupErrors+=$_.ToString()}}
|
||||
foreach($listener in $removed){try {$null=NewCheckpointListener @{Address=$listener.Address;Transport=$listener.Transport} @{Port=$listener.Port;Hostname=$listener.Hostname;Enabled=$listener.Enabled;URLPrefix=$listener.URLPrefix;CertificateThumbprint=$listener.CertificateThumbprint}}catch{$cleanupErrors+=$_.ToString()}}
|
||||
$restored=$null;$listenersOk=$false;$firewallOk=$false;$servicesOk=$false
|
||||
try {$restored=ReadListeners;Save 'listeners-restored.json' $restored;$listenersOk=$null -ne $original -and (Key $original) -ceq (Key $restored)}catch{$cleanupErrors+=$_.ToString()}
|
||||
try {if(@($services|Where-Object Name -eq 'WinRM')[0].State -eq 'Stopped'){Stop-Service WinRM -ErrorAction Stop};$endServices=ReadServices;Save 'services-restored.json' $endServices;$servicesOk=($services|ConvertTo-Json -Compress) -ceq ($endServices|ConvertTo-Json -Compress)}catch{$cleanupErrors+=$_.ToString()}
|
||||
try {$endFirewall=ReadFirewall;Save 'firewall-restored.json' $endFirewall;$firewallOk=($firewall|ConvertTo-Json -Compress) -ceq ($endFirewall|ConvertTo-Json -Compress)}catch{$cleanupErrors+=$_.ToString()}
|
||||
Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$cleanupErrors;ListenersRestored=$listenersOk;ServicesRestored=$servicesOk;FirewallPreserved=$firewallOk;Complete=($listenersOk -and $servicesOk -and $firewallOk -and -not $cleanupErrors.Count);DisposableBoundary='Fixture temporarily replaced ordinary original HTTP listeners and restored their captured configuration; product creation must refuse overlap.'}
|
||||
if(-not $listenersOk -or -not $servicesOk -or -not $firewallOk -or $cleanupErrors.Count){throw 'Native checkpoint cleanup incomplete; inspect retained artifacts.'}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-listener-cli-'+[guid]::NewGuid().ToString('N'))
|
||||
$cases=@(
|
||||
@{Args=@('wec-listener','-Help');Code=0;Pattern='HTTP5985'},
|
||||
@{Args=@('wec-listener','-Help','-Auto');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-listener','-Help','-DryRun');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-listener','-WecListenerAction','Apply','-WhatIf');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-listener','-Help','-Typo');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-listener','-Help','-ResultsPath',$root);Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-listener','-Help','-WecIngressAction','Apply');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('configure','-WecListenerAction','Apply','-Auto');Code=1;Pattern='WecListener options require'},
|
||||
@{Args=@('wec-listener');Code=1;Pattern='Plan requires'},
|
||||
@{Args=@('wec-listener','-WecListenerComputerName','placeholder','-WecListenerLocalAddress','192.0.2.10','-WecListenerOutputPath',$root,'-WecListenerPlanPath','unused');Code=1;Pattern='Plan requires'},
|
||||
@{Args=@('wec-listener','-WecListenerAction','Apply','-WecListenerOutputPath',$root);Code=1;Pattern='Apply requires'},
|
||||
@{Args=@('wec-listener','-WecListenerAction','Apply','-WecListenerPlanPath','unused','-WecListenerPlanHash',('a'*64),'-WecListenerOutputPath',$root,'-WecListenerComputerName','placeholder');Code=1;Pattern='Apply requires'}
|
||||
)
|
||||
foreach($case in $cases){
|
||||
$prior=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$output=&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
if($code -ne $case.Code -or $output -notmatch $case.Pattern){throw "CLI failed [$code]: $output"};$count++
|
||||
if(Test-Path -LiteralPath $root){throw 'Rejected CLI inputs must not create an output directory.'}
|
||||
}
|
||||
Write-Host "PASS: $count public WEC listener CLI checks. No Windows settings changed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,112 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/WecListener.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action,[string]$Pattern='.'){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected refusal $Pattern, got: $message; input: $bad; action: $Action"}
|
||||
function Copy-TestListener($Value){Get-WelaListenerKey $Value|ConvertFrom-Json}
|
||||
$special=[pscustomobject]@{Xml='<a x="v">&</a>';Name="O'Neil"}
|
||||
$specialKey=Get-WelaListenerKey $special
|
||||
Assert ($specialKey -notmatch "[<>&']" -and $specialKey.Contains('\u003c') -and $specialKey.Contains('\u0027')) 'Context JSON spelling is consistent across native5.1 and host7.'
|
||||
Assert (($specialKey|ConvertFrom-Json).Xml -ceq $special.Xml -and ($specialKey|ConvertFrom-Json).Name -ceq $special.Name) 'Canonical JSON escaping preserves exact values.'
|
||||
Assert ((Get-WelaListenerKey (Copy-TestListener ([pscustomobject]@{Nested=$specialKey}))) -ceq (Get-WelaListenerKey ([pscustomobject]@{Nested=$specialKey}))) 'Nested context JSON keeps its reviewed value.'
|
||||
|
||||
$selection=Get-WelaListenerSelection 'test-host' '192.0.2.10'
|
||||
$xml='<cfg:Listener xmlns:cfg="http://schemas.microsoft.com/wbem/wsman/1/config/listener" xml:lang="en-US"><cfg:Address>IP:192.0.2.10</cfg:Address><cfg:Transport>HTTP</cfg:Transport><cfg:Port>5985</cfg:Port><cfg:Hostname/><cfg:Enabled>true</cfg:Enabled><cfg:URLPrefix>wsman</cfg:URLPrefix><cfg:CertificateThumbprint/><cfg:ListeningOn>192.0.2.10</cfg:ListeningOn></cfg:Listener>'
|
||||
Assert ($selection.ComputerName -ceq 'TEST-HOST') 'Actual computer selection is canonical.'
|
||||
foreach($bad in @('*','IP:192.0.2.10','192.0.2.10/32','192.0.2.0/24','192.0.2.01','010.1.2.3','127.0.0.1','0.0.0.0','169.254.1.2','224.0.0.1','255.255.255.255','256.1.2.3','1.2.3','example.test','::1','',' 192.0.2.10')){Reject {Get-WelaListenerSelection 'TEST' $bad}}
|
||||
foreach($bad in @('','test.example','*','-TEST','TEST HOST','TEST/OTHER')){Reject {Get-WelaListenerSelection $bad '192.0.2.10'}}
|
||||
Reject {Get-WelaListenerSelection $true '192.0.2.10'};Reject {Get-WelaListenerSelection 'TEST' $true}
|
||||
$listener=ConvertFrom-WelaListenerXml $xml;Assert-WelaListenerCreated $listener $selection;$count++
|
||||
Assert ($listener.ListeningOn.Count -eq 1 -and -not $listener.PolicyOwned) 'Actual native shape has exact address and local provenance.'
|
||||
foreach($bad in @($xml.Replace('<cfg:Port>5985</cfg:Port>',''),$xml.Replace('</cfg:Listener>','<cfg:Enabled>true</cfg:Enabled></cfg:Listener>'),$xml.Replace('cfg:Port','cfg:Unknown'),$xml.Replace('http://schemas.microsoft.com/wbem/wsman/1/config/listener','urn:wrong'),$xml.Replace('<cfg:Hostname/>','<cfg:Hostname unexpected="x"/>'),$xml.Replace('<cfg:Hostname/>','<cfg:Hostname><cfg:Nested/></cfg:Hostname>'),$xml.Replace('<cfg:Hostname/>','<?unexpected data?><cfg:Hostname/>'),$xml.Replace('>true<','>True<'),$xml.Replace('>5985<','>05985<'),$xml.Replace('</cfg:Listener>','<cfg:ListeningOn>192.0.2.10</cfg:ListeningOn></cfg:Listener>'),$xml.Replace('>192.0.2.10<','>not-an-address<'),('<!DOCTYPE x [<!ENTITY e SYSTEM "file:///does-not-exist">]>'+$xml))){Reject {ConvertFrom-WelaListenerXml $bad}}
|
||||
foreach($name in @('Address','Transport','Port','Hostname','Enabled','URLPrefix','CertificateThumbprint')){$copy=Copy-TestListener $listener;$copy.$name='unexpected';Reject {Assert-WelaListenerCreated $copy $selection} 'differs'}
|
||||
$copy=Copy-TestListener $listener;$copy.ListeningOn=@('192.0.2.10','192.0.2.11');Reject {Assert-WelaListenerCreated $copy $selection} 'exactly'
|
||||
$copy=Copy-TestListener $listener;$copy.PolicyOwned=$true;Reject {Assert-WelaListenerCreated $copy $selection} 'local'
|
||||
$copy=Copy-TestListener $listener;$copy.PolicyOwned='False';Reject {Assert-WelaListenerCreated $copy $selection} 'local'
|
||||
$owned=ConvertFrom-WelaListenerXml ($xml.Replace('<cfg:Port>','<cfg:Port Source="GPO">'));Assert $owned.PolicyOwned 'Native GPO provenance remains explicit.'
|
||||
Reject {Assert-WelaListenerAbsent @($listener) $selection} 'Existing'
|
||||
$copy=Copy-TestListener $listener;$copy.Address='*';$copy.Port='6000';Reject {Assert-WelaListenerAbsent @($copy) $selection} 'Existing'
|
||||
$copy=Copy-TestListener $listener;$copy.Address='IP:192.0.2.11';Reject {Assert-WelaListenerAbsent @($copy) $selection} 'Existing'
|
||||
$copy=Copy-TestListener $listener;$copy.Port='6000';Reject {Assert-WelaListenerAbsent @($copy) $selection} 'Existing'
|
||||
$other=Copy-TestListener $listener;$other.Address='*';$other.Transport='HTTPS';$other.Port='5986';$other.ListeningOn=@('192.0.2.10');Assert-WelaListenerAbsent @($other) $selection;$count++
|
||||
$reader=[pscustomobject][ordered]@{Computer='TEST-HOST';ProcessId=100;UserSid='S-1-5-21-1-2-3-1001';UserName='TEST-HOST\operator';TokenId='111';ModifiedId='222';AuthenticationId='333';GroupSids=@('S-1-5-32-544');GroupCount=1;PrivilegeCount=20;ElevatedAdministrator=$true;TokenType='Primary';Impersonation='Absent'}
|
||||
$baseline=[pscustomobject][ordered]@{Local=[pscustomobject][ordered]@{Host=@{Computer='TEST-HOST';Build=26100;UBR=123;ProductType=3;DomainRole=2};MachineGuid='00000000-0000-0000-0000-000000000001';Reader=$reader;Services=@(@{Name='WinRM';State='Running';StartMode='Auto'});Addresses=@(@{IPAddress='192.0.2.10';AddressState='Preferred'});Policy='empty';WinrmXml='<Config/>';Listeners=@($other)};Profiles=@('protected');Rules=@('digest');NativeFirewall='native';NativeReader='native-reader';Adapter=@{ModulePath='native51-modules';Engine='native51';EngineSha256='a'*64;Worker='fixed-worker';WorkerSha256='b'*64};Sources='sources'}
|
||||
$changed=Copy-TestListener $baseline;$changed.Local.Reader.ProcessId=101;$changed.Local.Reader.TokenId='different';$changed.Local.Reader.ModifiedId='other';Assert ((Get-WelaListenerReviewKey $changed) -ceq (Get-WelaListenerReviewKey $baseline)) 'Plans permit separate processes in the same actual logon.'
|
||||
$changed.Local.Reader.AuthenticationId='444';Assert ((Get-WelaListenerReviewKey $changed) -cne (Get-WelaListenerReviewKey $baseline)) 'Different logon requires a new plan.'
|
||||
Assert-WelaListenerSelectedHost $baseline.Local $selection;$count++
|
||||
$changed=Copy-TestListener $baseline;$changed.Local.Addresses[0].AddressState='Tentative';Reject {Assert-WelaListenerSelectedHost $changed.Local $selection} 'Preferred'
|
||||
$changed=Copy-TestListener $baseline;$changed.Local.Host.Computer='OTHER';Reject {Assert-WelaListenerSelectedHost $changed.Local $selection} 'actual'
|
||||
$plan=[pscustomobject]@{SchemaVersion=1;Kind='WelaExactIpListenerPlan';Selection=$selection;StateKey=(Get-WelaListenerReviewKey $baseline);RecordedUtc='2026-09-21T00:00:00Z'};Assert-WelaListenerPlan $plan;$count++
|
||||
foreach($field in @('SchemaVersion','Kind','StateKey')){$bad=Copy-TestListener $plan;$bad.$field=$true;Reject {Assert-WelaListenerPlan $bad} 'mistyped'}
|
||||
$bad=Copy-TestListener $plan;$bad.Selection.ComputerName='test-host';Reject {Assert-WelaListenerPlan $bad} 'canonical'
|
||||
$bad=Copy-TestListener $plan;$bad|Add-Member NoteProperty Extra true;Reject {Assert-WelaListenerPlan $bad}
|
||||
Initialize-WelaListenerPipe
|
||||
$textReader=[IO.StringReader]::new('bounded');try{$task=[Wela.ListenerPipe.Bounded]::Read($textReader,7);Assert ($task.GetAwaiter().GetResult() -ceq 'bounded') 'Bounded stream reads complete content.'}finally{$textReader.Dispose()}
|
||||
$textReader=[IO.StringReader]::new('x'*65536);try{Reject {$task=[Wela.ListenerPipe.Bounded]::Read($textReader,1024);$task.GetAwaiter().GetResult()} 'bound'}finally{$textReader.Dispose()}
|
||||
foreach($failure in @('kill','wait','dispose')){
|
||||
$fake=[pscustomobject]@{HasExited=$false;Mode=$failure}
|
||||
$fake|Add-Member ScriptMethod Kill {if($this.Mode -eq 'kill'){throw 'Natural-exit race'}}
|
||||
$fake|Add-Member ScriptMethod WaitForExit {param($Timeout);if($this.Mode -eq 'wait'){throw 'Wait failed'};return $true}
|
||||
$fake|Add-Member ScriptMethod Dispose {if($this.Mode -eq 'dispose'){throw 'Dispose failed'}}
|
||||
$result=[pscustomobject]@{Started=$true;TerminationConfirmed=$false;Diagnostic=''};Close-WelaListenerAdapterProcess $fake $result
|
||||
Assert ($result.Started -and $result.Diagnostic) "Possible creation remains recorded after $failure cleanup failure."
|
||||
Assert ($result.TerminationConfirmed -eq ($failure -ne 'wait')) 'Termination certainty is separately retained.'
|
||||
}
|
||||
$receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaNative51ListenerCreate';Status='Created';NativeCreateAttempted=$true;ProcessId=123;Engine='native51';EngineVersion='5.1.26100.1';ModulePath='native51-modules';Reader=$reader;Selection=$selection;CreatedXml='<EPR/>';After=@($listener);Diagnostic='';NativeHResult=$null}
|
||||
Assert-WelaListenerAdapterReceipt $receipt $baseline 123 0;$count++
|
||||
$bad=Copy-TestListener $receipt;$bad.ModulePath='unexpected-search-root';Reject {Assert-WelaListenerAdapterReceipt $bad $baseline 123 0} 'identity|status'
|
||||
foreach($field in @('Kind','Engine','EngineVersion','ModulePath','Status','ProcessId','SchemaVersion')){$bad=Copy-TestListener $receipt;$bad.$field=$true;Reject {Assert-WelaListenerAdapterReceipt $bad $baseline 123 0} 'identity|status'}
|
||||
$bad=Copy-TestListener $receipt;$bad.Reader.AuthenticationId='OTHER';Reject {Assert-WelaListenerAdapterReceipt $bad $baseline 123 0} 'logon'
|
||||
Reject {Assert-WelaListenerAdapterReceipt $receipt $baseline 124 0} 'identity'
|
||||
Reject {Assert-WelaListenerAdapterReceipt $receipt $baseline 123 1} 'success'
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-listener-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$script:mode='ok';$script:reads=0;$script:starts=0;$script:created=$false
|
||||
function Get-WelaListenerState {
|
||||
$script:reads++;$state=Copy-TestListener $baseline
|
||||
if($script:created){$state.Local.Listeners=@((Copy-TestListener $other),(Copy-TestListener $listener))}
|
||||
if($script:mode -eq 'race' -and $script:reads -eq 2){$state.Local.WinrmXml='<Changed/>'}
|
||||
if($script:created -and $script:mode -eq 'firewall-drift'){$state.Rules=@('changed')}
|
||||
if($script:created -and $script:mode -eq 'token-drift'){$state.Local.Reader.ModifiedId='changed'}
|
||||
if($script:created -and $script:mode -eq 'broader'){$state.Local.Listeners[1].ListeningOn=@('192.0.2.10','192.0.2.11')}
|
||||
$state
|
||||
}
|
||||
function Start-WelaListenerAdapter {
|
||||
param($State,$RequestPath,$RequestHash)
|
||||
$script:starts++;$dir=Split-Path $RequestPath -Parent
|
||||
$intent=Get-Content (Join-Path $dir 'before-create.json') -Raw|ConvertFrom-Json
|
||||
Assert ($intent.Status -ceq 'Pending' -and (Read-WelaWecUpdateFile $RequestPath).Hash -ceq $RequestHash -and (Get-Content (Join-Path $dir 'native-payload.xml') -Raw) -ceq (New-WelaListenerPayload)) 'Durable intent and fixed payload precede adapter startup.'
|
||||
if($script:mode -eq 'timeout'){return [pscustomobject]@{Started=$true;Receipt=$null;Diagnostic='timeout';TerminationConfirmed=$false}}
|
||||
$reply=Copy-TestListener $receipt
|
||||
if($script:mode -eq 'refused'){$reply.Status='Refused';$reply.NativeCreateAttempted=$false;$reply.Diagnostic='fresh worker context differs'}else{$script:created=$true}
|
||||
if($script:mode -eq 'native-error'){$reply.Status='CreateAttemptedUnverified';$reply.Diagnostic='native failed'}
|
||||
[pscustomobject]@{Started=$true;Receipt=$reply;Diagnostic=$(if($script:mode -eq 'cleanup-error'){'cleanup failed'}else{''});TerminationConfirmed=$true}
|
||||
}
|
||||
try {
|
||||
foreach($scenario in @('ok','hash','schema','duplicate-json','context','race','refused','timeout','native-error','firewall-drift','token-drift','broader','cleanup-error','replay')){
|
||||
$script:mode='ok';$script:reads=0;$script:starts=0;$script:created=$false
|
||||
$planned=Invoke-WelaWecListener -ComputerName 'TEST-HOST' -LocalAddress '192.0.2.10' -OutputPath (Join-Path $root ($scenario+'-plan'))
|
||||
Assert ($planned.Status -ceq 'ReviewRequired' -and $planned.ExitCode -eq 0 -and $script:starts -eq 0) "Plan reads only: $($planned.Diagnostic)"
|
||||
$path=Join-Path $planned.OutputPath 'plan.json';$hash=$planned.PlanHash
|
||||
if($scenario -eq 'hash'){$hash='f'*64}
|
||||
if($scenario -in @('schema','duplicate-json','context')){
|
||||
$text=[IO.File]::ReadAllText($path)
|
||||
if($scenario -eq 'schema'){$text=$text -replace '"SchemaVersion"\s*:\s*1','"SchemaVersion": true'}
|
||||
if($scenario -eq 'duplicate-json'){$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')}
|
||||
if($scenario -eq 'context'){$text=$text.Replace('TEST-HOST','OTHER-HOST')}
|
||||
[IO.File]::WriteAllText($path,$text);$hash=(Get-FileHash $path).Hash.ToLowerInvariant()
|
||||
}
|
||||
$script:mode=$scenario;$script:reads=0;$applied=Invoke-WelaWecListener Apply -PlanPath $path -PlanHash $hash -OutputPath (Join-Path $root ($scenario+'-apply'))
|
||||
Assert (($applied.ExitCode -eq 0) -eq ($scenario -in @('ok','replay'))) "Outcome $scenario : $($applied.Diagnostic)"
|
||||
Assert ($applied.ReadyRuleCredit -eq 0 -and $applied.ServiceChanges -eq 0 -and $applied.FirewallChanges -eq 0 -and (Test-Path (Join-Path $applied.OutputPath 'manifest.json'))) 'No unrelated changes or detection credit; final receipt retained.'
|
||||
foreach($artifact in $applied.Artifacts){Assert ((Get-FileHash (Join-Path $applied.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Receipt artifact hash matches actual bytes.'}
|
||||
if($scenario -in @('hash','schema','duplicate-json','context','race')){Assert ($script:starts -eq 0 -and $applied.Status -ceq 'Refused') 'Refusal precedes any adapter start.'}
|
||||
if($scenario -in @('timeout','native-error','firewall-drift','token-drift','broader','cleanup-error')){Assert ($applied.AdapterStarted -and $applied.Status -ceq 'CreateAttemptedUnverified') 'Possible native creation is never mislabeled Refused.'}
|
||||
if($scenario -eq 'refused'){Assert ($applied.Status -ceq 'Refused' -and $applied.NativeCreateAttempted -eq $false) 'Authenticated native refusal before create stays distinct.'}
|
||||
if($scenario -eq 'replay'){$again=Invoke-WelaWecListener Apply -PlanPath $path -PlanHash $hash -OutputPath (Join-Path $root 'replay-again');Assert ($again.Status -ceq 'Refused' -and $script:starts -eq 1) 'Applied plan cannot be replayed.'}
|
||||
}
|
||||
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
Write-Host "PASS: $count focused WEC listener assertions. No native listener proof is claimed."
|
||||
@@ -0,0 +1,129 @@
|
||||
param([switch]$AllowDisposableListenerReplacement)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableListenerReplacement -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows listener replacement opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/NativeChannelConfiguration.ps1"
|
||||
. "$repo/scripts/WefDeployment.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/ChannelRead.ps1"
|
||||
. "$repo/scripts/FirewallLoggingRecovery.ps1"
|
||||
. "$repo/scripts/WecListener.ps1"
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
function Public([string[]]$Arguments,[int]$Code=0){
|
||||
$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1|Out-String;$actual=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
if($actual -ne $Code){Write-Host $text;Get-ChildItem $root -Filter manifest.json -Recurse|ForEach-Object {Write-Host (Get-Content $_.FullName -Raw)};throw "Public command exit $actual differs from expected $Code"}
|
||||
}
|
||||
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-listener-native-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
function Save($Name,$Value){$Value|ConvertTo-Json -Depth 20|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
|
||||
function ReadListeners {
|
||||
@(Microsoft.WSMan.Management\Get-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -Enumerate -ErrorAction Stop|ForEach-Object {
|
||||
[pscustomobject][ordered]@{Address=[string]$_.Address;Transport=[string]$_.Transport;Port=[string]$_.Port;Hostname=[string]$_.Hostname;Enabled=[string]$_.Enabled;URLPrefix=[string]$_.URLPrefix;CertificateThumbprint=[string]$_.CertificateThumbprint;ListeningOn=@($_.ListeningOn|ForEach-Object {[string]$_}|Sort-Object);RawXml=$_.OuterXml}
|
||||
}|Sort-Object Address,Transport)
|
||||
}
|
||||
function Key($Value){ConvertTo-Json -InputObject @($Value|Select-Object Address,Transport,Port,Hostname,Enabled,URLPrefix,CertificateThumbprint,ListeningOn) -Depth 10 -Compress}
|
||||
function ReadServices {@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='MpsSvc' OR Name='BFE'"|Sort-Object Name|Select-Object Name,StartMode,State)}
|
||||
function ReadFirewall {@(NetSecurity\Get-NetFirewallRule -PolicyStore ActiveStore|Sort-Object Name|Select-Object Name,Enabled,Profile,Direction,Action,PolicyStoreSourceType)}
|
||||
$adapter=Join-Path $root 'checkpoint-native51.ps1'
|
||||
@'
|
||||
param([string]$ListenerAddress,[string]$PayloadPath)
|
||||
$env:PSModulePath=[IO.Path]::Combine([Environment]::SystemDirectory,'WindowsPowerShell\v1.0\Modules')
|
||||
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()}
|
||||
$r=[ordered]@{EngineMajor=$PSVersionTable.PSVersion.Major;Engine=$PSVersionTable.PSVersion.ToString();ProcessId=$PID;UserSid=$sid;Status='Failed';Xml='';Diagnostic=''}
|
||||
try {
|
||||
if($PSVersionTable.PSVersion.Major -ne 5 -or $ListenerAddress -notmatch '^(\*|IP:[0-9.]+)$'){throw 'Only fixture native5.1 HTTP selectors are supported.'}
|
||||
$held=[IO.File]::Open($PayloadPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
|
||||
try {$v=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$ListenerAddress;Transport='HTTP'} -FilePath $PayloadPath -ErrorAction Stop;$r.Xml=[string]$v.OuterXml;$r.Status='Created'}finally{$held.Dispose()}
|
||||
}catch{$r.Diagnostic=$_.ToString()}
|
||||
$r|ConvertTo-Json -Compress
|
||||
if($r.Status -ne 'Created'){exit 1}
|
||||
'@|Set-Content -LiteralPath $adapter -Encoding UTF8
|
||||
function NewCheckpointListener($Selector,$Values) {
|
||||
$doc=[Xml.XmlDocument]::new();$element=$doc.CreateElement('cfg','Listener','http://schemas.microsoft.com/wbem/wsman/1/config/listener');$null=$doc.AppendChild($element)
|
||||
foreach($name in @('Port','Hostname','Enabled','URLPrefix','CertificateThumbprint')){$child=$doc.CreateElement('cfg',$name,$element.NamespaceURI);$child.InnerText=[string]$Values[$name];$null=$element.AppendChild($child)}
|
||||
$payload=Join-Path $root ('native-listener-'+[guid]::NewGuid().ToString('N')+'.xml');[IO.File]::WriteAllText($payload,$doc.OuterXml,[Text.UTF8Encoding]::new($false))
|
||||
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe'
|
||||
$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$adapter+'" -ListenerAddress "'+$Selector.Address+'" -PayloadPath "'+$payload+'"'
|
||||
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false)
|
||||
Initialize-WelaListenerPipe
|
||||
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$result=[pscustomobject]@{Started=$false;TerminationConfirmed=$false;Diagnostic=''}
|
||||
try {
|
||||
if(-not $process.Start()){throw 'Native5.1 adapter did not start.'};$result.Started=$true;$childId=$process.Id;$stdout=[Wela.ListenerPipe.Bounded]::Read($process.StandardOutput,65536);$stderr=[Wela.ListenerPipe.Bounded]::Read($process.StandardError,65536)
|
||||
if(-not $process.WaitForExit(20000)){throw 'Native5.1 adapter timed out.'};if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Fixture adapter output drain timed out.'};$text=$stdout.Result;$errorText=$stderr.Result
|
||||
if($errorText -or $text.Length -gt 65536){throw 'Unexpected native adapter output.'}
|
||||
$receipt=$text|ConvertFrom-Json;Save ('adapter-'+[guid]::NewGuid().ToString('N')+'.json') $receipt
|
||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()}
|
||||
Assert ($receipt.EngineMajor -eq 5 -and $receipt.ProcessId -eq $childId -and $receipt.UserSid -ceq $sid) 'Actual native5.1 child identity must match the invoking account and observed PID.'
|
||||
if($process.ExitCode -ne 0 -or $receipt.Status -cne 'Created'){throw $receipt.Diagnostic}
|
||||
[string]$receipt.Xml
|
||||
}finally{Close-WelaListenerAdapterProcess $process $result;if($result.Diagnostic){$script:cleanupErrors+=$result.Diagnostic;Write-Host $result.Diagnostic}}
|
||||
}
|
||||
|
||||
$services=ReadServices;$firewall=ReadFirewall;$original=$null;$fullOriginal=$null;$removed=@();$created=$false;$failure=$null;$cleanupErrors=@();$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
try {
|
||||
$os=Get-CimInstance Win32_OperatingSystem;Assert ($os.BuildNumber -in @('20348','26100') -and $os.ProductType -eq 3) 'Standalone Server 2022/2025 fixture required.'
|
||||
$s=@($services|Where-Object Name -eq 'WinRM');Assert ($s.Count -eq 1 -and $s[0].StartMode -in @('Auto','Manual') -and $s[0].State -in @('Running','Stopped')) 'Stable non-disabled WinRM required.'
|
||||
if($s[0].State -ne 'Running'){Start-Service WinRM -ErrorAction Stop}
|
||||
$original=ReadListeners;$fullOriginal=Get-WelaListenerState;Save 'complete-original.json' $fullOriginal;Save 'listeners-original.json' $original;Save 'services-original.json' $services;Save 'firewall-original.json' $firewall
|
||||
# Replacement is a fixture-only, disposable-VM operation. Product must refuse overlaps.
|
||||
foreach($listener in @($original|Where-Object Transport -eq 'HTTP')){
|
||||
Assert ($listener.Address -match '^(\*|IP:[0-9.]+)$' -and $listener.Port -eq '5985' -and $listener.URLPrefix -eq 'wsman' -and $listener.Enabled -in @('true','false') -and -not $listener.CertificateThumbprint -and $listener.RawXml -notmatch 'Source="GPO"') 'Only ordinary local HTTP fixture listeners can be temporarily replaced.'
|
||||
Microsoft.WSMan.Management\Remove-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$listener.Address;Transport='HTTP'} -ErrorAction Stop
|
||||
$removed+=$listener
|
||||
}
|
||||
$ip=@(NetTCPIP\Get-NetIPAddress -AddressFamily IPv4|Where-Object {$_.AddressState -eq 'Preferred' -and $_.IPAddress -notmatch '^(127\.|169\.254\.|0\.)'}|Sort-Object IPAddress|Select-Object -First 1).IPAddress
|
||||
Assert ([bool]$ip) 'An assigned preferred IPv4 address is required.';$selector=@{Address='IP:'+$ip;Transport='HTTP'}
|
||||
$values=@{Port='5985';Hostname='';Enabled='true';URLPrefix='wsman';CertificateThumbprint=''}
|
||||
Save 'selection.json' @{Selector=$selector;Values=$values}
|
||||
$planDir=Join-Path $root 'public-plan';$applyDir=Join-Path $root 'public-apply'
|
||||
Public @('wec-listener','-WecListenerComputerName',[Environment]::MachineName,'-WecListenerLocalAddress',$ip,'-WecListenerOutputPath',$planDir)
|
||||
$plan=Get-Content (Join-Path $planDir 'manifest.json') -Raw|ConvertFrom-Json;Assert ($plan.Status -ceq 'ReviewRequired' -and -not $plan.AdapterStarted) 'Public Plan makes no native create attempt.'
|
||||
$planPath=Join-Path $planDir 'plan.json';Assert ((Get-FileHash $planPath).Hash.ToLowerInvariant() -ceq $plan.PlanHash) 'Public plan hash is exact.'
|
||||
$badDir=Join-Path $root 'bad-hash'
|
||||
Public @('wec-listener','-WecListenerAction','Apply','-WecListenerPlanPath',$planPath,'-WecListenerPlanHash',('f'*64),'-WecListenerOutputPath',$badDir) 1
|
||||
$bad=Get-Content (Join-Path $badDir 'manifest.json') -Raw|ConvertFrom-Json;Assert ($bad.Status -ceq 'Refused' -and -not $bad.AdapterStarted) 'Wrong plan hash refuses before adapter startup.'
|
||||
$created=$true
|
||||
Public @('wec-listener','-WecListenerAction','Apply','-WecListenerPlanPath',$planPath,'-WecListenerPlanHash',$plan.PlanHash,'-WecListenerOutputPath',$applyDir)
|
||||
$applied=Get-Content (Join-Path $applyDir 'manifest.json') -Raw|ConvertFrom-Json
|
||||
Assert ($applied.Status -ceq 'CreatedAndVerified' -and $applied.AdapterStarted -and $applied.NativeCreateAttempted -and $applied.Adapter.TerminationConfirmed -and $applied.Adapter.Receipt.EngineVersion -match '^5\.1\.') 'Public Apply uses the verified native5.1 adapter and confirms native creation.'
|
||||
Assert ($applied.Adapter.Receipt.ModulePath -ceq [IO.Path]::Combine([Environment]::SystemDirectory,'WindowsPowerShell\v1.0\Modules')) 'Actual adapter startup retains only the fixed native5.1 module directory.'
|
||||
Assert ($applied.Adapter.Receipt.ProcessId -eq $applied.Adapter.ProcessId -and $applied.Adapter.Receipt.Reader.UserSid -eq $fullOriginal.Local.Reader.UserSid -and $applied.Adapter.Receipt.Reader.AuthenticationId -eq $fullOriginal.Local.Reader.AuthenticationId) 'Actual native worker PID/account/logon is bound.'
|
||||
Assert ($applied.ReadyRuleCredit -eq 0 -and $applied.ServiceChanges -eq 0 -and $applied.AuthenticationChanges -eq 0 -and $applied.FirewallChanges -eq 0) 'No unrelated configuration changes or detection credit.'
|
||||
foreach($artifact in $applied.Artifacts){Assert ((Get-FileHash (Join-Path $applyDir $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Retained public artifact hash matches.'}
|
||||
$replayDir=Join-Path $root 'replay'
|
||||
Public @('wec-listener','-WecListenerAction','Apply','-WecListenerPlanPath',$planPath,'-WecListenerPlanHash',$plan.PlanHash,'-WecListenerOutputPath',$replayDir) 1
|
||||
$replay=Get-Content (Join-Path $replayDir 'manifest.json') -Raw|ConvertFrom-Json;Assert ($replay.Status -ceq 'Refused' -and -not $replay.AdapterStarted) 'Actual existing listener and changed context refuse replay.'
|
||||
$overlapDir=Join-Path $root 'overlap'
|
||||
Public @('wec-listener','-WecListenerComputerName',[Environment]::MachineName,'-WecListenerLocalAddress',$ip,'-WecListenerOutputPath',$overlapDir) 1
|
||||
$overlap=Get-Content (Join-Path $overlapDir 'manifest.json') -Raw|ConvertFrom-Json;Assert ($overlap.Status -ceq 'Refused' -and -not $overlap.AdapterStarted) 'Public Plan refuses an existing HTTP5985 listener.'
|
||||
$after=ReadListeners;Save 'listeners-created.json' $after;$chosen=@($after|Where-Object {$_.Address -ceq $selector.Address -and $_.Transport -ceq 'HTTP'})
|
||||
Assert ($chosen.Count -eq 1) 'Exactly one assigned-IP listener must exist.'
|
||||
Assert ($chosen[0].Port -ceq '5985' -and $chosen[0].Enabled -ceq 'true' -and $chosen[0].URLPrefix -ceq 'wsman' -and -not $chosen[0].CertificateThumbprint -and -not $chosen[0].Hostname) 'Every fixed listener property must match.'
|
||||
Assert ($chosen[0].ListeningOn.Count -eq 1 -and $chosen[0].ListeningOn[0] -ceq $ip) 'Actual ListeningOn must contain exactly the selected IPv4 address.'
|
||||
$duplicateRejected=$false;$duplicateError=''
|
||||
try {$null=NewCheckpointListener $selector $values}catch{$duplicateRejected=$true;$duplicateError=$_.ToString()}
|
||||
Save 'collision.json' @{Rejected=$duplicateRejected;Diagnostic=$duplicateError};Assert $duplicateRejected 'Windows must reject creating the same listener selector twice.'
|
||||
Assert ((Key (ReadListeners)) -ceq (Key $after)) 'Rejected collision must preserve the listener definition.'
|
||||
$prereq=@(Get-WelaWefCollectorPrerequisites ([pscustomobject]@{CollectorFqdn='fixture.invalid';ListenerAddress=$selector.Address;IngressRuleName='WELA-checkpoint-does-not-exist';IngressLocalAddresses=@($ip);IngressRemoteAddresses=@('192.0.2.0/24')}))
|
||||
Save 'collector-prerequisite.json' $prereq;$field=@($prereq|Where-Object Name -eq 'Existing matching HTTP listener');Assert ($field.Count -eq 1 -and $field[0].Verified) 'Existing collector prerequisite must recognize the actual exact-IP listener.'
|
||||
Write-Host "PASS: $count actual public listener assertions. No WEF delivery proof."
|
||||
}catch{$failure=$_.ToString();Write-Host $failure;throw}finally{
|
||||
try {if($created -and @(ReadListeners|Where-Object {$_.Address -ceq $selector.Address -and $_.Transport -ceq 'HTTP'}).Count){Microsoft.WSMan.Management\Remove-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $selector -ErrorAction Stop}}catch{$cleanupErrors+=$_.ToString()}
|
||||
foreach($listener in $removed){try {$null=NewCheckpointListener @{Address=$listener.Address;Transport=$listener.Transport} @{Port=$listener.Port;Hostname=$listener.Hostname;Enabled=$listener.Enabled;URLPrefix=$listener.URLPrefix;CertificateThumbprint=$listener.CertificateThumbprint}}catch{$cleanupErrors+=$_.ToString()}}
|
||||
$restored=$null;$listenersOk=$false;$firewallOk=$false;$servicesOk=$false;$configurationOk=$false
|
||||
try {$restored=ReadListeners;Save 'listeners-restored.json' $restored;$listenersOk=$null -ne $original -and (Key $original) -ceq (Key $restored)}catch{$cleanupErrors+=$_.ToString()}
|
||||
try {$fullRestored=Get-WelaListenerState;Save 'complete-restored.json' $fullRestored;$configurationOk=(Get-WelaListenerReviewKey $fullOriginal) -ceq (Get-WelaListenerReviewKey $fullRestored)}catch{$cleanupErrors+=$_.ToString()}
|
||||
try {if(@($services|Where-Object Name -eq 'WinRM')[0].State -eq 'Stopped'){Stop-Service WinRM -ErrorAction Stop};$endServices=ReadServices;Save 'services-restored.json' $endServices;$servicesOk=($services|ConvertTo-Json -Compress) -ceq ($endServices|ConvertTo-Json -Compress)}catch{$cleanupErrors+=$_.ToString()}
|
||||
try {$endFirewall=ReadFirewall;Save 'firewall-restored.json' $endFirewall;$firewallOk=($firewall|ConvertTo-Json -Compress) -ceq ($endFirewall|ConvertTo-Json -Compress)}catch{$cleanupErrors+=$_.ToString()}
|
||||
Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$cleanupErrors;FullConfigurationPreserved=$configurationOk;ListenersRestored=$listenersOk;ServicesRestored=$servicesOk;FirewallPreserved=$firewallOk;Complete=($configurationOk -and $listenersOk -and $servicesOk -and $firewallOk -and -not $cleanupErrors.Count);DisposableBoundary='Fixture temporarily replaced ordinary original HTTP listeners and restored their captured configuration; product creation must refuse overlap.'}
|
||||
if(-not $configurationOk -or -not $listenersOk -or -not $servicesOk -or -not $firewallOk -or $cleanupErrors.Count){throw 'Native checkpoint cleanup incomplete; inspect retained artifacts.'}
|
||||
}
|
||||
|
||||
# Negative public CLI probes intentionally return 1; successful complete cleanup ends the fixture with 0.
|
||||
exit 0
|
||||
@@ -0,0 +1,17 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
|
||||
$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$cases=@(
|
||||
@{Args=@('wec-state','-Help');Code=0;Pattern='Disable interrupts'},
|
||||
@{Args=@('configure','-WecStateAction','Apply','-Auto');Code=1;Pattern='require wec-state'},
|
||||
@{Args=@('wec-state','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-state','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-state','-Help','-WecUpdateAction','Apply');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-state','-Help','-Auto');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-state','-Help','-DryRun');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-state','-Help','-ResultsPath','not-created');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-state','-WecStateAction','Apply','-WecStateOutputPath','not-created');Code=1;Pattern='reviewed plan'},
|
||||
@{Args=@('wec-state','-WecStateOutputPath','not-created');Code=1;Pattern='Plan requires'}
|
||||
)
|
||||
foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++}
|
||||
Write-Host "WEC state CLI: $count checks passed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,114 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/WecState.ps1"
|
||||
Initialize-WelaWecStateNative
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"}
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-wec-state-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$sid='S-1-5-21-11-22-33-1001';$id='WELA Native Security Example'
|
||||
$base=[IO.File]::ReadAllText("$repo/config/wef-examples/native-security.xml").Replace('<Enabled>true</Enabled>','<Enabled>false</Enabled>').Replace('<AllowedSourceDomainComputers></AllowedSourceDomainComputers>',('<AllowedSourceDomainComputers>'+(Get-WelaWefAuthorization @($sid))+'</AllowedSourceDomainComputers>'))
|
||||
$script:xml=$base;$script:saves=0;$script:reads=0;$script:contextReads=0;$script:mode='ok';$script:journal=''
|
||||
function Get-WelaWecStateContext {
|
||||
$script:contextReads++;$token='11'*56
|
||||
if($script:mode -eq 'token-drift' -and $script:contextReads -gt 1){$token='22'*56}
|
||||
[pscustomobject][ordered]@{Computer='TEST';HostKey='20348';Reader=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1000';TokenStatistics=$token};Service='Running';DestinationLog=[pscustomobject]@{Enabled=($script:mode -ne 'disabled-destination')}}
|
||||
}
|
||||
function Read-WelaWecStateDefinition {
|
||||
param($Id,$SourceSids)
|
||||
$script:reads++
|
||||
if($script:mode -eq 'drift' -and $script:reads -eq 2){$script:xml=$script:xml.Replace('MinLatency','Normal')}
|
||||
if($script:mode -eq 'denied'){throw 'Native access denied'}
|
||||
Get-WelaWecStateDefinition $script:xml $SourceSids
|
||||
}
|
||||
function Read-WelaWecStateRuntime {param($Id);[pscustomobject]@{Status='Unknown';Diagnostic='Runtime unavailable';ReadyRuleCredit=0}}
|
||||
function New-WelaWecStateEdit {
|
||||
param($Before)
|
||||
$edit=[pscustomobject]@{SaveAttempted=$false}
|
||||
$edit|Add-Member ScriptMethod Save {param($Enabled)
|
||||
Assert (Test-Path -LiteralPath $script:journal) 'Durable pending record precedes native save'
|
||||
$pending=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($script:journal))
|
||||
Assert ($pending.Status -ceq 'Pending' -and $pending.DesiredEnabled -eq $Enabled) 'Pending receipt names exact desired state'
|
||||
if($script:mode -eq 'native-refusal'){throw 'Native view changed before save'}
|
||||
$this.SaveAttempted=$true;$script:saves++
|
||||
if($script:mode -eq 'failure'){throw 'native save failed'}
|
||||
if($script:mode -eq 'false-success'){return}
|
||||
$doc=Read-WelaWefXml $script:xml;$doc.Subscription.Enabled=$Enabled.ToString().ToLowerInvariant()
|
||||
if($script:mode -eq 'preservation'){$doc.Subscription.ReadExistingEvents='true'}
|
||||
$script:xml=$doc.OuterXml
|
||||
if($script:mode -eq 'evidence-tamper'){[IO.File]::AppendAllText($script:journal,' ')}
|
||||
}
|
||||
$edit|Add-Member ScriptMethod Dispose {}
|
||||
$edit
|
||||
}
|
||||
try {
|
||||
$before=Get-WelaWecStateDefinition $base @($sid)
|
||||
Assert (-not $before.Enabled -and $before.Id -ceq $id) 'Disabled original parsed'
|
||||
$enabled=Get-WelaWecStateDefinition ($base.Replace('<Enabled>false','<Enabled>true')) @($sid)
|
||||
Assert ($enabled.Enabled -and $enabled.PreservedKey -ceq $before.PreservedKey -and $enabled.WholeKey -cne $before.WholeKey) 'Only Enabled excluded from preservation comparison'
|
||||
Reject {Get-WelaWecStateDefinition $base @('S-1-1-0')} 'SID'
|
||||
Reject {Get-WelaWecStateDefinition ($base.Replace('Path="Security"','Path="Microsoft-Windows-Sysmon/Operational"')) @($sid)} 'Sysmon'
|
||||
Reject {Get-WelaWecStateDefinition ($base.Replace('SourceInitiated','CollectorInitiated')) @($sid)} 'source-initiated'
|
||||
Reject {Get-WelaWecStateDefinition ($base.Replace('<Enabled>false</Enabled>','<Enabled>false</Enabled><Enabled>true</Enabled>')) @($sid)} 'duplicate'
|
||||
Reject {Invoke-WelaWecState -Id $id -SourceSids @($sid) -OutputPath (Join-Path $root 'invalid')} 'Plan requires'
|
||||
Reject {Invoke-WelaWecState -Action Apply -PlanPath missing -PlanHash ('a'*64) -State Enabled -OutputPath (Join-Path $root 'invalid')} 'only'
|
||||
foreach($scenario in @('ok','drift','token-drift','failure','false-success','preservation','hash','stale','context','duplicate-key','wrong-type','source-hash','denied','native-refusal','evidence-tamper')){
|
||||
$script:xml=$base;$script:mode='ok';$script:reads=0;$script:contextReads=0;$script:saves=0
|
||||
$planResult=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Enabled -OutputPath (Join-Path $root ($scenario+'-plan'))
|
||||
Assert ($planResult.ExitCode -eq 0 -and $planResult.Status -eq 'ReviewRequired') "Plan created: $($planResult.Diagnostic)"
|
||||
Assert ($script:saves -eq 0 -and -not $planResult.BeforeEnabled -and $planResult.DesiredEnabled) 'Plan is read only and states exact transition'
|
||||
$planPath=Join-Path $planResult.OutputPath 'plan.json';$hash=$planResult.PlanHash
|
||||
$script:mode=$scenario;$script:reads=0;$script:contextReads=0
|
||||
if($scenario -eq 'hash'){$hash='b'*64}
|
||||
if($scenario -eq 'stale'){$script:xml=$base.Replace('MinLatency','Normal')}
|
||||
if($scenario -in @('context','duplicate-key','wrong-type','source-hash')){
|
||||
$text=[IO.File]::ReadAllText($planPath)
|
||||
switch($scenario){
|
||||
context {$text=$text.Replace('TEST','OTHER')}
|
||||
duplicate-key {$text=$text.Replace('"SchemaVersion":','"SchemaVersion": 1, "SchemaVersion":')}
|
||||
wrong-type {$text=$text -replace '"DesiredEnabled":\s*true','"DesiredEnabled": "true"'}
|
||||
source-hash {$text=$text.Replace('scripts/WecState.ps1','scripts/Untrusted.ps1')}
|
||||
}
|
||||
[IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant()
|
||||
}
|
||||
$out=Join-Path $root ($scenario+'-apply');$script:journal=Join-Path $out 'before-save.json'
|
||||
$result=Invoke-WelaWecState Apply -PlanPath $planPath -PlanHash $hash -OutputPath $out
|
||||
Assert (($result.ExitCode -eq 0) -eq ($scenario -eq 'ok')) "Scenario $scenario : $($result.Diagnostic)"
|
||||
Assert ($result.ReadyRuleCredit -eq 0 -and $result.BookmarkContinuity -eq 'Not established') 'No delivery/bookmark/Sigma credit'
|
||||
Assert (Test-Path (Join-Path $out 'manifest.json')) 'Result retained'
|
||||
if($scenario -in @('drift','token-drift','hash','stale','context','duplicate-key','wrong-type','source-hash','denied','native-refusal')){Assert ($script:saves -eq 0 -and -not $result.NativeSaveAttempted) 'Rejected before native save'}
|
||||
if($scenario -in @('failure','false-success','preservation','evidence-tamper')){Assert ($result.Status -eq 'SaveAttemptedUnverified' -and $result.NativeSaveAttempted) 'Partial failure remains explicit'}
|
||||
if($scenario -eq 'ok'){
|
||||
$after=Get-WelaWecStateDefinition $script:xml @($sid)
|
||||
Assert ($after.PreservedKey -ceq $before.PreservedKey -and $after.Enabled -and $result.Status -eq 'StateChangedAndVerified') 'Only Enabled changed'
|
||||
Assert ($result.RuntimeAfter.Status -eq 'Unknown') 'Unknown runtime does not become healthy or invalidate observed configuration'
|
||||
}
|
||||
}
|
||||
$script:mode='disabled-destination';$script:xml=$base;$script:saves=0
|
||||
$blocked=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Enabled -OutputPath (Join-Path $root 'disabled-destination-plan')
|
||||
Assert ($blocked.Status -eq 'Refused' -and $blocked.Diagnostic -match 'ForwardedEvents' -and $script:saves -eq 0) 'Disabled destination is rejected before planning activation'
|
||||
$disabled=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Disabled -OutputPath (Join-Path $root 'disabled-destination-disable')
|
||||
Assert ($disabled.ExitCode -eq 0) 'Disabled destination does not block a reviewed disable plan'
|
||||
foreach($desired in @('Enabled','Disabled')){
|
||||
$script:mode='ok';$script:xml=if($desired -eq 'Disabled'){$base}else{$base.Replace('<Enabled>false','<Enabled>true')};$script:saves=0
|
||||
$planResult=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State $desired -OutputPath (Join-Path $root ($desired+'-same-plan'))
|
||||
$result=Invoke-WelaWecState Apply -PlanPath (Join-Path $planResult.OutputPath 'plan.json') -PlanHash $planResult.PlanHash -OutputPath (Join-Path $root ($desired+'-same-apply'))
|
||||
Assert ($result.Status -eq 'AlreadyMatches' -and $result.ExitCode -eq 0 -and $script:saves -eq 0) 'Idempotent enabled/disabled state never saves/reactivates'
|
||||
}
|
||||
$script:xml=$base.Replace('<Enabled>false','<Enabled>true');$script:saves=0
|
||||
$planResult=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Disabled -OutputPath (Join-Path $root 'disable-plan')
|
||||
$out=Join-Path $root 'disable-apply';$script:journal=Join-Path $out 'before-save.json'
|
||||
$result=Invoke-WelaWecState Apply -PlanPath (Join-Path $planResult.OutputPath 'plan.json') -PlanHash $planResult.PlanHash -OutputPath $out
|
||||
Assert ($result.ExitCode -eq 0 -and $result.BeforeEnabled -and -not $result.DesiredEnabled -and (Get-WelaWecStateDefinition $script:xml @($sid)).WholeKey -ceq $before.WholeKey) 'Explicit disable restores exact original XML semantics'
|
||||
Reject {Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Disabled -OutputPath $root} 'new directory'
|
||||
$one=Get-WelaWecStateContext;$two=Get-WelaWecStateContext;$two.Reader.TokenStatistics=('22'*8)+$two.Reader.TokenStatistics.Substring(16)
|
||||
Assert ((Get-WelaWecStateReviewKey $one) -ceq (Get-WelaWecStateReviewKey $two)) 'Different token objects in the same logon can use a reviewed plan'
|
||||
$two.Reader.TokenStatistics='22'*56
|
||||
Assert ((Get-WelaWecStateReviewKey $one) -cne (Get-WelaWecStateReviewKey $two)) 'Different actual logon cannot reuse a reviewed plan'
|
||||
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
Write-Host "WEC state tests passed: $count assertions."
|
||||
@@ -0,0 +1,112 @@
|
||||
param([switch]$AllowDisposableSubscription)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableSubscription -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows subscription opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/ControlApplicability.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/WecRuntime.ps1"
|
||||
. "$repo/scripts/WecState.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 16 -Compress}
|
||||
function ServiceState {Get-CimInstance Win32_Service -Filter "Name='Wecsvc'"|Select-Object Name,State,StartMode}
|
||||
function ChannelState {
|
||||
$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents')
|
||||
try {[pscustomobject]@{Enabled=$c.IsEnabled;Mode=[string]$c.LogMode;MaximumBytes=$c.MaximumSizeInBytes;Path=$c.LogFilePath;SecurityDescriptor=$c.SecurityDescriptor}}finally{$c.Dispose()}
|
||||
}
|
||||
function Set-ChannelEnabled([bool]$Enabled){$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{$c.IsEnabled=$Enabled;$c.SaveChanges()}finally{$c.Dispose()}}
|
||||
function Subscriptions {@((Invoke-WelaNative 'wecutil.exe' @('es')).Output|ForEach-Object {$_.ToString().Trim()}|Where-Object {$_})}
|
||||
function Invoke-Cli {
|
||||
param([string[]]$Arguments,[string]$Output,[bool]$Success=$true)
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" wec-state @Arguments -WecStateOutputPath $Output 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
Assert (($code -eq 0) -eq $Success) "Public CLI exit $code : $($text -join ' ')"
|
||||
$manifest=Join-Path $Output 'manifest.json';Assert (Test-Path $manifest) 'Public command emitted actual durable result'
|
||||
Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json
|
||||
}
|
||||
$beforeService=ServiceState;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart
|
||||
if($beforeService.State -notin @('Running','Stopped') -or $beforeService.StartMode -notin @('Auto','Manual','Disabled')){throw 'Stable Wecsvc state required.'}
|
||||
$nonce=[guid]::NewGuid().ToString('N');$id='WELA-State-Test-'+$nonce;$description='Owned state '+([string][char]0x65e5)+([string][char]0x672c)+([string][char]0x8a9e)+' '+$nonce;$changedDescription=$description
|
||||
$sid='S-1-5-21-111111111-222222222-333333333-1234'
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-wec-state-'+$nonce);$null=New-Item -ItemType Directory $root
|
||||
$created=$false;$beforeIds=$null;$primary=$null;$beforeChannel=ChannelState
|
||||
try {
|
||||
if($beforeService.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual}
|
||||
if($beforeService.State -eq 'Stopped'){Start-Service Wecsvc}
|
||||
if(-not $beforeChannel.Enabled){Set-ChannelEnabled $true}
|
||||
$duringChannel=ChannelState
|
||||
Assert ($duringChannel.Enabled) 'Disposable fixture enabled only destination channel prerequisite'
|
||||
[pscustomobject]@{Destination=$duringChannel;WinRM=(Get-CimInstance Win32_Service -Filter "Name='WinRM'"|Select-Object Name,State,StartMode);Wecsvc=(ServiceState)}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath (Join-Path $root 'fixture-prerequisites.json') -Encoding UTF8
|
||||
$beforeIds=@(Subscriptions);if($beforeIds -contains $id){throw 'Unique ID already exists.'}
|
||||
$query='<QueryList><Query Id="0" Path="Application"><Select Path="Application">*[System[(EventID=1)]]</Select></Query></QueryList>'
|
||||
$xml=@"
|
||||
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription"><SubscriptionId>$id</SubscriptionId><SubscriptionType>SourceInitiated</SubscriptionType><Description>$description</Description><Enabled>false</Enabled><Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri><ConfigurationMode>Normal</ConfigurationMode><Query><![CDATA[$query]]></Query><ReadExistingEvents>false</ReadExistingEvents><TransportName>HTTP</TransportName><ContentFormat>Events</ContentFormat><Locale Language="en-US"/><LogFile>ForwardedEvents</LogFile><AllowedSourceDomainComputers>$(Get-WelaWefAuthorization @($sid))</AllowedSourceDomainComputers></Subscription>
|
||||
"@
|
||||
$xmlPath=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($xmlPath,$xml);$created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$xmlPath)
|
||||
$before=Read-WelaWecStateDefinition $id @($sid);$duringService=ServiceState
|
||||
Assert (-not $before.Enabled -and $before.Description -ceq $description) 'Real owned disabled subscription preserves Unicode description'
|
||||
Initialize-WelaWecStateNative
|
||||
$missing=$false;try{$unexpected=[Wela.WecState.Edit]::new($id+'-absent');$unexpected.Dispose()}catch{$missing=$true}
|
||||
Assert ($missing -and @(Subscriptions) -notcontains ($id+'-absent')) 'Native existing-only open never creates missing subscription'
|
||||
$enablePlan=$null
|
||||
foreach($state in @('Disabled','Enabled','Enabled','Disabled','Disabled')){
|
||||
$index=$count;$out=Join-Path $root ("plan-$index")
|
||||
$prior=Read-WelaWecStateDefinition $id @($sid)
|
||||
$plan=Invoke-Cli -Arguments @('-WecStateId',$id,'-WecStateSourceSid',$sid,'-WecStateDesired',$state) -Output $out
|
||||
Assert ($plan.Status -eq 'ReviewRequired' -and -not $plan.NativeSaveAttempted) 'Public plan never changes Enabled'
|
||||
Assert ((Read-WelaWecStateDefinition $id @($sid)).WholeKey -ceq $prior.WholeKey) 'Plan preserved complete native subscription'
|
||||
$planPath=Join-Path $out 'plan.json'
|
||||
$apply=Invoke-Cli -Arguments @('-WecStateAction','Apply','-WecStatePlanPath',$planPath,'-WecStatePlanHash',$plan.PlanHash) -Output (Join-Path $root ("apply-$index"))
|
||||
$expected=($state -eq 'Enabled');$changed=($prior.Enabled -ne $expected)
|
||||
Assert ($apply.NativeSaveAttempted -eq $changed -and $apply.Status -eq $(if($changed){'StateChangedAndVerified'}else{'AlreadyMatches'})) 'Only an actual state transition invokes EcSaveSubscription'
|
||||
$after=Read-WelaWecStateDefinition $id @($sid)
|
||||
Assert ($after.Enabled -eq $expected -and $after.PreservedKey -ceq $before.PreservedKey) 'Native readback differs only in Enabled'
|
||||
Assert ($apply.ReadyRuleCredit -eq 0 -and $apply.BookmarkContinuity -eq 'Not established' -and $null -ne $apply.RuntimeAfter) 'Separate native runtime observation supplies no delivery or bookmark claim'
|
||||
foreach($artifact in $apply.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $apply.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Saved native artifacts match hashes'}
|
||||
if($changed -and $expected){
|
||||
$enablePlan=$plan
|
||||
$stale=Invoke-Cli -Arguments @('-WecStateAction','Apply','-WecStatePlanPath',$planPath,'-WecStatePlanHash',$plan.PlanHash) -Output (Join-Path $root 'stale-enabled-plan') -Success $false
|
||||
Assert ($stale.Status -eq 'Refused' -and -not $stale.NativeSaveAttempted -and $stale.Diagnostic -match 'differs') 'A completed transition cannot replay its stale pre-state'
|
||||
Assert ((Read-WelaWecStateDefinition $id @($sid)).WholeKey -ceq $after.WholeKey) 'Stale plan refusal preserved enabled definition'
|
||||
}
|
||||
}
|
||||
Assert ((Read-WelaWecStateDefinition $id @($sid)).WholeKey -ceq $before.WholeKey) 'Explicit disable restored entire original native definition'
|
||||
# A separately opened native handle sees a changed description and refuses save.
|
||||
$edit=New-WelaWecStateEdit $before
|
||||
try {
|
||||
$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+($description+' drift')))
|
||||
$refused=$false;try{$edit.Save($true)}catch{$refused=$true}
|
||||
Assert ($refused -and -not $edit.SaveAttempted) 'Fresh native handle guards description drift before saving'
|
||||
Assert (-not(Read-WelaWecStateDefinition $id @($sid)).Enabled) 'Native drift refusal did not enable subscription'
|
||||
}finally{$edit.Dispose();$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+$description))}
|
||||
Assert ((Read-WelaWecStateDefinition $id @($sid)).WholeKey -ceq $before.WholeKey) 'Native drift fixture restored original description'
|
||||
Assert ((Key (ChannelState)) -ceq (Key $duringChannel)) 'Product command preserved complete channel configuration'
|
||||
Assert ((Key (ServiceState)) -ceq (Key $duringService)) 'Product command preserved service state/startup'
|
||||
Write-Host "Native WEC state passed $count assertions on $([Environment]::OSVersion.Version), PowerShell $($PSVersionTable.PSVersion). No real source, listener or bookmark claim."
|
||||
}catch{$primary=$_}
|
||||
finally {
|
||||
$errors=@()
|
||||
try {
|
||||
if($created -and @(Subscriptions) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('s',$doc.DocumentElement.NamespaceURI);$observed=$doc.SelectSingleNode('/s:Subscription/s:Description',$ns).InnerText;if($observed -cnotin @($description,$changedDescription)){throw 'Fixture ownership changed; refusing deletion.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)}
|
||||
if($null -ne $beforeIds -and (Key @($beforeIds|Sort-Object)) -cne (Key @(Subscriptions|Sort-Object))){throw 'Subscription inventory differs after cleanup.'}
|
||||
}catch{$errors+=$_.Exception.Message}
|
||||
try {
|
||||
if((ChannelState).Enabled -ne $beforeChannel.Enabled){Set-ChannelEnabled $beforeChannel.Enabled}
|
||||
if((Key (ChannelState)) -cne (Key $beforeChannel)){throw 'Original destination channel configuration differs.'}
|
||||
}catch{$errors+=$_.Exception.Message}
|
||||
try {
|
||||
if($beforeService.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc}
|
||||
if($beforeService.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled}
|
||||
if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}}
|
||||
if((Key (ServiceState)) -cne (Key $beforeService) -or (Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){throw 'Original Wecsvc state/startup differs.'}
|
||||
}catch{$errors+=$_.Exception.Message}
|
||||
if($errors.Count){throw "Fixture cleanup failed; retained $root : $($errors -join '; '); primary failure: $primary"}
|
||||
[pscustomobject]@{Passed=($null -eq $primary);Assertions=$count;OriginalSubscriptionsRestored=$true;OriginalServiceRestored=$true;OriginalChannelRestored=$true;Computer=[Environment]::MachineName;Engine=$PSVersionTable.PSVersion.ToString();Scope='Owned native Enabled transitions only; no real source, listener, forwarding or bookmark proof'}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root 'acceptance.json') -Encoding UTF8
|
||||
Write-Host 'Original subscription inventory and Wecsvc state/startup restored.'
|
||||
}
|
||||
if($primary){throw $primary}
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,34 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
& (Get-Module WefSubscriptions) {Initialize-WelaWecSubscriptionInventory}
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action){$failed=$false;try{&$Action|Out-Null}catch{$failed=$true};Assert $failed 'Malformed, duplicate, partial or oversized native inventory must be rejected'}
|
||||
Assert ([Wela.WecInventory.Reader]::SourceSha256 -ceq (Get-FileHash "$repo/modules/WecSubscriptionInventory.cs").Hash.ToLowerInvariant()) 'Loaded inventory binds exact source bytes'
|
||||
Assert ([Wela.WecInventory.Reader]::ValidateNames([string[]]@()).Length -eq 0) 'Completed empty inventory is distinct from an error'
|
||||
$unicode='Name '+[char]0x65e5+[char]0x672c
|
||||
$names=[string[]]@('z',$unicode,'A',' leading ',([string][char]0xfeff))
|
||||
$observed=[Wela.WecInventory.Reader]::ValidateNames($names)
|
||||
Assert ($observed.Length -eq 5 -and $observed -ccontains $unicode -and $observed -ccontains ' leading ' -and $observed -ccontains ([string][char]0xfeff)) 'Actual Unicode and whitespace names are retained, never console-trimmed'
|
||||
Assert ($names[0] -ceq 'z') 'Validation does not mutate caller inventory'
|
||||
Reject {[Wela.WecInventory.Reader]::ValidateNames($null)}
|
||||
Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@('same','SAME'))}
|
||||
Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(''))}
|
||||
Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@("ab`0cd"))}
|
||||
Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@('x'*1024))}
|
||||
Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@([string][char]0xd800))}
|
||||
Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(1..4097|ForEach-Object {"id-$_"}))}
|
||||
Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(1..1025|ForEach-Object {$prefix=[string]$_;$prefix+('x'*(1023-$prefix.Length))}))}
|
||||
$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(64)
|
||||
try {
|
||||
for($i=0;$i -lt 64;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)}
|
||||
$bytes=[Text.Encoding]::Unicode.GetBytes($unicode+[char]0);[Runtime.InteropServices.Marshal]::Copy($bytes,0,$buffer,$bytes.Length)
|
||||
Assert ([Wela.WecInventory.Reader]::DecodeName($buffer,($unicode.Length+1),32) -ceq $unicode) 'Native used length counts UTF16 characters including terminator'
|
||||
foreach($used in @(0,1,33)){Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,$used,32)}}
|
||||
Reject {[Wela.WecInventory.Reader]::DecodeName([IntPtr]::Zero,2,32)}
|
||||
Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,2,1025)}
|
||||
Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,$unicode.Length,32)}
|
||||
[Runtime.InteropServices.Marshal]::WriteInt16($buffer,2,0);Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,($unicode.Length+1),32)}
|
||||
[Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,[int16]-10240);Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,2,32)}
|
||||
}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)}
|
||||
Write-Host "PASS: $count native subscription inventory buffer/boundary assertions."
|
||||
@@ -78,8 +78,17 @@ function Get-WSManInstance {
|
||||
}
|
||||
function Get-NetFirewallRule { param($Name,$PolicyStore) Assert ($PolicyStore -eq 'ActiveStore') 'Ingress is read from effective ActiveStore'; [pscustomobject]@{ Name=$Name; Enabled=$global:WelaWefFixture.Ingress; Direction='Inbound'; Action='Allow'; Profile='Domain'; PolicyStoreSourceType='Local'; EnforcementStatus='Full' } }
|
||||
function Get-NetFirewallPortFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ Protocol='TCP'; LocalPort='5985'; RemotePort='Any' } } }
|
||||
function Get-NetFirewallAddressFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ LocalAddress=@('192.0.2.10'); RemoteAddress=@('192.0.2.0/24') } } }
|
||||
function Get-NetFirewallAddressFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ LocalAddress=@('192.0.2.10/255.255.255.255'); RemoteAddress=@('192.0.2.0/255.255.255.0') } } }
|
||||
function Read-Host { param($Prompt) return $global:WelaWefFixture.Prompt }
|
||||
function Get-WelaWecSubscriptionIds {
|
||||
if($global:WelaWefFixture.Fail -eq 'Inventory'){throw 'Incomplete native inventory'}
|
||||
@($global:WelaWefFixture.Subs.Keys)
|
||||
}
|
||||
function Read-WelaWecSubscriptionXml {
|
||||
param($Id)
|
||||
if($global:WelaWefFixture.Fail -eq 'ReadXml' -or -not $global:WelaWefFixture.Subs.ContainsKey($Id)){throw 'Native definition is no longer readable'}
|
||||
$global:WelaWefFixture.Subs[$Id]
|
||||
}
|
||||
function Invoke-WelaNative {
|
||||
param($FilePath,$Arguments)
|
||||
$f=$global:WelaWefFixture
|
||||
@@ -90,8 +99,7 @@ function Invoke-WelaNative {
|
||||
}
|
||||
Assert ($FilePath -eq 'wecutil.exe') 'Only native wecutil subscription API is called'
|
||||
switch ($Arguments[0]) {
|
||||
'es' { return [pscustomobject]@{ ExitCode=0; Output=@($f.Subs.Keys); Diagnostic=(@($f.Subs.Keys) -join "`n") } }
|
||||
'gs' { if (-not $f.Subs.ContainsKey($Arguments[1])) { throw 'No fixture subscription' }; return [pscustomobject]@{ ExitCode=0; Output=@($f.Subs[$Arguments[1]]); Diagnostic=$f.Subs[$Arguments[1]] } }
|
||||
{$_ -in @('es','gs')} {throw 'Subscription inventory and XML must bypass console decoding.'}
|
||||
'gr' { return [pscustomobject]@{ ExitCode=0; Output=@('Localized runtime fixture'); Diagnostic='Localized runtime fixture' } }
|
||||
'cs' {
|
||||
Record-Write Subscription $Arguments
|
||||
@@ -215,6 +223,22 @@ try {
|
||||
Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'An existing disabled/different subscription is never silently updated'
|
||||
Assert ($report.Subscriptions[0].RequestedEnabled -and $report.Subscriptions[0].ObservedEnabled -eq $false) 'Inventory distinguishes an observed disabled subscription from the requested enabled definition'
|
||||
Reset-Fixture
|
||||
foreach($failure in @('Inventory','ReadXml')) {
|
||||
Reset-Fixture
|
||||
$model=Import-WelaWefConfig (Join-Path $temp 'collector.json') Collector
|
||||
$global:WelaWefFixture.Subs[$model.Subscriptions[0].Id]=$model.Subscriptions[0].Xml
|
||||
$global:WelaWefFixture.Fail=$failure
|
||||
$report=Invoke-Collector
|
||||
Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'Incomplete enumeration or disappearing/unreadable XML cannot authorize creation'
|
||||
Assert ($null -eq $report.Subscriptions[0].ObservedSubscription -and $null -eq $report.Subscriptions[0].ObservedEnabled -and $report.Subscriptions[0].ObservationError) 'Read failure stays unknown rather than absent or disabled'
|
||||
Assert (@($report.Controls|Where-Object {$_.Kind -eq 'Subscription' -and $_.Status -eq 'Unknown'}).Count -eq 1) 'Partial observation remains an unknown control'
|
||||
}
|
||||
Reset-Fixture
|
||||
$model=Import-WelaWefConfig (Join-Path $temp 'collector.json') Collector
|
||||
$global:WelaWefFixture.Subs[$model.Subscriptions[0].Id]=$model.Subscriptions[0].Xml.Replace($model.Subscriptions[0].Id,'Different native ID')
|
||||
$report=Invoke-Collector
|
||||
Assert ($report.ExitCode -eq 1 -and $report.Subscriptions[0].ObservationError -match 'identity differs' -and $global:WelaWefFixture.Writes.Count -eq 0) 'Mismatched native XML identity cannot become selected subscription evidence'
|
||||
Reset-Fixture
|
||||
$global:WelaWefFixture.Fail='false-subscription'
|
||||
$report=Invoke-Collector
|
||||
Assert ($report.ExitCode -eq 1) 'A successful native exit without matching subscription readback fails'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Scope comparison only; no firewall or Windows setting mutation.
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Code){$caught=$false;try{&$Code|Out-Null}catch{$caught=$true};Assert $caught 'Malformed, broad or non-IP observed scope must be refused.'}
|
||||
foreach($pair in @(
|
||||
@('192.0.2.0/24','192.0.2.0/255.255.255.0'),
|
||||
@('10.0.0.0/8','10.0.0.0/255.0.0.0'),
|
||||
@('192.0.2.1','192.0.2.1/255.255.255.255'),
|
||||
@('192.0.2.1/32','192.0.2.1'),
|
||||
@('192.0.2.128/25','192.0.2.128/255.255.255.128'),
|
||||
@('192.0.2.129/25','192.0.2.128/255.255.255.128'),
|
||||
@('2001:0DB8:0000:0000::/64','2001:db8::/64'),
|
||||
@('2001:db8::1/128','2001:0db8::1'),
|
||||
@('2001:db8::1/64','2001:db8::/64'),
|
||||
@('fe80::1%3','fe80:0:0:0:0:0:0:1%3')
|
||||
)){
|
||||
Assert (Test-WelaWefFirewallAddressSet @($pair[0]) @($pair[1])) ('Equivalent scopes compare equal: '+($pair -join ' / '))
|
||||
}
|
||||
foreach($pair in @(
|
||||
@('192.0.2.0/24','192.0.2.0/255.255.254.0'),
|
||||
@('192.0.2.0/24','192.0.2.0/255.255.255.128'),
|
||||
@('192.0.2.0/24','198.51.100.0/255.255.255.0'),
|
||||
@('192.0.2.1','192.0.2.2'),
|
||||
@('2001:db8::/64','2001:db8::/63'),
|
||||
@('2001:db8::/64','2001:db8:0:1::/64'),
|
||||
@('192.0.2.1','::ffff:192.0.2.1'),
|
||||
@('fe80::1%3','fe80::1%4')
|
||||
)){
|
||||
Assert (-not(Test-WelaWefFirewallAddressSet @($pair[0]) @($pair[1]))) ('Different scope is refused: '+($pair -join ' / '))
|
||||
}
|
||||
Assert (Test-WelaWefFirewallAddressSet @('2001:db8::/64','192.0.2.0/24') @('192.0.2.0/255.255.255.0','2001:0db8::/64')) 'Unordered mixed IPv4/IPv6 scopes remain equivalent.'
|
||||
Assert (-not(Test-WelaWefFirewallAddressSet @('192.0.2.1') @('192.0.2.1','192.0.2.2'))) 'Extra native scope is not a match.'
|
||||
Assert (-not(Test-WelaWefFirewallAddressSet @('192.0.2.1','192.0.2.2') @('192.0.2.1'))) 'Missing native scope is not a match.'
|
||||
Assert (-not(Test-WelaWefFirewallAddressSet @('192.0.2.1') @())) 'Empty native scope is unknown, never Any.'
|
||||
foreach($value in @('Any','LocalSubnet','example.org','192.0.2.1-192.0.2.10','192.0.2.0/0','192.0.2.0/0.0.0.0','192.0.2.0/255.0.255.0','192.0.2.0/255.255.999.0','192.0.2.0/255.255.0','192.0.2.0/33','::/0','::1/129','2001:db8::/255.255.255.0','192.0.2.1/24/32','')){Reject {Test-WelaWefFirewallAddressSet @('192.0.2.0/24') @($value)}}
|
||||
Reject {Test-WelaWefFirewallAddressSet @('192.0.2.0/255.255.255.0') @('192.0.2.0/24')}
|
||||
Reject {Test-WelaWefFirewallAddressSet @(1) @('192.0.2.1')}
|
||||
Reject {Test-WelaWefFirewallAddressSet @('192.0.2.1') @(1)}
|
||||
Write-Host "WEF firewall address comparison: $count assertions passed."
|
||||
@@ -35,8 +35,32 @@ $token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='LAB\Reader';Authenticat
|
||||
$descriptor=[pscustomobject]@{ControlFlags=32788;Owner=$null;Group=$null;DACL=@();SACL=@([pscustomobject]@{AceType=2;AceFlags=64;AccessMask=1;Trustee=[pscustomobject]@{SIDString='S-1-1-0'}})}
|
||||
$state=[pscustomobject][ordered]@{Namespace='root\default';Computer='LAB';Service='Running';Host=[pscustomobject]@{Status='Observed';Build=26100;ProductType=3;DomainJoined=$false};Token=$token;Descriptor=[pscustomobject]@{Namespace='root\default';DescriptorJson=($descriptor|ConvertTo-Json -Depth 10 -Compress);DescriptorMof='fixture descriptor'};AuditMask=1;Precedence=[pscustomobject]@{ValueExists=$true;Type='DWord';Value=1};Channel=[pscustomobject]@{Name='Security';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)'};Engine='/fixture';EngineHash=('a'*64);Sources='fixture-sources'}
|
||||
$operation=[pscustomobject]@{Namespace='root\default';StartedUtc='2025-01-02T03:04:05.1234500Z';CompletedUtc='2025-01-02T03:04:06.1234500Z';ExpectedAccessMask=1;SecurityRecordIdBefore=100;BeforeToken=$token;AfterToken=$token}
|
||||
# Clock evidence and exact bounds: coarse or padded intervals cannot authorize events.
|
||||
$timed=Clone $operation;$timed|Add-Member NoteProperty Clock 'GetSystemTimePreciseAsFileTime'
|
||||
$launch=[DateTimeOffset]'2025-01-02T03:04:05Z';$observed=[DateTimeOffset]'2025-01-02T03:04:07Z'
|
||||
$interval=Assert-WelaWmiProbeInterval $timed $launch $observed
|
||||
Assert ($interval.Start.UtcDateTime.Ticks -eq ([DateTimeOffset]'2025-01-02T03:04:05.1234500Z').UtcDateTime.Ticks) 'Precise fractional timestamp survives normalization.'
|
||||
foreach($case in @('MissingClock','CoarseClock','Reversed','BeforeLaunch','Future','Overlong','ParentReversed')){
|
||||
$bad=Clone $timed;$l=$launch;$o=$observed
|
||||
switch($case){
|
||||
MissingClock {$bad.PSObject.Properties.Remove('Clock')}
|
||||
CoarseClock {$bad.Clock='DateTime.UtcNow'}
|
||||
Reversed {$bad.CompletedUtc='2025-01-02T03:04:05Z'}
|
||||
BeforeLaunch {$bad.StartedUtc='2025-01-02T03:04:04.9999999Z'}
|
||||
Future {$bad.CompletedUtc='2025-01-02T03:04:07.0000001Z'}
|
||||
Overlong {$bad.CompletedUtc='2025-01-02T03:04:25.1234501Z';$o=[DateTimeOffset]'2025-01-02T03:05:00Z'}
|
||||
ParentReversed {$l=$observed;$o=$launch}
|
||||
}
|
||||
Reject {Assert-WelaWmiProbeInterval $bad $l $o} 'precise fixed worker time interval'
|
||||
}
|
||||
$clockImport=[Wela.WmiProbe.Native].GetMethod('GetSystemTimePreciseAsFileTime',[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0]
|
||||
Assert ($clockImport.ExactSpelling -and $clockImport.EntryPoint -ceq 'GetSystemTimePreciseAsFileTime') 'The native UTC clock is bound exactly.'
|
||||
$xml='<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-a5ba-3e3b0328c30d}"/><EventID>4662</EventID><Version>0</Version><Keywords>0x8020000000000000</Keywords><EventRecordID>101</EventRecordID><Channel>Security</Channel><Computer>LAB</Computer><TimeCreated SystemTime="2025-01-02T03:04:05.5000000Z"/></System><EventData><Data Name="SubjectUserSid">S-1-5-21-1-2-3-1001</Data><Data Name="SubjectLogonId">0x123</Data><Data Name="ObjectServer">WMI</Data><Data Name="ObjectName">root\default</Data><Data Name="AccessMask">0x1</Data></EventData></Event>'
|
||||
Assert (Test-WelaWmiProbeEvent $xml $operation $state) 'Exact synthetic WMI namespace event matches.'
|
||||
foreach($edge in @(@('03:04:05.1234500Z',$true),@('03:04:06.1234500Z',$true),@('03:04:05.1234499Z',$false),@('03:04:06.1234501Z',$false))){
|
||||
Assert ((Test-WelaWmiProbeEvent $xml.Replace('03:04:05.5000000Z',$edge[0]) $operation $state) -eq $edge[1]) ('Exact 100ns boundary without positive time padding: '+$edge[0])
|
||||
}
|
||||
|
||||
$mutations=@(
|
||||
@('4662','4663'),@('>0</Version>','>1</Version>'),@('>WMI<','>DS<'),@('root\default','root\cimv2'),@('0x1</Data>','0x2</Data>'),@('0x123','0x124'),@('S-1-5-21-1-2-3-1001','S-1-5-21-1-2-3-1002'),@('>LAB<','>OTHER<'),@('>Security<','>Application<'),@('0x8020000000000000','0x8010000000000000'),@('>101<','>100<'),@('03:04:05.5000000Z','03:04:05.1000000Z'),@('03:04:05.5000000Z','03:04:06.5000000Z'),@('54849625-5478-4994-a5ba-3e3b0328c30d','54849625-5478-4994-a5ba-3e3b0328c30e'),@('Name="AccessMask"','Name="SubjectLogonId"'))
|
||||
foreach($pair in $mutations){$changed=$xml.Replace($pair[0],$pair[1]);Assert ($changed -cne $xml) 'Mutation changed the fixture.';Assert (-not(Test-WelaWmiProbeEvent $changed $operation $state)) ('Reject mismatched '+$pair[0])}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Real native APIs and public CLI. Never dot-source mocked fixture functions.
|
||||
param([switch]$AllowDisposableNamespaceWrite)
|
||||
param([switch]$AllowDisposableNamespaceWrite,[ValidateRange(1,5)][int]$ProbeRuns=3)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableNamespaceWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable GitHub-hosted Windows opt-in is required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
@@ -43,21 +43,24 @@ try{
|
||||
Assert (Test-WelaWmiDescriptorPreserved ($before.DescriptorJson|ConvertFrom-Json) ($after.DescriptorJson|ConvertFrom-Json)) 'Owner/group/DACL and existing SACL entries survived.'
|
||||
Set-ItemProperty -LiteralPath $path -Name $name -Type DWord -Value 1
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 -Mode minimum
|
||||
$out=Join-Path $private 'probe'
|
||||
$ErrorActionPreference='Continue'
|
||||
$cli=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') wmi-probe -WmiProbeAction Run -WmiProbeNamespace $namespace -WmiProbeOutputPath $out -WmiProbeTimeoutSeconds 20 2>&1|Out-String
|
||||
$code=$LASTEXITCODE;$ErrorActionPreference='Stop'
|
||||
$manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $out 'manifest.json')))
|
||||
# Bounded raw native diagnostics are useful when an unreviewed OS schema differs.
|
||||
Write-Host ($manifest|ConvertTo-Json -Depth 18)
|
||||
foreach($file in @(Get-ChildItem -LiteralPath $out -Filter '*.xml' -ErrorAction Stop)){Write-Host ([IO.File]::ReadAllText($file.FullName))}
|
||||
Assert ($code -eq 0 -and $manifest.Status -eq 'LocalNamespaceAccessObserved' -and $manifest.ExitCode -eq 0) ('Public native probe failed: '+$manifest.Diagnostic+' '+$cli)
|
||||
Assert ($manifest.Matches -ge 1 -and $manifest.Matches -le 16 -and $manifest.ReadyRuleCredit -eq 0 -and $manifest.PolicyChanges -eq 0 -and $manifest.NamespaceChanges -eq 0) 'Bounded native evidence grants no policy or Sigma claim.'
|
||||
foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Protected artifact hash verifies.'}
|
||||
foreach($file in @(Get-ChildItem -LiteralPath $out -Filter 'event-*.xml')){Assert (Test-WelaWmiProbeEvent ([IO.File]::ReadAllText($file.FullName)) $manifest.Operation $manifest.Before) 'Real WMI event passes exact source/namespace/token/mask/time checks.'}
|
||||
Assert ((Get-WelaWmiNamespaceSnapshot $namespace).DescriptorJson -ceq $after.DescriptorJson) 'Public probe made no namespace security changes.'
|
||||
Assert ((Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Get-WelaWmiProbeTokenKey $originalToken)) 'Native descriptor reads/writes restored caller token state.'
|
||||
Assert ((Get-Acl -LiteralPath $out).AreAccessRulesProtected) 'Evidence directory blocks inherited broad access.'
|
||||
for($trial=1;$trial -le $ProbeRuns;$trial++){
|
||||
$out=Join-Path $private ('probe-'+$trial)
|
||||
$ErrorActionPreference='Continue'
|
||||
$cli=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') wmi-probe -WmiProbeAction Run -WmiProbeNamespace $namespace -WmiProbeOutputPath $out -WmiProbeTimeoutSeconds 20 2>&1|Out-String
|
||||
$code=$LASTEXITCODE;$ErrorActionPreference='Stop'
|
||||
$manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $out 'manifest.json')))
|
||||
# Bounded raw native diagnostics are useful when an unreviewed OS schema differs.
|
||||
Write-Host ($manifest|ConvertTo-Json -Depth 18)
|
||||
foreach($file in @(Get-ChildItem -LiteralPath $out -Filter '*.xml' -ErrorAction Stop)){Write-Host ([IO.File]::ReadAllText($file.FullName))}
|
||||
Assert ($code -eq 0 -and $manifest.Status -eq 'LocalNamespaceAccessObserved' -and $manifest.ExitCode -eq 0) ('Public native probe failed: '+$manifest.Diagnostic+' '+$cli)
|
||||
Assert ($manifest.Operation.Clock -ceq 'GetSystemTimePreciseAsFileTime') 'Actual worker identifies the native precise UTC clock.'
|
||||
Assert ($manifest.Matches -ge 1 -and $manifest.Matches -le 16 -and $manifest.ReadyRuleCredit -eq 0 -and $manifest.PolicyChanges -eq 0 -and $manifest.NamespaceChanges -eq 0) 'Bounded native evidence grants no policy or Sigma claim.'
|
||||
foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Protected artifact hash verifies.'}
|
||||
foreach($file in @(Get-ChildItem -LiteralPath $out -Filter 'event-*.xml')){Assert (Test-WelaWmiProbeEvent ([IO.File]::ReadAllText($file.FullName)) $manifest.Operation $manifest.Before) 'Real WMI event passes exact source/namespace/token/mask/time checks.'}
|
||||
Assert ((Get-WelaWmiNamespaceSnapshot $namespace).DescriptorJson -ceq $after.DescriptorJson) 'Public probe made no namespace security changes.'
|
||||
Assert ((Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Get-WelaWmiProbeTokenKey $originalToken)) 'Native descriptor reads/writes restored caller token state.'
|
||||
Assert ((Get-Acl -LiteralPath $out).AreAccessRulesProtected) 'Evidence directory blocks inherited broad access.'
|
||||
}
|
||||
}catch{$failure=$_}
|
||||
finally{
|
||||
try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $originalPolicies[$guid] -Mode exact}catch{$cleanupErrors+='Audit restoration: '+$_.Exception.Message}
|
||||
@@ -68,5 +71,5 @@ finally{
|
||||
[pscustomobject]@{Namespace=$namespace;Created=$created;Failure=$(if($failure){$failure.Exception.Message}else{$null});CleanupErrors=$cleanupErrors;Evidence=$private;Complete=($null -eq $failure -and $cleanupErrors.Count -eq 0)}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $private 'cleanup.json') -Encoding UTF8
|
||||
}
|
||||
if($failure){throw $failure};if($cleanupErrors.Count){throw ($cleanupErrors -join '; ')}
|
||||
Write-Host "PASS: $script:count actual native WMI4662/public CLI assertions, original policies restored and owned namespace removed. No remote or Sigma claim."
|
||||
Write-Host "PASS: $script:count actual native WMI4662/public CLI assertions across $ProbeRuns independent public runs, original policies restored and owned namespace removed. No remote or Sigma claim."
|
||||
$global:LASTEXITCODE=0
|
||||
Reference in new issue
Block a user