Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions Test-LoggingBaseline.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<absent or 0>' '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
Expand Down
36 changes: 35 additions & 1 deletion WinLogKit.Common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
68 changes: 67 additions & 1 deletion tests/Kit.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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' {
Expand Down
Loading