From 2dc64c33330052623685613c2198f3ae3bd80774 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:57:00 +0200 Subject: [PATCH 1/2] fix: dump unrecognised API error entries at Depth 100, not 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The errors[] fallback exists to surface an entry whose shape we don't recognise. Dumping it at -Depth 5 assumed the actionable field sits above the cut — an assumption the fallback is, by definition, in no position to make. Past the limit PowerShell doesn't error: it substitutes "@{...}", which is not JSON, writes a truncation warning into the caller's stream, and drops the rest. A nested {"reason":"tenant not in key scope"} rendered as {"L8":{"L7":{"L6":{"L5":{"L4":{"L3":"@{L2=}"}}}}}} — the reason gone, replaced by something unparseable. This closes audit item A4, which had proposed the opposite resolution (amend the contract to allow a shallow depth for error rendering). Checking what truncation actually produces settled it the other way: the guardian rule was right. Swept the rest while here — all 30 ConvertTo-Json calls in module/Public and module/Private are Depth 100, and the only deliberate shallow one is the documented Format.ps1xml PolicyData preview at Depth 3. Test mutation-verified against the old depth. 452/0/2. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++++++ Tests/Consistency.Tests.ps1 | 12 ++++++++++++ module/InforcerCommunity.psd1 | 2 +- module/Private/Format-InforcerErrorDetail.ps1 | 5 ++++- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 959b745..980cb04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format follows [Conventional Commits](https://www.conventionalcommits.org/) and [Semantic Versioning](https://semver.org/): a new feature or capability bumps MINOR, a bug fix or performance change that leaves the public surface alone bumps PATCH, and docs/tests/chore-only commits don't bump. While the module is pre-1.0 a breaking change also bumps MINOR and is called out under a **Breaking Changes** heading — 1.0.0 is reserved for the point the public surface is declared stable, after which breaking changes bump MAJOR. There is intentionally no `[Unreleased]` section — every entry is dated at ship time. +## [0.7.2] - 2026-09-11 + +### Bug Fixes + +- **API validation errors no longer lose the reason that explains them.** When an entry in the API's `errors[]` array doesn't match any known shape, the module falls back to dumping it as JSON so you still see something actionable — but it dumped at `-Depth 5`. That fallback runs *precisely* when the entry's shape is unknown, so there was no basis for assuming the useful field sat above the cut. Past the limit PowerShell substitutes `@{...}`, which isn't JSON, emits a truncation warning into your stream, and drops the rest: a nested `{"reason":"tenant not in key scope"}` came out as `{"L8":{"L7":{"L6":{"L5":{"L4":{"L3":"@{L2=}"}}}}}}`. Now `-Depth 100`, matching every other `ConvertTo-Json` in the module. + ## [0.7.1] - 2026-09-11 ### Bug Fixes diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index 4bb4b22..73eae66 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -2150,6 +2150,18 @@ Describe 'Private helpers (via module scope)' { $out | Should -Match '"weird"' $out | Should -Match '"thing"' } + + It 'Does not truncate a deeply nested unrecognized entry' { + # This branch runs only when the entry shape is unknown, so a display-depth cut + # can silently drop the one field that explains the failure. PowerShell also + # substitutes "@{...}" for the truncated remainder, which is not even JSON. + $json = '{"errors":[{"L8":{"L7":{"L6":{"L5":{"L4":{"L3":{"L2":{"L1":{"reason":"tenant not in key scope"}}}}}}}}}]}' + $parsed = $json | ConvertFrom-Json -Depth 100 + $out = & (Get-Module InforcerCommunity) { param($e) Format-InforcerErrorDetail -Errors $e } $parsed.errors + + $out | Should -Match 'tenant not in key scope' + $out | Should -Not -Match '@\{' + } } Context 'Disconnect-Inforcer cache clearing' { diff --git a/module/InforcerCommunity.psd1 b/module/InforcerCommunity.psd1 index c656de7..0c4799a 100644 --- a/module/InforcerCommunity.psd1 +++ b/module/InforcerCommunity.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'InforcerCommunity.psm1' - ModuleVersion = '0.7.1' + ModuleVersion = '0.7.2' GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' Author = 'Roy Klooster' Description = 'Community PowerShell module for the Inforcer API. Created by Roy Klooster. Not owned or officially maintained by Inforcer.' diff --git a/module/Private/Format-InforcerErrorDetail.ps1 b/module/Private/Format-InforcerErrorDetail.ps1 index c75b865..c7e9ace 100644 --- a/module/Private/Format-InforcerErrorDetail.ps1 +++ b/module/Private/Format-InforcerErrorDetail.ps1 @@ -55,10 +55,13 @@ function Format-InforcerErrorDetail { $rendered.Add(($parts -join ' ')) } else { # Nothing we recognize — dump JSON so the user still sees something useful. + # Depth 100, not a display depth: this runs precisely when the entry shape is + # unknown, so there is no way to tell that the actionable field isn't below the + # cut. Truncation also emits a warning and substitutes "@{...}", which is not JSON. # Serialization can fail for objects with circular refs or COM types; in that case # we drop the entry silently (the top-level message will still surface). try { - $rendered.Add(($e | ConvertTo-Json -Compress -Depth 5)) + $rendered.Add(($e | ConvertTo-Json -Compress -Depth 100)) } catch { Write-Verbose "Format-InforcerErrorDetail: skipping unserializable entry ($($_.Exception.Message))" } From f167c2f3e4b0c6351f09c08bb3ec286873868f5b Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:02:19 +0200 Subject: [PATCH 2/2] docs: show the -OutputType JsonObject path in Get-Help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six cmdlets documented the parameter but never demonstrated it, so Get-Help -Examples gave no hint that JSON output existed: Get-InforcerReportRun, Get-InforcerReportType, Get-InforcerUser, Invoke-InforcerAssessment, Invoke-InforcerReport, Save-InforcerReportOutput. docs/CMDLET-REFERENCE.md had covered all six for a while — only the in-shell help was short, which is why this read as a docs gap for longer than it was. Each example matches its own file's indentation and blank-line convention, so Get-Help renders it identically to the examples already there (two styles are in use across module/Public). Guarded by a discovery-time scan of the source rather than a hardcoded list: every cmdlet whose ValidateSet offers JsonObject is checked, so a new one inherits the test for free. 15 today, mutation-verified. Closes audit item A3. 467/0/2. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 ++ CHANGELOG.md | 1 + Tests/Consistency.Tests.ps1 | 31 ++++++++++++++----- .../ConvertFrom-InforcerBase64Text.ps1 | 4 +-- .../Private/ConvertTo-InforcerSettingRows.ps1 | 3 +- module/Private/Format-InforcerErrorDetail.ps1 | 7 +---- module/Public/Get-InforcerReportRun.ps1 | 4 +++ module/Public/Get-InforcerReportType.ps1 | 4 +++ module/Public/Get-InforcerUser.ps1 | 5 +++ module/Public/Invoke-InforcerAssessment.ps1 | 4 +++ module/Public/Invoke-InforcerReport.ps1 | 4 +++ module/Public/Save-InforcerReportOutput.ps1 | 4 +++ 12 files changed, 55 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 424f373..313316e 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,6 @@ api-feedback-evidence/ # reads it: build-and-test.yml takes the result from -PassThru in memory and there # is no test-result publisher in the pipeline. testResults.xml + +# Local demo exports — real tenant data, never commit +demo/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 980cb04..be7d919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/) ### Bug Fixes +- **`Get-Help` now shows the JSON path for every cmdlet that has one.** `Get-InforcerReportRun`, `Get-InforcerReportType`, `Get-InforcerUser`, `Invoke-InforcerAssessment`, `Invoke-InforcerReport` and `Save-InforcerReportOutput` all documented `-OutputType` but never demonstrated it, so `Get-Help -Examples` gave no hint that JSON output existed. `docs/CMDLET-REFERENCE.md` had covered all six for a while; only the in-shell help was missing them. A test now covers all 15 `-OutputType` cmdlets so the two can't drift apart again. - **API validation errors no longer lose the reason that explains them.** When an entry in the API's `errors[]` array doesn't match any known shape, the module falls back to dumping it as JSON so you still see something actionable — but it dumped at `-Depth 5`. That fallback runs *precisely* when the entry's shape is unknown, so there was no basis for assuming the useful field sat above the cut. Past the limit PowerShell substitutes `@{...}`, which isn't JSON, emits a truncation warning into your stream, and drops the rest: a nested `{"reason":"tenant not in key scope"}` came out as `{"L8":{"L7":{"L6":{"L5":{"L4":{"L3":"@{L2=}"}}}}}}`. Now `-Depth 100`, matching every other `ConvertTo-Json` in the module. ## [0.7.1] - 2026-09-11 diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index 73eae66..c96a83f 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -2152,9 +2152,6 @@ Describe 'Private helpers (via module scope)' { } It 'Does not truncate a deeply nested unrecognized entry' { - # This branch runs only when the entry shape is unknown, so a display-depth cut - # can silently drop the one field that explains the failure. PowerShell also - # substitutes "@{...}" for the truncated remainder, which is not even JSON. $json = '{"errors":[{"L8":{"L7":{"L6":{"L5":{"L4":{"L3":{"L2":{"L1":{"reason":"tenant not in key scope"}}}}}}}}}]}' $parsed = $json | ConvertFrom-Json -Depth 100 $out = & (Get-Module InforcerCommunity) { param($e) Format-InforcerErrorDetail -Errors $e } $parsed.errors @@ -2568,23 +2565,19 @@ Describe 'Base64 fields decode to text or stay base64, never mojibake' { } It 'never returns a string containing U+FFFD' { - # 256 raw bytes is not valid UTF-8; a lenient decode would hand back replacement chars. $bytes = [byte[]](0..255) $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } ([Convert]::ToBase64String($bytes)) $out | Should -BeNullOrEmpty } It 'rejects a literal U+FFFD, which strict UTF-8 decoding accepts' { - # EF BF BD *is* valid UTF-8 — it encodes U+FFFD — so the strict decoder does not - # throw on it. Without an explicit check a digest carrying those bytes comes back - # as "text" and the caller renders mojibake, which is the bug this helper exists for. + # EF BF BD is valid UTF-8 for U+FFFD, so the strict decoder does not throw on it. $bytes = [byte[]](0x48,0x69,0xEF,0xBF,0xBD,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69) $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } ([Convert]::ToBase64String($bytes)) $out | Should -BeNullOrEmpty } It 'rejects DEL and the C1 control range, which are also valid UTF-8' { - # 0x7F and C2 80..C2 9F decode cleanly but never appear in a plist, script or JSON. $bytes = [byte[]](0x48,0x69,0xC2,0x85,0x48,0x69,0x7F,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69) $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } ([Convert]::ToBase64String($bytes)) $out | Should -BeNullOrEmpty @@ -2680,3 +2673,25 @@ Describe 'Base64 fields decode to text or stay base64, never mojibake' { } } } + +Describe 'Every -OutputType cmdlet documents the JsonObject path' { + # Audit item A3: six cmdlets documented the parameter but never showed it in an example, + # so Get-Help -Examples never surfaced the JSON route. + + It 'has a -OutputType JsonObject example in ' -ForEach @( + $publicDir = Join-Path (Split-Path -Parent $PSCommandPath) '../module/Public' + Get-ChildItem -Path $publicDir -Filter *.ps1 | ForEach-Object { + $src = Get-Content $_.FullName -Raw + if ($src -match '(?m)^\s*\[string\]\$OutputType|\$OutputType\s*=|\bOutputType\b\s*\)') { + if ($src -match "ValidateSet\('PowerShellObject'\s*,\s*'JsonObject'\)") { + @{ Name = $_.BaseName; Source = $src } + } + } + } + ) { + $blocks = [regex]::Matches($Source, '(?ms)^\s*\.EXAMPLE\s*$(.*?)(?=^\s*\.[A-Z]+\s*$|#>)') + $blocks.Count | Should -BeGreaterThan 0 -Because "$Name should document at least one example" + ($blocks | ForEach-Object { $_.Groups[1].Value }) -join "`n" | + Should -Match 'OutputType\s+JsonObject' -Because "$Name exposes -OutputType, so its help must show the JsonObject path" + } +} diff --git a/module/Private/ConvertFrom-InforcerBase64Text.ps1 b/module/Private/ConvertFrom-InforcerBase64Text.ps1 index 6709df7..a331c7c 100644 --- a/module/Private/ConvertFrom-InforcerBase64Text.ps1 +++ b/module/Private/ConvertFrom-InforcerBase64Text.ps1 @@ -27,13 +27,11 @@ function ConvertFrom-InforcerBase64Text { try { $bytes = [System.Convert]::FromBase64String($Value) } catch { return $null } if ($bytes.Length -eq 0) { return $null } - # Strict decode: invalid UTF-8 throws instead of silently yielding U+FFFD. try { $text = [System.Text.UTF8Encoding]::new($false, $true).GetString($bytes) } catch { return $null } $text = $text.TrimStart([char]0xFEFF) - # Strict decoding is necessary but not sufficient: EF BF BD is *valid* UTF-8 for U+FFFD, - # and C2 80..C2 9F are valid encodings of the C1 controls, so a digest can survive it. + # Strict decoding isn't enough: EF BF BD and C2 80..C2 9F are valid UTF-8, so a digest can survive it. if ($text.Contains([char]0xFFFD) -or $text -match '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]') { return $null } $text diff --git a/module/Private/ConvertTo-InforcerSettingRows.ps1 b/module/Private/ConvertTo-InforcerSettingRows.ps1 index b4e0828..6161977 100644 --- a/module/Private/ConvertTo-InforcerSettingRows.ps1 +++ b/module/Private/ConvertTo-InforcerSettingRows.ps1 @@ -328,8 +328,7 @@ function ConvertTo-FlatSettingRows { $joined = @($val | ForEach-Object { if ($_ -is [string] -or $_ -is [ValueType]) { $_.ToString() } }) -join ', ' if ([string]::IsNullOrWhiteSpace($joined) -and $val.Count -gt 0) { "$($val.Count) items" } else { $joined } } else { $val.ToString() } - # Decode scripts, rulesContent JSON and .mobileconfig payloads. hashedScriptContent - # matches this pattern too but is a digest — the helper rejects it. + # hashedScriptContent matches this pattern too, but the helper rejects digests. if ($prop.Name -match '(?i)scriptContent|rulesContent|^payload$' -and $strVal -is [string]) { $decoded = ConvertFrom-InforcerBase64Text -Value $strVal if ($decoded) { $strVal = "__SCRIPT_CODE__$decoded" } diff --git a/module/Private/Format-InforcerErrorDetail.ps1 b/module/Private/Format-InforcerErrorDetail.ps1 index c7e9ace..180588a 100644 --- a/module/Private/Format-InforcerErrorDetail.ps1 +++ b/module/Private/Format-InforcerErrorDetail.ps1 @@ -54,12 +54,7 @@ function Format-InforcerErrorDetail { if ($parts.Count -gt 0) { $rendered.Add(($parts -join ' ')) } else { - # Nothing we recognize — dump JSON so the user still sees something useful. - # Depth 100, not a display depth: this runs precisely when the entry shape is - # unknown, so there is no way to tell that the actionable field isn't below the - # cut. Truncation also emits a warning and substitutes "@{...}", which is not JSON. - # Serialization can fail for objects with circular refs or COM types; in that case - # we drop the entry silently (the top-level message will still surface). + # Shape is unknown, so a shallow depth could cut off the only useful field. try { $rendered.Add(($e | ConvertTo-Json -Compress -Depth 100)) } catch { diff --git a/module/Public/Get-InforcerReportRun.ps1 b/module/Public/Get-InforcerReportRun.ps1 index ddc2123..0c92fce 100644 --- a/module/Public/Get-InforcerReportRun.ps1 +++ b/module/Public/Get-InforcerReportRun.ps1 @@ -46,6 +46,10 @@ .EXAMPLE Get-InforcerReportRun -IncludeOutputs Returns every run with its outputs array embedded (1 extra API call per run). +.EXAMPLE + Get-InforcerReportRun -RunId 8842 -OutputType JsonObject + Returns the run as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject or String .LINK diff --git a/module/Public/Get-InforcerReportType.ps1 b/module/Public/Get-InforcerReportType.ps1 index d9cef72..23ed292 100644 --- a/module/Public/Get-InforcerReportType.ps1 +++ b/module/Public/Get-InforcerReportType.ps1 @@ -40,6 +40,10 @@ Discovers every security-tagged report type and pipes each into Invoke-InforcerReport. Works because Get-InforcerReportType emits a 'Key' alias which binds to Invoke-InforcerReport's -ReportType (ValueFromPipelineByPropertyName). +.EXAMPLE + Get-InforcerReportType -Key ActiveUserCount -OutputType JsonObject + Returns the report type as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject or String .LINK diff --git a/module/Public/Get-InforcerUser.ps1 b/module/Public/Get-InforcerUser.ps1 index 02b54bf..d4f28ba 100644 --- a/module/Public/Get-InforcerUser.ps1 +++ b/module/Public/Get-InforcerUser.ps1 @@ -48,6 +48,11 @@ function Get-InforcerUser { Lists all users in the piped tenant. + .EXAMPLE + Get-InforcerUser -TenantId 482 -OutputType JsonObject + + Returns the users as a JSON string (depth 100) instead of objects. + .LINK https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#get-inforceruser diff --git a/module/Public/Invoke-InforcerAssessment.ps1 b/module/Public/Invoke-InforcerAssessment.ps1 index 5c930d8..08be7e0 100644 --- a/module/Public/Invoke-InforcerAssessment.ps1 +++ b/module/Public/Invoke-InforcerAssessment.ps1 @@ -40,6 +40,10 @@ .EXAMPLE Invoke-InforcerAssessment -TenantId 144 -AssessmentId "Copilot Readiness" -OutputPath ./report.csv Exports assessment results to CSV. +.EXAMPLE + Invoke-InforcerAssessment -TenantId 144 -AssessmentId "Copilot Readiness" -OutputType JsonObject + Returns the assessment results as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject, String, or System.IO.FileInfo .LINK diff --git a/module/Public/Invoke-InforcerReport.ps1 b/module/Public/Invoke-InforcerReport.ps1 index 8f89eec..513331d 100644 --- a/module/Public/Invoke-InforcerReport.ps1 +++ b/module/Public/Invoke-InforcerReport.ps1 @@ -87,6 +87,10 @@ .EXAMPLE Invoke-InforcerReport -Key TenantAuditReport -OutputFormat html -TenantId 482 -Open Runs the report, saves it, and immediately opens the HTML in the default browser. +.EXAMPLE + Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 -OutputType JsonObject + Returns the run result as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject or String. The shape depends on the mode: -NoWait → one object per run with { RunId, Status='queued', TenantId, ReportType, ... } diff --git a/module/Public/Save-InforcerReportOutput.ps1 b/module/Public/Save-InforcerReportOutput.ps1 index e05cf74..e311f1a 100644 --- a/module/Public/Save-InforcerReportOutput.ps1 +++ b/module/Public/Save-InforcerReportOutput.ps1 @@ -42,6 +42,10 @@ ForEach-Object { $_.outputs } | Save-InforcerReportOutput -OutputPath ./bulk Bulk-downloads every output from every visible run. +.EXAMPLE + Save-InforcerReportOutput -RunId 8842 -OutputId 1 -OutputPath ./out -OutputType JsonObject + Returns the saved-file result as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject or String — per saved file, with { RunId, OutputId, TenantId, ReportType, OutputFormat, FilePath, FileName, FileSize, ContentType, CorrelationId }. TenantId,