From 372edeb0cdf0d74ace55d33dceca01f54b9eb7b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 07:15:03 +0000 Subject: [PATCH 1/3] Export Assert-IdleNoScriptBlock from IdLE.Core and update mailbox step to use centralized validation Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/extend/steps.md | 14 +- src/IdLE.Core/IdLE.Core.psd1 | 13 +- src/IdLE.Core/IdLE.Core.psm1 | 13 +- .../Public/Assert-IdleNoScriptBlock.ps1 | 100 +++++++++ ...nvoke-IdleStepMailboxOutOfOfficeEnsure.ps1 | 6 +- tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 | 195 ++++++++++++++++++ ...IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 | 2 +- 7 files changed, 324 insertions(+), 19 deletions(-) create mode 100644 src/IdLE.Core/Public/Assert-IdleNoScriptBlock.ps1 create mode 100644 tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 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/Public/Assert-IdleNoScriptBlock.ps1 b/src/IdLE.Core/Public/Assert-IdleNoScriptBlock.ps1 new file mode 100644 index 00000000..34a2fca7 --- /dev/null +++ b/src/IdLE.Core/Public/Assert-IdleNoScriptBlock.ps1 @@ -0,0 +1,100 @@ +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)] + [AllowNull()] + [object] $InputObject, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + if ($null -eq $InputObject) { return } + + if ($InputObject -is [scriptblock]) { + throw [System.ArgumentException]::new( + "ScriptBlocks are not allowed in request data. Found at: $Path", + $Path + ) + } + + # Hashtable / Dictionary + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + Assert-IdleNoScriptBlock -InputObject $InputObject[$key] -Path "$Path.$key" + } + return + } + + # Enumerable (but not string) + if (($InputObject -is [System.Collections.IEnumerable]) -and ($InputObject -isnot [string])) { + $i = 0 + foreach ($item in $InputObject) { + Assert-IdleNoScriptBlock -InputObject $item -Path "$Path[$i]" + $i++ + } + return + } + + # PSCustomObject (walk note properties) + if ($InputObject -is [pscustomobject]) { + # Exempt trusted IdLE types that legitimately contain ScriptBlocks + # AuthSessionBroker contains ValidateAuthSession scriptblock which is an internal implementation detail + if ($InputObject.PSTypeNames -contains 'IdLE.AuthSessionBroker') { + return + } + + foreach ($p in $InputObject.PSObject.Properties) { + if ($p.MemberType -eq 'NoteProperty') { + # PSPropertyInfo does not expose "InputObject" here; the value is in .Value. + Assert-IdleNoScriptBlock -InputObject $p.Value -Path "$Path.$($p.Name)" + } + } + } +} 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' { From 75c04cd7fbdf0e93c3815132c7235d0fa6a6e238 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 07:17:13 +0000 Subject: [PATCH 2/3] Update STYLEGUIDE.md to mandate centralized ScriptBlock validation for steps Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- STYLEGUIDE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From e88a1dc102944c15af23d5c45741bad7606d16ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:09:27 +0000 Subject: [PATCH 3/3] Remove duplicate Assert-IdleNoScriptBlock from Private folder Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Assert-IdleNoScriptBlock.ps1 | 58 ------------------- 1 file changed, 58 deletions(-) delete mode 100644 src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 diff --git a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 deleted file mode 100644 index d5737679..00000000 --- a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 +++ /dev/null @@ -1,58 +0,0 @@ -# Asserts that the provided InputObject does not contain any ScriptBlock objects. -# Recursively walks hashtables, enumerables, and PSCustomObjects. - -function Assert-IdleNoScriptBlock { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowNull()] - [object] $InputObject, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Path - ) - - if ($null -eq $InputObject) { return } - - if ($InputObject -is [scriptblock]) { - throw [System.ArgumentException]::new( - "ScriptBlocks are not allowed in request data. Found at: $Path", - $Path - ) - } - - # Hashtable / Dictionary - if ($InputObject -is [System.Collections.IDictionary]) { - foreach ($key in $InputObject.Keys) { - Assert-IdleNoScriptBlock -InputObject $InputObject[$key] -Path "$Path.$key" - } - return - } - - # Enumerable (but not string) - if (($InputObject -is [System.Collections.IEnumerable]) -and ($InputObject -isnot [string])) { - $i = 0 - foreach ($item in $InputObject) { - Assert-IdleNoScriptBlock -InputObject $item -Path "$Path[$i]" - $i++ - } - return - } - - # PSCustomObject (walk note properties) - if ($InputObject -is [pscustomobject]) { - # Exempt trusted IdLE types that legitimately contain ScriptBlocks - # AuthSessionBroker contains ValidateAuthSession scriptblock which is an internal implementation detail - if ($InputObject.PSTypeNames -contains 'IdLE.AuthSessionBroker') { - return - } - - foreach ($p in $InputObject.PSObject.Properties) { - if ($p.MemberType -eq 'NoteProperty') { - # PSPropertyInfo does not expose "InputObject" here; the value is in .Value. - Assert-IdleNoScriptBlock -InputObject $p.Value -Path "$Path.$($p.Name)" - } - } - } -}