From aa7797e53b873962f8e42956a2ecd42dc1501b5b Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:17:15 +0100 Subject: [PATCH 1/9] core: enforce trust boundaries and fix ScriptBlock validation --- .../Private/Assert-IdleNoScriptBlock.ps1 | 8 +- .../Private/Get-IdleStepRegistry.ps1 | 73 ++++++++++++------- .../Private/Resolve-IdleStepHandler.ps1 | 36 +++++---- .../Public/Invoke-IdlePlanObject.ps1 | 19 ++--- 4 files changed, 82 insertions(+), 54 deletions(-) diff --git a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 index 984a1a41..0b528ee4 100644 --- a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 @@ -16,7 +16,10 @@ function Assert-IdleNoScriptBlock { if ($null -eq $InputObject) { return } if ($InputObject -is [scriptblock]) { - throw [System.ArgumentException]::new("ScriptBlocks are not allowed in request data. Found at: $Path", $Path) + throw [System.ArgumentException]::new( + "ScriptBlocks are not allowed in request data. Found at: $Path", + $Path + ) } # Hashtable / Dictionary @@ -41,7 +44,8 @@ function Assert-IdleNoScriptBlock { if ($InputObject -is [pscustomobject]) { foreach ($p in $InputObject.PSObject.Properties) { if ($p.MemberType -eq 'NoteProperty') { - Assert-IdleNoScriptBlock -InputObject $p.InputObject -Path "$Path.$($p.Name)" + # 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.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index d538d430..83e4d850 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -6,49 +6,66 @@ function Get-IdleStepRegistry { [object] $Providers ) - # Registry maps workflow Step.Type -> handler - # Handler can be: - # - [string] : PowerShell function name - # - [scriptblock] : executable handler (useful for tests / hosts) + # Registry maps workflow Step.Type -> handler function name (string). + # + # Trust boundary: + # - The registry is a host-provided extension point. It is not loaded from workflow configuration. + # - Workflows are data-only and must not contain executable code. + # + # Security / secure defaults: + # - Only string handlers (function names) are supported. + # - ScriptBlock handlers are intentionally rejected to avoid arbitrary code execution. + $registry = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) # 1) Copy host-provided StepRegistry (optional) # We support two shapes for compatibility: - # - StepRegistry['Type'] = 'FunctionName' | { scriptblock } - # - StepRegistry['Type'] = @{ Handler = 'FunctionName' } (legacy/demo style) - if ($null -ne $Providers) { - - $source = $null + # - Providers.StepRegistry (hashtable) + # - Providers['StepRegistry'] (hashtable) + $hostRegistry = $null - if ($Providers -is [System.Collections.IDictionary]) { - if ($Providers.Contains('StepRegistry')) { - $source = $Providers['StepRegistry'] - } + if ($null -ne $Providers) { + if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepRegistry')) { + $hostRegistry = $Providers['StepRegistry'] } - else { - $prop = $Providers.PSObject.Properties['StepRegistry'] - if ($null -ne $prop) { - $source = $prop.Value - } + elseif ($Providers.PSObject.Properties.Name -contains 'StepRegistry') { + $hostRegistry = $Providers.StepRegistry + } + } + + if ($null -ne $hostRegistry) { + if ($hostRegistry -isnot [hashtable]) { + throw [System.ArgumentException]::new('Providers.StepRegistry must be a hashtable that maps Step.Type to a function name (string).', 'Providers') } - if ($null -ne $source -and ($source -is [System.Collections.IDictionary])) { - foreach ($k in @($source.Keys)) { + foreach ($key in $hostRegistry.Keys) { + if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) { + throw [System.ArgumentException]::new('Providers.StepRegistry contains an empty step type key.', 'Providers') + } - $v = $source[$k] + $value = $hostRegistry[$key] - # Allow legacy shape: @{ Handler = 'Invoke-...' } - if ($v -is [hashtable] -and $v.ContainsKey('Handler')) { - $v = $v['Handler'] - } + if ($value -is [scriptblock]) { + throw [System.ArgumentException]::new( + "Providers.StepRegistry entry for step type '$key' is a ScriptBlock. ScriptBlock handlers are not allowed. Provide a function name (string) instead.", + 'Providers' + ) + } - $registry[[string]$k] = $v + if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$value)) { + throw [System.ArgumentException]::new( + "Providers.StepRegistry entry for step type '$key' must be a non-empty string (function name).", + 'Providers' + ) } + + $registry[[string]$key] = ([string]$value).Trim() } } - # 2) Built-in defaults (only if commands are available) - # Do not overwrite host-provided entries. + # 2) Register built-in steps if available. + # + # These are optional modules (Steps.Common, etc.). If they are not loaded, the registry entry is not added. if (-not $registry.ContainsKey('IdLE.Step.EmitEvent')) { $cmd = Get-Command -Name 'Invoke-IdleStepEmitEvent' -ErrorAction SilentlyContinue if ($null -ne $cmd) { diff --git a/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 index 8d1f8b45..b57986a0 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 @@ -10,30 +10,30 @@ function Resolve-IdleStepHandler { [hashtable] $Registry ) - # Registry maps StepType -> handler - # Handler can be: - # - [string] : PowerShell function name - # - [scriptblock] : executable handler (useful for tests / hosts) + # Registry maps StepType -> handler. + # + # Trust boundary: + # - The registry is a host-controlled extension point and must be treated as trusted input. + # - Workflows must never be able to provide code (ScriptBlocks) that is executed by the engine. + # + # Security / secure defaults: + # - Only string handlers (function names) are supported. + # - ScriptBlock handlers are intentionally rejected to avoid arbitrary code execution. + if (-not $Registry.ContainsKey($StepType)) { return $null } $handler = $Registry[$StepType] - if ($null -eq $handler) { - return $null - } - - if ($handler -is [scriptblock]) { - return $handler - } if ($handler -is [string]) { - $fn = [string]$handler + $fn = $handler.Trim() if ([string]::IsNullOrWhiteSpace($fn)) { return $null } # Ensure the function exists in the current session. + # The host is responsible for importing the module that provides the handler. $cmd = Get-Command -Name $fn -ErrorAction SilentlyContinue if ($null -eq $cmd) { return $null @@ -42,6 +42,16 @@ function Resolve-IdleStepHandler { return $cmd.Name } + if ($handler -is [scriptblock]) { + throw [System.ArgumentException]::new( + "Invalid step handler for type '$StepType'. ScriptBlock handlers are not allowed. Provide a string with a function name instead.", + 'Registry' + ) + } + # Any other type is invalid configuration. - throw [System.ArgumentException]::new("Invalid step handler type for '$StepType'. Allowed: string (function name) or scriptblock.", 'Registry') + throw [System.ArgumentException]::new( + "Invalid step handler for type '$StepType'. Allowed: string (function name).", + 'Registry' + ) } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index e01fa670..b9dd8738 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -42,6 +42,11 @@ function Invoke-IdlePlanObject { } } + # Secure default: treat host-provided extension points as privileged inputs. + # The engine rejects ScriptBlocks in the plan and providers to avoid accidental code execution. + Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' + Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' + $events = [System.Collections.Generic.List[object]]::new() $corr = [string]$Plan.CorrelationId @@ -121,22 +126,14 @@ function Invoke-IdlePlanObject { try { # Resolve implementation handler for this step type. - # Handler can be: - # - [scriptblock] : invoked as & $handler $context $step - # - [string] : function name invoked as & $handler -Context $context -Step $step + # Handler must be a function name (string). $handler = Resolve-IdleStepHandler -StepType $stepType -Registry $registry if ($null -eq $handler) { throw [System.InvalidOperationException]::new("Step type '$stepType' is not registered.") } - # Invoke the step plugin depending on handler type. - if ($handler -is [scriptblock]) { - $stepResult = & $handler $context $step - } - else { - # handler is a function name (string) - $stepResult = & $handler -Context $context -Step $step - } + # Invoke the step plugin. + $stepResult = & $handler -Context $context -Step $step $stepResults += $stepResult From 501234bc0ee2f71bab06846400fa104c75730bea Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:17:15 +0100 Subject: [PATCH 2/9] tests: make step handlers module-visible and update security coverage --- tests/Invoke-IdlePlan.Tests.ps1 | 130 ++++++++++++++++----------- tests/Invoke-IdlePlan.When.Tests.ps1 | 56 ++++++------ 2 files changed, 110 insertions(+), 76 deletions(-) diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index fc35fc35..57b3c832 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -1,6 +1,58 @@ BeforeAll { $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' Import-Module $modulePath -Force + + # The engine invokes step handlers by function name (string) inside module scope. + # Therefore, test handler functions must be visible to the module (global scope). + function global:Invoke-IdleTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + function global:Invoke-IdleTestEmitStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $Context.EventSink.WriteEvent('Custom', 'Hello from test step', $Step.Name, @{ StepType = $Step.Type }) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + # Cleanup global test functions to avoid polluting the session. + Remove-Item -Path 'Function:\Invoke-IdleTestNoopStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdleTestEmitStep' -ErrorAction SilentlyContinue } Describe 'Invoke-IdlePlan' { @@ -20,21 +72,10 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $noop = { - param($Context, $Step) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = $noop - 'IdLE.Step.EnsureAttributes' = $noop + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' } } @@ -80,20 +121,9 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $noop = { - param($Context, $Step) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = $noop + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' } } @@ -127,25 +157,38 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $noop = { - param($Context, $Step) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' } } + $sink = { param($e) } + { Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sink } | Should -Throw + } + + It 'rejects ScriptBlock step handlers in the StepRegistry (security)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $providers = @{ StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = $noop + 'IdLE.Step.ResolveIdentity' = { param($Context, $Step) } } } - $sink = { param($e) } - { Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sink } | Should -Throw + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw } It 'executes a registered step and returns Completed status' { @@ -163,22 +206,9 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $emit = { - param($Context, $Step) - $Context.EventSink.WriteEvent('Custom', 'Hello from test step', $Step.Name, @{ StepType = $Step.Type }) - - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = $emit + 'IdLE.Step.EmitEvent' = 'Invoke-IdleTestEmitStep' } } diff --git a/tests/Invoke-IdlePlan.When.Tests.ps1 b/tests/Invoke-IdlePlan.When.Tests.ps1 index 93fee51b..5b5a729c 100644 --- a/tests/Invoke-IdlePlan.When.Tests.ps1 +++ b/tests/Invoke-IdlePlan.When.Tests.ps1 @@ -1,6 +1,34 @@ BeforeAll { $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' Import-Module $modulePath -Force + + function global:Invoke-IdleWhenTestEmitStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $Context.EventSink.WriteEvent('Custom', 'Hello', $Step.Name, @{ StepType = $Step.Type }) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + # Cleanup global test functions to avoid polluting the session. + Remove-Item -Path 'Function:\Invoke-IdleWhenTestEmitStep' -ErrorAction SilentlyContinue } Describe 'Invoke-IdlePlan - When conditions' { @@ -24,21 +52,9 @@ Describe 'Invoke-IdlePlan - When conditions' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $emit = { - param($Context, $Step) - $Context.EventSink.WriteEvent('Custom', 'Hello', $Step.Name, @{ StepType = $Step.Type }) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = $emit + 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' } } @@ -69,21 +85,9 @@ Describe 'Invoke-IdlePlan - When conditions' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $emit = { - param($Context, $Step) - $Context.EventSink.WriteEvent('Custom', 'Hello', $Step.Name, @{ StepType = $Step.Type }) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = $emit + 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' } } From 6ea3c1efd5294f48a6e8d49e51b6a7076ad9b10b Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:17:15 +0100 Subject: [PATCH 3/9] docs: document trust boundaries and secure defaults --- docs/_sidebar.md | 1 + docs/advanced/architecture.md | 6 ++++ docs/advanced/extensibility.md | 9 ++++++ docs/advanced/security.md | 50 ++++++++++++++++++++++++++++++++++ docs/index.md | 1 + docs/usage/providers.md | 7 +++++ docs/usage/steps.md | 6 ++++ 7 files changed, 80 insertions(+) create mode 100644 docs/advanced/security.md diff --git a/docs/_sidebar.md b/docs/_sidebar.md index a5882a66..f54db79f 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -14,5 +14,6 @@ ### Advanced - [Architecture](advanced/architecture.md) +- [Security](advanced/security.md) - [Extensibility](advanced/extensibility.md) - [Testing](advanced/testing.md) diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 6aea307e..8cd0dbcc 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -68,3 +68,9 @@ No deep merge: replace-at-path semantics only. - Providers integrate target systems See: [Extensibility](extensibility.md). + +## Trust boundaries + +IdLE treats workflow configuration and lifecycle requests as **untrusted data** and validates that they contain no ScriptBlocks. + +Host-provided extension points (step registry, providers, external event sinks) are **trusted inputs** and are validated for safe shapes (object contracts). For details, see `advanced/security.md`. diff --git a/docs/advanced/extensibility.md b/docs/advanced/extensibility.md index 2a13c99b..972ad0e7 100644 --- a/docs/advanced/extensibility.md +++ b/docs/advanced/extensibility.md @@ -43,3 +43,12 @@ Do not add: - UI or web server dependencies Those belong in a host application. + +## Register step handlers + +Steps are executed via a host-provided step registry. + +- Workflows reference steps by `Type` (identifier). +- The host maps this identifier to a **function name** (string) in the step registry. + +ScriptBlock handlers are intentionally not supported as a secure default. diff --git a/docs/advanced/security.md b/docs/advanced/security.md new file mode 100644 index 00000000..ffa1d436 --- /dev/null +++ b/docs/advanced/security.md @@ -0,0 +1,50 @@ +# Security and Trust Boundaries + +IdLE is designed to execute *data-driven* lifecycle workflows in a deterministic way. + +Because IdLE is an orchestration engine, it must be very explicit about **what is trusted** and **what is untrusted**. + +## Trust boundaries + +### Untrusted inputs (data-only) + +These inputs may come from users, CI pipelines, or external systems and **must be treated as untrusted**: + +- Workflow definitions (PSD1) +- Lifecycle requests (input objects) +- Step parameters (`With`, `When`) + +**Rule:** Untrusted inputs must be *data-only*. They must not contain ScriptBlocks or other executable objects. + +IdLE enforces this by rejecting ScriptBlocks when importing workflow definitions and by validating inputs at runtime. + +### Trusted extension points (code) + +These inputs are provided by the host and are **privileged** because they determine what code is executed: + +- Step registry (maps `Step.Type` to a handler function name) +- Provider modules / provider objects (system-specific adapters) +- External event sinks (streaming events) + +**Rule:** Only trusted code should populate these extension points. + +## Secure defaults + +IdLE applies secure defaults to reduce accidental code execution: + +- Workflow configuration is loaded as data and ScriptBlocks are rejected. +- Event streaming uses an object-based contract (`WriteEvent(event)`); ScriptBlock event sinks are rejected. +- Step registry handlers must be function names (strings); ScriptBlock handlers are rejected. + +## Guidance for hosts + +- Keep workflow files in a protected location and review them like code (even though they are data-only). +- Load step and provider modules explicitly before execution. +- Treat the step registry as privileged configuration and do not let workflow authors change it. +- If you stream events, implement a small sink object with a `WriteEvent(event)` method and keep it side-effect free. + +## Guidance for step authors + +- Use providers for system operations; do not embed authentication logic inside steps. +- Emit events using `Context.EventSink.WriteEvent(Type, Message, StepName, Data)`. +- Avoid global state. Steps should be idempotent whenever possible. diff --git a/docs/index.md b/docs/index.md index 64bde587..4748d1ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,7 @@ for identity and account processes (Joiner / Mover / Leaver) built for **PowerSh ## Advanced - [Architecture](advanced/architecture.md) +- [Security](advanced/security.md) - [Extensibility](advanced/extensibility.md) - [Testing](advanced/testing.md) diff --git a/docs/usage/providers.md b/docs/usage/providers.md index 4aa8a2d5..0e8e133c 100644 --- a/docs/usage/providers.md +++ b/docs/usage/providers.md @@ -34,3 +34,10 @@ Unit tests must not call live systems. - [Testing](../advanced/testing.md) - [Architecture](../advanced/architecture.md) + +## Trust and security + +Providers and the step registry are host-controlled extension points and should be treated as trusted code. +Workflows and lifecycle requests are data-only and must not contain executable objects. + +For details, see `docs/advanced/security.md`. diff --git a/docs/usage/steps.md b/docs/usage/steps.md index df095e58..4d518768 100644 --- a/docs/usage/steps.md +++ b/docs/usage/steps.md @@ -76,3 +76,9 @@ IdLE uses a fail-fast execution model in V1: - [Workflows](workflows.md) - [Providers](providers.md) - [Architecture](../advanced/architecture.md) + +## Security notes + +- Steps emit events via `Context.EventSink.WriteEvent(...)`. +- Step handlers are referenced by function name (string) in the step registry. +- ScriptBlock handlers are not supported as a secure default. From d318d7a82e5d7e12ee93e909807ac50f4fa528af Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:18:31 +0100 Subject: [PATCH 4/9] docs: moving run-demo result to end --- examples/run-demo.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/run-demo.ps1 b/examples/run-demo.ps1 index bb10a822..dea29656 100644 --- a/examples/run-demo.ps1 +++ b/examples/run-demo.ps1 @@ -98,7 +98,6 @@ $providers = @{ $result = Invoke-IdlePlan -Plan $plan -Providers $providers Write-DemoHeader "IdLE Demo – Plan Execution" -Write-ResultSummary -Result $result Write-Host "" Write-DemoHeader "Step Results" @@ -113,3 +112,5 @@ Write-DemoHeader "Event Stream" $result.Events | ForEach-Object { Format-EventRow $_ } | Format-Table Time, Type, Step, Message -AutoSize + +Write-ResultSummary -Result $result \ No newline at end of file From f5e89363f94a8c49c7c70ffb238ce278eaade537 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:26:42 +0100 Subject: [PATCH 5/9] ci: removed macos-latest --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be8d0507..1e0f8672 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 From ba014fd3ba46ea23efec0ce490e2269f70a9ae69 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:17:15 +0100 Subject: [PATCH 6/9] core: enforce trust boundaries and fix ScriptBlock validation --- .../Private/Assert-IdleNoScriptBlock.ps1 | 8 +- .../Private/Get-IdleStepRegistry.ps1 | 73 ++++++++++++------- .../Private/Resolve-IdleStepHandler.ps1 | 36 +++++---- .../Public/Invoke-IdlePlanObject.ps1 | 19 ++--- 4 files changed, 82 insertions(+), 54 deletions(-) diff --git a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 index 984a1a41..0b528ee4 100644 --- a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 @@ -16,7 +16,10 @@ function Assert-IdleNoScriptBlock { if ($null -eq $InputObject) { return } if ($InputObject -is [scriptblock]) { - throw [System.ArgumentException]::new("ScriptBlocks are not allowed in request data. Found at: $Path", $Path) + throw [System.ArgumentException]::new( + "ScriptBlocks are not allowed in request data. Found at: $Path", + $Path + ) } # Hashtable / Dictionary @@ -41,7 +44,8 @@ function Assert-IdleNoScriptBlock { if ($InputObject -is [pscustomobject]) { foreach ($p in $InputObject.PSObject.Properties) { if ($p.MemberType -eq 'NoteProperty') { - Assert-IdleNoScriptBlock -InputObject $p.InputObject -Path "$Path.$($p.Name)" + # 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.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index d538d430..83e4d850 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -6,49 +6,66 @@ function Get-IdleStepRegistry { [object] $Providers ) - # Registry maps workflow Step.Type -> handler - # Handler can be: - # - [string] : PowerShell function name - # - [scriptblock] : executable handler (useful for tests / hosts) + # Registry maps workflow Step.Type -> handler function name (string). + # + # Trust boundary: + # - The registry is a host-provided extension point. It is not loaded from workflow configuration. + # - Workflows are data-only and must not contain executable code. + # + # Security / secure defaults: + # - Only string handlers (function names) are supported. + # - ScriptBlock handlers are intentionally rejected to avoid arbitrary code execution. + $registry = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) # 1) Copy host-provided StepRegistry (optional) # We support two shapes for compatibility: - # - StepRegistry['Type'] = 'FunctionName' | { scriptblock } - # - StepRegistry['Type'] = @{ Handler = 'FunctionName' } (legacy/demo style) - if ($null -ne $Providers) { - - $source = $null + # - Providers.StepRegistry (hashtable) + # - Providers['StepRegistry'] (hashtable) + $hostRegistry = $null - if ($Providers -is [System.Collections.IDictionary]) { - if ($Providers.Contains('StepRegistry')) { - $source = $Providers['StepRegistry'] - } + if ($null -ne $Providers) { + if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepRegistry')) { + $hostRegistry = $Providers['StepRegistry'] } - else { - $prop = $Providers.PSObject.Properties['StepRegistry'] - if ($null -ne $prop) { - $source = $prop.Value - } + elseif ($Providers.PSObject.Properties.Name -contains 'StepRegistry') { + $hostRegistry = $Providers.StepRegistry + } + } + + if ($null -ne $hostRegistry) { + if ($hostRegistry -isnot [hashtable]) { + throw [System.ArgumentException]::new('Providers.StepRegistry must be a hashtable that maps Step.Type to a function name (string).', 'Providers') } - if ($null -ne $source -and ($source -is [System.Collections.IDictionary])) { - foreach ($k in @($source.Keys)) { + foreach ($key in $hostRegistry.Keys) { + if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) { + throw [System.ArgumentException]::new('Providers.StepRegistry contains an empty step type key.', 'Providers') + } - $v = $source[$k] + $value = $hostRegistry[$key] - # Allow legacy shape: @{ Handler = 'Invoke-...' } - if ($v -is [hashtable] -and $v.ContainsKey('Handler')) { - $v = $v['Handler'] - } + if ($value -is [scriptblock]) { + throw [System.ArgumentException]::new( + "Providers.StepRegistry entry for step type '$key' is a ScriptBlock. ScriptBlock handlers are not allowed. Provide a function name (string) instead.", + 'Providers' + ) + } - $registry[[string]$k] = $v + if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$value)) { + throw [System.ArgumentException]::new( + "Providers.StepRegistry entry for step type '$key' must be a non-empty string (function name).", + 'Providers' + ) } + + $registry[[string]$key] = ([string]$value).Trim() } } - # 2) Built-in defaults (only if commands are available) - # Do not overwrite host-provided entries. + # 2) Register built-in steps if available. + # + # These are optional modules (Steps.Common, etc.). If they are not loaded, the registry entry is not added. if (-not $registry.ContainsKey('IdLE.Step.EmitEvent')) { $cmd = Get-Command -Name 'Invoke-IdleStepEmitEvent' -ErrorAction SilentlyContinue if ($null -ne $cmd) { diff --git a/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 index 8d1f8b45..b57986a0 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 @@ -10,30 +10,30 @@ function Resolve-IdleStepHandler { [hashtable] $Registry ) - # Registry maps StepType -> handler - # Handler can be: - # - [string] : PowerShell function name - # - [scriptblock] : executable handler (useful for tests / hosts) + # Registry maps StepType -> handler. + # + # Trust boundary: + # - The registry is a host-controlled extension point and must be treated as trusted input. + # - Workflows must never be able to provide code (ScriptBlocks) that is executed by the engine. + # + # Security / secure defaults: + # - Only string handlers (function names) are supported. + # - ScriptBlock handlers are intentionally rejected to avoid arbitrary code execution. + if (-not $Registry.ContainsKey($StepType)) { return $null } $handler = $Registry[$StepType] - if ($null -eq $handler) { - return $null - } - - if ($handler -is [scriptblock]) { - return $handler - } if ($handler -is [string]) { - $fn = [string]$handler + $fn = $handler.Trim() if ([string]::IsNullOrWhiteSpace($fn)) { return $null } # Ensure the function exists in the current session. + # The host is responsible for importing the module that provides the handler. $cmd = Get-Command -Name $fn -ErrorAction SilentlyContinue if ($null -eq $cmd) { return $null @@ -42,6 +42,16 @@ function Resolve-IdleStepHandler { return $cmd.Name } + if ($handler -is [scriptblock]) { + throw [System.ArgumentException]::new( + "Invalid step handler for type '$StepType'. ScriptBlock handlers are not allowed. Provide a string with a function name instead.", + 'Registry' + ) + } + # Any other type is invalid configuration. - throw [System.ArgumentException]::new("Invalid step handler type for '$StepType'. Allowed: string (function name) or scriptblock.", 'Registry') + throw [System.ArgumentException]::new( + "Invalid step handler for type '$StepType'. Allowed: string (function name).", + 'Registry' + ) } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index e01fa670..b9dd8738 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -42,6 +42,11 @@ function Invoke-IdlePlanObject { } } + # Secure default: treat host-provided extension points as privileged inputs. + # The engine rejects ScriptBlocks in the plan and providers to avoid accidental code execution. + Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' + Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' + $events = [System.Collections.Generic.List[object]]::new() $corr = [string]$Plan.CorrelationId @@ -121,22 +126,14 @@ function Invoke-IdlePlanObject { try { # Resolve implementation handler for this step type. - # Handler can be: - # - [scriptblock] : invoked as & $handler $context $step - # - [string] : function name invoked as & $handler -Context $context -Step $step + # Handler must be a function name (string). $handler = Resolve-IdleStepHandler -StepType $stepType -Registry $registry if ($null -eq $handler) { throw [System.InvalidOperationException]::new("Step type '$stepType' is not registered.") } - # Invoke the step plugin depending on handler type. - if ($handler -is [scriptblock]) { - $stepResult = & $handler $context $step - } - else { - # handler is a function name (string) - $stepResult = & $handler -Context $context -Step $step - } + # Invoke the step plugin. + $stepResult = & $handler -Context $context -Step $step $stepResults += $stepResult From cfc4d9a2a0d1418e68b6e709b7bc3943c8af6dc2 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:17:15 +0100 Subject: [PATCH 7/9] tests: make step handlers module-visible and update security coverage --- tests/Invoke-IdlePlan.Tests.ps1 | 130 ++++++++++++++++----------- tests/Invoke-IdlePlan.When.Tests.ps1 | 56 ++++++------ 2 files changed, 110 insertions(+), 76 deletions(-) diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index fc35fc35..57b3c832 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -1,6 +1,58 @@ BeforeAll { $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' Import-Module $modulePath -Force + + # The engine invokes step handlers by function name (string) inside module scope. + # Therefore, test handler functions must be visible to the module (global scope). + function global:Invoke-IdleTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + function global:Invoke-IdleTestEmitStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $Context.EventSink.WriteEvent('Custom', 'Hello from test step', $Step.Name, @{ StepType = $Step.Type }) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + # Cleanup global test functions to avoid polluting the session. + Remove-Item -Path 'Function:\Invoke-IdleTestNoopStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdleTestEmitStep' -ErrorAction SilentlyContinue } Describe 'Invoke-IdlePlan' { @@ -20,21 +72,10 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $noop = { - param($Context, $Step) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = $noop - 'IdLE.Step.EnsureAttributes' = $noop + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' } } @@ -80,20 +121,9 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $noop = { - param($Context, $Step) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = $noop + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' } } @@ -127,25 +157,38 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $noop = { - param($Context, $Step) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' } } + $sink = { param($e) } + { Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sink } | Should -Throw + } + + It 'rejects ScriptBlock step handlers in the StepRegistry (security)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $providers = @{ StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = $noop + 'IdLE.Step.ResolveIdentity' = { param($Context, $Step) } } } - $sink = { param($e) } - { Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sink } | Should -Throw + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw } It 'executes a registered step and returns Completed status' { @@ -163,22 +206,9 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $emit = { - param($Context, $Step) - $Context.EventSink.WriteEvent('Custom', 'Hello from test step', $Step.Name, @{ StepType = $Step.Type }) - - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = $emit + 'IdLE.Step.EmitEvent' = 'Invoke-IdleTestEmitStep' } } diff --git a/tests/Invoke-IdlePlan.When.Tests.ps1 b/tests/Invoke-IdlePlan.When.Tests.ps1 index 93fee51b..5b5a729c 100644 --- a/tests/Invoke-IdlePlan.When.Tests.ps1 +++ b/tests/Invoke-IdlePlan.When.Tests.ps1 @@ -1,6 +1,34 @@ BeforeAll { $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' Import-Module $modulePath -Force + + function global:Invoke-IdleWhenTestEmitStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $Context.EventSink.WriteEvent('Custom', 'Hello', $Step.Name, @{ StepType = $Step.Type }) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + # Cleanup global test functions to avoid polluting the session. + Remove-Item -Path 'Function:\Invoke-IdleWhenTestEmitStep' -ErrorAction SilentlyContinue } Describe 'Invoke-IdlePlan - When conditions' { @@ -24,21 +52,9 @@ Describe 'Invoke-IdlePlan - When conditions' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $emit = { - param($Context, $Step) - $Context.EventSink.WriteEvent('Custom', 'Hello', $Step.Name, @{ StepType = $Step.Type }) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = $emit + 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' } } @@ -69,21 +85,9 @@ Describe 'Invoke-IdlePlan - When conditions' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $emit = { - param($Context, $Step) - $Context.EventSink.WriteEvent('Custom', 'Hello', $Step.Name, @{ StepType = $Step.Type }) - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } - } - $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = $emit + 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' } } From bc347d4f3d91d417a97f9de78f565f4a516d496b Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:17:15 +0100 Subject: [PATCH 8/9] docs: document trust boundaries and secure defaults --- docs/_sidebar.md | 1 + docs/advanced/architecture.md | 6 ++++ docs/advanced/extensibility.md | 9 ++++++ docs/advanced/security.md | 50 ++++++++++++++++++++++++++++++++++ docs/index.md | 1 + docs/usage/providers.md | 7 +++++ docs/usage/steps.md | 6 ++++ 7 files changed, 80 insertions(+) create mode 100644 docs/advanced/security.md diff --git a/docs/_sidebar.md b/docs/_sidebar.md index a5882a66..f54db79f 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -14,5 +14,6 @@ ### Advanced - [Architecture](advanced/architecture.md) +- [Security](advanced/security.md) - [Extensibility](advanced/extensibility.md) - [Testing](advanced/testing.md) diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 6aea307e..8cd0dbcc 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -68,3 +68,9 @@ No deep merge: replace-at-path semantics only. - Providers integrate target systems See: [Extensibility](extensibility.md). + +## Trust boundaries + +IdLE treats workflow configuration and lifecycle requests as **untrusted data** and validates that they contain no ScriptBlocks. + +Host-provided extension points (step registry, providers, external event sinks) are **trusted inputs** and are validated for safe shapes (object contracts). For details, see `advanced/security.md`. diff --git a/docs/advanced/extensibility.md b/docs/advanced/extensibility.md index 2a13c99b..972ad0e7 100644 --- a/docs/advanced/extensibility.md +++ b/docs/advanced/extensibility.md @@ -43,3 +43,12 @@ Do not add: - UI or web server dependencies Those belong in a host application. + +## Register step handlers + +Steps are executed via a host-provided step registry. + +- Workflows reference steps by `Type` (identifier). +- The host maps this identifier to a **function name** (string) in the step registry. + +ScriptBlock handlers are intentionally not supported as a secure default. diff --git a/docs/advanced/security.md b/docs/advanced/security.md new file mode 100644 index 00000000..ffa1d436 --- /dev/null +++ b/docs/advanced/security.md @@ -0,0 +1,50 @@ +# Security and Trust Boundaries + +IdLE is designed to execute *data-driven* lifecycle workflows in a deterministic way. + +Because IdLE is an orchestration engine, it must be very explicit about **what is trusted** and **what is untrusted**. + +## Trust boundaries + +### Untrusted inputs (data-only) + +These inputs may come from users, CI pipelines, or external systems and **must be treated as untrusted**: + +- Workflow definitions (PSD1) +- Lifecycle requests (input objects) +- Step parameters (`With`, `When`) + +**Rule:** Untrusted inputs must be *data-only*. They must not contain ScriptBlocks or other executable objects. + +IdLE enforces this by rejecting ScriptBlocks when importing workflow definitions and by validating inputs at runtime. + +### Trusted extension points (code) + +These inputs are provided by the host and are **privileged** because they determine what code is executed: + +- Step registry (maps `Step.Type` to a handler function name) +- Provider modules / provider objects (system-specific adapters) +- External event sinks (streaming events) + +**Rule:** Only trusted code should populate these extension points. + +## Secure defaults + +IdLE applies secure defaults to reduce accidental code execution: + +- Workflow configuration is loaded as data and ScriptBlocks are rejected. +- Event streaming uses an object-based contract (`WriteEvent(event)`); ScriptBlock event sinks are rejected. +- Step registry handlers must be function names (strings); ScriptBlock handlers are rejected. + +## Guidance for hosts + +- Keep workflow files in a protected location and review them like code (even though they are data-only). +- Load step and provider modules explicitly before execution. +- Treat the step registry as privileged configuration and do not let workflow authors change it. +- If you stream events, implement a small sink object with a `WriteEvent(event)` method and keep it side-effect free. + +## Guidance for step authors + +- Use providers for system operations; do not embed authentication logic inside steps. +- Emit events using `Context.EventSink.WriteEvent(Type, Message, StepName, Data)`. +- Avoid global state. Steps should be idempotent whenever possible. diff --git a/docs/index.md b/docs/index.md index 64bde587..4748d1ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,7 @@ for identity and account processes (Joiner / Mover / Leaver) built for **PowerSh ## Advanced - [Architecture](advanced/architecture.md) +- [Security](advanced/security.md) - [Extensibility](advanced/extensibility.md) - [Testing](advanced/testing.md) diff --git a/docs/usage/providers.md b/docs/usage/providers.md index 4aa8a2d5..0e8e133c 100644 --- a/docs/usage/providers.md +++ b/docs/usage/providers.md @@ -34,3 +34,10 @@ Unit tests must not call live systems. - [Testing](../advanced/testing.md) - [Architecture](../advanced/architecture.md) + +## Trust and security + +Providers and the step registry are host-controlled extension points and should be treated as trusted code. +Workflows and lifecycle requests are data-only and must not contain executable objects. + +For details, see `docs/advanced/security.md`. diff --git a/docs/usage/steps.md b/docs/usage/steps.md index df095e58..4d518768 100644 --- a/docs/usage/steps.md +++ b/docs/usage/steps.md @@ -76,3 +76,9 @@ IdLE uses a fail-fast execution model in V1: - [Workflows](workflows.md) - [Providers](providers.md) - [Architecture](../advanced/architecture.md) + +## Security notes + +- Steps emit events via `Context.EventSink.WriteEvent(...)`. +- Step handlers are referenced by function name (string) in the step registry. +- ScriptBlock handlers are not supported as a secure default. From f1f573fd165e95d0591f06bebe808f8a6b6bcfe1 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:18:31 +0100 Subject: [PATCH 9/9] docs: moving run-demo result to end --- examples/run-demo.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/run-demo.ps1 b/examples/run-demo.ps1 index bb10a822..dea29656 100644 --- a/examples/run-demo.ps1 +++ b/examples/run-demo.ps1 @@ -98,7 +98,6 @@ $providers = @{ $result = Invoke-IdlePlan -Plan $plan -Providers $providers Write-DemoHeader "IdLE Demo – Plan Execution" -Write-ResultSummary -Result $result Write-Host "" Write-DemoHeader "Step Results" @@ -113,3 +112,5 @@ Write-DemoHeader "Event Stream" $result.Events | ForEach-Object { Format-EventRow $_ } | Format-Table Time, Type, Step, Message -AutoSize + +Write-ResultSummary -Result $result \ No newline at end of file