Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions STYLEGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion docs/extend/steps.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Steps and Metadata

## Purpose
Expand Down Expand Up @@ -140,7 +140,19 @@
- `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.

---

Expand Down
13 changes: 7 additions & 6 deletions src/IdLE.Core/IdLE.Core.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = @()
Expand Down
13 changes: 7 additions & 6 deletions src/IdLE.Core/IdLE.Core.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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 @()
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
195 changes: 195 additions & 0 deletions tests/Core/Assert-IdleNoScriptBlock.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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*'
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand Down
Loading