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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@

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 (`V<FFFD><FFFD>0<FFFD><FFFD>…`) 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.
- **Code blocks scroll horizontally instead of wrapping.** `white-space: pre-wrap` plus `word-break: break-all` re-flowed every code block to the column width and broke lines mid-identifier, which flattened the nesting of a plist and split shell variables in half. Both renderers already declared `overflow-x: auto` — the intent was always horizontal scrolling, it just never took effect. Indentation is now preserved and long lines scroll, for plists, scripts and compliance rules JSON alike.
- **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 `<br>`. 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

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Thank you for your interest in contributing. This project is maintained by the c
git checkout -b feature/your-feature-name
# or: git checkout -b fix/bug-description
```
4. **Make your changes** in `module/` (see [Development setup](#development-setup) and [Code style](#code-style)).
4. **Make your changes** in `module/` (see [Development setup](#development-setup) and [Code style](#code-style-and-consistency)).
5. **Run tests** (see [Testing](#testing)).
6. **Commit and push** to your fork
```powershell
Expand Down
157 changes: 157 additions & 0 deletions Tests/Consistency.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2511,3 +2511,160 @@ 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 "V<FFFD><FFFD>0..." and left macOS payloads as raw base64.

BeforeAll {
Remove-Module -Name 'InforcerCommunity' -ErrorAction SilentlyContinue
Import-Module (Get-InforcerCommunityManifestPath) -Force

$script:PlistXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>PayloadType</key>
<string>com.google.Chrome</string>
</dict>
</plist>
"@
$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 '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.
$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
}

It 'still accepts text carrying tab, CR and LF' {
$sample = "line one`r`n`tindented line two`n`tindented line three`n"
$out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } ([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($sample)))
$out | Should -Be $sample
}

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
}
}
}
26 changes: 13 additions & 13 deletions docs/API-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Retrieves baseline groups and their members.

**Cmdlet**: `Get-InforcerBaseline`

**Required scope**: `Baselines.Read` (or `Tenants.Read`)
**Required API scope(s)**: `Baselines.Read` (or `Tenants.Read`)

| Parameter | Location | Required | Type | Description |
|-----------|----------|----------|------|-------------|
Expand All @@ -141,7 +141,7 @@ Retrieves alignment scores for tenants.

**Cmdlet**: `Get-InforcerAlignmentDetails`

**Required scope**: `AlignmentScores.Read` (or `Tenants.Read`)
**Required API scope(s)**: `AlignmentScores.Read` (or `Tenants.Read`)

**Response**: Array of [AlignmentScore](#alignmentscore)

Expand All @@ -155,7 +155,7 @@ Retrieves all tenants.

**Cmdlet**: `Get-InforcerTenant`

**Required scope**: `Tenants.Read`
**Required API scope(s)**: `Tenants.Read`

**Response**: Array of [Tenant](#tenant)

Expand All @@ -167,7 +167,7 @@ Retrieves a specific tenant by ID.

**Cmdlet**: `Get-InforcerTenant -TenantId`

**Required scope**: `Tenants.Read`
**Required API scope(s)**: `Tenants.Read`

| Parameter | Location | Required | Type | Description |
|-----------|----------|----------|------|-------------|
Expand All @@ -187,7 +187,7 @@ Retrieves policies for a specific tenant.

**Cmdlet**: `Get-InforcerTenantPolicies`

**Required scope**: `tenants.policies.Read`
**Required API scope(s)**: `tenants.policies.Read`

| Parameter | Location | Required | Type | Description |
|-----------|----------|----------|------|-------------|
Expand All @@ -205,7 +205,7 @@ Retrieves available event types for filtering audit events.

**Cmdlet**: Internal use (populates `-EventType` tab completion)

**Required scope**: `Audit.Read` *(needs confirmation — see [API Scopes](#api-scopes))*
**Required API scope(s)**: `Audit.Read` *(needs confirmation — see [API Scopes](#api-scopes))*

**Response**: Array of [EventType](#eventtype)

Expand All @@ -217,7 +217,7 @@ Searches the activity log with optional filters.

**Cmdlet**: `Get-InforcerAuditEvent`

**Required scope**: `Audit.Read` *(needs confirmation — see [API Scopes](#api-scopes))*
**Required API scope(s)**: `Audit.Read` *(needs confirmation — see [API Scopes](#api-scopes))*

**Request Body**:

Expand Down Expand Up @@ -250,7 +250,7 @@ Searches the activity log with optional filters.

Returns a paginated list of user summaries for a tenant.

**Required scope**: `Tenants.Users.Read` (or `Tenants.Read`)
**Required API scope(s)**: `Tenants.Users.Read` (or `Tenants.Read`)

| Parameter | In | Type | Required | Description |
|-----------|----|------|----------|-------------|
Expand All @@ -264,7 +264,7 @@ Returns a paginated list of user summaries for a tenant.

Returns full detail for a single user.

**Required scope**: `Tenants.Users.Read` (or `Tenants.Read`)
**Required API scope(s)**: `Tenants.Users.Read` (or `Tenants.Read`)

| Parameter | In | Type | Required | Description |
|-----------|----|------|----------|-------------|
Expand All @@ -279,7 +279,7 @@ Returns full detail for a single user.

Returns a paginated list of Entra ID group summaries for a tenant.

**Required scope**: `Tenants.Groups.Read` (or `Tenants.Read`)
**Required API scope(s)**: `Tenants.Groups.Read` (or `Tenants.Read`)

| Parameter | In | Type | Required | Description |
|-----------|----|------|----------|-------------|
Expand All @@ -293,7 +293,7 @@ Returns a paginated list of Entra ID group summaries for a tenant.

Returns full detail for a single group including members.

**Required scope**: `Tenants.Groups.Read` (or `Tenants.Read`)
**Required API scope(s)**: `Tenants.Groups.Read` (or `Tenants.Read`)

| Parameter | In | Type | Required | Description |
|-----------|----|------|----------|-------------|
Expand All @@ -308,7 +308,7 @@ Returns full detail for a single group including members.

Returns the list of Entra ID directory role definitions for a tenant.

**Required scope**: `Tenants.Roles.Read` (or `Tenants.Read`)
**Required API scope(s)**: `Tenants.Roles.Read` (or `Tenants.Read`)

| Parameter | In | Type | Required | Description |
|-----------|----|------|----------|-------------|
Expand All @@ -322,7 +322,7 @@ Returns the list of Entra ID directory role definitions for a tenant.

Returns the current and historic Microsoft Secure Score for a tenant. Includes up to 90 days of daily score history, per-category scores, and actionable control profiles.

**Required scope**: `Tenants.SecureScores.Read` (or `Tenants.Read`)
**Required API scope(s)**: `Tenants.SecureScores.Read` (or `Tenants.Read`)

| Parameter | In | Type | Required | Description |
|-----------|----|------|----------|-------------|
Expand Down
6 changes: 4 additions & 2 deletions docs/CMDLET-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,9 @@ 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
- Code blocks keep their original indentation and scroll horizontally; lines are never re-wrapped or broken mid-identifier
- 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
Expand Down Expand Up @@ -794,7 +796,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). Code blocks keep their indentation and scroll horizontally rather than re-wrapping
- **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%
Expand Down
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.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.'
Expand Down
Loading