PSSecurity.psm1
|
using namespace System using namespace System.IO using namespace System.Security.Cryptography using namespace System.Collections.Immutable Set-StrictMode -Version Latest #### <h1 style="color: #DCA657;">🎆 PSSecurity</h1> #### #### > Loads the security toolkit into a PowerShell session. #### #### The loader runs once each time the module is imported. It performs five phases in order: #### #### 1. Read `Setting.psd1` from the module configuration directory. #### 2. Create the module-scoped settings used by the imported functions. #### 3. Detect whether the host can safely ask interactive questions. #### 4. Load private helpers, load public commands, and export only the public commands. #### 5. Offer dependency installation to a person, or report missing dependencies to an agent or build. #### #### --- #### #### <h2 style="color: #DCA657;">1. Read the module settings</h2> #### #### Every path starts from the loader's own directory. The caller's current directory does #### not affect the import. #### #### The settings file is read once as a hashtable. This preserves each dependency entry as #### a hashtable, which is the shape expected by the dependency commands. #### $env:POWERSHELL_TELEMETRY_OPTOUT = 'true' $root = Split-Path -Parent -Path $MyInvocation.MyCommand.Path $script:SettingsPath = Join-Path -Path $root -ChildPath 'Config' -AdditionalChildPath 'Setting.psd1' $psSecuritySettings = Import-PowerShellDataFile -LiteralPath $script:SettingsPath $script:HashIndexAlgorithm = $psSecuritySettings.HashIndexAlgorithm $script:HashIndexInclude = $psSecuritySettings.HashIndexInclude $script:HashIndexExclude = $psSecuritySettings.HashIndexExclude $script:PSSecurityRuntimeModules = $psSecuritySettings.PSSecurityRuntimeModules $script:PSSecurityBuildtimeModules = $psSecuritySettings.PSSecurityBuildtimeModules #### <h2 style="color: #DCA657;">2. Create shared module state</h2> #### #### Values copied into `$script:` scope are available to every file loaded into the module. #### #### | State | Purpose | #### | --- | --- | #### | `SettingsPath` | Locates the settings file that was read during import. | #### | `HashIndexAlgorithm` | Selects the algorithm used to hash repository files. | #### | `HashIndexInclude` | Lists the file patterns included in a hash index. | #### | `HashIndexExclude` | Lists the directories excluded from a hash index. | #### | `PSSecurityRuntimeModules` | Defines modules needed while using PSSecurity. | #### | `PSSecurityBuildtimeModules` | Defines modules needed to build and test PSSecurity. | #### | `OutputEncoding` | Sets console input and output to UTF-8. | #### $script:OutputEncoding = [console]::InputEncoding = [console]::OutputEncoding = [System.Text.UTF8Encoding]::new() #### <h2 style="color: #DCA657;">3. Detect the host</h2> #### #### Dependency questions are allowed only when PowerShell is running interactively with an #### attached console. Agents, pipelines, redirected shells, and build runners take the #### non-interactive path and are never asked to answer a prompt. #### #### | Check | True when | #### | --- | --- | #### | `IsInteractive` | The host is `ConsoleHost`, exposes its UI, and belongs to an interactive user. | #### | `HasConsole` | Standard input and standard output are both attached to the console. | #### $script:IsInteractive = $Host.Name -eq 'ConsoleHost' -and $Host.UI -and $Host.UI.RawUI -and [Environment]::UserInteractive $script:HasConsole = -not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected #### #### <h2 style="color: #DCA657;">4. Load and export the commands</h2> #### #### The loader dot-sources every `.ps1` file directly inside `Private`, then records the #### current function table. It next dot-sources every `.ps1` file directly inside `Public`. #### The functions added by the second pass are the public commands exported by the module. #### #### This order lets public commands call private helpers without exposing those helpers to #### the caller. Adding a function file to `Public` is enough to export it; the loader does #### not maintain a separate function-name list. #### Get-ChildItem -Path "$(Join-Path -Path $root -ChildPath 'Private')" -Filter '*.ps1' | Resolve-Path | ForEach-Object { . $_ } $sysFuncs = Get-ChildItem Function: Get-ChildItem -Path "$(Join-Path -Path $root -ChildPath 'Public')" -Filter '*.ps1' | Resolve-Path | ForEach-Object { . $_ } $funcs = Get-ChildItem Function: | Where-Object { $sysFuncs -notcontains $_ } #### #### <b style="color: #C22514;">Throws</b> #### #### - When loading `Public` adds no functions. #### if ($funcs) { Export-ModuleMember -Function $funcs.Name } else { throw 'PSSecurity function load failed. Check module .psd1' } #### #### --- #### #### <h2 style="color: #DCA657;">5. Handle module dependencies</h2> #### #### Dependency handling happens after the commands are loaded because it uses the public #### dependency commands from the previous phase. #### #### | Host | Loader behavior | #### | --- | --- | #### | Interactive console | Ask whether to install missing runtime and build-time modules for the current user. | #### | Agent, build, or redirected shell | Skip every prompt and write one warning that names the missing modules. | #### #### `DependencyPromptAnswered` persists the interactive answer in #### `Setting.psd1`, so importing again does not repeat the questions. Set it to #### `$false` to ask again. The flag is written only after both prompts finish. If the module #### directory is read-only, the loader warns that it could not save the answer and continues. #### #### Missing dependencies do not stop the module from loading. The loader installs a missing #### module only when an interactive user accepts the prompt; otherwise it reports what is #### absent and leaves installation to the caller. #### if ($script:IsInteractive -and $script:HasConsole) { if (-not $psSecuritySettings.DependencyPromptAnswered) { Request-DependencyInstall -Label 'runtime' -Dependency $script:PSSecurityRuntimeModules Request-DependencyInstall -Label 'build time' -Dependency $script:PSSecurityBuildtimeModules try { $settingsContent = Get-Content -LiteralPath $script:SettingsPath -Raw $settingPattern = '(\bDependencyPromptAnswered\s*=\s*)\$false\b' $updatedSettingsContent = $settingsContent -replace $settingPattern, '${1}$$true' if ($updatedSettingsContent -ceq $settingsContent) { throw 'Could not locate DependencyPromptAnswered = $false in Setting.psd1.' } Set-Content -LiteralPath $script:SettingsPath -Value $updatedSettingsContent -Encoding utf8NoBOM -NoNewline $psSecuritySettings.DependencyPromptAnswered = $true } catch { Write-Warning "Could not record the dependency prompt answer: $($_.Exception.Message)" } } } else { $absentRuntime = @(Get-Dependency -Dependency $script:PSSecurityRuntimeModules) $absentBuild = @(Get-Dependency -Dependency $script:PSSecurityBuildtimeModules) if (($absentRuntime.Count + $absentBuild.Count) -gt 0) { $missing = @($absentRuntime + $absentBuild | ForEach-Object { "$($_.ModuleName) $($_.ModuleVersion)" }) -join ', ' Write-Warning "PSSecurity: these dependencies may not be installed: $missing" } } #### #### --- #### #### <h2 style="color: #DCA657;">Import result</h2> #### #### After a successful import: #### #### - Functions loaded from `Public` are available to the caller. #### - Functions loaded from `Private` remain available only inside the module. #### - Hash-index settings and dependency definitions are shared in module scope. #### - Non-interactive hosts complete without a prompt, even when dependencies are missing. #### |