Files
WELA/scripts/PowerShellTranscription.ps1

247 lines
18 KiB
PowerShell

# Optional Windows PowerShell 5.1 transcription. Text files, not EVTX coverage.
function Get-WelaTranscriptRegistryValue {
param([ValidateSet('LocalMachine', 'CurrentUser')][string]$Hive = 'LocalMachine',
[ValidateSet('Registry64', 'Registry32')][string]$View = 'Registry64',
[string]$SubKey = 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription', [string]$Name)
$base = $null; $key = $null
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::$Hive, [Microsoft.Win32.RegistryView]::$View)
$key = $base.OpenSubKey($SubKey, $false)
$exists = $null -ne $key -and $key.GetValueNames() -contains $Name
[pscustomobject]@{ KeyExists = $null -ne $key; ValueExists = [bool]$exists;
Value = $(if ($exists) { $key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } else { $null });
Type = $(if ($exists) { $key.GetValueKind($Name).ToString() } else { $null }) }
} finally { if ($key) { $key.Dispose() }; if ($base) { $base.Dispose() } }
}
function Set-WelaTranscriptRegistryValue {
param([ValidateSet('EnableTranscripting', 'OutputDirectory')][string]$Name, $Value,
[ValidateSet('DWord', 'String')][string]$Type)
$base = $null; $key = $null
try {
# SOFTWARE\Policies is shared by Registry32/Registry64 on supported Windows.
# Do not create a literal Wow6432Node policy subtree.
$view = if ([Environment]::Is64BitOperatingSystem) { [Microsoft.Win32.RegistryView]::Registry64 } else { [Microsoft.Win32.RegistryView]::Registry32 }
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
$key = $base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription')
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::$Type)
} finally { if ($key) { $key.Dispose() }; if ($base) { $base.Dispose() } }
}
function Get-WelaTranscriptCapability {
$result = [pscustomobject]@{ Status = 'Unknown'; TargetEngine = 'Windows PowerShell 5.1'; EngineVersion = $null;
WelaHostEdition = [string]$PSVersionTable.PSEdition; WelaHostVersion = $PSVersionTable.PSVersion.ToString(); Views = @(); Diagnostic = '' }
try {
if ($env:OS -ne 'Windows_NT') { throw 'This command requires Windows; PowerShell 7 on non-Windows cannot configure Windows PowerShell policy.' }
$result.Views = if ([Environment]::Is64BitOperatingSystem) { @('Registry64', 'Registry32') } else { @('Registry32') }
$engine = Get-WelaTranscriptRegistryValue -View $result.Views[0] -SubKey 'SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine' -Name PowerShellVersion
if (-not $engine.ValueExists -or $engine.Type -ne 'String' -or [string]$engine.Value -notmatch '^5\.1(?:\.|$)') { throw 'Installed Windows PowerShell 5.1 engine could not be confirmed from its registry version.' }
$result.EngineVersion = [string]$engine.Value
$executable = Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe'
if (-not (Test-Path -LiteralPath $executable -PathType Leaf -ErrorAction Stop)) { throw 'Native Windows PowerShell executable is missing.' }
$result.Status = 'Supported'
$result.Diagnostic = 'Windows PowerShell 5.1 policy only. PowerShell 7 has separate policy/configuration; its sessions are not assessed or configured.'
} catch { $result.Diagnostic = $_.Exception.Message }
return $result
}
function Get-WelaTranscriptPolicy {
param([string[]]$Views)
foreach ($view in $Views) {
$machine = [ordered]@{}; $user = [ordered]@{}
foreach ($name in @('EnableTranscripting', 'OutputDirectory', 'EnableInvocationHeader')) {
$machine[$name] = Get-WelaTranscriptRegistryValue -View $view -Name $name
$user[$name] = Get-WelaTranscriptRegistryValue -Hive CurrentUser -View $view -Name $name
}
[pscustomobject]@{ View = $view; Machine = [pscustomobject]$machine; CurrentUser = [pscustomobject]$user }
}
}
function Test-WelaTranscriptSharedPolicy {
param([array]$Policy)
if (-not $Policy.Count) { throw 'No registry view was observed.' }
if ($Policy.Count -gt 1) {
foreach ($hive in @('Machine', 'CurrentUser')) {
foreach ($name in @('EnableTranscripting', 'OutputDirectory', 'EnableInvocationHeader')) {
foreach ($field in @('KeyExists', 'ValueExists', 'Type', 'Value')) {
if ((ConvertTo-Json -InputObject $Policy[0].$hive.$name.$field -Compress) -cne
(ConvertTo-Json -InputObject $Policy[1].$hive.$name.$field -Compress)) { throw "Shared policy views differ: $hive/$name/$field. No architecture coverage is assumed." }
}
}
}
}
}
function Test-WelaTranscriptDirectoryPath {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path) -or $Path -match '[*?%\x00-\x1f]' -or
$Path -notmatch '^(?:[A-Za-z]:\\|\\\\[^\\:]+\\[^\\:]+(?:\\|$))' -or
$Path -match '(?:^|\\)\.\.?($|\\)' -or $Path.Substring(2).Contains(':')) {
throw 'Supply a literal absolute drive or UNC directory, without wildcards, environment variables, device paths, alternate streams or dot segments.'
}
}
function Get-WelaTranscriptAclObservation {
param([string]$Path)
$acl = Get-Acl -LiteralPath $Path -ErrorAction Stop
$sddl = $acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]'Owner,Group,Access')
$sd = [Security.AccessControl.RawSecurityDescriptor]::new($sddl)
$entries = @(); $risks = @(); $unknown = @()
if ($null -eq $sd.DiscretionaryAcl) { $risks += 'Null DACL permits unrestricted access.' }
elseif ($sd.DiscretionaryAcl.Count -eq 0) { $risks += 'Empty DACL permits no transcript writers.' }
foreach ($ace in $sd.DiscretionaryAcl) {
if ($ace -isnot [Security.AccessControl.CommonAce] -or $ace.IsCallback) { $unknown += 'Uninterpreted or conditional access ACE; authorization needs deployment review.'; continue }
$sid = $ace.SecurityIdentifier.Value
$mask = [int64]$ace.AccessMask; $flags = [int]$ace.AceFlags
$entries += [pscustomobject]@{ Sid = $sid; RightsMask = $mask; AceFlags = $flags; Type = $ace.AceQualifier.ToString() }
if ($ace.AceQualifier -ne [Security.AccessControl.AceQualifier]::AccessAllowed) { continue }
$broad = $sid -in @('S-1-1-0', 'S-1-5-7', 'S-1-5-11', 'S-1-5-32-545', 'S-1-5-32-546', 'S-1-5-2', 'S-1-5-4')
if (-not $broad) { continue }
# Conservative grant inspection, not a token/group/deny-aware AccessCheck.
# GenericRead/GenericAll and ReadData/ListDirectory expose transcript data/names.
if ($mask -band 2415919105) { $risks += "Broad principal $sid has a read/list grant; other users' transcripts may be exposed." }
# File-inheritable write/append, delete, change-permissions/owner, or generic write/all.
if (($mask -band 1343029312) -or (($flags -band 1) -and ($mask -band 6))) {
$risks += "Broad principal $sid has a modification grant; existing transcripts may be alterable."
}
}
[pscustomobject]@{ Sddl = $sddl; Owner = [string]$sd.Owner; Entries = $entries; Risks = $risks; Unknown = $unknown;
Assessment = 'Conservative ACL observations only; effective writer/collector access and authorized group membership require deployment validation.' }
}
function Get-WelaTranscriptDestination {
param([string]$Path)
$result = [pscustomobject]@{ RequestedPath = $Path; Path = $null; Status = 'Unknown'; ConfigureAllowed = $false;
IsUnc = $Path.StartsWith('\\'); CreationTimeUtc = $null; Attributes = $null; Acl = $null;
ShareAuthorization = 'NotApplicable'; WriterAuthorization = 'Unknown'; CollectorAuthorization = 'Unknown'; Diagnostic = '' }
try {
Test-WelaTranscriptDirectoryPath $Path
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item -isnot [IO.DirectoryInfo]) { throw 'Transcript destination must be an existing filesystem directory.' }
$result.Path = $item.FullName
$result.CreationTimeUtc = $item.CreationTimeUtc.ToString('o')
$result.Attributes = [int]$item.Attributes
# Refuse local or UNC path components that are observed reparse points.
$ancestor = $item
while ($null -ne $ancestor) {
if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse-point destination component requires separate review: $($ancestor.FullName)." }
$ancestor = $ancestor.Parent
}
$result.Acl = Get-WelaTranscriptAclObservation $result.Path
if ($result.Acl.Risks.Count) { $result.Status = 'Blocked'; throw ($result.Acl.Risks -join ' ') }
if ($result.Acl.Unknown.Count) { throw ($result.Acl.Unknown -join ' ') }
$result.Status = 'Observed'; $result.ConfigureAllowed = $true
$result.Diagnostic = 'Directory and DACL observed without identified broad access grants. This does not prove effective access, protected child files, append-only storage, retention or collection; review the deployment before Configure.'
if ($result.IsUnc) {
$result.ShareAuthorization = 'Unknown'
$result.Diagnostic += ' UNC share permissions, remote identities and server-side quotas are not evaluated.'
}
} catch { $result.Diagnostic = $_.Exception.Message }
return $result
}
function Get-WelaTranscriptState {
param([string]$OutputDirectory)
$capability = Get-WelaTranscriptCapability
if ($capability.Status -ne 'Supported') { throw $capability.Diagnostic }
$policy = @(Get-WelaTranscriptPolicy $capability.Views)
Test-WelaTranscriptSharedPolicy $policy
$observedPath = $OutputDirectory
if (-not $observedPath -and $policy[0].Machine.OutputDirectory.ValueExists -and $policy[0].Machine.OutputDirectory.Type -eq 'String') {
$observedPath = [string]$policy[0].Machine.OutputDirectory.Value
}
$destination = if ($observedPath) { Get-WelaTranscriptDestination $observedPath } else {
[pscustomobject]@{ RequestedPath = $null; Path = $null; Status = 'Unknown'; ConfigureAllowed = $false;
Diagnostic = 'No explicit machine output directory was observed; per-user defaults/current-user policy and destination authorization are not established.' }
}
[pscustomobject]@{ Capability = $capability; Policy = $policy; Destination = $destination;
TranscriptGeneration = 'Unverified'; PowerShell7Sessions = 'NotAssessed'; Retention = 'Unknown'; Collection = 'Unknown' }
}
function Test-WelaTranscriptConfigured {
param($Snapshot, [string]$OutputDirectory)
if (-not $Snapshot.Destination.ConfigureAllowed) { return $false }
foreach ($view in $Snapshot.Policy) {
if (-not $view.Machine.EnableTranscripting.ValueExists -or $view.Machine.EnableTranscripting.Type -ne 'DWord' -or
$view.Machine.EnableTranscripting.Value -ne 1 -or -not $view.Machine.OutputDirectory.ValueExists -or
$view.Machine.OutputDirectory.Type -ne 'String' -or [string]$view.Machine.OutputDirectory.Value -cne $OutputDirectory) { return $false }
}
return $Snapshot.Policy.Count -gt 0
}
function Set-WelaTranscriptControl {
param($Context, [string]$OutputDirectory)
$state = @{ OutputDirectory = $OutputDirectory; Observed = $null; PreservedHeader = $null }
$read = { param($state)
$snapshot = Get-WelaTranscriptState $state.OutputDirectory
if (-not $snapshot.Destination.ConfigureAllowed) { throw "Transcript destination cannot be configured: $($snapshot.Destination.Diagnostic)" }
if ($null -ne $state.PreservedHeader -and
($snapshot.Policy[0].Machine.EnableInvocationHeader | Select-Object ValueExists, Type, Value | ConvertTo-Json -Compress) -cne $state.PreservedHeader) {
throw 'Invocation-header preference changed during configuration; no header change was requested.'
}
$state.Observed = $snapshot
return $snapshot
}
$test = { param($snapshot, $state) Test-WelaTranscriptConfigured $snapshot $state.OutputDirectory }
$apply = { param($state)
$before = $state.Observed
$fresh = Get-WelaTranscriptState $state.OutputDirectory
if (-not $fresh.Destination.ConfigureAllowed -or
($fresh.Policy | ConvertTo-Json -Depth 10 -Compress) -cne ($before.Policy | ConvertTo-Json -Depth 10 -Compress) -or
($fresh.Destination | ConvertTo-Json -Depth 10 -Compress) -cne ($before.Destination | ConvertTo-Json -Depth 10 -Compress)) {
throw 'Policy or destination changed after the recovery snapshot; no write was sent. Review and retry.'
}
$state.PreservedHeader = $before.Policy[0].Machine.EnableInvocationHeader | Select-Object ValueExists, Type, Value | ConvertTo-Json -Compress
# Establish the reviewed location before enabling new-session transcription.
if (-not $before.Policy[0].Machine.OutputDirectory.ValueExists -or $before.Policy[0].Machine.OutputDirectory.Type -ne 'String' -or
[string]$before.Policy[0].Machine.OutputDirectory.Value -cne $state.OutputDirectory) {
Set-WelaTranscriptRegistryValue -Name OutputDirectory -Value $state.OutputDirectory -Type String
}
$location = Get-WelaTranscriptRegistryValue -View $before.Capability.Views[0] -Name OutputDirectory
if (-not $location.ValueExists -or $location.Type -ne 'String' -or [string]$location.Value -cne $state.OutputDirectory) {
throw 'OutputDirectory write did not verify; EnableTranscripting was not changed.'
}
if (-not $before.Policy[0].Machine.EnableTranscripting.ValueExists -or $before.Policy[0].Machine.EnableTranscripting.Type -ne 'DWord' -or
$before.Policy[0].Machine.EnableTranscripting.Value -ne 1) {
Set-WelaTranscriptRegistryValue -Name EnableTranscripting -Value 1 -Type DWord
}
'Windows PowerShell machine transcription policy written. New-session transcript generation, writer/collector authorization and collection remain unverified. No invocation-header, ACL, share, quota or retention setting was changed.'
}
Invoke-WelaConfigurationControl -Context $Context -Id 'PowerShellTranscription/CisV4L2' -Kind PowerShellTranscription `
-Target @{ Hive = 'LocalMachine'; SubKey = 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'; OutputDirectory = $OutputDirectory } `
-Desired @{ EnableTranscripting = @{ Type = 'DWord'; Value = 1 }; OutputDirectory = @{ Type = 'String'; Value = $OutputDirectory }; EnableInvocationHeader = 'Preserve' } `
-Read $read -Compliant $test -Apply $apply -CallbackState $state `
-Description 'Enable CIS Level 2 Windows PowerShell transcription using this explicitly reviewed destination. Transcript text can contain sensitive input/output.'
}
function Invoke-WelaTranscriptCommand {
param([ValidateSet('Audit', 'Plan', 'Configure')][string]$Action = 'Audit', [string]$OutputDirectory,
[switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
if ($DryRun -and $Action -ne 'Configure') { throw 'DryRun applies only to transcription Configure.' }
if ($Action -in @('Plan', 'Configure') -and -not $OutputDirectory) { throw 'Transcription Plan/Configure requires an explicit -TranscriptDirectory to review.' }
if ($Action -eq 'Configure') {
$context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
Set-WelaTranscriptControl $context $OutputDirectory
$report = Complete-WelaConfiguration -Context $context -Scope 'windows-powershell-transcription-policy-only' `
-SuccessMessage 'Windows PowerShell transcription policy verified; transcript generation and destination authorization/collection remain separate checks.'
} else {
$snapshot = $null; $diagnostic = ''; $status = 'Unknown'
try {
$snapshot = Get-WelaTranscriptState $OutputDirectory
$targetPath = if ($OutputDirectory) { $OutputDirectory } else { [string]$snapshot.Policy[0].Machine.OutputDirectory.Value }
$status = if (-not $snapshot.Destination.ConfigureAllowed) { $snapshot.Destination.Status }
elseif (Test-WelaTranscriptConfigured $snapshot $targetPath) { 'PolicyConfigured' } else { 'ChangeRequired' }
$diagnostic = $snapshot.Destination.Diagnostic
} catch { $diagnostic = $_.Exception.Message }
$report = [pscustomobject]@{ Scope = 'windows-powershell-transcription-policy-only';
ExitCode = $(if ($status -in @('Unknown', 'Blocked')) { 1 } else { 0 });
Results = @([pscustomobject]@{ Status = $status; DesiredDirectory = $OutputDirectory; Before = $snapshot; Diagnostic = $diagnostic }) }
}
$report | Add-Member NoteProperty Action $Action
$report | Add-Member NoteProperty Benchmark 'CIS Windows 11 Enterprise / Windows Server 2022 v4.0.0, 18.10.87.2, Level 2 only; this is not a complete CIS assessment.'
$report | Add-Member NoteProperty VerificationScope 'Windows PowerShell 5.1 machine registry policy and destination observations only. Existing sessions, other identities, PowerShell 7 sessions, transcript generation, quota/retention and central collection are unverified.'
$report | Add-Member NoteProperty Telemetry @{ Format = 'Text transcript files'; EventIds = @(); SigmaEvtxCredit = 0; RelationTo4103And4104 = 'Separate output; no automatic EVTX rule applicability or coverage uplift.' }
if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
return $report
}