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 @()