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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ 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

- **`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 <cmdlet> -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

### Bug Fixes
Expand Down
37 changes: 32 additions & 5 deletions Tests/Consistency.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2150,6 +2150,15 @@ Describe 'Private helpers (via module scope)' {
$out | Should -Match '"weird"'
$out | Should -Match '"thing"'
}

It 'Does not truncate a deeply nested unrecognized entry' {
$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' {
Expand Down Expand Up @@ -2556,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
Expand Down Expand Up @@ -2668,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 <cmdlet> -Examples never surfaced the JSON route.

It 'has a -OutputType JsonObject example in <Name>' -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"
}
}
2 changes: 1 addition & 1 deletion module/InforcerCommunity.psd1
Original file line number Diff line number Diff line change
@@ -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.'
Expand Down
4 changes: 1 addition & 3 deletions module/Private/ConvertFrom-InforcerBase64Text.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions module/Private/ConvertTo-InforcerSettingRows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
6 changes: 2 additions & 4 deletions module/Private/Format-InforcerErrorDetail.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,9 @@ 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.
# 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 5))
$rendered.Add(($e | ConvertTo-Json -Compress -Depth 100))
} catch {
Write-Verbose "Format-InforcerErrorDetail: skipping unserializable entry ($($_.Exception.Message))"
}
Expand Down
4 changes: 4 additions & 0 deletions module/Public/Get-InforcerReportRun.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions module/Public/Get-InforcerReportType.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions module/Public/Get-InforcerUser.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions module/Public/Invoke-InforcerAssessment.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions module/Public/Invoke-InforcerReport.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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, ... }
Expand Down
4 changes: 4 additions & 0 deletions module/Public/Save-InforcerReportOutput.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down