From c3d9a264101f165f3caea023f4fa8901999188d9 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:04:23 +0200 Subject: [PATCH 1/4] fix: decode macOS .mobileconfig payloads, stop decoding script hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from reading two generated documentation reports. macOS/iOS custom configuration profiles printed the `payload` field as raw base64 rather than the .mobileconfig plist it encodes, so a profile like "macOS - Security - D - Google Chrome - Extensions" showed 2,300 characters of base64 and never revealed which extensions it allowed. The decoder matched `hashedScriptContent` on name alongside `scriptContent` and UTF8-decoded the digest into replacement characters, presenting "V0..." as viewable script content. Both come from the same defect: the property name was the only test. The new `ConvertFrom-InforcerBase64Text` decodes with strict UTF-8 and rejects control bytes, returning $null so callers keep the base64 — the guard is now on the decoded bytes, which also covers signed profiles and any future binary field. It replaces all four duplicate inline decode sites, each of which gained `^payload$`. Both HTML renderers render decoded plists in an `xml-code` block. Scanning for more of the same turned up a third: the internal `__SCRIPT_CODE__` marker leaked into the Markdown and Excel exports, and newlines in decoded content ended the row mid-table in GFM, destroying the rest of the settings table for any policy with a script. 448/0/2 tests, 13 new. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 +- Tests/Consistency.Tests.ps1 | 135 ++++++++++++++++++ docs/CMDLET-REFERENCE.md | 5 +- module/InforcerCommunity.psd1 | 2 +- module/Private/Compare-InforcerDocModels.ps1 | 17 +-- .../ConvertFrom-InforcerBase64Text.ps1 | 37 +++++ .../ConvertTo-InforcerComparisonHtml.ps1 | 24 ++++ module/Private/ConvertTo-InforcerHtml.ps1 | 10 ++ module/Private/ConvertTo-InforcerMarkdown.ps1 | 4 +- .../Private/ConvertTo-InforcerSettingRows.ps1 | 12 +- module/Private/Export-InforcerDocExcel.ps1 | 2 +- module/Private/Get-InforcerComparisonData.ps1 | 5 +- 12 files changed, 237 insertions(+), 26 deletions(-) create mode 100644 module/Private/ConvertFrom-InforcerBase64Text.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1c647..8793126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,15 @@ All notable changes to this project will be documented in this file. -The format follows [Conventional Commits](https://www.conventionalcommits.org/). Versioning deviates from strict SemVer: every shipped change (feat / fix / perf / non-breaking refactor) bumps MINOR; 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. +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.1] - 2026-09-11 + +### Bug Fixes + +- **macOS and iOS configuration profiles now show what is actually in the profile.** The `payload` field of a custom configuration profile is a base64-encoded `.mobileconfig` — a plist XML document — and the reports printed the base64 verbatim. A 2,300-character wall of `PD94bWwgdmVyc2lvbj0iMS4w…` told you a profile existed and nothing about what it configured, so `macOS - Security - D - Google Chrome - Extensions` never revealed which extensions it allowed. `Export-InforcerTenantDocumentation` and `Compare-InforcerEnvironments` now decode it and render the plist in a collapsible, syntax-highlighted block alongside the existing script and JSON blocks. +- **Script hashes are no longer decoded into mojibake.** `hashedScriptContent` is a raw digest, and the base64 decoder matched it on name alongside `scriptContent`, so macOS and Windows script policies rendered a "Hashed Script Content" block full of replacement characters (`V0…`) presented as viewable script. The decoder now verifies the decoded bytes really are text and leaves anything else as base64, which also covers signed `.mobileconfig` profiles and any future binary field — the property name alone was never a safe test. +- **Multi-line values no longer break the Markdown export.** Decoded scripts and plists contain newlines, and a newline ends the row in a GFM table, so any policy with script content silently destroyed the rest of its settings table. Newlines are now escaped to `
`. The Markdown and Excel exports also leaked the internal `__SCRIPT_CODE__` render marker into the value column; both strip it. ## [0.7.0] - 2026-09-09 diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index de20567..c220eda 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -2511,3 +2511,138 @@ Describe '0.7.0 bug fixes that only live runs covered' { } } } + +Describe 'Base64 fields decode to text or stay base64, never mojibake' { + # Intune base64-encodes both text (scriptContent, rulesContent, the macOS .mobileconfig + # payload plist) and binary (hashedScriptContent digests, signed profiles). Name-matching + # alone decoded a SHA digest into "V0..." and left macOS payloads as raw base64. + + BeforeAll { + Remove-Module -Name 'InforcerCommunity' -ErrorAction SilentlyContinue + Import-Module (Get-InforcerCommunityManifestPath) -Force + + $script:PlistXml = @" + + + + PayloadType + com.google.Chrome + + +"@ + $script:PlistB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($script:PlistXml)) + $script:ScriptSh = "#!/usr/bin/env zsh`nset -u`n/usr/bin/defaults write com.apple.controlcenter BatteryShowPercentage -bool true`n" + $script:ScriptB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($script:ScriptSh)) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { $script:HashB64 = [Convert]::ToBase64String($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($script:ScriptSh))) } + finally { $sha.Dispose() } + } + + Context 'ConvertFrom-InforcerBase64Text' { + It 'decodes a .mobileconfig payload plist' { + $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } $script:PlistB64 + $out | Should -Match '^<\?xml' + $out | Should -Match 'com\.google\.Chrome' + } + + It 'decodes a shell script body' { + $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } $script:ScriptB64 + $out | Should -Match '^#!/usr/bin/env zsh' + } + + It 'rejects a SHA-256 digest rather than returning mojibake' { + $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } $script:HashB64 + $out | Should -BeNullOrEmpty + } + + 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 'returns $null for values that are not base64 text' -ForEach @( + @{ V = '' }, @{ V = ' ' }, @{ V = 'short' }, + @{ V = 'plain text with spaces that is long enough to pass the length floor' }, + @{ V = '__SCRIPT_CODE__already-decoded' } + ) { + $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } $V + $out | Should -BeNullOrEmpty + } + } + + Context 'ConvertTo-FlatSettingRows' { + It 'decodes payload and marks it as code' { + $row = & (Get-Module InforcerCommunity) { + param($b64) + $pd = [PSCustomObject]@{ payload = $b64; payloadName = 'Chrome' } + @(ConvertTo-FlatSettingRows -PolicyData $pd) | Where-Object { $_.Name -eq 'Payload' } + } $script:PlistB64 + + $row.Value | Should -Match '^__SCRIPT_CODE__<\?xml' + } + + It 'leaves hashedScriptContent as base64' { + $rows = & (Get-Module InforcerCommunity) { + param($hash, $script) + $pd = [PSCustomObject]@{ hashedScriptContent = $hash; scriptContent = $script } + @(ConvertTo-FlatSettingRows -PolicyData $pd) + } $script:HashB64 $script:ScriptB64 + + $hashed = $rows | Where-Object { $_.Name -eq 'Hashed Script Content' } + $hashed.Value | Should -Be $script:HashB64 + $hashed.Value | Should -Not -Match "$([char]0xFFFD)" + + $content = $rows | Where-Object { $_.Name -eq 'Script Content' } + $content.Value | Should -Match '^__SCRIPT_CODE__#!/usr/bin/env zsh' + } + } + + Context 'Renderers' { + It 'renders a decoded plist in an xml-code block, not as base64' { + $html = & (Get-Module InforcerCommunity) { + param($b64) + $model = @{ + TenantName = 'T'; GeneratedAt = (Get-Date); Baselines = @() + Products = [ordered]@{ 'Intune' = @{ Categories = [ordered]@{ 'macOS / Configuration Profiles' = @(@{ + Basics = @{ Name = 'Chrome'; Description = ''; ProfileType = ''; Platform = '' + Created = ''; Modified = ''; ScopeTags = ''; Tags = '' } + Settings = @(ConvertTo-FlatSettingRows -PolicyData ([PSCustomObject]@{ payload = $b64 })) + Assignments = @() + PolicyTypeId = 13 + }) } } } + } + ConvertTo-InforcerHtml -DocModel $model + } $script:PlistB64 + + $html | Should -Match 'class="xml-code"' + $html | Should -Match 'View profile' + $html | Should -Match 'PayloadType' + $html | Should -Not -Match ([regex]::Escape($script:PlistB64)) + $html | Should -Not -Match '__SCRIPT_CODE__' + } + + It 'strips the __SCRIPT_CODE__ marker out of Markdown and escapes newlines' { + $md = & (Get-Module InforcerCommunity) { + param($b64) + $model = @{ + TenantName = 'T'; GeneratedAt = (Get-Date); Baselines = @() + Products = [ordered]@{ 'Intune' = @{ Categories = [ordered]@{ 'macOS / Scripts' = @(@{ + Basics = @{ Name = 'Battery'; Description = ''; ProfileType = ''; Platform = '' + Created = ''; Modified = ''; ScopeTags = ''; Tags = '' } + Settings = @(ConvertTo-FlatSettingRows -PolicyData ([PSCustomObject]@{ scriptContent = $b64 })) + Assignments = @() + PolicyTypeId = 12 + }) } } } + } + ConvertTo-InforcerMarkdown -DocModel $model + } $script:ScriptB64 + + $md | Should -Not -Match '__SCRIPT_CODE__' + $md | Should -Match 'BatteryShowPercentage' + # Every table row must stay on one line or the GFM table falls apart. + ($md -split "`n" | Where-Object { $_ -match '^\|' -and $_ -notmatch '\|\s*$' }) | Should -BeNullOrEmpty + } + } +} diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index 06f3ee5..f10e218 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -707,7 +707,8 @@ With `-OutputPath`: returns `FileInfo` objects for the exported file(s). Without - Back-to-top floating button - Notch-style status bar showing tenant/baseline name and policy count - Collapsible long values with Expand/Collapse button -- Collapsible code blocks for detection/remediation scripts (PowerShell syntax highlighting) and compliance rules JSON (JSON syntax highlighting) +- Collapsible code blocks for detection/remediation scripts (PowerShell/Bash syntax highlighting), compliance rules JSON (JSON syntax highlighting), and macOS/iOS `.mobileconfig` payloads (decoded from base64 to the plist XML, with XML syntax highlighting) +- Binary base64 fields are left as base64 rather than decoded into unreadable text — `hashedScriptContent` is a digest, not a script - Friendly setting names — camelCase property names converted to Title Case (e.g., `allowBluetooth` → "Allow Bluetooth") - Categories sorted alphabetically, grouped by platform - Policy tags shown inline as blue-bordered badges @@ -794,7 +795,7 @@ With `-OutputPath`: returns a `FileInfo` object for the exported HTML report. Wi ### HTML report features - **Comparison tab**: Flat table with all Settings Catalog settings, sortable columns, status filter pills (Matched/Conflicting/Source Only/Dest Only), category dropdown, advanced column filters with AND/OR logic, search -- **Manual Review tab**: Non-Settings-Catalog policies (compliance, enrollment, scripts) in a 50/50 source/destination layout grouped by platform. Matching policy names aligned side-by-side. Independent column layout — expanding a policy on one side does not affect the other. Collapsible code blocks with syntax highlighting for scripts (PowerShell/Bash) and compliance rules (JSON) +- **Manual Review tab**: Non-Settings-Catalog policies (compliance, enrollment, scripts) in a 50/50 source/destination layout grouped by platform. Matching policy names aligned side-by-side. Independent column layout — expanding a policy on one side does not affect the other. Collapsible code blocks with syntax highlighting for scripts (PowerShell/Bash), compliance rules (JSON), and macOS/iOS `.mobileconfig` payloads (decoded plist XML) - **Duplicates tab**: Settings configured in 2+ policies with different values, with automated analysis - **Deprecated tab**: Settings flagged as deprecated by Microsoft, grouped by source/destination - **Configuration Match score**: Animated percentage with color gradient, confetti at 100% diff --git a/module/InforcerCommunity.psd1 b/module/InforcerCommunity.psd1 index 756e748..c656de7 100644 --- a/module/InforcerCommunity.psd1 +++ b/module/InforcerCommunity.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'InforcerCommunity.psm1' - ModuleVersion = '0.7.0' + ModuleVersion = '0.7.1' 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/Compare-InforcerDocModels.ps1 b/module/Private/Compare-InforcerDocModels.ps1 index 4cac782..648e0b2 100644 --- a/module/Private/Compare-InforcerDocModels.ps1 +++ b/module/Private/Compare-InforcerDocModels.ps1 @@ -354,9 +354,9 @@ function Compare-InforcerDocModels { $sName = "$($s.Name)" $sValue = "$($s.Value)" if ($sName -match '@odata|^hashed|Hash$') { continue } - # Decode base64 script content - if ($sName -match '(?i)script.*content|detection.*script|remediation.*script' -and $sValue.Length -gt 20) { - try { $sValue = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($sValue)) } catch {} + if ($sName -match '(?i)script.*content|detection.*script|remediation.*script|^payload$') { + $decoded = ConvertFrom-InforcerBase64Text -Value $sValue + if ($decoded) { $sValue = $decoded } } [void]$scriptSettings.Add(@{ Name = $sName; Value = $sValue }) } @@ -487,14 +487,9 @@ function Compare-InforcerDocModels { if ($settingName -match '^hashed|Hash$') { continue } if ($settingName -match '@odata') { continue } if ($settingName -match '(?i)^notification\s*template\s*id$' -and $settingValue -match '^0{8}-') { continue } - # Decode base64 content (script content + compliance rules) - if ($settingName -match '(?i)^(script\s*content|detection\s*script\s*content|remediation\s*script\s*content|rules\s*content|scriptContent|detectionScriptContent|remediationScriptContent|rulesContent)$') { - try { - $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($settingValue)) - $settingValue = $decoded - } catch { - # Not valid base64 — keep original - } + if ($settingName -match '(?i)^(script\s*content|detection\s*script\s*content|remediation\s*script\s*content|rules\s*content|payload|scriptContent|detectionScriptContent|remediationScriptContent|rulesContent)$') { + $decoded = ConvertFrom-InforcerBase64Text -Value $settingValue + if ($decoded) { $settingValue = $decoded } } [void]$settingsSummary.Add(@{ Name = $settingName; Value = $settingValue }) # Embed linked discovery script when we find a Device Compliance Script ID diff --git a/module/Private/ConvertFrom-InforcerBase64Text.ps1 b/module/Private/ConvertFrom-InforcerBase64Text.ps1 new file mode 100644 index 0000000..2d8d620 --- /dev/null +++ b/module/Private/ConvertFrom-InforcerBase64Text.ps1 @@ -0,0 +1,37 @@ +function ConvertFrom-InforcerBase64Text { + <# + .SYNOPSIS + Decodes a base64 string, returning it only when the result is real text. + .DESCRIPTION + Intune base64-encodes both text (scriptContent, rulesContent, the macOS/iOS + .mobileconfig payload plist) and binary (hashedScriptContent digests, signed + profiles) — so the property name alone is not a safe test. This decodes and + verifies the bytes were text, returning $null otherwise so callers keep the + base64 as-is rather than rendering mojibake. + .PARAMETER Value + The candidate base64 string. + .OUTPUTS + [string] The decoded text, or $null when the input isn't base64-encoded text. + .EXAMPLE + ConvertFrom-InforcerBase64Text -Value $policy.payload + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value) -or $Value.Length -le 20 -or $Value -match '\s') { return $null } + + 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) + if ($text -match '[\x00-\x08\x0B\x0C\x0E-\x1F]') { return $null } + + $text +} diff --git a/module/Private/ConvertTo-InforcerComparisonHtml.ps1 b/module/Private/ConvertTo-InforcerComparisonHtml.ps1 index 516ad81..2ecf3a6 100644 --- a/module/Private/ConvertTo-InforcerComparisonHtml.ps1 +++ b/module/Private/ConvertTo-InforcerComparisonHtml.ps1 @@ -451,6 +451,13 @@ td.value-cell:hover .value-copy-btn { opacity: 1; } .json-code { background: #1a1b26 !important; color: #a9b1d6; } .json-code-summary { color: #7aa2f7; border: 1px solid #7aa2f7; background: rgba(122,162,247,0.1); } .json-code-summary:hover { background: #7aa2f7; color: #1a1b26; } +.xml-code { background: #10161c !important; color: #c9d1d9; } +.xml-code-summary { color: #56d4dd; border: 1px solid #56d4dd; background: rgba(86,212,221,0.1); } +.xml-code-summary:hover { background: #56d4dd; color: #10161c; } +.xml-tag { color: #7aa2f7; } +.xml-attr { color: #e0af68; } +.xml-string { color: #9ece6a; } +.xml-comment { color: #6a9955; font-style: italic; } .json-key { color: #7aa2f7; } .json-string { color: #9ece6a; } .json-bool { color: #ff9e64; font-weight: 600; } @@ -1341,6 +1348,7 @@ h3:first-child { margin-top: var(--sp-4); } $encCode = [System.Net.WebUtility]::HtmlEncode($scriptCode) $trimmedCode = $scriptCode.TrimStart() if ($trimmedCode -match '^\s*[\{\[]') { $codeClass = 'json-code'; $summaryClass = 'json-code-summary'; $codeLabel = 'View JSON' } + elseif ($trimmedCode -match '^\s*<') { $codeClass = 'xml-code'; $summaryClass = 'xml-code-summary'; $codeLabel = 'View profile' } elseif ($trimmedCode -match '^\s*#!/') { $codeClass = 'sh-code'; $summaryClass = 'sh-code-summary'; $codeLabel = 'View script' } else { $codeClass = 'ps-code'; $summaryClass = 'ps-code-summary'; $codeLabel = 'View script' } [void]$sb.AppendLine("
$encSName
") @@ -2237,6 +2245,21 @@ h3:first-child { margin-top: var(--sp-4); } [void]$sb.AppendLine(' if (lastIdx < text.length) tokens.push(escHtml(text.substring(lastIdx)));') [void]$sb.AppendLine(' code.innerHTML = tokens.join("");') [void]$sb.AppendLine('}') + [void]$sb.AppendLine('function highlightXML(el) {') + [void]$sb.AppendLine(' var text = el.textContent;') + [void]$sb.AppendLine(' var tokens = [];') + [void]$sb.AppendLine(' var re = /|<[!?\/]?[A-Za-z_][\w:.\-]*(?:\s[^>]*?)?\/?>/g;') + [void]$sb.AppendLine(' var lastIdx = 0, m;') + [void]$sb.AppendLine(' while ((m = re.exec(text)) !== null) {') + [void]$sb.AppendLine(' if (m.index > lastIdx) tokens.push(escHtml(text.substring(lastIdx, m.index)));') + [void]$sb.AppendLine(' var t = m[0];') + [void]$sb.AppendLine(' if (t.indexOf("|<[!?\/]?[A-Za-z_][\w:.\-]*(?:\s[^>]*?)?\/?>/g,last=0,m;while((m=re.exec(t))!==null){if(m.index>last)out.push(escHtml(t.substring(last,m.index)));var s=m[0];if(s.indexOf("