From 3f613ea19c7e3836e71c8392d1b13f6dd9524131 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:37:38 +0900 Subject: [PATCH] Add scoped Windows PowerShell logging policy and native validation --- .github/workflows/powershell-logging.yml | 46 +++++++ WELA.ps1 | 20 ++- scripts/Configuration.ps1 | 2 +- scripts/PowerShellLogging.ps1 | 157 ++++++++++++++++++++++ tests/PowerShellLogging.Cli.Tests.ps1 | 19 +++ tests/PowerShellLogging.Tests.ps1 | 78 +++++++++++ tests/PowerShellLogging.Windows.Tests.ps1 | 140 +++++++++++++++++++ 7 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/powershell-logging.yml create mode 100644 scripts/PowerShellLogging.ps1 create mode 100644 tests/PowerShellLogging.Cli.Tests.ps1 create mode 100644 tests/PowerShellLogging.Tests.ps1 create mode 100644 tests/PowerShellLogging.Windows.Tests.ps1 diff --git a/.github/workflows/powershell-logging.yml b/.github/workflows/powershell-logging.yml new file mode 100644 index 00000000..9bba5b87 --- /dev/null +++ b/.github/workflows/powershell-logging.yml @@ -0,0 +1,46 @@ +name: Scoped Windows PowerShell event logging +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/PowerShellLogging.ps1' + - 'scripts/Configuration.ps1' + - 'tests/PowerShellLogging*' + - '.github/workflows/powershell-logging.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + powershell-logging: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Scoped policy and native events in Windows PowerShell + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/PowerShellLogging.Tests.ps1 + ./tests/PowerShellLogging.Cli.Tests.ps1 + ./tests/PowerShellLogging.Windows.Tests.ps1 -AllowDisposableLoggingWrite + - name: Scoped policy and native events in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/PowerShellLogging.Tests.ps1 + ./tests/PowerShellLogging.Cli.Tests.ps1 + ./tests/PowerShellLogging.Windows.Tests.ps1 -AllowDisposableLoggingWrite + - name: Retain native policy, event and cleanup evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: powershell-logging-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-powershell-logging-*/ + if-no-files-found: error diff --git a/WELA.ps1 b/WELA.ps1 index 7ece9a25..d6b438fa 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -77,6 +77,9 @@ [string]$RuleEvidencePath, [string]$RuleCorpusPath, [string]$RuleManifestPath, + [ValidateSet('Audit','Plan','Configure')][string]$PowerShellLoggingAction = 'Audit', + [ValidateSet('ScriptBlock','Module')][string[]]$PowerShellLoggingControl, + [string[]]$PowerShellLoggingModuleName, [ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit', [string]$TranscriptDirectory, [ValidateSet('Audit','Plan','Configure')][string]$LdapAction = 'Audit', @@ -266,6 +269,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Capi2Probe.ps1") . (Join-Path $ScriptRoot "scripts/FailedLogonProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") +. (Join-Path $ScriptRoot "scripts/PowerShellLogging.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction Stop @@ -2087,6 +2091,7 @@ Usage: ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write ./WELA.ps1 wec-listener -Help # Review one fixed-address native HTTP5985 listener ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation + ./WELA.ps1 powershell-logging -Help # Configure selected Windows PowerShell event policies ./WELA.ps1 ntlm-auditing -Help # Configure selected incoming/domain NTLM auditing ./WELA.ps1 outgoing-ntlm -Help # Configure outgoing NTLM auditing independently ./WELA.ps1 wec-authorization -Help # Review source SID authorization on a disabled subscription @@ -2198,6 +2203,12 @@ if ($Cmd -eq 'ntlm-auditing') { if ($NtlmAuditAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require NtlmAuditAction Configure.'} if ($NtlmAuditAction -eq 'Configure' -and -not $PSBoundParameters.ContainsKey('NtlmAuditScope')) {throw 'Configure requires explicit NtlmAuditScope Incoming, Domain or Both.'} } +if ($Cmd -ne 'powershell-logging' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'PowerShellLogging*'}).Count) {throw 'PowerShellLogging options require powershell-logging.'} +if ($Cmd -eq 'powershell-logging') { + if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','PowerShellLoggingAction','PowerShellLoggingControl','PowerShellLoggingModuleName','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'powershell-logging accepts only its dedicated options.'} + if (-not $Help) {Assert-WelaPsLoggingSelection $PowerShellLoggingAction $PowerShellLoggingControl $PowerShellLoggingModuleName} + if ($PowerShellLoggingAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require PowerShellLoggingAction Configure.'} +} if ($Cmd -ne 'outgoing-ntlm' -and $PSBoundParameters.ContainsKey('NtlmAction')) {throw 'NtlmAction requires outgoing-ntlm.'} if ($Cmd -eq 'outgoing-ntlm') { if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','NtlmAction','OutgoingNtlmMode','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'outgoing-ntlm accepts only its dedicated options.'} @@ -2294,7 +2305,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { }).Count) { throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.' } -if ($DryRun -and -not ($Cmd -eq 'ntlm-auditing' -and $NtlmAuditAction -eq 'Configure') -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and +if ($DryRun -and -not ($Cmd -eq 'powershell-logging' -and $PowerShellLoggingAction -eq 'Configure') -and -not ($Cmd -eq 'ntlm-auditing' -and $NtlmAuditAction -eq 'Configure') -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-recovery' -and $FirewallRecoveryAction -eq 'Restore') -and @@ -2745,6 +2756,13 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] SMB auditing: $_" -ForegroundColor Red; exit 1 } } + 'powershell-logging' { + if ($Help) {Write-Host 'Usage: powershell-logging [-PowerShellLoggingAction Audit|Plan|Configure] [-PowerShellLoggingControl ScriptBlock,Module] [-PowerShellLoggingModuleName literal-name,...] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath new.json]. Plan/Configure require explicit controls; Module requires explicit names. Target is Windows PowerShell 5.1; PowerShell Core settings and invocation logging are preserved. See docs/powershell-logging.md.';return} + if ($PowerShellLoggingAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'PowerShell logging configuration requires Administrator privileges.'} + $report=Invoke-WelaPowerShellLogging -Action $PowerShellLoggingAction -Control $PowerShellLoggingControl -ModuleName $PowerShellLoggingModuleName -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report + if($report.ExitCode){exit $report.ExitCode} + } 'powershell-transcription' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 powershell-transcription [-TranscriptionAction Audit|Plan|Configure] [-TranscriptDirectory absolute-existing-directory] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 5202f504..d419837d 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] + [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "windows-powershell-event-logging-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') if ($Context.PSObject.Properties['CustomProfileGuard']) { diff --git a/scripts/PowerShellLogging.ps1 b/scripts/PowerShellLogging.ps1 new file mode 100644 index 00000000..e62c45b8 --- /dev/null +++ b/scripts/PowerShellLogging.ps1 @@ -0,0 +1,157 @@ +# Scoped machine policy for the built-in Windows PowerShell 5.1 engine. +function ConvertTo-WelaPsLoggingKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress } +function Assert-WelaPsLoggingSelection { + param([string]$Action,[string[]]$Control,[string[]]$ModuleName) + if ($null -eq $Control) {$Control=@()}; if ($null -eq $ModuleName) {$ModuleName=@()} + if ($Action -ne 'Audit' -and -not $Control.Count) { throw 'Plan and Configure require explicit PowerShellLoggingControl selection.' } + if (@($Control | ForEach-Object {$_.ToLowerInvariant()} | Select-Object -Unique).Count -ne $Control.Count -or @($Control | Where-Object {$_ -notin @('ScriptBlock','Module')}).Count) { throw 'Select unique ScriptBlock and/or Module controls.' } + if ($ModuleName.Count -and $Control -notcontains 'Module') { throw 'Module names require explicit Module selection.' } + if ($Action -ne 'Audit' -and $Control -contains 'Module' -and -not $ModuleName.Count) { throw 'Module selection requires explicit PowerShellLoggingModuleName values; use * only after reviewing all-module scope.' } + if ($ModuleName.Count -gt 32 -or @($ModuleName | ForEach-Object {$_.ToLowerInvariant()} | Select-Object -Unique).Count -ne $ModuleName.Count) { throw 'Select at most 32 unique module names.' } + foreach ($name in $ModuleName) { + if ($name -cne '*' -and $name -cnotmatch '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$') { throw 'Use literal module names or the explicitly selected * all-module value; paths and other wildcard patterns are refused.' } + } +} +function Get-WelaPsLoggingTree { + param([ValidateSet('LocalMachine','CurrentUser')][string]$Hive,[ValidateSet('Registry64','Registry32')][string]$View,[string]$Root) + $base=$null;$rows=New-Object 'System.Collections.Generic.List[object]';$queue=New-Object 'System.Collections.Generic.Queue[string]';$queue.Enqueue('') + try { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::$Hive,[Microsoft.Win32.RegistryView]::$View) + while ($queue.Count) { + if ($rows.Count -ge 64) { throw 'PowerShell policy tree exceeds 64 keys; no partial snapshot is accepted.' } + $relative=$queue.Dequeue();$path=$Root;if ($relative) {$path+='\'+$relative};$key=$null + try { + $key=$base.OpenSubKey($path,$false) + if (-not $key) { if ($relative) {throw 'Policy key disappeared during enumeration.'};return [pscustomobject]@{Exists=$false;Keys=@()} } + $names=@($key.GetValueNames()|Sort-Object);$children=@($key.GetSubKeyNames()|Sort-Object) + if ($names.Count -gt 128 -or $children.Count -gt 64) {throw 'PowerShell policy key inventory is too large.'} + $values=@(foreach ($name in $names) {[pscustomobject][ordered]@{Name=$name;Type=$key.GetValueKind($name).ToString();Value=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}}) + $acl=if ($PSVersionTable.PSVersion.Major -ge 6) {[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($key)}else{$key.GetAccessControl()} + $rows.Add([pscustomobject][ordered]@{Path=$relative;Values=$values;Children=$children;Access=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]'Owner,Group,Access')}) + foreach ($child in $children) {$next=if($relative){$relative+'\'+$child}else{$child};if($next.Split('\').Count -gt 8){throw 'PowerShell policy tree exceeds eight levels.'};$queue.Enqueue($next)} + } finally {if($key){$key.Dispose()}} + } + $result=[pscustomobject]@{Exists=$true;Keys=@($rows.ToArray()|Sort-Object Path)} + if ((ConvertTo-WelaPsLoggingKey $result).Length -gt 1048576) {throw 'PowerShell policy snapshot exceeds one Mi character bound.'} + return $result + } finally {if($base){$base.Dispose()}} +} +function Get-WelaPsLoggingSources { + $root=Split-Path $PSScriptRoot -Parent + @(foreach ($name in @('WELA.ps1','scripts/PowerShellLogging.ps1','scripts/Configuration.ps1')) {[pscustomobject]@{Path=$name;Sha256=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256).Hash.ToLowerInvariant()}}) +} +function Get-WelaPsLoggingSnapshot { + if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {throw 'Use native 64-bit PowerShell on Windows.'} + foreach($service in @('Winmgmt','EventLog')) {if((Get-Service -Name $service -ErrorAction Stop).Status -ne 'Running'){throw "$service must already be running; no service is started."}} + $os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -Property Name,Domain,DomainRole,PartOfDomain -ErrorAction Stop + if ([string]$os.BuildNumber -notmatch '^\d+$' -or $computer.PartOfDomain -isnot [bool] -or $computer.DomainRole -notin @(0,1,2,3,4,5)) {throw 'Complete actual Windows role/build/join context is required.'} + $build=[int]$os.BuildNumber;$product=[int]$os.ProductType;$role=[int]$computer.DomainRole;$joined=$computer.PartOfDomain + $coherent=($product -eq 1 -and (($role -eq 0 -and -not $joined) -or ($role -eq 1 -and $joined))) -or ($product -eq 3 -and (($role -eq 2 -and -not $joined) -or ($role -eq 3 -and $joined))) -or ($product -eq 2 -and $role -in @(4,5) -and $joined) + if (-not $coherent -or [string]::IsNullOrWhiteSpace($computer.Name)) {throw 'Native host role observations conflict.'} + if (-not (($product -eq 1 -and $build -in @(22000,22621,22631,26100,26200)) -or ($product -in @(2,3) -and $build -in @(20348,26100)))) {throw 'Windows role/build is outside reviewed scope.'} + $engine=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine' PowerShellVersion + if (-not $engine.ValueExists -or $engine.Type -cne 'String' -or $engine.Value -notmatch '^5\.1(?:\.|$)') {throw 'Installed Windows PowerShell 5.1 is not confirmed.'} + $exe=Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe' + $engineHash=(Get-FileHash -LiteralPath $exe -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if (-not (Get-WelaRegistryState 'HKLM:\SOFTWARE\Policies\Microsoft\Windows' '__WelaObservationOnly').KeyExists) {throw 'Existing Microsoft Windows policy parent key is required; unrecorded ancestors will not be created.'} + $windowsRoot='SOFTWARE\Policies\Microsoft\Windows\PowerShell';$coreRoot='SOFTWARE\Policies\Microsoft\PowerShellCore' + $machine=Get-WelaPsLoggingTree LocalMachine Registry64 $windowsRoot;$user=Get-WelaPsLoggingTree CurrentUser Registry64 $windowsRoot + if ((ConvertTo-WelaPsLoggingKey $machine) -cne (ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingTree LocalMachine Registry32 $windowsRoot)) -or (ConvertTo-WelaPsLoggingKey $user) -cne (ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingTree CurrentUser Registry32 $windowsRoot))) {throw 'Shared Windows PowerShell policy views disagree.'} + $coreMachine=Get-WelaPsLoggingTree LocalMachine Registry64 $coreRoot;$coreUser=Get-WelaPsLoggingTree CurrentUser Registry64 $coreRoot + $protected=Get-WelaPsLoggingTree LocalMachine Registry64 'SOFTWARE\Policies\Microsoft\Windows\EventLog\ProtectedEventLogging' + $channel=$null + try {$channel=Get-WinEvent -ListLog 'Microsoft-Windows-PowerShell/Operational' -ErrorAction Stop;$channelState=[pscustomobject]@{Name=[string]$channel.LogName;Enabled=[bool]$channel.IsEnabled;MaximumBytes=[long]$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Security=[string]$channel.SecurityDescriptor}}finally{if($channel -is [IDisposable]){$channel.Dispose()}} + $patch=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' UBR + if (-not $patch.ValueExists -or $patch.Type -ne 'DWord' -or $patch.Value -lt 0) {throw 'Exact native patch evidence is required.'} + [pscustomobject][ordered]@{Host=[pscustomobject]@{Computer=[string]$computer.Name;Domain=[string]$computer.Domain;Build=$build;Patch=$patch.Value;ProductType=$product;DomainRole=$role;PartOfDomain=$joined;CertSvcPresent=[bool](Get-Service CertSvc -ErrorAction SilentlyContinue)};Engine=[pscustomobject]@{Target='Windows PowerShell 5.1';Version=$engine.Value;Path=$exe;Sha256=$engineHash;WelaHostVersion=$PSVersionTable.PSVersion.ToString()};Sources=@(Get-WelaPsLoggingSources);Machine=$machine;CurrentUser=$user;PowerShellCoreMachine=$coreMachine;PowerShellCoreUser=$coreUser;ProtectedEventLogging=$protected;Channel=$channelState} +} +function Get-WelaPsLoggingValue { + param($Tree,[string]$Path,[string]$Name) + $keys=@($Tree.Keys|Where-Object Path -ieq $Path) + if ($keys.Count -gt 1) {throw 'Ambiguous policy key.'} + if ($keys.Count -eq 1) {$rows=@($keys[0].Values|Where-Object Name -ieq $Name);if($rows.Count -gt 1){throw 'Ambiguous policy value.'};if($rows.Count){return $rows[0]}} + return $null +} +function Get-WelaPsLoggingDefinitions { + param([string[]]$Control,[string[]]$ModuleName) + # Add selected module entries before enabling module logging. No existing name is removed. + if ($Control -contains 'Module') { + foreach ($name in @($ModuleName|Sort-Object)) {[pscustomobject]@{Control='Module';Path='ModuleLogging\ModuleNames';Name=$name;Type='String';Value=$name}} + [pscustomobject]@{Control='Module';Path='ModuleLogging';Name='EnableModuleLogging';Type='DWord';Value=1} + } + if ($Control -contains 'ScriptBlock') {[pscustomobject]@{Control='ScriptBlock';Path='ScriptBlockLogging';Name='EnableScriptBlockLogging';Type='DWord';Value=1}} +} +function Test-WelaPsLoggingValue {param($Snapshot,$Definition) $value=Get-WelaPsLoggingValue $Snapshot.Machine $Definition.Path $Definition.Name;return $null -ne $value -and $value.Type -ceq $Definition.Type -and (ConvertTo-WelaPsLoggingKey $value.Value) -ceq (ConvertTo-WelaPsLoggingKey $Definition.Value)} +function Assert-WelaPsLoggingKnown { + param($Snapshot,[array]$Definitions) + foreach ($definition in $Definitions) { + $value=Get-WelaPsLoggingValue $Snapshot.Machine $definition.Path $definition.Name + if ($value -and ($value.Type -cne $definition.Type -or ($definition.Type -eq 'DWord' -and $value.Value -notin @(0,1)) -or ($definition.Type -eq 'String' -and $value.Value -cne $definition.Value))) {throw "Selected policy value has an unknown type/value or a name collision: $($definition.Path)/$($definition.Name)."} + } + if (@($Definitions|Where-Object Control -eq Module).Count) { + foreach($key in @($Snapshot.Machine.Keys|Where-Object Path -ieq 'ModuleLogging\ModuleNames')) {foreach($value in $key.Values) {if($value.Type -cne 'String' -or [string]::IsNullOrWhiteSpace($value.Value)){throw 'Existing module-name policy contains an unsupported type/empty value; preserve and review it.'}}} + } +} +function Set-WelaPsLoggingValue { + param($Definition) + if ($Definition.Path -notin @('ModuleLogging','ModuleLogging\ModuleNames','ScriptBlockLogging')) {throw 'Unsupported policy destination.'} + if (($Definition.Path -eq 'ModuleLogging' -and ($Definition.Name -cne 'EnableModuleLogging' -or $Definition.Type -cne 'DWord' -or $Definition.Value -ne 1)) -or ($Definition.Path -eq 'ScriptBlockLogging' -and ($Definition.Name -cne 'EnableScriptBlockLogging' -or $Definition.Type -cne 'DWord' -or $Definition.Value -ne 1))) {throw 'Unsupported logging DWORD mutation.'} + if ($Definition.Path -eq 'ModuleLogging\ModuleNames') {Assert-WelaPsLoggingSelection Configure @('Module') @($Definition.Name);if($Definition.Type -cne 'String' -or $Definition.Value -cne $Definition.Name){throw 'Unsupported module-name mutation.'}} + $base=$null;$key=$null + try {$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$Definition.Path);$key.SetValue($Definition.Name,$Definition.Value,[Microsoft.Win32.RegistryValueKind]::$($Definition.Type));$key.Flush()} + finally{if($key){$key.Dispose()};if($base){$base.Dispose()}} +} +function Assert-WelaPsLoggingTransition { + param($Before,$After,$Definition) + foreach($property in $Before.PSObject.Properties.Name|Where-Object {$_ -cne 'Machine'}) {if((ConvertTo-WelaPsLoggingKey $Before.$property) -cne (ConvertTo-WelaPsLoggingKey $After.$property)){throw "Unselected state changed: $property"}} + if (-not (Test-WelaPsLoggingValue $After $Definition)) {throw 'Selected policy value did not match native readback.'} + $allowed=@('');$parts=$Definition.Path.Split('\');$part='';foreach($segment in $parts){$part=if($part){$part+'\'+$segment}else{$segment};$allowed+=$part} + $old=@{};foreach($row in $Before.Machine.Keys){$old[$row.Path]=$row} + $new=@{};foreach($row in $After.Machine.Keys){$new[$row.Path]=$row} + foreach($path in $old.Keys){if(-not $new.ContainsKey($path)){throw 'An original policy key disappeared.'};if($new[$path].Access -cne $old[$path].Access){throw 'An existing policy key access descriptor changed.'}} + foreach($path in $new.Keys){ + if(-not $old.ContainsKey($path) -and $path -notin $allowed){throw 'An unrequested policy key appeared.'} + $expected=@();if($old.ContainsKey($path)){$expected=@($old[$path].Values|Where-Object {-not ($path -ieq $Definition.Path -and $_.Name -ieq $Definition.Name)})} + $observed=@($new[$path].Values|Where-Object {-not ($path -ieq $Definition.Path -and $_.Name -ieq $Definition.Name)}) + if((ConvertTo-WelaPsLoggingKey $expected) -cne (ConvertTo-WelaPsLoggingKey $observed)){throw 'Unrelated policy values changed.'} + $expectedChildren=@();if($old.ContainsKey($path)){$expectedChildren=@($old[$path].Children)} + foreach($possible in $allowed){if(-not $possible){continue};$separator=$possible.LastIndexOf('\');$parent=if($separator -ge 0){$possible.Substring(0,$separator)}else{''};$leaf=if($separator -ge 0){$possible.Substring($separator+1)}else{$possible};if($parent -ieq $path){$expectedChildren+= $leaf}} + if((ConvertTo-WelaPsLoggingKey @($expectedChildren|Sort-Object -Unique)) -cne (ConvertTo-WelaPsLoggingKey @($new[$path].Children|Sort-Object -Unique))){throw 'Unrelated policy subkeys changed.'} + } +} +function Invoke-WelaPowerShellLogging { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[string[]]$Control=@(),[string[]]$ModuleName=@(),[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) + Assert-WelaPsLoggingSelection $Action $Control $ModuleName + if($Action -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)){throw 'Consent, dry-run and backup options require PowerShellLoggingAction Configure.'} + if($ResultsPath -and (Test-Path -LiteralPath $ResultsPath)){throw 'ResultsPath must name a new file.'} + $definitions=@(Get-WelaPsLoggingDefinitions $Control $ModuleName);$before=$null;$diagnostic='';$known=$false + try {$before=Get-WelaPsLoggingSnapshot;Assert-WelaPsLoggingKnown $before $definitions;$known=$true}catch{$diagnostic=$_.Exception.Message} + $plan=[pscustomobject]@{Selection=@($Control);ModuleNames=@($ModuleName);Before=$before;Controls=@(foreach($definition in $definitions){[pscustomobject]@{Definition=$definition;Status=$(if(-not $known){'Unknown'}elseif(Test-WelaPsLoggingValue $before $definition){'AlreadyCompliant'}else{'ChangeRequired'})}});Status=$(if($known){'Observed'}else{'Unknown'});Diagnostic=$diagnostic;Provenance=@('https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1','https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-powershellexecutionpolicy');Meaning='Explicit Windows PowerShell 5.1 machine-policy selection. Existing module names remain active when Module logging is enabled; no claim of a complete Microsoft/CIS/ASD baseline.'} + if($Action -eq 'Configure') { + if(-not $known){$report=[pscustomobject]@{ExitCode=1;Scope='windows-powershell-event-logging-policy-only';Results=@();Diagnostic=$diagnostic}} + else { + $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + $shared=@{Expected=$before;Definitions=$definitions;Failed=$false} + foreach($definition in $definitions){ + $state=@{Shared=$shared;Definition=$definition} + $read={param($s) if($s.Shared.Failed){throw 'An earlier operation failed; remaining operations are stopped.'};$snapshot=Get-WelaPsLoggingSnapshot;if((ConvertTo-WelaPsLoggingKey $snapshot) -cne (ConvertTo-WelaPsLoggingKey $s.Shared.Expected)){throw 'Policy, host, channel, engine or source changed from the reviewed state.'};Assert-WelaPsLoggingKnown $snapshot $s.Shared.Definitions;return $snapshot} + $test={param($snapshot,$s) Test-WelaPsLoggingValue $snapshot $s.Definition} + $apply={param($s) + try {$fresh=Get-WelaPsLoggingSnapshot;if((ConvertTo-WelaPsLoggingKey $fresh) -cne (ConvertTo-WelaPsLoggingKey $s.Shared.Expected)){throw 'Pre-write state drifted after journal/approval; no write attempted.'};Set-WelaPsLoggingValue $s.Definition;$after=Get-WelaPsLoggingSnapshot;Assert-WelaPsLoggingTransition $fresh $after $s.Definition;$s.Shared.Expected=$after;'Only the named Windows PowerShell policy value was changed and read back.'}catch{$s.Shared.Failed=$true;throw} + } + Invoke-WelaConfigurationControl -Context $context -Id ('PowerShellLogging/'+$definition.Path+'/'+$definition.Name) -Kind Registry -Target @{Hive='LocalMachine';View='Registry64';Path=('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$definition.Path);Name=$definition.Name} -Desired @{Type=$definition.Type;Value=$definition.Value} -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Enable the explicitly selected event-logging policy; existing module names are preserved.' + if($context.Results[$context.Results.Count-1].Status -eq 'Failed'){$shared.Failed=$true;break} + if($context.Results[$context.Results.Count-1].Status -eq 'Skipped' -and -not $DryRun){break} + } + $report=Complete-WelaConfiguration -Context $context -Scope 'windows-powershell-event-logging-policy-only' -SuccessMessage 'Selected local machine policy values verified; fresh-session events and policy persistence remain separate.' + } + }else{$report=[pscustomobject]@{ExitCode=$(if($known){0}else{1});Scope='windows-powershell-event-logging-policy-only'}} + $report|Add-Member NoteProperty Action $Action;$report|Add-Member NoteProperty Plan $plan + $report|Add-Member NoteProperty EventGeneration 'Unverified; run a separately reviewed new Windows PowerShell session and retain native XML.' + $report|Add-Member NoteProperty PowerShell7Sessions 'Not assessed. Separate PowerShell Core policy/configuration and Windows-policy fallback are preserved; fallback users can inherit changed Windows settings.' + $report|Add-Member NoteProperty PolicyAuthority 'Local registry observations only; GPO/MDM persistence and current winning authority are not established.' + $report|Add-Member NoteProperty ReadyRuleCredit 0 + if($ResultsPath){$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($report|ConvertTo-Json -Depth 28));$file=[IO.File]::Open($ResultsPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);try{$file.Write($bytes,0,$bytes.Length)}finally{$file.Dispose()}} + return $report +} diff --git a/tests/PowerShellLogging.Cli.Tests.ps1 b/tests/PowerShellLogging.Cli.Tests.ps1 new file mode 100644 index 00000000..b5304391 --- /dev/null +++ b/tests/PowerShellLogging.Cli.Tests.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('powershell-logging','-Help');Code=0;Pattern='Windows PowerShell 5.1'}, + @{Args=@('configure','-PowerShellLoggingAction','Configure');Code=1;Pattern='require powershell-logging'}, + @{Args=@('audit','-PowerShellLoggingControl','ScriptBlock');Code=1;Pattern='require powershell-logging'}, + @{Args=@('powershell-logging','-PowerShellLoggingAction','Configure');Code=1;Pattern='explicit PowerShellLoggingControl'}, + @{Args=@('powershell-logging','-PowerShellLoggingAction','Plan');Code=1;Pattern='explicit PowerShellLoggingControl'}, + @{Args=@('powershell-logging','-PowerShellLoggingAction','Configure','-PowerShellLoggingControl','Module');Code=1;Pattern='PowerShellLoggingModuleName'}, + @{Args=@('powershell-logging','-PowerShellLoggingControl','ScriptBlock','-PowerShellLoggingModuleName','Microsoft.PowerShell.Utility');Code=1;Pattern='Module selection'}, + @{Args=@('powershell-logging','-Role','DomainController');Code=1;Pattern='dedicated options'}, + @{Args=@('powershell-logging','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'}, + @{Args=@('powershell-logging','-Auto');Code=1;Pattern='PowerShellLoggingAction'}, + @{Args=@('powershell-logging','-DryRun');Code=1;Pattern='PowerShellLoggingAction'}, + @{Args=@('powershell-logging','-PowerShellLoggingControl','Module','-PowerShellLoggingModuleName','Mod*');Code=1;Pattern='literal module'}, + @{Args=@('powershell-logging','-Help','-Typo');Code=1;Pattern='Unsupported trailing arguments'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$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 ($output -join "`n") -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++} +Write-Host "PASS: $count scoped PowerShell logging CLI guards." +exit 0 diff --git a/tests/PowerShellLogging.Tests.ps1 b/tests/PowerShellLogging.Tests.ps1 new file mode 100644 index 00000000..931bbfbb --- /dev/null +++ b/tests/PowerShellLogging.Tests.ps1 @@ -0,0 +1,78 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/PowerShellLogging.ps1') +$script:count=0;$script:ScriptRoot=$repo;$script:writes=0;$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-pslogging-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +function Assert($Value,$Message){if(-not $Value){throw "FAIL: $Message"};$script:count++} +function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected rejection $Pattern; got $message"} +function CloneFixture($Value){ConvertTo-WelaPsLoggingKey $Value|ConvertFrom-Json} +function Row($Path,$Values=@(),$Children=@()){[pscustomobject]@{Path=$Path;Values=@($Values);Children=@($Children);Access='original-acl'}} +function Fixture { + [pscustomobject][ordered]@{Host=[pscustomobject]@{Computer='fixture';Build=20348};Engine=[pscustomobject]@{Target='Windows PowerShell 5.1'};Sources=@('sha');Machine=[pscustomobject]@{Exists=$true;Keys=@((Row '' @() @('ModuleLogging','ScriptBlockLogging','Transcription')),(Row 'ModuleLogging' @([pscustomobject]@{Name='EnableModuleLogging';Type='DWord';Value=0}) @('ModuleNames')),(Row 'ModuleLogging\ModuleNames' @([pscustomobject]@{Name='existing';Type='String';Value='Existing.Module'})),(Row 'ScriptBlockLogging' @([pscustomobject]@{Name='EnableScriptBlockLogging';Type='DWord';Value=0},[pscustomobject]@{Name='EnableScriptBlockInvocationLogging';Type='DWord';Value=1})),(Row 'Transcription' @([pscustomobject]@{Name='EnableTranscripting';Type='DWord';Value=1})))};CurrentUser=@('preserved-user');PowerShellCoreMachine=@('preserved-core');PowerShellCoreUser=@('preserved-core-user');ProtectedEventLogging=@('preserved-protected');Channel=@('preserved-channel')} +} +function Mutate($Before,$Definition){ + $after=CloneFixture $Before;$after.Machine.Exists=$true + $allowed=@('');$p='';foreach($segment in $Definition.Path.Split('\')){$p=if($p){$p+'\'+$segment}else{$segment};$allowed+=$p} + foreach($path in $allowed){if(-not @($after.Machine.Keys|Where-Object Path -ieq $path).Count){$after.Machine.Keys+=Row $path}} + foreach($key in $after.Machine.Keys){$key.Children=@($after.Machine.Keys|Where-Object {$_.Path -and $(if($_.Path.Contains('\')){$_.Path.Substring(0,$_.Path.LastIndexOf('\'))}else{''}) -ceq $key.Path}|ForEach-Object {$_.Path.Split('\')[-1]}|Sort-Object)} + $row=@($after.Machine.Keys|Where-Object Path -ieq $Definition.Path)[0];$row.Values=@($row.Values|Where-Object Name -ine $Definition.Name)+[pscustomobject]@{Name=$Definition.Name;Type=$Definition.Type;Value=$Definition.Value};$row.Values=@($row.Values|Sort-Object Name);$after.Machine.Keys=@($after.Machine.Keys|Sort-Object Path);return $after +} +function Get-WelaPsLoggingSnapshot {CloneFixture $script:observed} +function Set-WelaPsLoggingValue {param($Definition)$script:writes++;if($script:failWrite){throw 'injected native write failure'};$script:observed=Mutate $script:observed $Definition;if($script:corrupt){$script:observed.CurrentUser=@('changed-user')}} +function Reset {$script:observed=Fixture;$script:writes=0;$script:failWrite=$false;$script:corrupt=$false} +try { + foreach($action in @('Plan','Configure')){Reject {Assert-WelaPsLoggingSelection $action @() @()} 'explicit'} + Reject {Assert-WelaPsLoggingSelection Configure @('Module') @()} 'ModuleName' + Reject {Assert-WelaPsLoggingSelection Plan @('ScriptBlock') @('x')} 'Module selection' + foreach($name in @('','C:\module','Mod*','../a','a?','a\b',('x'*129))){Reject {Assert-WelaPsLoggingSelection Plan @('Module') @($name)} 'literal'} + Reject {Assert-WelaPsLoggingSelection Plan @('Module','module') @('a')} 'unique' + Reject {Assert-WelaPsLoggingSelection Plan @('Module') @('A','a')} 'unique' + Assert-WelaPsLoggingSelection Plan @('Module') @('*');Assert $true 'Explicit all-module accepted' + Assert-WelaPsLoggingSelection Audit @() @();Assert $true 'Unselected Audit accepted' + $definitions=@(Get-WelaPsLoggingDefinitions @('Module','ScriptBlock') @('Microsoft.PowerShell.Utility')) + Assert ($definitions.Count -eq 3 -and $definitions[0].Type -eq 'String' -and $definitions[1].Name -eq 'EnableModuleLogging' -and $definitions[2].Name -eq 'EnableScriptBlockLogging') 'Name-before-enable ordering' + Reset;$before=CloneFixture $script:observed;$report=Invoke-WelaPowerShellLogging -Action Audit + Assert ($report.ExitCode -eq 0 -and $script:writes -eq 0 -and $report.ReadyRuleCredit -eq 0) 'Audit is read-only with zero readiness credit' + $plan=Invoke-WelaPowerShellLogging -Action Plan -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility + Assert ($plan.Plan.Controls.Count -eq 3 -and @($plan.Plan.Controls|Where-Object Status -eq ChangeRequired).Count -eq 3) 'Plan identifies all selected values' + $dry=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -DryRun -BackupPath (Join-Path $root 'dry') + Assert ($dry.ExitCode -eq 0 -and $dry.Skipped -eq 3 -and $script:writes -eq 0 -and -not (Test-Path (Join-Path $root 'dry'))) 'Dry run creates no journal or write' + $run=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'run') + Assert ($run.ExitCode -eq 0 -and $script:writes -eq 3 -and @($run.Results|Where-Object Status -eq Applied).Count -eq 3) 'Three exact writes applied' + $journal=@(Get-Content (Join-Path $root 'run/before.jsonl')|ForEach-Object {$_|ConvertFrom-Json}) + Assert ($journal.Count -eq 3 -and (ConvertTo-WelaPsLoggingKey $journal[0].Before) -ceq (ConvertTo-WelaPsLoggingKey $before)) 'Complete original snapshot journaled before any mutation' + Assert ((Get-WelaPsLoggingValue $script:observed.Machine 'ModuleLogging\ModuleNames' 'existing').Value -ceq 'Existing.Module') 'Other module names retained' + Assert ((Get-WelaPsLoggingValue $script:observed.Machine 'ScriptBlockLogging' EnableScriptBlockInvocationLogging).Value -eq 1) 'Invocation logging retained' + Assert ((ConvertTo-WelaPsLoggingKey $script:observed.CurrentUser) -ceq (ConvertTo-WelaPsLoggingKey $before.CurrentUser)) 'User policy retained' + $again=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'again') + Assert ($again.ExitCode -eq 0 -and $script:writes -eq 3 -and @($again.Results|Where-Object Status -eq AlreadyCompliant).Count -eq 3) 'Repeat is idempotent' + Reset;$script:observed.Machine=[pscustomobject]@{Exists=$false;Keys=@()} + $absent=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'absent') + Assert ($absent.ExitCode -eq 0 -and $script:writes -eq 3) 'Absent root creates only declared ancestors' + foreach($case in @(@{Type='String';Value='0'},@{Type='DWord';Value=2})){ + Reset;$value=Get-WelaPsLoggingValue $script:observed.Machine 'ScriptBlockLogging' EnableScriptBlockLogging;$value.Type=$case.Type;$value.Value=$case.Value + $bad=Invoke-WelaPowerShellLogging -Action Configure -Control ScriptBlock -Auto -BackupPath (Join-Path $root ([guid]::NewGuid().ToString('N'))) + Assert ($bad.ExitCode -eq 1 -and $script:writes -eq 0) 'Wrong type/unknown DWORD refused before write' + } + Reset;$badName=Get-WelaPsLoggingValue $script:observed.Machine 'ModuleLogging\ModuleNames' existing;$badName.Type='DWord';$badName.Value=1 + $bad=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'badname') + Assert ($bad.ExitCode -eq 1 -and -not (Test-Path (Join-Path $root 'badname'))) 'Unknown module value fails entire preflight' + Reset;$script:failWrite=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'writefailure') + Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1) 'Native failure stops later writes' + Reset;$script:corrupt=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'corrupt') + Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1) 'Preservation failure stops later writes' + Reset;$snapshot=CloneFixture $script:observed;$definition=$definitions[0];$after=Mutate $snapshot $definition;Assert-WelaPsLoggingTransition $snapshot $after $definition;Assert $true 'Exact additive transition accepted' + foreach($property in @('Host','Sources','CurrentUser','PowerShellCoreMachine','PowerShellCoreUser','ProtectedEventLogging','Channel')){$changed=CloneFixture $after;$changed.$property='drift';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'Unselected state'} + $changed=CloneFixture $after;$changed.Machine.Keys[0].Access='new-acl';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'descriptor' + $changed=CloneFixture $after;$changed.Machine.Keys=@($changed.Machine.Keys|Where-Object Path -ne Transcription);Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'disappeared' + $changed=CloneFixture $after;$changed.Machine.Keys+=Row 'extra';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'unrequested' + $changed=CloneFixture $after;(Get-WelaPsLoggingValue $changed.Machine 'Transcription' EnableTranscripting).Value=0;Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'Unrelated policy values' + $changed=CloneFixture $after;$changed.Machine.Keys[0].Children+= 'extra';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'subkeys' + Reset;$script:promptBefore=CloneFixture $script:observed + function Read-Host {param($Prompt)$script:observed.Host.Computer='changed-during-prompt';'Y'} + $drift=Invoke-WelaPowerShellLogging -Action Configure -Control ScriptBlock -BackupPath (Join-Path $root 'drift') + Assert ($drift.ExitCode -eq 1 -and $script:writes -eq 0) 'Prompt-time drift refused before mutation' + Remove-Item Function:\Read-Host + Reset;Set-Content -LiteralPath (Join-Path $root 'existing.json') -Value 'keep';Reject {Invoke-WelaPowerShellLogging -ResultsPath (Join-Path $root 'existing.json')} 'new file';Assert ((Get-Content -Raw (Join-Path $root 'existing.json')).Trim() -ceq 'keep') 'Existing report preserved' + foreach($options in @(@{Auto=$true},@{DryRun=$true},@{BackupPath='x'})){Reject {Invoke-WelaPowerShellLogging @options} 'require PowerShellLoggingAction Configure'} + Write-Host "PASS: $script:count scoped PowerShell logging assertions." +} finally {Remove-Item -LiteralPath $root -Recurse -Force} diff --git a/tests/PowerShellLogging.Windows.Tests.ps1 b/tests/PowerShellLogging.Windows.Tests.ps1 new file mode 100644 index 00000000..8c692fab --- /dev/null +++ b/tests/PowerShellLogging.Windows.Tests.ps1 @@ -0,0 +1,140 @@ +param([switch]$AllowDisposableLoggingWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableLoggingWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on disposable GitHub-hosted Windows is required.'} +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/PowerShellLogging.ps1') +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +$root=Join-Path $env:RUNNER_TEMP ('wela-powershell-logging-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$engine=(Get-Process -Id $PID).Path;$script:count=0;$failure=$null;$cleanupErrors=@();$original=$null;$prepared=$null;$masks=$null;$workerPath=$null +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Save($Name,$Value){$Value|ConvertTo-Json -Depth 28|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} +Add-Type -TypeDefinition @' +using System;using System.IO;using System.Text;using System.Threading.Tasks;using System.Runtime.InteropServices; +public static class WelaPsLoggingFixture { + [DllImport("kernel32.dll")] static extern void GetSystemTimePreciseAsFileTime(out long value); + public static DateTime Now(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + public static async Task Read(TextReader reader){var text=new StringBuilder();var buffer=new char[1024];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Owned child output exceeds one Mi character bound.");text.Append(buffer,0,n);}} +} +'@ +function Child([string]$Label,[string]$Executable,[string[]]$Arguments){ + foreach($a in $Arguments){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture process argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$Executable;$info.Arguments=(@($Arguments|ForEach-Object{'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $p=[Diagnostics.Process]::new();$p.StartInfo=$info;$started=$false + try{ + $start=[WelaPsLoggingFixture]::Now();if(-not $p.Start()){throw 'Child did not start.'};$started=$true;$ownedId=$p.Id + $stdout=[WelaPsLoggingFixture]::Read($p.StandardOutput);$stderr=[WelaPsLoggingFixture]::Read($p.StandardError) + if(-not $p.WaitForExit(180000)){throw 'Owned child exceeded three minutes.'};$end=[WelaPsLoggingFixture]::Now() + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Owned child output drain timed out.'} + $receipt=[pscustomobject]@{Executable=$Executable;Arguments=$info.Arguments;Pid=$ownedId;StartedUtc=$start.ToString('o');ExitedUtc=$end.ToString('o');ExitCode=$p.ExitCode;Output=$stdout.Result;Error=$stderr.Result} + Save ($Label+'-process.json') $receipt;return $receipt + }finally{if($started -and -not $p.HasExited){$p.Kill();if(-not $p.WaitForExit(5000)){throw 'Owned child exit could not be confirmed; cleanup may be incomplete.'}};$p.Dispose()} +} +$wrapper=Join-Path $root 'public.ps1' +@' +param([string]$Repository,[string]$Request) +$ErrorActionPreference='Stop' +$data=Get-Content -LiteralPath $Request -Raw|ConvertFrom-Json;$options=@{} +foreach($property in $data.PSObject.Properties){$options[$property.Name]=$property.Value} +& (Join-Path $Repository 'WELA.ps1') @options +exit 0 +'@ | Set-Content -LiteralPath $wrapper -Encoding UTF8 +function Public([string]$Label,[hashtable]$Parameters,[int]$ExpectedExit=0){ + $Parameters.Cmd='powershell-logging';$Parameters.ResultsPath=Join-Path $root ($Label+'.json');$request=Join-Path $root ($Label+'-request.json');$Parameters|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $request -Encoding UTF8 + $process=Child $Label $engine @('-NoLogo','-NoProfile','-NonInteractive','-File',$wrapper,'-Repository',$repo,'-Request',$request) + Assert (($process.ExitCode -eq 0) -eq ($ExpectedExit -eq 0)) "Public $Label unexpected exit $($process.ExitCode): $($process.Output) $($process.Error)" + if(Test-Path -LiteralPath $Parameters.ResultsPath){return Get-Content -Raw -LiteralPath $Parameters.ResultsPath|ConvertFrom-Json};throw 'Public report missing.' +} +function RestoreValue($Tree,[string]$Path,[string]$Name){ + $originalValue=Get-WelaPsLoggingValue $Tree $Path $Name;$base=$null;$key=$null + try{$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$Path,$true);if($originalValue){if(-not $key){throw 'Original policy key disappeared.'};$value=$originalValue.Value;if($originalValue.Type -eq 'DWord'){$value=[int]$value};if($originalValue.Type -eq 'QWord'){$value=[long]$value};if($originalValue.Type -eq 'Binary'){$value=[byte[]]$value};if($originalValue.Type -eq 'MultiString'){$value=[string[]]$value};$key.SetValue($Name,$value,[Microsoft.Win32.RegistryValueKind]::$($originalValue.Type));$key.Flush()}elseif($key){$key.DeleteValue($Name,$false);$key.Flush()}} + finally{if($key){$key.Dispose()};if($base){$base.Dispose()}} +} +function RemoveCreatedKeys($Tree){ + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + try{foreach($path in @('ModuleLogging\ModuleNames','ScriptBlockLogging','ModuleLogging','')){ + if(@($Tree.Keys|Where-Object Path -ieq $path).Count){continue};$full='SOFTWARE\Policies\Microsoft\Windows\PowerShell';if($path){$full+='\'+$path};$key=$null + try{$key=$base.OpenSubKey($full,$false);if(-not $key){continue};if($key.ValueCount -or $key.SubKeyCount){throw "Created key is no longer empty: $full"}}finally{if($key){$key.Dispose()}} + $base.DeleteSubKey($full,$false) + }}finally{$base.Dispose()} +} +function ReadEvents([int]$OwnedId,[long]$Watermark){ + $query="*[System[(EventID=4103 or EventID=4104) and Execution[@ProcessID='$OwnedId'] and EventRecordID > $Watermark]]" + $q=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-PowerShell/Operational',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$q.TolerateQueryErrors=$false + $reader=$null;$list=New-Object 'System.Collections.Generic.List[string]' + try{$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);for($i=0;$i -le 64;$i++){$record=$reader.ReadEvent([TimeSpan]::FromSeconds(2));if(-not $record){break};try{$xml=$record.ToXml();if($xml.Length -gt 262144){throw 'Owned child event exceeds XML bound.'};$list.Add($xml)}finally{$record.Dispose()};if($i -eq 64){throw 'Owned child event candidate cap exceeded.'}};foreach($status in $reader.LogStatus){if($status.StatusCode -ne 0){throw 'Native query reports an incomplete channel read.'}};return @($list.ToArray())}finally{if($reader){$reader.Dispose()}} +} +try{ + $original=Get-WelaPsLoggingSnapshot;Save 'original.json' $original;$masks=Masks + Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain -and -not $original.Host.CertSvcPresent) 'Fixture requires a standalone disposable non-CA server.' + Assert $original.Channel.Enabled 'Existing PowerShell operational channel must already be enabled.' + $protected=@($original.ProtectedEventLogging.Keys|ForEach-Object {$_.Values}|Where-Object {$_.Name -eq 'EnableProtectedEventLogging' -and $_.Value -ne 0}) + Assert ($protected.Count -eq 0) 'Protected logging must not obscure this plaintext event fixture.' + # Test fixture only: prepare explicit disabled values; production has no disable action. + foreach($pair in @(@('ModuleLogging','EnableModuleLogging'),@('ScriptBlockLogging','EnableScriptBlockLogging'))){ + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null + try{$key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$pair[0]);$key.SetValue($pair[1],0,[Microsoft.Win32.RegistryValueKind]::DWord);$key.Flush()}finally{if($key){$key.Dispose()};$base.Dispose()} + } + $selectedName=Get-WelaPsLoggingValue $original.Machine 'ModuleLogging\ModuleNames' 'Microsoft.PowerShell.Utility' + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null + try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging\ModuleNames',$true);if($key){$key.DeleteValue('Microsoft.PowerShell.Utility',$false);$key.Flush()}}finally{if($key){$key.Dispose()};$base.Dispose()} + $prepared=Get-WelaPsLoggingSnapshot;Save 'prepared.json' $prepared + $audit=Public audit @{};Assert ($audit.ExitCode -eq 0 -and $audit.ReadyRuleCredit -eq 0) 'Unselected public Audit is observational.' + $plan=Public plan @{PowerShellLoggingAction='Plan';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility')} + Assert (@($plan.Plan.Controls|Where-Object Status -eq ChangeRequired).Count -eq 3) 'Public plan contains three exact value changes.' + $dry=Public dry @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility');DryRun=$true;BackupPath=(Join-Path $root 'dry-backup')} + Assert ($dry.Skipped -eq 3 -and -not (Test-Path (Join-Path $root 'dry-backup'))) 'Public dry run writes neither policy nor backup.' + Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $prepared)) 'Audit/Plan/DryRun preserve the complete prepared snapshot.' + $apply=Public configure @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility');Auto=$true;BackupPath=(Join-Path $root 'configure-backup')} + Assert ($apply.ExitCode -eq 0 -and @($apply.Results|Where-Object Status -eq Applied).Count -eq 3) 'Public Configure applies three named values.' + $journal=@(Get-Content -LiteralPath (Join-Path $root 'configure-backup/before.jsonl')|ForEach-Object{$_|ConvertFrom-Json});Assert ($journal.Count -eq 3) 'Each native write has its own original journal.' + Assert ((ConvertTo-WelaPsLoggingKey $journal[0].Before) -ceq (ConvertTo-WelaPsLoggingKey $prepared)) 'First journal matches exact typed prepared state.' + $configured=Get-WelaPsLoggingSnapshot;Save 'configured.json' $configured + $again=Public repeat @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility');Auto=$true;BackupPath=(Join-Path $root 'repeat-backup')} + Assert (@($again.Results|Where-Object Status -eq AlreadyCompliant).Count -eq 3 -and -not (Test-Path (Join-Path $root 'repeat-backup/before.jsonl'))) 'Second Configure makes no native writes.' + Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $configured)) 'Repeated configuration preserves complete observed state.' + $nonce='WELA_PS_LOG_'+[guid]::NewGuid().ToString('N');$workerPath=Join-Path $root ('worker-'+[guid]::NewGuid().ToString('N')+'.ps1') + $workerText="Microsoft.PowerShell.Utility\Write-Output -InputObject '$nonce'`r`n" + [IO.File]::WriteAllText($workerPath,$workerText,[Text.UTF8Encoding]::new($false));Save 'worker-source.json' @{Path=$workerPath;Sha256=(Get-FileHash $workerPath -Algorithm SHA256).Hash;Text=$workerText;Nonce=$nonce} + $latest=Get-WinEvent -LogName 'Microsoft-Windows-PowerShell/Operational' -MaxEvents 1 -ErrorAction Stop;try{$watermark=[long]$latest.RecordId}finally{$latest.Dispose()} + $process=Child 'event-worker' $configured.Engine.Path @('-NoLogo','-NoProfile','-NonInteractive','-File',$workerPath) + Assert ($process.ExitCode -eq 0 -and $process.Output.Trim() -ceq $nonce) 'Fixed native Windows PowerShell child executed the benign marker.' + $selected=@{};$candidates=@();$timer=[Diagnostics.Stopwatch]::StartNew() + do{ + $candidates=@(ReadEvents $process.Pid $watermark);$selected=@{} + foreach($xml in $candidates){ + $doc=[xml]$xml;$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$system=$doc.SelectSingleNode('/e:Event/e:System',$ns);$data=@{} + foreach($node in $doc.SelectNodes('/e:Event/e:EventData/e:Data',$ns)){if($data.ContainsKey($node.GetAttribute('Name'))){throw 'Duplicate native event field.'};$data[$node.GetAttribute('Name')]=$node.InnerText} + $id=[int]$system.SelectSingleNode('e:EventID',$ns).InnerText + if($system.SelectSingleNode('e:Provider',$ns).GetAttribute('Name') -cne 'Microsoft-Windows-PowerShell' -or $system.SelectSingleNode('e:Provider',$ns).GetAttribute('Guid').Trim('{}') -ine 'a0c1853b-5c40-4b15-8766-3cf1c58f985a' -or [int]$system.SelectSingleNode('e:Execution',$ns).GetAttribute('ProcessID') -ne $process.Pid -or $system.SelectSingleNode('e:Channel',$ns).InnerText -cne 'Microsoft-Windows-PowerShell/Operational' -or $system.SelectSingleNode('e:Computer',$ns).InnerText -ine $env:COMPUTERNAME){continue} + $time=[DateTimeOffset]::Parse($system.SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime')).UtcDateTime;if($time -lt [DateTimeOffset]::Parse($process.StartedUtc).UtcDateTime -or $time -gt [DateTimeOffset]::Parse($process.ExitedUtc).UtcDateTime){continue} + if($system.SelectSingleNode('e:Security',$ns).GetAttribute('UserID') -cne [Security.Principal.WindowsIdentity]::GetCurrent().User.Value){continue} + if($id -eq 4104 -and $data.ScriptBlockText -ceq $workerText -and $data.Path -ieq $workerPath -and $data.MessageNumber -ceq '1' -and $data.MessageTotal -ceq '1' -and $data.ScriptBlockId -match '^[0-9a-f-]{36}$'){$selected['4104']=@($selected['4104'])+ $xml} + if($id -eq 4103 -and $data.ContainsKey('Payload') -and $data.ContainsKey('ContextInfo') -and $data.Payload.Contains($nonce) -and $data.ContextInfo.Contains($workerPath)){$selected['4103']=@($selected['4103'])+ $xml} + } + if(@($selected['4103']|Where-Object {$_}).Count -eq 1 -and @($selected['4104']|Where-Object {$_}).Count -eq 1){break};Start-Sleep -Milliseconds 200 + }while($timer.Elapsed.TotalSeconds -lt 15) + Save 'event-candidates.json' $candidates + foreach($id in @('4103','4104')){$events=@($selected[$id]|Where-Object {$_});Assert ($events.Count -eq 1) "Exactly one worker-attributed native $id event required; found $($events.Count).";[IO.File]::WriteAllText((Join-Path $root ('event-'+$id+'.xml')),$events[0],[Text.UTF8Encoding]::new($false))} + Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $configured)) 'Fresh event probe changes no policy or channel state.' + Assert ((Masks) -ceq $masks) 'All 59 audit masks remain unchanged.' + # Wrong-type selected value is refused by the public command without repairing it. + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null + try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging',$true);$key.SetValue('EnableScriptBlockLogging','1',[Microsoft.Win32.RegistryValueKind]::String)}finally{if($key){$key.Dispose()};$base.Dispose()} + $wrong=Get-WelaPsLoggingSnapshot;$refused=Public wrong-type @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('ScriptBlock');Auto=$true;BackupPath=(Join-Path $root 'wrong-backup')} 1 + Assert ($refused.ExitCode -eq 1 -and -not (Test-Path (Join-Path $root 'wrong-backup'))) 'Wrong-type preflight refuses before backup or writes.' + Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $wrong)) 'Refusal preserves the typed wrong value.' +}catch{$failure=$_.ToString();Save 'failure.json' @{Error=$failure;Stack=$_.ScriptStackTrace}} +finally{ + if($original){ + foreach($item in @(@('ScriptBlockLogging','EnableScriptBlockLogging'),@('ModuleLogging','EnableModuleLogging'),@('ModuleLogging\ModuleNames','Microsoft.PowerShell.Utility'))){try{RestoreValue $original.Machine $item[0] $item[1]}catch{$cleanupErrors+=$_.ToString()}} + try{RemoveCreatedKeys $original.Machine}catch{$cleanupErrors+=$_.ToString()} + try{$after=Get-WelaPsLoggingSnapshot;Save 'cleanup-after.json' $after;if((ConvertTo-WelaPsLoggingKey $after) -cne (ConvertTo-WelaPsLoggingKey $original)){$cleanupErrors+='Full policy/host/source/channel snapshot did not restore exactly.'};if($masks -and (Masks) -cne $masks){$cleanupErrors+='Audit masks changed.'}}catch{$cleanupErrors+=$_.ToString()} + } + Save 'cleanup.json' @{Status=$(if($cleanupErrors.Count){'Failed'}else{'Restored'});Errors=$cleanupErrors;OriginalCaptured=[bool]$original;All59MasksUnchanged=($masks -and (Masks) -ceq $masks)} + $artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object{[pscustomobject]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}}) + Save 'manifest.json' @{Kind='WelaPowerShellLoggingNativeFixture';Head=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$script:count;Failure=$failure;CleanupErrors=$cleanupErrors;ReadyRuleCredit=0;Artifacts=$artifacts} +} +if($failure -or $cleanupErrors.Count){throw "Native fixture failed: $failure Cleanup: $($cleanupErrors -join '; ')"} +Write-Host "PASS: $script:count public PowerShell policy/native4103/native4104 assertions with exact cleanup."