Files
WELA/tests/SecurityWarningConfigure.Windows.Tests.ps1
T

102 lines
10 KiB
PowerShell

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