Private/01.BigBrother.Enforcer.ps1
|
<#
SPDX-License-Identifier: Apache-2.0 Copyright (c) 2025 Stefan Ploch #> <# .SYNOPSIS Big Brother Enforcer - security related policies and logic for the module. .DESCRIPTION This module provides cmdlets for managing and enforcing security related policies and logic in the Module. #> #region Etype Handling # ------------------------------------------------------------------------- # # Etype Handling # # ------------------------------------------------------------------------- $script:etypeMap = [ordered]@{ DSA_SHA1_CMS = 9 MD5_RSA_CMS = 10 SHA1_RSA_CMS = 11 RC2_CBC_ENV = 12 RSA_ENV = 13 RSA_ES_OAEP_ENV = 14 DES3_CBC_ENV = 15 DES3_CBC_SHA1 = 16 AES128_CTS_HMAC_SHA1_96 = 17 AES256_CTS_HMAC_SHA1_96 = 18 AES128_CTS_HMAC_SHA256_128 = 19 AES256_CTS_HMAC_SHA384_192 = 20 ARCFOUR_HMAC = 23 ARCFOUR_HMAC_EXP = 24 CAMELLIA128_CTS_CMAC = 25 CAMELLIA256_CTS_CMAC = 26 UNKNOWN = 511 } $script:ReverseEtypeMap = @{} foreach ($kv in $script:etypeMap.GetEnumerator()) { $script:ReverseEtypeMap[[int]$kv.Value] = $kv.Key } $script:SupportedEtypes = @(17,18,19,20,23) # Categorization helpers $script:AesEtypes = @(17,18) # Traditional AES-SHA1 (compatible) $script:ModernAesEtypes = @(17,18,19,20) # All AES including SHA2 (modern) $script:AllAesEtypes = @(17,18,19,20) # Alias for convenience $script:DeadEtypes = @(1,2,3,5,6,7,8,9,10,11,12,13,14,15,16,21,22,24,25,26,27,28,29,30) function Get-EtypeIdFromInput { <# .SYNOPSIS Normalize an encryption type input (name or id) to an integer id. #> param( [Parameter(Mandatory)][object]$Value ) if ($null -eq $Value) { return $null } if ($Value -is [int]) { return [int]$Value } if ($Value -is [string]) { $s = $Value.Trim() [int]$tmp = 0 if ([int]::TryParse($s,[ref]$tmp)) { return $tmp } if ($script:etypeMap.Contains($s)) { return [int]$script:etypeMap[$s] } return $null } try { return [int]$Value } catch { return $null } } function Get-EtypeNameFromId { <# .SYNOPSIS Get the Kerberos encryption type name for an integer id. #> param( [Parameter(Mandatory)][int]$Id ) if ($script:ReverseEtypeMap.ContainsKey($Id)) { return $script:ReverseEtypeMap[$Id] } return "ETYPE_$Id" } function Get-DeadEtypes { <# .SYNOPSIS Return the list of etype IDs considered obsolete/broken ("dead"). #> [OutputType([int[]])] param() return $script:DeadEtypes } function Resolve-EtypeSelection { <# .SYNOPSIS Compute final etype selection from available, include, and exclude lists. #> [CmdletBinding()] param( [Parameter(Mandatory)][int[]]$AvailableIds, [object[]]$Include, [object[]]$Exclude, [psobject]$Policy ) $available = [System.Collections.Generic.HashSet[int]]::new() $AvailableIds | Foreach-Object { [void]$available.Add($_) } $included = New-Object System.Collections.Generic.List[int] $missing = New-Object System.Collections.Generic.List[int] $unknownIncluded = New-Object System.Collections.Generic.List[object] $excluded = New-Object System.Collections.Generic.List[int] $unknownExcluded = New-Object System.Collections.Generic.List[object] if ($PSBoundParameters.ContainsKey('Policy') -and $Policy) { # Use pre-normalized ids; unknowns carried from policy if ($Policy.IncludeIds) { foreach ($id in $Policy.IncludeIds) { if ($available.Contains([int]$id)) { $included.Add([int]$id) } else { $missing.Add([int]$id) } } } if ($Policy.ExcludeIds) { foreach ($id in $Policy.ExcludeIds) { $excluded.Add([int]$id) } } foreach ($u in $Policy.UnknownInclude) { $unknownIncluded.Add($u) } foreach ($u in $Policy.UnknownExclude) { $unknownExcluded.Add($u) } } else { if ($Include) { foreach ($raw in $Include) { $id = Get-EtypeIdFromInput $raw if ($null -ne $id) { if ($available.Contains($id)) { $included.Add($id) } else { $missing.Add($id) } } else { $unknownIncluded.Add($raw) } } } if ($Exclude) { foreach ($raw in $Exclude) { $id = Get-EtypeIdFromInput $raw if ($null -ne $id) { $excluded.Add($id) } else { $unknownExcluded.Add($raw) } } } } $selected = if ($included.Count -gt 0) { $included } else { $available } if ($excluded.Count -gt 0) { $excludedSet = [System.Collections.Generic.HashSet[int]]::new() $excluded | ForEach-Object { [void]$excludedSet.Add($_) } $selected = @($selected | Where-Object { -not $excludedSet.Contains($_) }) } [pscustomobject]@{ Selected = @([int[]]$selected | Sort-Object -Unique) Missing = $missing UnknownInclude = $unknownIncluded UnknownExclude = $unknownExcluded } } function Get-PolicyIntent { <# .SYNOPSIS Compose an etype policy intent from user parameters and path kind. #> [CmdletBinding()] param( [object[]]$IncludeEtype, [object[]]$ExcludeEtype, [switch]$AESOnly, [switch]$IncludeLegacyRC4, [switch]$AllowDeadCiphers, [ValidateSet('Password','Replication')][string]$PathKind = 'Replication' ) # Normalize includes/excludes $incNorm = @() if ($null -ne $IncludeEtype) { $incNorm = @($IncludeEtype) } $excNorm = @() if ($null -ne $ExcludeEtype) { $excNorm = @($ExcludeEtype) } # Apply quick flags if ($AESOnly.IsPresent) { $incNorm = $script:AesEtypes } if ($IncludeLegacyRC4.IsPresent -and ($incNorm -notcontains 23)) { $incNorm += 23 } if (-not $AllowDeadCiphers.IsPresent) { $excNorm += $script:DeadEtypes } # Defaults if user did not specify include and AESOnly not set explicitly if (-not $PSBoundParameters.ContainsKey('IncludeEtype') -and -not $AESOnly.IsPresent) { $incNorm = $script:AesEtypes } # Materialize to int after name resolution (keep raw for unknown reporting later) $includeIds = @() $unknownInc = New-Object System.Collections.Generic.List[object] foreach ($i in $incNorm) { $id = Get-EtypeIdFromInput $i if ($null -ne $id) { $includeIds += [int]$id } else { $unknownInc.Add($i) } } $excludeIds = @() $unknownExc = New-Object System.Collections.Generic.List[object] foreach ($e in $excNorm) { $id = Get-EtypeIdFromInput $e if ($null -ne $id) { $excludeIds += [int]$id } else { $unknownExc.Add($e) } } [pscustomobject]@{ PathKind = $PathKind AESOnly = [bool]$AESOnly IncludeLegacyRC4= [bool]$IncludeLegacyRC4 AllowDeadCiphers= [bool]$AllowDeadCiphers IncludeIds = @($includeIds) ExcludeIds = @($excludeIds) UnknownInclude = $unknownInc UnknownExclude = $unknownExc } } function Validate-PasswordPathCompatibility { <# .SYNOPSIS Ensure password S2K path only requests AES etypes. #> [CmdletBinding()] param( [Parameter(Mandatory)][psobject]$Policy, [switch]$SuppressWarnings ) if ($Policy.PathKind -ne 'Password') { return } # Any non-AES in IncludeIds is invalid for S2K at present $nonAes = @($Policy.IncludeIds | Where-Object { $_ -notin $script:AllAesEtypes }) if ($nonAes.Count -gt 0 -or $Policy.IncludeLegacyRC4 -or $Policy.AllowDeadCiphers) { try { Write-SecurityWarning -RiskLevel 'High' -SamAccountName 'Password-S2K' -Suppress:$SuppressWarnings.IsPresent | Out-Null } catch { Write-Error "Failed to write security warning: $_" } $names = ($nonAes | ForEach-Object { Get-EtypeNameFromId $_ }) -join ', ' $hint = 'Password derivation supports AES only (17,18,19,20).' + ' Remove legacy etypes or use the replication path if you must include RC4/DES.' if ([string]::IsNullOrWhiteSpace($names)) { throw "EtypeUnsupportedForPath: Non-AES requested. $hint" } throw "EtypeUnsupportedForPath: Non-AES requested (${names}). $hint" } } function Select-CombinedEtypes { <# .SYNOPSIS Return the set of unique etype ids present across key sets. #> param( [object[]]$KeySets ) $set = New-Object System.Collections.Generic.HashSet[int] foreach ($keySet in $keySets) { foreach ($key in $keySet.Keys.Keys) { [void]$set.Add([int]$key) } } @($set) } #endregion #region Acl Helpers # ---------------------------------------------------------------------- # # # Acl Helpers # # ---------------------------------------------------------------------- # function Set-UserOnlyAcl { <# .SYNOPSIS Set a user-only ACL on a file or directory. .DESCRIPTION This function sets the access control list (ACL) of a file or directory to allow only the current user full control. The current owner will also be set Owner of the file. Inheritance is dropped unless explicitly kept. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('FullName','LiteralPath')] [string]$Path, [switch]$KeepInheritance ) process { if (-not (Test-Path -LiteralPath $Path)) { throw "Path not found: $Path" } $isDir = Test-Path -LiteralPath $Path -PathType Container $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User $inheritFlags = if ($isDir) { [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor ` [System.Security.AccessControl.InheritanceFlags]::ObjectInherit } else { [System.Security.AccessControl.InheritanceFlags]::None } $propFlags = [System.Security.AccessControl.PropagationFlags]::None $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $sid, [System.Security.AccessControl.FileSystemRights]::FullControl, $inheritFlags, $propFlags, [System.Security.AccessControl.AccessControlType]::Allow ) $acl = if ($isDir) { New-Object System.Security.AccessControl.DirectorySecurity } else { New-Object System.Security.AccessControl.FileSecurity } # !important: protect DACL; drop inheritance unless explicitly kept $preserveInheritance = $KeepInheritance.IsPresent $acl.SetAccessRuleProtection($true, $preserveInheritance) # Set owner first; may requite TakeOwnership privilege try { $acl.SetOwner($sid) } catch { throw "Failed to set owner on '$Path (need SeTakeOwnership?): $($_.Exception.Message)" } # Replace DACL with a single allow for the owner $null = $acl.SetAccessRule($rule) if ($PSCmdlet.ShouldProcess($Path, 'Set user-only ACL')) { Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop } # output for assertions/pipeline Get-Acl -LiteralPath $Path } } #endregion |