Get-PSSecurity.psm1
|
function Get-LanguageMode { <# .SYNOPSIS Returns the language mode of the provided session state. .DESCRIPTION Wraps SessionState.LanguageMode in a function so it can be mocked cleanly in Pester tests. The caller must pass its own $PSCmdlet.SessionState so the value reflects the calling session rather than the module's own (always-FullLanguage) session state. .PARAMETER SessionState The SessionState object to read LanguageMode from. Pass $PSCmdlet.SessionState from the calling advanced function. .OUTPUTS System.String - one of FullLanguage, ConstrainedLanguage, RestrictedLanguage, NoLanguage. #> [CmdletBinding()] param ( [Parameter(Mandatory)] $SessionState ) process { "$($SessionState.LanguageMode)" } } function Get-PolicySource { <# .SYNOPSIS Determines the source of a registry-based policy setting via gpresult. .DESCRIPTION Shells out to gpresult /Scope Computer /X to parse the resulting XML, locating the GPO responsible for a specific registry value path. Falls back gracefully to Source='Policy' if gpresult is unavailable or fails. .PARAMETER RegistryPath The full registry path whose policy source to look up. .OUTPUTS PSCustomObject with Source and SourceDetail properties. Source is one of: DomainGPO, LocalPolicy, Policy (fallback) .NOTES Requires the gpresult.exe binary. On non-domain machines or when run without elevation, the XML output may be incomplete. The function returns Source='Policy' rather than throwing in those cases. #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [string]$RegistryPath ) process { $FallbackResult = New-Object PSObject $FallbackResult | Add-Member -MemberType NoteProperty -Name Source -Value 'Policy' $FallbackResult | Add-Member -MemberType NoteProperty -Name SourceDetail -Value $RegistryPath try { $TempFile = (New-TemporaryFile).FullName $GpresultProcess = & gpresult /Scope Computer /X $TempFile /F 2>&1 if ($LASTEXITCODE -ne 0) { Write-Verbose "gpresult exited with code $LASTEXITCODE; using fallback source." return $FallbackResult } if (-not (Test-Path $TempFile)) { return $FallbackResult } [xml]$GpXml = Get-Content -Path $TempFile -Raw -ErrorAction Stop # Clean up temp file Remove-Item $TempFile -Force -ErrorAction SilentlyContinue # Normalise registry path for comparison (remove trailing slashes, lowercase) $NormalisedPath = $RegistryPath.TrimEnd('\').ToLowerInvariant() # Search all RegistrySetting nodes for a matching key path $SettingNodes = $GpXml.SelectNodes('//RegistrySetting') $MatchedGpo = $null foreach ($Node in $SettingNodes) { $NodeKeyPath = $Node.KeyPath if ($null -ne $NodeKeyPath -and $NodeKeyPath.ToLowerInvariant().TrimEnd('\') -eq $NormalisedPath) { # Walk up to find the GPO name — it lives in the parent GPO element $GpoNameNode = $Node.SelectSingleNode('ancestor::GPO/Name') if ($null -ne $GpoNameNode) { $MatchedGpo = $GpoNameNode.'#text' } break } } if ($null -eq $MatchedGpo) { return $FallbackResult } $IsLocal = $MatchedGpo -like '*Local Group Policy*' -or $MatchedGpo -eq 'Local Group Policy' $MatchedSource = if ($IsLocal) { 'LocalPolicy' } else { 'DomainGPO' } $GpoResult = New-Object PSObject $GpoResult | Add-Member -MemberType NoteProperty -Name Source -Value $MatchedSource $GpoResult | Add-Member -MemberType NoteProperty -Name SourceDetail -Value $MatchedGpo return $GpoResult } catch { Write-Verbose "Get-PolicySource encountered an error: $_" return $FallbackResult } } } function Get-PSLoggingStatus { <# .SYNOPSIS Reads PowerShell logging registry keys and returns their raw state. .DESCRIPTION Queries the three PowerShell logging policy registry paths and returns a structured object indicating whether each setting is enabled and which registry value key was read. .PARAMETER CheckName The logging check name: ModuleLogging, ScriptBlockLogging, or TranscriptLogging. .OUTPUTS PSCustomObject with RegistryPath, EnabledKey, Value, and IsEnabled properties. .NOTES Windows-only; reads HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell paths. #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [ValidateSet('ModuleLogging', 'ScriptBlockLogging', 'TranscriptLogging')] [string]$CheckName ) process { $RegistryMap = @{ ModuleLogging = @{ Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging' EnabledKey = 'EnableModuleLogging' } ScriptBlockLogging = @{ Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' EnabledKey = 'EnableScriptBlockLogging' } TranscriptLogging = @{ Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' EnabledKey = 'EnableTranscripting' } } $Entry = $RegistryMap[$CheckName] $RegistryPath = $Entry.Path $EnabledKey = $Entry.EnabledKey $RegistryValue = Get-ItemProperty -Path $RegistryPath -Name $EnabledKey -ErrorAction SilentlyContinue $StatusValue = if ($null -ne $RegistryValue) { $RegistryValue.$EnabledKey } else { $null } $StatusIsEnabled = $null -ne $RegistryValue -and $RegistryValue.$EnabledKey -eq 1 $StatusResult = New-Object PSObject $StatusResult | Add-Member -MemberType NoteProperty -Name CheckName -Value $CheckName $StatusResult | Add-Member -MemberType NoteProperty -Name RegistryPath -Value $RegistryPath $StatusResult | Add-Member -MemberType NoteProperty -Name EnabledKey -Value $EnabledKey $StatusResult | Add-Member -MemberType NoteProperty -Name Value -Value $StatusValue $StatusResult | Add-Member -MemberType NoteProperty -Name IsEnabled -Value $StatusIsEnabled $StatusResult } } function New-CheckResult { <# .SYNOPSIS Creates a PSSecurity check result object compatible with Constrained Language Mode. .DESCRIPTION CLM-compatible alternative to [PSCustomObject]@{}. Builds the standard five-property object returned by Get-PSSecurity using New-Object and Add-Member. .PARAMETER Check The name of the security check (e.g. ModuleLogging, ConstrainedLanguageMode). .PARAMETER Enabled $true if the security control is enabled, $false if not, $null if indeterminate. .PARAMETER Source Where the setting originates (e.g. DomainGPO, LocalPolicy, AppLocker, NotConfigured). .PARAMETER SourceDetail Free-text elaboration of Source (GPO name, registry path, etc.). .PARAMETER Guidance URL pointing to Microsoft documentation for this control. .OUTPUTS PSObject with Check, Enabled, Source, SourceDetail, and Guidance properties. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Check, [Parameter(Mandatory)] [AllowNull()] $Enabled, [Parameter(Mandatory)] [string]$Source, [string]$SourceDetail, [Parameter(Mandatory)] [string]$Guidance ) process { $Result = New-Object PSObject $Result | Add-Member -MemberType NoteProperty -Name Check -Value $Check $Result | Add-Member -MemberType NoteProperty -Name Enabled -Value $Enabled $Result | Add-Member -MemberType NoteProperty -Name Source -Value $Source $Result | Add-Member -MemberType NoteProperty -Name SourceDetail -Value $SourceDetail $Result | Add-Member -MemberType NoteProperty -Name Guidance -Value $Guidance $Result } } function Get-PSSecurity { <# .SYNOPSIS Audits PowerShell security posture on a Windows machine. .DESCRIPTION Performs five checks covering PowerShell logging, Constrained Language Mode, and PowerShell 2.0 availability. Returns structured, pipeline-friendly objects so results can be filtered, exported, or displayed as a table. .OUTPUTS PSCustomObject with Check, Enabled, Source, SourceDetail, and Guidance properties. One object per sub-check; five objects total. .EXAMPLE Get-PSSecurity | Format-Table -AutoSize Displays all five security checks in a table. .EXAMPLE Get-PSSecurity | Where-Object { $_.Enabled -eq $true } Returns only the checks that are currently enabled. .EXAMPLE Get-PSSecurity | Where-Object { $_.Enabled -eq $false } Returns only the checks that are NOT enabled, showing gaps in security posture. .NOTES Windows-only. Requires elevation for the PowerShell 2.0 optional feature check. Run as Administrator for complete results. CalVer: 2026.5.231200 #> [CmdletBinding()] [OutputType([PSCustomObject])] param () begin { $LoggingChecks = @( @{ CheckName = 'ModuleLogging' Guidance = 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_logging_windows' }, @{ CheckName = 'ScriptBlockLogging' Guidance = 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_logging_windows' }, @{ CheckName = 'TranscriptLogging' Guidance = 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.host/start-transcript' } ) } process { #region Check 1 - PowerShell Logging (3 sub-checks) foreach ($LoggingCheck in $LoggingChecks) { $CheckName = $LoggingCheck.CheckName $Guidance = $LoggingCheck.Guidance $LoggingStatus = Get-PSLoggingStatus -CheckName $CheckName if ($LoggingStatus.IsEnabled) { $PolicySource = Get-PolicySource -RegistryPath $LoggingStatus.RegistryPath New-CheckResult -Check $CheckName -Enabled $true -Source $PolicySource.Source -SourceDetail $PolicySource.SourceDetail -Guidance $Guidance } else { New-CheckResult -Check $CheckName -Enabled $false -Source 'NotConfigured' -SourceDetail $LoggingStatus.RegistryPath -Guidance $Guidance } } #endregion #region Check 2 - Constrained Language Mode $LanguageMode = Get-LanguageMode -SessionState $PSCmdlet.SessionState $ClmGuidance = 'https://learn.microsoft.com/en-us/powershell/scripting/security/preventing-script-injection' if ($LanguageMode -eq 'ConstrainedLanguage') { $ClmSource = 'Unknown' $ClmSourceDetail = $null $AppLockerPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\SrpV2' $WdacPoliciesPath = "$env:SystemRoot\System32\CodeIntegrity\CiPolicies\Active" if ($env:__PSLockdownPolicy -eq '4') { $ClmSource = 'EnvironmentVariable' $ClmSourceDetail = '__PSLockdownPolicy=4' } elseif (Test-Path -Path $AppLockerPath) { $PolicySource = Get-PolicySource -RegistryPath $AppLockerPath $ClmSource = 'AppLocker' $ClmSourceDetail = $PolicySource.SourceDetail } elseif (Test-Path -Path "$WdacPoliciesPath\*.cip") { $ClmSource = 'WDAC/DeviceGuard' $ClmSourceDetail = $WdacPoliciesPath } New-CheckResult -Check 'ConstrainedLanguageMode' -Enabled $true -Source $ClmSource -SourceDetail $ClmSourceDetail -Guidance $ClmGuidance } else { New-CheckResult -Check 'ConstrainedLanguageMode' -Enabled $false -Source 'NotConfigured' -SourceDetail "Current mode: $LanguageMode" -Guidance $ClmGuidance } #endregion #region Check 3 - PowerShell 2.0 availability $Ps2Guidance = 'https://learn.microsoft.com/en-us/powershell/scripting/windows-powershell/install/windows-powershell-system-requirements#removing-the-windows-powershell-20-engine' try { $Ps2Feature = Get-WindowsOptionalFeature -Online -FeatureName 'MicrosoftWindowsPowerShellV2Root' -ErrorAction Stop $Net2Present = Test-Path -Path "$env:SystemRoot\Microsoft.NET\Framework\v2.0.50727" if ("$($Ps2Feature.State)" -eq 'Enabled' -or $Net2Present) { $Ps2Detail = if ($Net2Present) { 'MicrosoftWindowsPowerShellV2Root present; .NET v2.0.50727 found - PS 2.0 is launchable' } else { 'MicrosoftWindowsPowerShellV2Root is Enabled' } New-CheckResult -Check 'PowerShell2' -Enabled $true -Source 'WindowsOptionalFeature' -SourceDetail $Ps2Detail -Guidance $Ps2Guidance } else { New-CheckResult -Check 'PowerShell2' -Enabled $false -Source 'WindowsOptionalFeature' -SourceDetail "MicrosoftWindowsPowerShellV2Root state: $($Ps2Feature.State)" -Guidance $Ps2Guidance } } catch { New-CheckResult -Check 'PowerShell2' -Enabled $null -Source 'RequiresElevation' -SourceDetail 'Run as Administrator to check PS 2.0 feature state' -Guidance $Ps2Guidance } #endregion } } # Export functions and aliases as required Export-ModuleMember -Function @('Get-PSSecurity') -Alias @() |