diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 2b2b946e..75993c1d 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -81,6 +81,18 @@ Steps must: - not perform authentication - write only declared `State.*` outputs +### ScriptBlock Validation in Steps + +**All step implementations must validate their inputs using the centralized helper:** + +```powershell +Assert-IdleNoScriptBlock -InputObject $config -Path 'With.Config' +``` + +- Do not implement custom ScriptBlock checks +- Use `Assert-IdleNoScriptBlock` from `IdLE.Core` for consistent enforcement +- The helper recursively validates hashtables, arrays, and PSCustomObjects + --- ## Providers diff --git a/docs/extend/steps.md b/docs/extend/steps.md index 3576f63b..fd63ff72 100644 --- a/docs/extend/steps.md +++ b/docs/extend/steps.md @@ -140,7 +140,19 @@ Steps receive inputs from the workflow under `Inputs` and may reference: - `State.*` - `Policy.*` (optional root, host-defined) -Avoid executing code from configuration. Keep inputs data-only. +### Security: Data-only constraint + +**All step inputs must be data-only and must not contain ScriptBlocks.** + +Step implementations MUST validate their inputs using the centralized helper: + +```powershell +Assert-IdleNoScriptBlock -InputObject $config -Path 'With.Config' +``` + +The `Assert-IdleNoScriptBlock` function is exported from `IdLE.Core` and recursively validates hashtables, arrays, and PSCustomObjects. + +**Do not implement custom ScriptBlock validation.** Use the centralized helper to ensure consistent enforcement across all steps. --- diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index 40a22754..f9b98cf7 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -9,14 +9,15 @@ HelpInfoUri = 'https://blindzero.github.io/IdentityLifecycleEngine/' FunctionsToExport = @( - 'New-IdleLifecycleRequestObject', - 'Test-IdleWorkflowDefinitionObject', - 'New-IdlePlanObject', - 'Invoke-IdlePlanObject', + 'Assert-IdleNoScriptBlock', 'Export-IdlePlanObject', - 'New-IdleAuthSessionBroker', + 'Invoke-IdlePlanObject', 'Invoke-IdleProviderMethod', - 'Test-IdleProviderMethodParameter' + 'New-IdleAuthSessionBroker', + 'New-IdleLifecycleRequestObject', + 'New-IdlePlanObject', + 'Test-IdleProviderMethodParameter', + 'Test-IdleWorkflowDefinitionObject' ) CmdletsToExport = @() AliasesToExport = @() diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index 06956a7b..831c7ce5 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -40,12 +40,13 @@ foreach ($path in @($PrivatePath, $PublicPath)) { # Core exports selected factory functions. The meta module (IdLE) exposes the public API. Export-ModuleMember -Function @( - 'New-IdleLifecycleRequestObject', - 'Test-IdleWorkflowDefinitionObject', - 'New-IdlePlanObject', - 'Invoke-IdlePlanObject', + 'Assert-IdleNoScriptBlock', 'Export-IdlePlanObject', - 'New-IdleAuthSessionBroker', + 'Invoke-IdlePlanObject', 'Invoke-IdleProviderMethod', - 'Test-IdleProviderMethodParameter' + 'New-IdleAuthSessionBroker', + 'New-IdleLifecycleRequestObject', + 'New-IdlePlanObject', + 'Test-IdleProviderMethodParameter', + 'Test-IdleWorkflowDefinitionObject' ) -Alias @() diff --git a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 b/src/IdLE.Core/Public/Assert-IdleNoScriptBlock.ps1 similarity index 53% rename from src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 rename to src/IdLE.Core/Public/Assert-IdleNoScriptBlock.ps1 index d5737679..34a2fca7 100644 --- a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 +++ b/src/IdLE.Core/Public/Assert-IdleNoScriptBlock.ps1 @@ -1,7 +1,49 @@ -# Asserts that the provided InputObject does not contain any ScriptBlock objects. -# Recursively walks hashtables, enumerables, and PSCustomObjects. - function Assert-IdleNoScriptBlock { + <# + .SYNOPSIS + Asserts that the provided object does not contain any ScriptBlock objects. + + .DESCRIPTION + This is a security-critical helper that validates data-only constraints. + It recursively walks hashtables, enumerables, and PSCustomObjects to ensure + no ScriptBlock objects are present. + + This helper enforces IdLE's security boundary: workflow configuration and step inputs + must not contain executable code. + + Step implementations should use this helper to validate their inputs rather than + implementing custom ScriptBlock checks. + + .PARAMETER InputObject + The object to validate. Can be null, a scalar value, or a complex nested structure. + + .PARAMETER Path + The logical path describing the current position in the data structure. + Used in error messages to pinpoint where a ScriptBlock was found. + + .OUTPUTS + None. Throws an ArgumentException if a ScriptBlock is found. + + .EXAMPLE + # Validate a hashtable + $config = @{ + Mode = 'Enabled' + Message = 'Out of office' + } + Assert-IdleNoScriptBlock -InputObject $config -Path 'With.Config' + + .EXAMPLE + # Detect ScriptBlock in nested structure + $data = @{ + Setting = { Write-Host "bad" } + } + Assert-IdleNoScriptBlock -InputObject $data -Path 'Input' + # Throws: ScriptBlocks are not allowed in request data. Found at: Input.Setting + + .NOTES + The function includes an exemption for IdLE.AuthSessionBroker objects, + which contain internal ScriptBlocks as part of their implementation. + #> [CmdletBinding()] param( [Parameter(Mandatory)] diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 index 22f3f577..7c8da290 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -207,11 +207,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { } # Security: reject ScriptBlocks in Config (data-only constraint) - foreach ($key in $config.Keys) { - if ($config[$key] -is [ScriptBlock]) { - throw "Mailbox.OutOfOffice.Ensure With.Config must not contain ScriptBlocks. Found ScriptBlock in key '$key'." - } - } + Assert-IdleNoScriptBlock -InputObject $config -Path 'With.Config' # Validate MessageFormat if provided if ($config.ContainsKey('MessageFormat')) { diff --git a/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 b/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 new file mode 100644 index 00000000..94214954 --- /dev/null +++ b/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 @@ -0,0 +1,195 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Assert-IdleNoScriptBlock' { + Context 'Valid data-only inputs' { + It 'accepts null input' { + { Assert-IdleNoScriptBlock -InputObject $null -Path 'Test' } | Should -Not -Throw + } + + It 'accepts scalar values' { + { Assert-IdleNoScriptBlock -InputObject 'text' -Path 'Test' } | Should -Not -Throw + { Assert-IdleNoScriptBlock -InputObject 42 -Path 'Test' } | Should -Not -Throw + { Assert-IdleNoScriptBlock -InputObject $true -Path 'Test' } | Should -Not -Throw + } + + It 'accepts simple hashtable' { + $data = @{ + Mode = 'Enabled' + Message = 'Out of office' + Count = 5 + } + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Config' } | Should -Not -Throw + } + + It 'accepts nested hashtable' { + $data = @{ + Level1 = @{ + Level2 = @{ + Value = 'deep' + } + } + } + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Nested' } | Should -Not -Throw + } + + It 'accepts arrays' { + $data = @('one', 'two', 'three') + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Array' } | Should -Not -Throw + } + + It 'accepts arrays of hashtables' { + $data = @( + @{ Name = 'First'; Value = 1 } + @{ Name = 'Second'; Value = 2 } + ) + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Items' } | Should -Not -Throw + } + + It 'accepts PSCustomObject' { + $data = [pscustomobject]@{ + Property1 = 'value1' + Property2 = 42 + } + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Object' } | Should -Not -Throw + } + + It 'accepts nested PSCustomObject' { + $data = [pscustomobject]@{ + Outer = [pscustomobject]@{ + Inner = 'value' + } + } + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Object' } | Should -Not -Throw + } + } + + Context 'ScriptBlock detection' { + It 'rejects direct ScriptBlock' { + $block = { Write-Host "bad" } + { Assert-IdleNoScriptBlock -InputObject $block -Path 'Direct' } | + Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*Direct*' + } + + It 'rejects ScriptBlock in hashtable' { + $data = @{ + Good = 'value' + Bad = { Write-Host "malicious" } + } + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Config' } | + Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*Config.Bad*' + } + + It 'rejects ScriptBlock in nested hashtable' { + $data = @{ + Level1 = @{ + Level2 = @{ + Code = { Get-Process } + } + } + } + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Nested' } | + Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*Nested.Level1.Level2.Code*' + } + + It 'rejects ScriptBlock in array' { + $data = @( + 'normal', + { Write-Host "bad" }, + 'another' + ) + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Array' } | + Should -Throw -ExceptionType ([System.ArgumentException]) + + try { + Assert-IdleNoScriptBlock -InputObject $data -Path 'Array' + } + catch { + $_.Exception.Message | Should -Match 'Array\[1\]' + } + } + + It 'rejects ScriptBlock in PSCustomObject' { + $data = [pscustomobject]@{ + SafeProperty = 'value' + UnsafeProperty = { Write-Host "code" } + } + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Object' } | + Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*Object.UnsafeProperty*' + } + + It 'rejects ScriptBlock deeply nested in complex structure' { + $data = @{ + Items = @( + @{ + Name = 'Item1' + Properties = [pscustomobject]@{ + Setting = 'safe' + } + } + @{ + Name = 'Item2' + Properties = [pscustomobject]@{ + Action = { Invoke-Command } + } + } + ) + } + { Assert-IdleNoScriptBlock -InputObject $data -Path 'Data' } | + Should -Throw -ExceptionType ([System.ArgumentException]) + + try { + Assert-IdleNoScriptBlock -InputObject $data -Path 'Data' + } + catch { + $_.Exception.Message | Should -Match 'Data\.Items\[1\]\.Properties\.Action' + } + } + } + + Context 'Trusted type exemptions' { + It 'allows IdLE.AuthSessionBroker with internal ScriptBlock' { + $broker = [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSessionBroker' + ValidateAuthSession = { param($s) return $true } + } + $broker.PSObject.TypeNames.Insert(0, 'IdLE.AuthSessionBroker') + + { Assert-IdleNoScriptBlock -InputObject $broker -Path 'Broker' } | Should -Not -Throw + } + } + + Context 'Path reporting' { + It 'includes correct path in error message for top-level ScriptBlock' { + $block = { Write-Host "test" } + try { + Assert-IdleNoScriptBlock -InputObject $block -Path 'TopLevel' + throw "Should have thrown" + } + catch { + $_.Exception.Message | Should -BeLike '*TopLevel*' + } + } + + It 'includes correct path in error message for nested ScriptBlock' { + $data = @{ + Outer = @{ + Middle = @{ + Inner = { Write-Host "nested" } + } + } + } + try { + Assert-IdleNoScriptBlock -InputObject $data -Path 'Root' + throw "Should have thrown" + } + catch { + $_.Exception.Message | Should -BeLike '*Root.Outer.Middle.Inner*' + } + } + } +} diff --git a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 index e2a4cdae..09e21710 100644 --- a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 @@ -197,7 +197,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' { & $handler -Context $script:Context -Step $step } | - Should -Throw "*must not contain ScriptBlocks*" + Should -Throw "*ScriptBlocks are not allowed*" } It 'throws when provider is missing' {