diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e1ba39..f40f517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Releases are tagged `vX.Y.Z` and published with a zip and a SHA256 checksum. ## [Unreleased] +### Added + +- 🧾 **Transcription policy check.** `Test-LoggingBaseline.ps1` now has a read-only Safety row for the PowerShell transcription policy, which the kit never sets. On with no `OutputDirectory` fails, because every session then writes a transcript into the user's Documents folder; on with a folder passes with a note; off passes. Machine policy takes precedence; the user policy of the account running the test is read when no machine policy is set, and a `Wow6432Node` copy that disagrees is noted. The Safety never-do table explains it. [#91](https://github.com/spydisec/WinLogKit/issues/91) + ## [2.2.2] - 2026-09-24 Documentation only: one checklist for fitting the kit to your own infrastructure before rolling it out. No script or setting changes; nothing to do when upgrading from 2.2.1. diff --git a/Test-LoggingBaseline.ps1 b/Test-LoggingBaseline.ps1 index ed85a44..1457020 100644 --- a/Test-LoggingBaseline.ps1 +++ b/Test-LoggingBaseline.ps1 @@ -244,6 +244,29 @@ if ($null -eq $crash -or "$crash" -eq '0') { Add-Row @('Logging tampered with') 'Safety' $crashLabel '0 or absent' "$crash" 'FAIL' 'CrashOnAuditFail is on: the host halts when the Security log fills. Set by another policy; the kit never changes it. See Safety (never-do list).' } +# PowerShell transcription (#91): the kit never sets it, but another policy +# might. On with no OutputDirectory, every session writes a transcript into +# the user's Documents folder (secrets typed on a command line included), so +# that state fails. On with a folder is someone's deliberate choice: PASS +# with a note. Uncategorised: it is a data-exposure finding, not a logging +# one, so it drives the exit code without landing in a behaviour category. +$tx = Get-TranscriptionPolicyState +$txLabel = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription\EnableTranscripting' +$txExpected = 'absent or 0, or 1 with an OutputDirectory' +$txScope = '' +if ($tx.Scope -eq 'User') { $txScope = ' (user policy of the account running this test; no machine policy is set)' } +# The Wow6432Node copy is noted whenever it is present and disagrees with +# the effective state, in either direction; an absent copy is not a mismatch. +$txNote = '' +if ($tx.Wow6432NodeValue -ne '' -and (($tx.Wow6432NodeValue -eq '1') -ne ($tx.State -ne 'Off'))) { + $txNote = " The Wow6432Node copy of the key reads EnableTranscripting=$($tx.Wow6432NodeValue), which differs; check that path too." +} +switch ($tx.State) { + 'Off' { Add-Row @() 'Safety' $txLabel $txExpected '' 'PASS' $txNote.Trim() } + 'Directed' { Add-Row @() 'Safety' $txLabel $txExpected "1, OutputDirectory=$($tx.OutputDirectory)$txScope" 'PASS' ('Transcription is on and directed to a folder. Set by another policy; the kit never changes it. Keep that folder readable only by the people who need the transcripts.' + $txNote) } + 'Undirected' { Add-Row @() 'Safety' $txLabel $txExpected "1, no OutputDirectory$txScope" 'FAIL' ('Transcription is on with no OutputDirectory: every Windows PowerShell session writes a transcript into the user''s Documents folder, including anything typed on a command line. Set by another policy, not the kit. Either set an OutputDirectory with restricted access, or remove the Transcription policy. See Safety (never-do list).' + $txNote) } +} + # ----------------------- SMB auditing (Windows 11 24H2 / Server 2025+) ------ $smbState = Get-SmbAuditState diff --git a/WinLogKit.Common.ps1 b/WinLogKit.Common.ps1 index 4c5990d..88b1dc9 100644 --- a/WinLogKit.Common.ps1 +++ b/WinLogKit.Common.ps1 @@ -52,7 +52,7 @@ function Get-OsType { # Registry access uses the .NET API throughout, not *-ItemProperty, because # one required value is literally named '*' and the ItemProperty cmdlets # treat that as a wildcard. -function ConvertTo-NetRegPath { param([string]$Path) $Path -replace '^HKLM:\\', 'HKEY_LOCAL_MACHINE\' } +function ConvertTo-NetRegPath { param([string]$Path) $Path -replace '^HKLM:\\', 'HKEY_LOCAL_MACHINE\' -replace '^HKCU:\\', 'HKEY_CURRENT_USER\' } function Get-RegValue { param([string]$Path, [string]$Name) @@ -140,6 +140,40 @@ function Test-RetentionForcedByPolicy { return ("$v" -eq '1') } +# The PowerShell transcription policy (#91). The kit never sets it (removed +# in 2.0.0, #36), but another policy might. Enabled with no OutputDirectory, +# every Windows PowerShell session writes a transcript into the user's +# Documents folder: Microsoft documents that as the policy's default and +# says to restrict access to the output location. The policy exists under +# Computer and User Configuration, Computer taking precedence, so the user +# hive (of the account running this) is read only when the machine hive has +# no value. Returns a hashtable: +# State Off | Directed (on, folder set) | Undirected (on, no folder) +# OutputDirectory the configured folder, or '' +# Scope Machine | User (which hive decided) | '' (neither configured) +# Wow6432NodeValue the Wow6432Node copy's EnableTranscripting as text, '' if absent +# Read-only; compared as text so DWORD and string 1 read the same. +function Get-TranscriptionPolicyState { + $machine = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' + $user = 'HKCU:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' + $scope = 'Machine'; $path = $machine + $raw = Get-RegValue -Path $machine -Name 'EnableTranscripting' + if ($null -eq $raw) { + $scope = 'User'; $path = $user + $raw = Get-RegValue -Path $user -Name 'EnableTranscripting' + if ($null -eq $raw) { $scope = '' } + } + $on = ("$raw" -eq '1') + $dir = '' + if ($on) { $dir = "$(Get-RegValue -Path $path -Name 'OutputDirectory')".Trim() } + $wowRaw = Get-RegValue -Path 'HKLM:\SOFTWARE\Wow6432Node\Policies\Microsoft\Windows\PowerShell\Transcription' -Name 'EnableTranscripting' + $wow = '' + if ($null -ne $wowRaw) { $wow = "$wowRaw" } + $state = 'Off' + if ($on) { if ($dir -ne '') { $state = 'Directed' } else { $state = 'Undirected' } } + return @{ State = $state; OutputDirectory = $dir; Scope = $scope; Wow6432NodeValue = $wow } +} + # The Set-/Get-Smb*Configuration property an SMB audit item maps to: its # Setting when it has one (the same setting exists on client and server, # and Ids must be unique), otherwise its Id. Works for settings-table diff --git a/docs/safety.md b/docs/safety.md index b729c14..f41ca3f 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -53,6 +53,7 @@ kit never touches them, in any mode: | Global object access auditing | SACLs on every kernel/file/registry object - extreme volume, measurable performance degradation. | | Blanket File System / Registry SACLs | A careless wildcard SACL can bury a file server. Scoping SACLs is a design decision, never a default. | | Shrinking logs, rebooting, restarting services | Sizes are only raised; the one restart-requiring setting (AD CS AuditFilter) is set with a warning and left to your change window. | +| PowerShell transcription | Writes text files outside the event log; removed from the kit in 2.0.0 (script block logging already records the code that ran). If another policy turns it on **without an `OutputDirectory`** (machine policy, or the user policy of the account running the test when no machine policy is set), every Windows PowerShell session writes a transcript into the user's Documents folder, which Microsoft documents as the policy's default behaviour ([Policy CSP](https://learn.microsoft.com/windows/client-management/mdm/policy-csp-admx-powershellexecutionpolicy#enabletranscripting)). The test **fails** that state and passes transcription that is directed to a folder. If you want transcripts, set them by GPO to a central folder readable only by the people who need them. | ## Volume-impact settings (the HighVolume tier and friends) diff --git a/tests/Kit.Tests.ps1 b/tests/Kit.Tests.ps1 index 65919c4..b59d825 100644 --- a/tests/Kit.Tests.ps1 +++ b/tests/Kit.Tests.ps1 @@ -116,7 +116,7 @@ Describe 'Scripts' { $expected = @('Test-IsAdmin', 'Get-DomainRole', 'Test-PowerShell7Installed', 'Get-OsType', 'ConvertTo-NetRegPath', 'Get-RegValue', 'ConvertFrom-AuditPolicyBackup', 'Get-AuditPolicyByGuid', 'Get-AuditSettingValue', 'Format-AuditSetting', 'Get-SmbAuditState', 'Get-BaselineItemKeySet', 'Import-BaselineSelection', 'Test-ReferenceBaselineItem', 'Write-IncludeOptionalWarning', - 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected') + 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected', 'Get-TranscriptionPolicyState') $notInCommon = @($expected | Where-Object { -not $Defs.ContainsKey($_) -or (($Defs[$_] -join ';') -ne 'WinLogKit.Common.ps1') }) $notInCommon | Should -BeNullOrEmpty } @@ -397,6 +397,72 @@ Describe 'Audit integrity' { Select-String -Path (Join-Path $KitRoot 'Test-LoggingBaseline.ps1') -Pattern "-Name 'CrashOnAuditFail'" -Quiet | Should -BeTrue } } +# #91: transcription on with no OutputDirectory fills every user's Documents +# folder. The kit never sets it; Test reports it. +Describe 'Transcription policy check' { + It 'never sets the transcription policy' { + @($BaselineRegistrySettings | Where-Object { $_.Path -like '*\PowerShell\Transcription' }) | Should -BeNullOrEmpty + } + + It 'reads Off when the value is absent or 0' { + Mock Get-RegValue { $null } + (Get-TranscriptionPolicyState).State | Should -Be 'Off' + Mock Get-RegValue { 0 } + (Get-TranscriptionPolicyState).State | Should -Be 'Off' + } + + It 'reads Directed when on with an OutputDirectory, and keeps the folder' { + Mock Get-RegValue { if ($Name -eq 'OutputDirectory') { 'D:\Transcripts' } else { 1 } } + $s = Get-TranscriptionPolicyState + $s.State | Should -Be 'Directed' + $s.OutputDirectory | Should -Be 'D:\Transcripts' + } + + It 'reads Undirected when on with no OutputDirectory, DWORD or string' { + Mock Get-RegValue { if ($Name -eq 'OutputDirectory') { $null } else { 1 } } + (Get-TranscriptionPolicyState).State | Should -Be 'Undirected' + Mock Get-RegValue { if ($Name -eq 'OutputDirectory') { ' ' } else { '1' } } + (Get-TranscriptionPolicyState).State | Should -Be 'Undirected' + } + + It 'reports the Wow6432Node copy as text, absent as empty' { + Mock Get-RegValue { if ($Path -like '*Wow6432Node*') { 1 } else { $null } } + $s = Get-TranscriptionPolicyState + $s.State | Should -Be 'Off' + $s.Wow6432NodeValue | Should -Be '1' + Mock Get-RegValue { if ($Path -like '*Wow6432Node*') { 0 } elseif ($Name -eq 'OutputDirectory') { $null } else { 1 } } + $s = Get-TranscriptionPolicyState + $s.State | Should -Be 'Undirected' + $s.Wow6432NodeValue | Should -Be '0' + Mock Get-RegValue { if ($Path -like '*Wow6432Node*') { $null } else { 1 } } + (Get-TranscriptionPolicyState).Wow6432NodeValue | Should -Be '' + } + + It 'falls back to the user policy only when the machine hive has no value' { + Mock Get-RegValue { if ($Path -like 'HKCU:*') { if ($Name -eq 'OutputDirectory') { $null } else { 1 } } else { $null } } + $s = Get-TranscriptionPolicyState + $s.State | Should -Be 'Undirected' + $s.Scope | Should -Be 'User' + Mock Get-RegValue { if ($Path -like 'HKCU:*') { 1 } elseif ($Path -like '*Wow6432Node*') { $null } else { 0 } } + $s = Get-TranscriptionPolicyState + $s.State | Should -Be 'Off' + $s.Scope | Should -Be 'Machine' + Mock Get-RegValue { $null } + (Get-TranscriptionPolicyState).Scope | Should -Be '' + } + + It 'maps HKCU: as well as HKLM: for the .NET registry API' { + ConvertTo-NetRegPath 'HKCU:\SOFTWARE\Policies\X' | Should -Be 'HKEY_CURRENT_USER\SOFTWARE\Policies\X' + ConvertTo-NetRegPath 'HKLM:\SOFTWARE\Policies\X' | Should -Be 'HKEY_LOCAL_MACHINE\SOFTWARE\Policies\X' + } + + It 'is assessed by Test as an uncategorised Safety row that fails only when Undirected' { + $src = Get-Content (Join-Path $KitRoot 'Test-LoggingBaseline.ps1') -Raw + $src | Should -Match 'Get-TranscriptionPolicyState' + $src | Should -Match "'Undirected' \{ Add-Row @\(\) 'Safety' .*'FAIL'" + $src | Should -Match "'Directed' \{ Add-Row @\(\) 'Safety' .*'PASS'" + } +} # #74: Test tells you when the AppLocker logs can't record anything. Describe 'AppLocker readiness note' { It 'checks for an effective AppLocker policy, read-only, and never fails on it' {