From b149112e633525fc4942027153773bb01f9e6f0b Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:47:22 +0200 Subject: [PATCH 01/22] docs: add Required API scope(s) to comment help + drop stale PolicyDiffFormatted mention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds `Required API scope(s):` line to the .SYNOPSIS block of 13 cmdlets that previously lacked it, matching the pattern already used by the Reports cmdlets (Get-InforcerReportRun, Get-InforcerReportType, Invoke-InforcerReport, Save-InforcerReportOutput). `Get-Help ` now surfaces the scope requirement without users needing to open docs/API-REFERENCE.md. - Removes the stale `PolicyDiffFormatted` reference in docs/CMDLET-REFERENCE.md that survived when the property itself was deleted from the module (FINDINGS #63). - 3 cmdlets intentionally excluded from scope lines: Connect-Inforcer, Disconnect-Inforcer, Test-InforcerConnection (no scope-gated API call). Scope values sourced from the existing Cmdlet → Endpoints → Required Scopes table in docs/API-REFERENCE.md. 159/159 Pester tests pass. --- docs/CMDLET-REFERENCE.md | 2 +- module/Public/Compare-InforcerEnvironments.ps1 | 2 ++ module/Public/Export-InforcerTenantDocumentation.ps1 | 2 ++ module/Public/Get-InforcerAlignmentDetails.ps1 | 2 ++ module/Public/Get-InforcerAssessment.ps1 | 2 ++ module/Public/Get-InforcerAuditEvent.ps1 | 2 ++ module/Public/Get-InforcerBaseline.ps1 | 2 ++ module/Public/Get-InforcerGroup.ps1 | 2 ++ module/Public/Get-InforcerRole.ps1 | 2 ++ module/Public/Get-InforcerSupportedEventType.ps1 | 2 ++ module/Public/Get-InforcerTenant.ps1 | 2 ++ module/Public/Get-InforcerTenantPolicies.ps1 | 2 ++ module/Public/Get-InforcerUser.ps1 | 2 ++ module/Public/Invoke-InforcerAssessment.ps1 | 2 ++ 14 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index bf53dab..606b63b 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -104,7 +104,7 @@ When not connected, an error is written (e.g. "Not connected. To connect, run: C ## Get-InforcerTenant -Retrieves tenant information. Optionally filter by `-TenantId` (numeric ID, Microsoft Tenant ID GUID, or tenant name). Licenses are shown as a comma-separated string; PolicyDiff and PolicyDiffFormatted show policy change info when the API provides it. +Retrieves tenant information. Optionally filter by `-TenantId` (numeric ID, Microsoft Tenant ID GUID, or tenant name). Licenses are shown as a comma-separated string; `PolicyDiff` shows policy change info when the API provides it. **Endpoints called**: `GET /beta/tenants` **Required API scope(s)**: `Tenants.Read` diff --git a/module/Public/Compare-InforcerEnvironments.ps1 b/module/Public/Compare-InforcerEnvironments.ps1 index 97a9610..2c5da6d 100644 --- a/module/Public/Compare-InforcerEnvironments.ps1 +++ b/module/Public/Compare-InforcerEnvironments.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Compares the Intune policy configuration of two tenants and generates an HTML report. + + Required API scope(s): Tenants.Read + Baselines.Read + tenants.policies.Read .DESCRIPTION Fetches all policies from two tenants via Get-InforcerTenantPolicies, compares Intune Settings Catalog settings at the settingDefinitionId level, and produces a self-contained diff --git a/module/Public/Export-InforcerTenantDocumentation.ps1 b/module/Public/Export-InforcerTenantDocumentation.ps1 index b563ccd..3b6bd1e 100644 --- a/module/Public/Export-InforcerTenantDocumentation.ps1 +++ b/module/Public/Export-InforcerTenantDocumentation.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Generates comprehensive tenant documentation across all M365 products managed via Inforcer. + + Required API scope(s): Tenants.Read + Baselines.Read + tenants.policies.Read .DESCRIPTION Export-InforcerTenantDocumentation collects configuration data for the specified tenant by calling Get-InforcerBaseline, Get-InforcerTenant, and Get-InforcerTenantPolicies (each using diff --git a/module/Public/Get-InforcerAlignmentDetails.ps1 b/module/Public/Get-InforcerAlignmentDetails.ps1 index 697af88..4964a62 100644 --- a/module/Public/Get-InforcerAlignmentDetails.ps1 +++ b/module/Public/Get-InforcerAlignmentDetails.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves alignment scores or alignment details from the Inforcer API. + + Required API scope(s): Baselines.Read + AlignmentScores.Read + Tenants.Read + tenants.policies.Read .DESCRIPTION Without -BaselineId: retrieves alignment score summaries (table or raw). With -BaselineId: retrieves detailed alignment data including metrics, diff --git a/module/Public/Get-InforcerAssessment.ps1 b/module/Public/Get-InforcerAssessment.ps1 index 3b6f0e7..931d6f6 100644 --- a/module/Public/Get-InforcerAssessment.ps1 +++ b/module/Public/Get-InforcerAssessment.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves available assessments from the Inforcer API. + + Required API scope(s): Assessments.Read .DESCRIPTION Lists all assessments available for evaluation (e.g. Copilot Readiness, CIS Benchmarks, Essential Eight). Each assessment has an ID, name, description, tags, and type. diff --git a/module/Public/Get-InforcerAuditEvent.ps1 b/module/Public/Get-InforcerAuditEvent.ps1 index 84e85fc..7eb8506 100644 --- a/module/Public/Get-InforcerAuditEvent.ps1 +++ b/module/Public/Get-InforcerAuditEvent.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves audit events from the Inforcer API. + + Required API scope(s): Audit.Read (unconfirmed — see docs/API-REFERENCE.md) .DESCRIPTION POST /beta/auditEvents/search. When -DateFrom and -DateTo are omitted, uses a wide default range (10 years ago to now). When -EventType is not specified, uses all event types from the API (or fallback authentication, failedAuthentication). diff --git a/module/Public/Get-InforcerBaseline.ps1 b/module/Public/Get-InforcerBaseline.ps1 index fb723f6..7d2c05a 100644 --- a/module/Public/Get-InforcerBaseline.ps1 +++ b/module/Public/Get-InforcerBaseline.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves baseline information from the Inforcer API. + + Required API scope(s): Baselines.Read .DESCRIPTION Retrieves baseline groups and members. Optionally filter by -TenantId (owner or member). .PARAMETER Format diff --git a/module/Public/Get-InforcerGroup.ps1 b/module/Public/Get-InforcerGroup.ps1 index 52731a9..641939e 100644 --- a/module/Public/Get-InforcerGroup.ps1 +++ b/module/Public/Get-InforcerGroup.ps1 @@ -3,6 +3,8 @@ function Get-InforcerGroup { .SYNOPSIS Retrieves groups from an Inforcer tenant. + Required API scope(s): Tenants.Groups.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) + .DESCRIPTION Gets a list of groups or a single group from the Inforcer API. When called without -Group, returns all groups (GroupSummary objects) with optional filtering and auto-pagination. diff --git a/module/Public/Get-InforcerRole.ps1 b/module/Public/Get-InforcerRole.ps1 index 138ca24..b0e6be8 100644 --- a/module/Public/Get-InforcerRole.ps1 +++ b/module/Public/Get-InforcerRole.ps1 @@ -3,6 +3,8 @@ function Get-InforcerRole { .SYNOPSIS Retrieves directory role definitions from an Inforcer tenant. + Required API scope(s): Tenants.Roles.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) + .DESCRIPTION Gets the list of Entra ID directory role definitions for a tenant from the Inforcer API. Returns role definitions including display name, description, and whether the role is diff --git a/module/Public/Get-InforcerSupportedEventType.ps1 b/module/Public/Get-InforcerSupportedEventType.ps1 index 7a55624..df41cda 100644 --- a/module/Public/Get-InforcerSupportedEventType.ps1 +++ b/module/Public/Get-InforcerSupportedEventType.ps1 @@ -34,6 +34,8 @@ function Get-InforcerSupportedEventType { .SYNOPSIS Returns a list of available audit event types from the Inforcer API. + Required API scope(s): Audit.Read (unconfirmed — see docs/API-REFERENCE.md) + .DESCRIPTION Retrieves the valid event type names that can be used with Get-InforcerAuditEvent. This function is primarily used for tab completion of the -EventType parameter in Get-InforcerAuditEvent. diff --git a/module/Public/Get-InforcerTenant.ps1 b/module/Public/Get-InforcerTenant.ps1 index cb8e327..27ee93d 100644 --- a/module/Public/Get-InforcerTenant.ps1 +++ b/module/Public/Get-InforcerTenant.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves tenant information from the Inforcer API. + + Required API scope(s): Tenants.Read .DESCRIPTION Lists tenants. Optionally filter by -TenantId (numeric ID, Microsoft Tenant ID GUID, or tenant name). Output includes PascalCase aliases (e.g. ClientTenantId, TenantFriendlyName). When the API diff --git a/module/Public/Get-InforcerTenantPolicies.ps1 b/module/Public/Get-InforcerTenantPolicies.ps1 index 1a58a09..58921e7 100644 --- a/module/Public/Get-InforcerTenantPolicies.ps1 +++ b/module/Public/Get-InforcerTenantPolicies.ps1 @@ -41,6 +41,8 @@ function EnrichPolicyObject { <# .SYNOPSIS Retrieves policies for a tenant from the Inforcer API. + + Required API scope(s): tenants.policies.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) .DESCRIPTION Gets all policies for the specified tenant. TenantId accepts a numeric ID, GUID, or tenant name. Output is normalized to use PolicyName (from displayName or name) so properties are consistent across all rows. diff --git a/module/Public/Get-InforcerUser.ps1 b/module/Public/Get-InforcerUser.ps1 index 41c0ee6..7f63072 100644 --- a/module/Public/Get-InforcerUser.ps1 +++ b/module/Public/Get-InforcerUser.ps1 @@ -3,6 +3,8 @@ function Get-InforcerUser { .SYNOPSIS Retrieves users from an Inforcer tenant. + Required API scope(s): Tenants.Users.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) + .DESCRIPTION Gets a list of users or a single user by ID from the Inforcer API. When called without -UserId, returns all users (UserSummary objects) with optional search filtering and auto-pagination. diff --git a/module/Public/Invoke-InforcerAssessment.ps1 b/module/Public/Invoke-InforcerAssessment.ps1 index c07311f..ebe705d 100644 --- a/module/Public/Invoke-InforcerAssessment.ps1 +++ b/module/Public/Invoke-InforcerAssessment.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Runs an assessment against one or more tenants via the Inforcer API. + + Required API scope(s): Tenants.Read + Assessments.Read + Assessments.Run .DESCRIPTION Triggers an assessment run and returns detailed results including per-check scores, passes, violations, warnings, and framework metadata. From 95cce1e4d5d7ba99106abdbf75adf62dbd348cf4 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:59:06 +0200 Subject: [PATCH 02/22] feat: add Get-InforcerSecureScore + -User filter on audit events + Id alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New cmdlet Get-InforcerSecureScore - Wraps GET /beta/tenants/{tenantId}/secureScores — 90-day score history, per-category breakdown, and actionable control profiles with remediation. - Accepts numeric ID, GUID, or tenant name via -TenantId. - Supports -OutputType JsonObject. - PSTypeName InforcerCommunity.SecureScore with a ListControl view showing current/max/percentage plus counts of nested arrays. - Aliases: CurrentScore, CurrentScorePercentage, MaxScore, LicensedUserCount, EnabledServices, Scores, ControlProfiles, ControlCategoryScores. Nested arrays (Scores, ControlProfiles) get their own PascalCase aliases. Get-InforcerAuditEvent -User - New parameter that server-side filters audit events by the specified user. - Sends the value as the `user` field in the POST /beta/auditEvents/search body. Previously users had to fetch all events and filter client-side. AuditEvent.Id alias - AuditEvent objects now expose the raw `id` field as PascalCase `Id`, consistent with every other object type in the module. Docs / plumbing - README, CMDLET-REFERENCE, and API-REFERENCE updated (endpoint section, three new schemas, cmdlet-to-scope table row, TOC). - CHANGELOG entry for 0.6.0. - Module version bumped 0.5.0 → 0.6.0. - Consistency tests: expected count 20 → 21, new not-connected test, new SecureScore and AuditEvent alias tests, updated Get-InforcerAuditEvent param list. - scripts/Test-AllCmdlets.ps1: new case for Get-InforcerSecureScore. 162/162 Pester tests pass. --- CHANGELOG.md | 14 +++ README.md | 1 + Tests/Consistency.Tests.ps1 | 56 ++++++++++- docs/API-REFERENCE.md | 67 +++++++++++++ docs/CMDLET-REFERENCE.md | 52 +++++++++- module/InforcerCommunity.Format.ps1xml | 22 +++++ module/InforcerCommunity.psd1 | 3 +- .../Private/Add-InforcerPropertyAliases.ps1 | 46 ++++++++- module/Public/Get-InforcerAuditEvent.ps1 | 8 ++ module/Public/Get-InforcerSecureScore.ps1 | 95 +++++++++++++++++++ scripts/Test-AllCmdlets.ps1 | 3 + 11 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 module/Public/Get-InforcerSecureScore.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bbfc6c..eb6a36b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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; only breaking changes bump MAJOR; docs/tests/chore-only commits don't bump. There is intentionally no `[Unreleased]` section — every entry is dated at ship time. +## [0.6.0] - 2026-07-06 + +### Features + +- **New cmdlet: `Get-InforcerSecureScore`** — retrieves the current and historic Microsoft Secure Score for a tenant from `GET /beta/tenants/{tenantId}/secureScores`. Returns the current score, max score, licensed user count, enabled services, up to 90 days of daily score history, per-category scores, and actionable control profiles with remediation guidance. `-TenantId` accepts numeric ID, GUID, or tenant name (name/GUID resolution via `Resolve-InforcerTenantId`). Supports `-OutputType JsonObject`. Required scopes: `Tenants.SecureScores.Read` + `Tenants.Read` (only when `-TenantId` is a GUID or tenant name). PSTypeName `InforcerCommunity.SecureScore` with a ListControl default view showing current/max/percentage plus counts of nested arrays. +- **`Get-InforcerAuditEvent -User `** — new parameter that server-side filters audit events by the specified user (matches the `user` field in the `POST /beta/auditEvents/search` request body). Previously users had to fetch all events and filter client-side. +- **`Id` alias on audit event output** — `AuditEvent` objects now expose the raw `id` field as PascalCase `Id`, consistent with every other object type in the module. + +### Documentation + +- **`Required API scope(s):` line added to `Get-Help` output for 13 cmdlets** — `Compare-InforcerEnvironments`, `Export-InforcerTenantDocumentation`, `Get-InforcerAlignmentDetails`, `Get-InforcerAssessment`, `Get-InforcerAuditEvent`, `Get-InforcerBaseline`, `Get-InforcerGroup`, `Get-InforcerRole`, `Get-InforcerSupportedEventType`, `Get-InforcerTenant`, `Get-InforcerTenantPolicies`, `Get-InforcerUser`, `Invoke-InforcerAssessment`. Users no longer need to leave PowerShell and open `docs/API-REFERENCE.md` to learn which scope a cmdlet needs. Follows the pattern already used by the Reports cmdlets. +- **Removed stale `PolicyDiffFormatted` mention** in `docs/CMDLET-REFERENCE.md` — the property was removed from the module in an earlier version (see FINDINGS #63) but one line in the docs was missed. +- Added `Get-InforcerSecureScore` section to `docs/CMDLET-REFERENCE.md`; endpoint description, schemas (`TenantSecureScoreDetails`, `TenantSecureScoreHistoryPoint`, `TenantSecureScoreControlProfile`), and scope-mapping row added to `docs/API-REFERENCE.md`; cmdlet added to the README public-surface table. + ## [0.5.0] - 2026-06-30 ### Features diff --git a/README.md b/README.md index 0d21d24..661588c 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Disconnect-Inforcer | **Get-InforcerUser** | Retrieves users from a tenant (list/search or detail by UserId). | | **Get-InforcerGroup** | Retrieves Entra ID groups from a tenant (list/search or detail by GroupId). | | **Get-InforcerRole** | Retrieves Entra ID directory role definitions from a tenant. | +| **Get-InforcerSecureScore** | Retrieves the current and 90-day historic Microsoft Secure Score for a tenant, including per-category scores and actionable control profiles. | | **Export-InforcerTenantDocumentation** | Generates comprehensive tenant documentation in HTML, Markdown, or Excel format. | | **Compare-InforcerEnvironments** | Compares two tenants' Intune configuration and generates an interactive HTML comparison report. Supports baseline-scoped comparison via `-SourceBaselineId` / `-DestinationBaselineId` with automatic baseline owner resolution. | | **Get-InforcerAssessment** | Lists available assessments (CIS, Essential Eight, etc.). | diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index c807360..0271ce9 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -30,12 +30,12 @@ Describe 'Consistency contract' { $path = Get-InforcerCommunityManifestPath Import-Module $path -Force $script:exported = (Get-Module -Name 'InforcerCommunity').ExportedCommands.Keys - $script:expectedCount = 20 + $script:expectedCount = 21 $script:expectedNames = @( 'Connect-Inforcer', 'Disconnect-Inforcer', 'Test-InforcerConnection', 'Get-InforcerTenant', 'Get-InforcerBaseline', 'Get-InforcerTenantPolicies', 'Get-InforcerAlignmentDetails', 'Get-InforcerAuditEvent', 'Get-InforcerSupportedEventType', - 'Get-InforcerUser', 'Get-InforcerGroup', 'Get-InforcerRole', + 'Get-InforcerUser', 'Get-InforcerGroup', 'Get-InforcerRole', 'Get-InforcerSecureScore', 'Export-InforcerTenantDocumentation', 'Compare-InforcerEnvironments', 'Get-InforcerAssessment', 'Invoke-InforcerAssessment', 'Get-InforcerReportType', 'Invoke-InforcerReport', 'Get-InforcerReportRun', 'Save-InforcerReportOutput' @@ -48,11 +48,12 @@ Describe 'Consistency contract' { 'Get-InforcerBaseline' = @('Format', 'TenantId', 'OutputType') 'Get-InforcerTenantPolicies' = @('Format', 'TenantId', 'OutputType') 'Get-InforcerAlignmentDetails' = @('Format', 'TenantId', 'BaselineId', 'Tag', 'OutputType') - 'Get-InforcerAuditEvent' = @('EventType', 'DateFrom', 'DateTo', 'PageSize', 'MaxResults', 'Format', 'OutputType') + 'Get-InforcerAuditEvent' = @('EventType', 'DateFrom', 'DateTo', 'User', 'PageSize', 'MaxResults', 'Format', 'OutputType') 'Get-InforcerSupportedEventType' = @() 'Get-InforcerUser' = @('Format', 'TenantId', 'Search', 'MaxResults', 'UserId', 'OutputType') 'Get-InforcerGroup' = @('TenantId', 'Search', 'Filter', 'MaxResults', 'Group', 'OutputType') 'Get-InforcerRole' = @('TenantId', 'OutputType') + 'Get-InforcerSecureScore' = @('TenantId', 'OutputType') 'Export-InforcerTenantDocumentation' = @('Format', 'TenantId', 'OutputPath', 'SettingsCatalogPath', 'FetchGraphData', 'Baseline', 'Tag') 'Compare-InforcerEnvironments' = @('SourceTenantId', 'DestinationTenantId', 'SourceSession', 'DestinationSession', 'SourceBaselineId', 'DestinationBaselineId', 'IncludingAssignments', 'SettingsCatalogPath', 'FetchGraphData', 'ExcludeOS', 'PolicyNameFilter', 'OutputPath') 'Get-InforcerAssessment' = @('Format', 'OutputType') @@ -84,7 +85,7 @@ Describe 'Consistency contract' { } It 'Get-* cmdlets that return API data have -OutputType' { - $getCmdlets = @('Get-InforcerTenant', 'Get-InforcerBaseline', 'Get-InforcerTenantPolicies', 'Get-InforcerAlignmentDetails', 'Get-InforcerAuditEvent', 'Get-InforcerUser', 'Get-InforcerGroup', 'Get-InforcerRole') + $getCmdlets = @('Get-InforcerTenant', 'Get-InforcerBaseline', 'Get-InforcerTenantPolicies', 'Get-InforcerAlignmentDetails', 'Get-InforcerAuditEvent', 'Get-InforcerUser', 'Get-InforcerGroup', 'Get-InforcerRole', 'Get-InforcerSecureScore') foreach ($name in $getCmdlets) { $cmd = Get-Command -Name $name -ErrorAction Stop $cmd.Parameters.Keys | Should -Contain 'OutputType' @@ -195,6 +196,12 @@ Describe 'No-silent-failure contract' { $err | Should -Not -BeNullOrEmpty -Because 'should report not connected, not return silence' } + It 'Get-InforcerSecureScore produces an error when not connected' { + $err = $null + Get-InforcerSecureScore -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -Not -BeNullOrEmpty -Because 'should report not connected, not return silence' + } + It 'Export-InforcerTenantDocumentation produces an error when not connected' { $err = $null Export-InforcerTenantDocumentation -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue @@ -782,6 +789,47 @@ Describe 'Private helpers (via module scope)' { $role.IsPrivileged | Should -BeTrue } } + + It 'SecureScore: adds top-level and nested aliases' { + & (Get-Module InforcerCommunity) { + $secure = [PSCustomObject]@{ + currentScore = 412.5 + currentScorePercentage = 64.45 + maxScore = 640 + licensedUserCount = 275 + enabledServices = @('AzureAD','Exchange') + scores = @( + [PSCustomObject]@{ createdDateTime = '2026-07-01'; currentScore = 410; maxScore = 640 } + ) + controlProfiles = @( + [PSCustomObject]@{ id = 'mfa-admins'; title = 'Require MFA for admins'; controlCategory = 'Identity'; currentScore = 0; maxScore = 10; scoreDifference = 10 } + ) + } + $null = Add-InforcerPropertyAliases -InputObject $secure -ObjectType SecureScore + $secure.CurrentScore | Should -Be 412.5 + $secure.MaxScore | Should -Be 640 + $secure.LicensedUserCount | Should -Be 275 + $secure.EnabledServices | Should -Be @('AzureAD','Exchange') + $secure.Scores[0].CreatedDateTime | Should -Be '2026-07-01' + $secure.Scores[0].CurrentScore | Should -Be 410 + $secure.ControlProfiles[0].Title | Should -Be 'Require MFA for admins' + $secure.ControlProfiles[0].ScoreDifference | Should -Be 10 + } + } + + It 'AuditEvent: adds Id alias' { + & (Get-Module InforcerCommunity) { + $ev = [PSCustomObject]@{ + id = 'e1a5-1234' + eventType = 'authentication' + timestamp = '2026-07-01T00:00:00Z' + user = 'admin@contoso.com' + } + $null = Add-InforcerPropertyAliases -InputObject $ev -ObjectType AuditEvent + $ev.Id | Should -Be 'e1a5-1234' + $ev.EventType | Should -Be 'authentication' + } + } } Context 'Filter-InforcerResponse' { diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index d343638..69b8ac6 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -19,6 +19,7 @@ This document describes the Inforcer REST API endpoints, schemas, and response s - [Users](#users) - [Groups](#groups) - [Roles](#roles) + - [Secure Scores](#secure-scores) - [Reports](#reports) - [Schemas](#schemas) - [BaselineGroup](#baselinegroup) @@ -39,6 +40,9 @@ This document describes the Inforcer REST API endpoints, schemas, and response s - [TenantGroupSummary](#tenantgroupsummary) - [TenantGroup](#tenantgroup) - [TenantRole](#tenantrole) + - [TenantSecureScoreDetails](#tenantsecurescoredetails) + - [TenantSecureScoreHistoryPoint](#tenantsecurescorehistorypoint) + - [TenantSecureScoreControlProfile](#tenantsecurescorecontrolprofile) - [ReportType](#reporttype) - [ReportRun](#reportrun) - [ReportOutput](#reportoutput) @@ -95,6 +99,7 @@ Built mechanically by tracing each public cmdlet through the module (including t | `Get-InforcerUser` | `GET /beta/tenants/{tenantId}/users[/...] ` + `GET /beta/tenants` *(GUID/name lookup)* | `Tenants.Users.Read` + `Tenants.Read`† | | `Get-InforcerGroup` | `GET /beta/tenants/{tenantId}/groups[/...] ` + `GET /beta/tenants` *(GUID/name lookup)* | `Tenants.Groups.Read` + `Tenants.Read`† | | `Get-InforcerRole` | `GET /beta/tenants/{tenantId}/roles` + `GET /beta/tenants` *(GUID/name lookup)* | `Tenants.Roles.Read` + `Tenants.Read`† | +| `Get-InforcerSecureScore` | `GET /beta/tenants/{tenantId}/secureScores` + `GET /beta/tenants` *(GUID/name lookup)* | `Tenants.SecureScores.Read` + `Tenants.Read`† | | `Get-InforcerAssessment` | `GET /beta/assessments` | `Assessments.Read` | | `Invoke-InforcerAssessment` | `GET /beta/tenants`, `GET /beta/assessments`, `POST /beta/tenants/{tenantId}/assessments/{assessmentId}/runs` | `Tenants.Read` + `Assessments.Read` + `Assessments.Run` | | `Export-InforcerTenantDocumentation` | `GET /beta/tenants`, `GET /beta/baselines`, `GET /beta/tenants/{tenantId}/policies` | `Tenants.Read` + `Baselines.Read` + `tenants.policies.Read` | @@ -311,6 +316,20 @@ Returns the list of Entra ID directory role definitions for a tenant. **Response**: Array of [TenantRole](#tenantrole) objects. +### Secure Scores + +#### `GET /beta/tenants/{tenantId}/secureScores` + +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`) + +| Parameter | In | Type | Required | Description | +|-----------|----|------|----------|-------------| +| `tenantId` | path | integer | Yes | Inforcer tenant ID. | + +**Response**: A single [TenantSecureScoreDetails](#tenantsecurescoredetails) object. + ### Reports Beta endpoints for triggering and retrieving asynchronous report runs. @@ -762,6 +781,54 @@ An Entra ID directory role definition. **PSTypeName:** `InforcerCommunity.Role` +### TenantSecureScoreDetails + +Top-level response object for `GET /beta/tenants/{tenantId}/secureScores`. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| currentScore | number | Yes | Current Microsoft Secure Score. | +| currentScorePercentage | number | No | Current score as a percentage of `maxScore`. | +| maxScore | number | Yes | Maximum possible Secure Score. | +| licensedUserCount | integer | No | Number of licensed users. | +| enabledServices | array\ | No | Services enabled for this tenant (e.g. `AzureAD`, `Exchange`). | +| scores | array\<[TenantSecureScoreHistoryPoint](#tenantsecurescorehistorypoint)\> | No | Up to 90 days of daily score history. | +| controlProfiles | array\<[TenantSecureScoreControlProfile](#tenantsecurescorecontrolprofile)\> | No | Actionable control profiles with remediation guidance. | +| controlCategoryScores | array\ | No | Per-category scores and history (e.g. Identity, Data, Device). | + +**PSTypeName:** `InforcerCommunity.SecureScore` + +### TenantSecureScoreHistoryPoint + +Single daily score entry in the `scores` array of `TenantSecureScoreDetails`. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| createdDateTime | string (datetime) | Yes | Date the score was recorded (UTC). | +| currentScore | number | Yes | Score at this date. | +| currentScorePercentage | number | No | Score expressed as a percentage. | +| maxScore | number | Yes | Maximum possible score at this date. | + +### TenantSecureScoreControlProfile + +Actionable control profile in the `controlProfiles` array of `TenantSecureScoreDetails`. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| id | string | Yes | Control profile identifier. | +| title | string | Yes | Human-readable title. | +| controlCategory | string | No | Category (e.g. Identity, Data). | +| service | string | No | Associated service (e.g. Azure AD, Exchange). | +| currentScore | number | No | Current score for this control. | +| currentScorePercentage | number | No | Current score as a percentage. | +| maxScore | number | No | Maximum score for this control. | +| maxScorePercentage | number | No | Maximum score as a percentage. | +| scoreDifference | number | No | Gap between current and max score (higher = larger remediation opportunity). | +| scoreDifferencePercentage | number | No | Score difference as a percentage. | +| remediation | string | No | Recommended remediation steps. | +| remediationImpact | string | No | Impact description of the remediation. | +| actionUrl | string | No | URL to take the recommended action. | + ### ReportType A catalog entry from `GET /beta/reports/types`. Field names verified against `api-uk.inforcer.com`. diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index 606b63b..d04bacc 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -324,7 +324,7 @@ A JSON string (array of objects) with properties such as `tenantId`, `tenantFrie ## Get-InforcerAuditEvent -Retrieves audit events from the Inforcer API. Supports optional `-EventType`, `-DateFrom`, `-DateTo`, `-PageSize`, and `-MaxResults`. +Retrieves audit events from the Inforcer API. Supports optional `-EventType`, `-DateFrom`, `-DateTo`, `-User`, `-PageSize`, and `-MaxResults`. **Endpoints called**: `POST /beta/auditEvents/search` **Required API scope(s)**: `Audit.Read` *(not in the API team's published scope→route mapping — the route `/beta/auditEvents/search` is unmapped; `Audit.Read` is the assumed scope based on naming. Confirm with Inforcer API team.)* @@ -336,6 +336,7 @@ Retrieves audit events from the Inforcer API. Supports optional `-EventType`, `- | **EventType** | String[] | No | Event types to include. Tab completion with supported event types. Omit for all types. | | **DateFrom** | DateTime | No | Start of date/time range (inclusive). | | **DateTo** | DateTime | No | End of date/time range (inclusive). | +| **User** | String | No | Filter events server-side by this user (matches the `user` field in the API request body). | | **PageSize** | Int | No | Page size per API request. Default: 100. | | **MaxResults** | Int | No | Max events to return. 0 = no limit. Default: 0. | | **Format** | String | No | `Raw` (default). | @@ -347,6 +348,7 @@ Retrieves audit events from the Inforcer API. Supports optional `-EventType`, `- Get-InforcerAuditEvent Get-InforcerAuditEvent -DateFrom (Get-Date).AddDays(-7) -DateTo (Get-Date) Get-InforcerAuditEvent -EventType authentication,failedAuthentication -DateFrom $from -DateTo $to +Get-InforcerAuditEvent -User admin@contoso.com -DateFrom (Get-Date).AddDays(-30) Get-InforcerAuditEvent -OutputType JsonObject ``` @@ -540,6 +542,54 @@ IsPrivileged : True --- +## Get-InforcerSecureScore + +Retrieves the current and historic Microsoft Secure Score for a tenant, including up to 90 days of daily score history, per-category breakdown, and actionable control profiles with remediation guidance. + +**Endpoints called**: `GET /beta/tenants/{tenantId}/secureScores` — plus `GET /beta/tenants` when `-TenantId` is a GUID or name (resolution). +**Required API scope(s)**: `Tenants.SecureScores.Read` + `Tenants.Read` *(the `Tenants.Read` part can be skipped if `-TenantId` is always passed as a numeric Client Tenant ID — no lookup needed)* + +**Output schema**: [TenantSecureScore](./API-REFERENCE.md#tenantsecurescoredetails) — includes `Scores` (90-day history), `ControlProfiles` (actionable recommendations), and `ControlCategoryScores`. + +| Parameter | Type | Mandatory | Description | +|-----------|------|-----------|--------------| +| **TenantId** | Object | Yes | Inforcer tenant ID (numeric ID, GUID, or tenant name). Alias: `ClientTenantId`. | +| **OutputType** | String | No | `PowerShellObject` (default) or `JsonObject`. | + +### Examples + +```powershell +# Summary view for one tenant +Get-InforcerSecureScore -TenantId 139 + +# 90-day score history +(Get-InforcerSecureScore -TenantId 139).Scores + +# Top 10 controls with the biggest score gap (highest-value remediation targets) +(Get-InforcerSecureScore -TenantId 139).ControlProfiles | + Sort-Object ScoreDifference -Descending | + Select-Object Title, Service, CurrentScore, MaxScore, Remediation -First 10 + +# Pipeline from Get-InforcerTenant +Get-InforcerTenant -TenantId 139 | Get-InforcerSecureScore +``` + +### Example output (default list view) + +``` +CurrentScore : 412.5 +MaxScore : 640 +CurrentScorePercentage : 64.45 +LicensedUserCount : 275 +EnabledServices : AzureAD, Exchange, SharePoint, Defender +ScoreHistoryDays : 90 +ControlProfilesCount : 128 +``` + +Use `| Select-Object *` to see all properties including `Scores`, `ControlProfiles`, and `ControlCategoryScores`. + +--- + ## Export-InforcerTenantDocumentation Generates comprehensive, human-readable documentation of an entire M365 tenant's configuration as managed through the Inforcer API. Pulls data from existing cmdlets (`Get-InforcerBaseline`, `Get-InforcerTenant`, `Get-InforcerTenantPolicies`), resolves Intune Settings Catalog settingDefinitionIDs to friendly names, and outputs in multiple formats. diff --git a/module/InforcerCommunity.Format.ps1xml b/module/InforcerCommunity.Format.ps1xml index 1fbfbda..b955b10 100644 --- a/module/InforcerCommunity.Format.ps1xml +++ b/module/InforcerCommunity.Format.ps1xml @@ -582,5 +582,27 @@ + + InforcerCommunity.SecureScore.Default + + InforcerCommunity.SecureScore + + + + + + CurrentScore + MaxScore + CurrentScorePercentage + LicensedUserCount + if ($_.enabledServices) { ($_.enabledServices) -join ', ' } else { '' } + if ($_.scores) { @($_.scores).Count } else { 0 } + if ($_.controlProfiles) { @($_.controlProfiles).Count } else { 0 } + + + + + + diff --git a/module/InforcerCommunity.psd1 b/module/InforcerCommunity.psd1 index 9f578f8..7909567 100644 --- a/module/InforcerCommunity.psd1 +++ b/module/InforcerCommunity.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'InforcerCommunity.psm1' - ModuleVersion = '0.5.0' + ModuleVersion = '0.6.0' 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.' @@ -21,6 +21,7 @@ 'Get-InforcerUser' 'Get-InforcerGroup' 'Get-InforcerRole' + 'Get-InforcerSecureScore' 'Export-InforcerTenantDocumentation' 'Compare-InforcerEnvironments' 'Get-InforcerAssessment' diff --git a/module/Private/Add-InforcerPropertyAliases.ps1 b/module/Private/Add-InforcerPropertyAliases.ps1 index c02e599..c06ec39 100644 --- a/module/Private/Add-InforcerPropertyAliases.ps1 +++ b/module/Private/Add-InforcerPropertyAliases.ps1 @@ -17,7 +17,7 @@ function Add-InforcerPropertyAliases { [object]$InputObject, [Parameter(Mandatory = $true)] - [ValidateSet('Tenant', 'Baseline', 'Policy', 'AlignmentScore', 'AlignmentDetail', 'AuditEvent', 'UserSummary', 'User', 'GroupSummary', 'Group', 'Role', 'Assessment', 'ReportType', 'ReportRun', 'ReportOutput')] + [ValidateSet('Tenant', 'Baseline', 'Policy', 'AlignmentScore', 'AlignmentDetail', 'AuditEvent', 'UserSummary', 'User', 'GroupSummary', 'Group', 'Role', 'Assessment', 'ReportType', 'ReportRun', 'ReportOutput', 'SecureScore')] [string]$ObjectType ) @@ -177,6 +177,7 @@ function Add-InforcerPropertyAliases { } } 'AuditEvent' { + AddAliasIfExists $obj 'Id' 'id' AddAliasIfExists $obj 'CorrelationId' 'correlationId' AddAliasIfExists $obj 'ClientId' 'clientId' AddAliasIfExists $obj 'RelType' 'relType' @@ -386,6 +387,49 @@ function Add-InforcerPropertyAliases { AddAliasIfExists $obj 'OutputFormat' 'format' AddAliasIfExists $obj 'FileSize' 'sizeBytes' } + 'SecureScore' { + AddAliasIfExists $obj 'CurrentScore' 'currentScore' + AddAliasIfExists $obj 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $obj 'MaxScore' 'maxScore' + AddAliasIfExists $obj 'LicensedUserCount' 'licensedUserCount' + AddAliasIfExists $obj 'EnabledServices' 'enabledServices' + AddAliasIfExists $obj 'Scores' 'scores' + AddAliasIfExists $obj 'ControlProfiles' 'controlProfiles' + AddAliasIfExists $obj 'ControlCategoryScores' 'controlCategoryScores' + # Nested historic score points + $scoresProp = $obj.PSObject.Properties['scores'] + if ($scoresProp -and $null -ne $scoresProp.Value) { + foreach ($s in @($scoresProp.Value)) { + if ($s -is [PSObject]) { + AddAliasIfExists $s 'CreatedDateTime' 'createdDateTime' + AddAliasIfExists $s 'CurrentScore' 'currentScore' + AddAliasIfExists $s 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $s 'MaxScore' 'maxScore' + } + } + } + # Nested control profiles + $cpProp = $obj.PSObject.Properties['controlProfiles'] + if ($cpProp -and $null -ne $cpProp.Value) { + foreach ($c in @($cpProp.Value)) { + if ($c -is [PSObject]) { + AddAliasIfExists $c 'Id' 'id' + AddAliasIfExists $c 'Title' 'title' + AddAliasIfExists $c 'ControlCategory' 'controlCategory' + AddAliasIfExists $c 'Service' 'service' + AddAliasIfExists $c 'CurrentScore' 'currentScore' + AddAliasIfExists $c 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $c 'MaxScore' 'maxScore' + AddAliasIfExists $c 'MaxScorePercentage' 'maxScorePercentage' + AddAliasIfExists $c 'ScoreDifference' 'scoreDifference' + AddAliasIfExists $c 'ScoreDifferencePercentage' 'scoreDifferencePercentage' + AddAliasIfExists $c 'Remediation' 'remediation' + AddAliasIfExists $c 'RemediationImpact' 'remediationImpact' + AddAliasIfExists $c 'ActionUrl' 'actionUrl' + } + } + } + } } $obj diff --git a/module/Public/Get-InforcerAuditEvent.ps1 b/module/Public/Get-InforcerAuditEvent.ps1 index 7eb8506..eaff72c 100644 --- a/module/Public/Get-InforcerAuditEvent.ps1 +++ b/module/Public/Get-InforcerAuditEvent.ps1 @@ -18,6 +18,8 @@ Start of date/time range (inclusive). Omit with DateTo = 10 years ago to now; if only DateTo set = 30 days before DateTo. .PARAMETER DateTo End of date/time range (inclusive). Omit with DateFrom = now (UTC). +.PARAMETER User + Filter events server-side by this user (matches the `user` field in the API request body). .PARAMETER PageSize Page size per API request. Default 100. .PARAMETER MaxResults @@ -111,6 +113,9 @@ param( [Parameter(Mandatory = $false)] [DateTime]$DateTo, + [Parameter(Mandatory = $false)] + [string]$User, + [Parameter(Mandatory = $false)] [int]$PageSize = 100, @@ -181,6 +186,9 @@ foreach ($batch in $typeBatches) { dateTo = $dateToStr pageSize = $PageSize } + if ($User) { + $bodyObj['user'] = $User + } if ($continuationToken) { $bodyObj['continuationToken'] = $continuationToken } diff --git a/module/Public/Get-InforcerSecureScore.ps1 b/module/Public/Get-InforcerSecureScore.ps1 new file mode 100644 index 0000000..d880144 --- /dev/null +++ b/module/Public/Get-InforcerSecureScore.ps1 @@ -0,0 +1,95 @@ +function Get-InforcerSecureScore { + <# + .SYNOPSIS + Retrieves the current and historic secure score for an Inforcer tenant. + + Required API scope(s): Tenants.SecureScores.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) + + .DESCRIPTION + Gets secure score data for a tenant from GET /beta/tenants/{tenantId}/secureScores. + Returns the current score, max score, licensed user count, enabled services, up to + 90 days of daily score history, per-category scores, and actionable control profiles + with remediation guidance. + + .PARAMETER TenantId + The Inforcer tenant ID. Accepts numeric ID, GUID, or tenant name. Supports pipeline input. + + .PARAMETER OutputType + Output type: 'PowerShellObject' (default) or 'JsonObject'. + + .EXAMPLE + Get-InforcerSecureScore -TenantId 139 + + Returns the secure score summary for tenant 139. + + .EXAMPLE + (Get-InforcerSecureScore -TenantId 139).Scores + + Returns the 90-day daily score history. + + .EXAMPLE + (Get-InforcerSecureScore -TenantId 139).ControlProfiles | Sort-Object ScoreDifference -Descending | Select-Object -First 10 + + Lists the 10 controls with the largest gap between current and max score. + + .EXAMPLE + Get-InforcerTenant -TenantId 139 | Get-InforcerSecureScore + + Retrieves secure scores for the piped tenant. + + .EXAMPLE + Get-InforcerSecureScore -TenantId 139 -OutputType JsonObject + + Returns the response as a JSON string. + + .OUTPUTS + PSObject or String (when -OutputType JsonObject) + + .LINK + https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#get-inforcersecurescore + + .LINK + Connect-Inforcer + #> + [CmdletBinding()] + [OutputType([PSObject], [string])] + param( + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [Alias('ClientTenantId')] + [object]$TenantId, + + [Parameter(Mandatory = $false)] + [ValidateSet('PowerShellObject', 'JsonObject')] + [string]$OutputType = 'PowerShellObject' + ) + + process { + if (-not (Test-InforcerSession)) { + Write-Error -Message "Not connected to Inforcer. Use Connect-Inforcer first." -ErrorId 'NotConnected' -Category ConnectionError + return + } + + try { + $resolvedTenantId = Resolve-InforcerTenantId -TenantId $TenantId + } catch { + Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument + return + } + + $endpoint = "/beta/tenants/$resolvedTenantId/secureScores" + Write-Verbose "Retrieving secure score for tenant $resolvedTenantId..." + + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject + if ($null -eq $response) { return } + + if ($OutputType -eq 'JsonObject') { + return ($response | ConvertTo-Json -Depth 100) + } + + if ($response -is [PSObject]) { + $null = Add-InforcerPropertyAliases -InputObject $response -ObjectType SecureScore + $response.PSObject.TypeNames.Insert(0, 'InforcerCommunity.SecureScore') + } + $response + } +} diff --git a/scripts/Test-AllCmdlets.ps1 b/scripts/Test-AllCmdlets.ps1 index b131809..15e4989 100644 --- a/scripts/Test-AllCmdlets.ps1 +++ b/scripts/Test-AllCmdlets.ps1 @@ -57,6 +57,9 @@ foreach ($name in $exported) { 'Get-InforcerRole' { $out = & $name -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue } + 'Get-InforcerSecureScore' { + $out = & $name -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue + } 'Get-InforcerSupportedEventType' { $out = & $name -ErrorVariable err -ErrorAction SilentlyContinue } From 31ac0cf3be2eb5ddbb0b822bad5c52a124ec52bb Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:37:59 +0200 Subject: [PATCH 03/22] fix: correct response-shape and aliasing bugs found by live-API verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two `Invoke-InforcerApiRequest` callers were missing `-PreserveStructure`, causing the helper's "if .data is a single object with an array property, unwrap to that array" convenience to return the wrong shape for single-object endpoints: - `Get-InforcerSecureScore`: was returning the 90-item `scores` array instead of the top-level `tenantSecureScoreDetails` object. PSTypeName was never applied. Live-verified fix: 90/90 assertions pass, aliases populated on the top-level object plus all three nested arrays. - `Get-InforcerUser -UserId`: same bug pattern — was returning one of the many nested arrays (groups, roles, devices, assignedLicenses, ...) instead of the user object. Live-verified fix. Also: - `SecureScore` aliasing now covers `controlCategoryScores` and its inner `historicScores` array (previously only top-level, `.Scores`, and `.ControlProfiles` were aliased). - `-EventType` tab completion is now dynamic — reads `$global:InforcerCachedEventTypes` (populated by `Get-InforcerSupportedEventType` at module import, refreshed on every authenticated call). New server-side event types show up automatically without a module release. Static fallback list updated to today's live 80 types (from 57). - `ReleaseNotes` in the psd1 manifest updated for 0.6.0 (was still describing 0.5.0). - CHANGELOG entry extended to cover all of the above. 162/162 Pester pass. Live-API verification against uk.inforcer.com: 85 assertions pass, 5 failures are all bugs in the verification harness (hashtable-splat mistakes), not the module. --- CHANGELOG.md | 2 + module/InforcerCommunity.psd1 | 2 +- .../Private/Add-InforcerPropertyAliases.ps1 | 25 ++++++++++ module/Public/Get-InforcerAuditEvent.ps1 | 33 ++++--------- module/Public/Get-InforcerSecureScore.ps1 | 7 ++- .../Public/Get-InforcerSupportedEventType.ps1 | 46 +++++++++++++++---- module/Public/Get-InforcerUser.ps1 | 5 +- 7 files changed, 83 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6a36b..19f6a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). - **New cmdlet: `Get-InforcerSecureScore`** — retrieves the current and historic Microsoft Secure Score for a tenant from `GET /beta/tenants/{tenantId}/secureScores`. Returns the current score, max score, licensed user count, enabled services, up to 90 days of daily score history, per-category scores, and actionable control profiles with remediation guidance. `-TenantId` accepts numeric ID, GUID, or tenant name (name/GUID resolution via `Resolve-InforcerTenantId`). Supports `-OutputType JsonObject`. Required scopes: `Tenants.SecureScores.Read` + `Tenants.Read` (only when `-TenantId` is a GUID or tenant name). PSTypeName `InforcerCommunity.SecureScore` with a ListControl default view showing current/max/percentage plus counts of nested arrays. - **`Get-InforcerAuditEvent -User `** — new parameter that server-side filters audit events by the specified user (matches the `user` field in the `POST /beta/auditEvents/search` request body). Previously users had to fetch all events and filter client-side. - **`Id` alias on audit event output** — `AuditEvent` objects now expose the raw `id` field as PascalCase `Id`, consistent with every other object type in the module. +- **Dynamic `-EventType` tab completion** — the completer on `Get-InforcerAuditEvent -EventType` now reads `$global:InforcerCachedEventTypes`, which `Get-InforcerSupportedEventType` refreshes with the live server-side list on any authenticated call. New event types the API adds appear in tab completion automatically after the next `Get-InforcerSupportedEventType` call — no module release required. A static fallback list (currently 80 event types) covers the pre-connect case. +- **`Get-InforcerSecureScore` nested aliasing extended** — `.ControlCategoryScores` and its inner `.historicScores` now get PascalCase aliases too (previously only top-level, `.Scores`, and `.ControlProfiles` were aliased). ### Documentation diff --git a/module/InforcerCommunity.psd1 b/module/InforcerCommunity.psd1 index 7909567..e42deff 100644 --- a/module/InforcerCommunity.psd1 +++ b/module/InforcerCommunity.psd1 @@ -38,7 +38,7 @@ PSData = @{ ProjectUri = 'https://github.com/royklo/InforcerCommunity' LicenseUri = 'https://github.com/royklo/InforcerCommunity/blob/main/LICENSE' - ReleaseNotes = 'v0.5.0: New Reports API cmdlets (Get-InforcerReportType, Invoke-InforcerReport, Get-InforcerReportRun, Save-InforcerReportOutput) wrapping /beta/reports/*. Sync+save default, -NoWait/-NoSave modes, -Open switch to launch saved files via OS default handler with extension allowlist, pipeline-bindable catalog objects, three-phase Write-Progress, and proper Content-Disposition parsing. Connect-Inforcer accepts any valid API key regardless of scope (envelope-shape detection) and best-effort primes the Reports catalog cache for instant TAB completion. Invoke-InforcerApiRequest now handles all three error envelope shapes (app-layer, APIM gateway, RFC 9110 ProblemDetails), captures x-correlation-id, and surfaces structured errors[] field details so validation failures tell you which field actually broke. Dynamic -AssessmentId completer with three-state UX (not connected / scope denied / connected) caching denial on 401 and 403. Breaking: Invoke-InforcerReport ConfirmImpact bumped from Low to Medium. See CHANGELOG.md for full details.' + ReleaseNotes = 'v0.6.0: New cmdlet Get-InforcerSecureScore wraps GET /beta/tenants/{tenantId}/secureScores — current + 90-day daily score history, per-category scores, and actionable control profiles with remediation. Get-InforcerAuditEvent gains a -User parameter for server-side filtering and now exposes the raw event id as PascalCase Id. -EventType tab completion is now dynamic — reads $global:InforcerCachedEventTypes refreshed by Get-InforcerSupportedEventType, so new server-side event types show up automatically without a module release. Required API scope(s) documentation added to Get-Help output for 13 cmdlets that were missing it. Stale PolicyDiffFormatted mention removed from CMDLET-REFERENCE. See CHANGELOG.md for full details.' Tags = @('Inforcer', 'API', 'Community') } } diff --git a/module/Private/Add-InforcerPropertyAliases.ps1 b/module/Private/Add-InforcerPropertyAliases.ps1 index c06ec39..a3b01bf 100644 --- a/module/Private/Add-InforcerPropertyAliases.ps1 +++ b/module/Private/Add-InforcerPropertyAliases.ps1 @@ -429,6 +429,31 @@ function Add-InforcerPropertyAliases { } } } + # Nested per-category scores + $ccsProp = $obj.PSObject.Properties['controlCategoryScores'] + if ($ccsProp -and $null -ne $ccsProp.Value) { + foreach ($cc in @($ccsProp.Value)) { + if ($cc -is [PSObject]) { + AddAliasIfExists $cc 'ControlCategory' 'controlCategory' + AddAliasIfExists $cc 'CurrentScore' 'currentScore' + AddAliasIfExists $cc 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $cc 'MaxScore' 'maxScore' + AddAliasIfExists $cc 'HistoricScores' 'historicScores' + # Historic score points inside each category + $hsProp = $cc.PSObject.Properties['historicScores'] + if ($hsProp -and $null -ne $hsProp.Value) { + foreach ($h in @($hsProp.Value)) { + if ($h -is [PSObject]) { + AddAliasIfExists $h 'CreatedDateTime' 'createdDateTime' + AddAliasIfExists $h 'CurrentScore' 'currentScore' + AddAliasIfExists $h 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $h 'MaxScore' 'maxScore' + } + } + } + } + } + } } } diff --git a/module/Public/Get-InforcerAuditEvent.ps1 b/module/Public/Get-InforcerAuditEvent.ps1 index eaff72c..33f63f6 100644 --- a/module/Public/Get-InforcerAuditEvent.ps1 +++ b/module/Public/Get-InforcerAuditEvent.ps1 @@ -69,30 +69,15 @@ param( $prefix = ($prefix -split ',' | ForEach-Object { $_.Trim() })[-1] } $prefix = $prefix.Trim() - # Inline list so completer never depends on script scope (avoids path completion fallback) - $types = @( - 'alertRuleCreate', 'alertRuleDelete', 'alertRuleUpdate', - 'apiKeyCreate', 'apiKeyDelete', 'apiKeyUsage', - 'authentication', - 'clientAdminUpdated', 'clientCreated', 'clientLicenseUpdate', 'clientStatusChanged', - 'copilotAssessmentFailure', 'copilotAssessmentRun', 'copilotAssessmentSuccess', - 'failedAuthentication', - 'policiesDelete', 'policiesDeployment', 'policiesRename', 'policiesRestore', - 'reportQueued', - 'salesAdminUpdated', - 'scheduleCreate', 'scheduleDelete', 'scheduleUpdate', - 'sharedBaselinesManaged', - 'supportAccessInvoke', - 'tenantAssessmentFailure', 'tenantAssessmentRun', 'tenantAssessmentSuccess', - 'tenantDelete', 'tenantGroupCreate', 'tenantGroupMembershipsModified', 'tenantGroupUpdate', 'tenantGroupsDeployment', - 'tenantLicenseUpdate', 'tenantOnboard', 'tenantRefresh', - 'tenantUserCreate', 'tenantUserGroupMembershipModified', 'tenantUserLicensesModified', - 'tenantUserOffboardingQueued', 'tenantUserResetMfa', 'tenantUserResetPassword', - 'tenantUserRevokedSessions', 'tenantUserUpdate', - 'userAutoProvision', 'userCreate', 'userDelete', - 'userGroupCreate', 'userGroupDelete', 'userGroupMembershipModified', 'userGroupUpdate', - 'userResetMfa', 'userResetPassword', 'userToggleEnable', 'userToggleSso' - ) + # Dynamic: read $global:InforcerCachedEventTypes populated by Get-InforcerSupportedEventType.ps1 + # at module import (static fallback) and refreshed with live data after any authenticated + # Get-InforcerSupportedEventType call. Two-item minimum fallback covers the case where a user + # somehow imports only Get-InforcerAuditEvent without the sibling file. + $types = if ($global:InforcerCachedEventTypes -and $global:InforcerCachedEventTypes.Count -gt 0) { + $global:InforcerCachedEventTypes + } else { + @('authentication', 'failedAuthentication') + } $filterByPrefix = $prefix -and $prefix -notmatch '^[./\\]' if ($filterByPrefix) { $filtered = @($types | Where-Object { $_ -like "$prefix*" }) diff --git a/module/Public/Get-InforcerSecureScore.ps1 b/module/Public/Get-InforcerSecureScore.ps1 index d880144..06f921b 100644 --- a/module/Public/Get-InforcerSecureScore.ps1 +++ b/module/Public/Get-InforcerSecureScore.ps1 @@ -79,7 +79,12 @@ function Get-InforcerSecureScore { $endpoint = "/beta/tenants/$resolvedTenantId/secureScores" Write-Verbose "Retrieving secure score for tenant $resolvedTenantId..." - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject + # -PreserveStructure prevents Invoke-InforcerApiRequest from unwrapping the first array + # property of .data. The secure-score payload is a single object whose interesting + # content sits in its `scores`, `controlProfiles`, and `controlCategoryScores` arrays — + # without -PreserveStructure the caller would receive one of those inner arrays instead + # of the top-level tenantSecureScoreDetails object. + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure if ($null -eq $response) { return } if ($OutputType -eq 'JsonObject') { diff --git a/module/Public/Get-InforcerSupportedEventType.ps1 b/module/Public/Get-InforcerSupportedEventType.ps1 index df41cda..d04ab4e 100644 --- a/module/Public/Get-InforcerSupportedEventType.ps1 +++ b/module/Public/Get-InforcerSupportedEventType.ps1 @@ -1,31 +1,57 @@ # Internal: returns audit event type names from the API. # Used by Get-InforcerAuditEvent when -EventType is omitted (resolve "all types"). -# Tab completion for -EventType uses the static list in Get-InforcerAuditEvent.ps1 so it always works. -# Uses global cache for API response so "all types" resolution is consistent. +# $global:InforcerCachedEventTypes is populated at module import from the static +# fallback below, then refreshed with the live API response on the first +# authenticated call. The -EventType ArgumentCompleter in Get-InforcerAuditEvent.ps1 +# reads this same variable, so tab completion tracks the server-side list once the +# user is connected — no need to ship a module release when new event types arrive. if (-not $global:InforcerCachedEventTypes) { + # Static fallback used until an authenticated Get-InforcerSupportedEventType call + # refreshes this cache with the live server-side list. Kept alphabetically sorted so + # diffs against a live API dump are readable. $global:InforcerCachedEventTypes = @( 'alertRuleCreate', 'alertRuleDelete', 'alertRuleUpdate', - 'apiKeyCreate', 'apiKeyDelete', 'apiKeyUsage', + 'apiKeyCreate', 'apiKeyDelete', 'apiKeyUpdate', 'apiKeyUsage', + 'assignmentsModification', 'authentication', - 'clientAdminUpdated', 'clientCreated', 'clientLicenseUpdate', 'clientStatusChanged', + 'clientAdminUpdated', 'clientCreated', 'clientLicenseUpdate', + 'clientSsoConfigurationCreated', 'clientSsoConfigurationRemoved', + 'clientSsoConfigurationToggled', 'clientSsoConfigurationUpdated', + 'clientStatusChanged', 'copilotAssessmentFailure', 'copilotAssessmentRun', 'copilotAssessmentSuccess', + 'copilotManagerToggle', 'failedAuthentication', + 'onboardingLinkCreated', 'onboardingLinkDeleted', + 'onboardingLinkSlugRotated', 'onboardingLinkUpdated', 'policiesDelete', 'policiesDeployment', 'policiesRename', 'policiesRestore', 'reportQueued', 'salesAdminUpdated', 'scheduleCreate', 'scheduleDelete', 'scheduleUpdate', + 'securityGroupCreate', 'securityGroupDelete', 'securityGroupFilterUpdate', + 'securityGroupMembersAdded', 'securityGroupMembersRemoved', + 'securityGroupRoleUpdate', 'securityGroupUpdate', 'sharedBaselinesManaged', 'supportAccessInvoke', 'tenantAssessmentFailure', 'tenantAssessmentRun', 'tenantAssessmentSuccess', - 'tenantDelete', 'tenantGroupCreate', 'tenantGroupMembershipsModified', 'tenantGroupUpdate', 'tenantGroupsDeployment', + 'tenantDelete', + 'tenantGroupCreate', 'tenantGroupMembershipsModified', + 'tenantGroupUpdate', 'tenantGroupsDeployment', 'tenantLicenseUpdate', 'tenantOnboard', 'tenantRefresh', - 'tenantUserCreate', 'tenantUserGroupMembershipModified', 'tenantUserLicensesModified', - 'tenantUserOffboardingQueued', 'tenantUserResetMfa', 'tenantUserResetPassword', - 'tenantUserRevokedSessions', 'tenantUserUpdate', + 'tenantUserAuthenticationMethodDelete', + 'tenantUserCreate', 'tenantUserGroupMembershipModified', + 'tenantUserLicensesModified', + 'tenantUserOffboardingFailed', 'tenantUserOffboardingQueued', + 'tenantUserOffboardingScheduled', 'tenantUserOffboardingSucceeded', + 'tenantUserResetMfa', 'tenantUserResetPassword', + 'tenantUserRevokedSessions', + 'tenantUserTemporaryAccessPassCreate', + 'tenantUserUpdate', 'userAutoProvision', 'userCreate', 'userDelete', - 'userGroupCreate', 'userGroupDelete', 'userGroupMembershipModified', 'userGroupUpdate', - 'userResetMfa', 'userResetPassword', 'userToggleEnable', 'userToggleSso' + 'userGroupCreate', 'userGroupDelete', + 'userGroupMembershipModified', 'userGroupUpdate', + 'userResetMfa', 'userResetPassword', + 'userToggleClientAdmin', 'userToggleEnable', 'userToggleSso' ) } diff --git a/module/Public/Get-InforcerUser.ps1 b/module/Public/Get-InforcerUser.ps1 index 7f63072..02b54bf 100644 --- a/module/Public/Get-InforcerUser.ps1 +++ b/module/Public/Get-InforcerUser.ps1 @@ -98,7 +98,10 @@ function Get-InforcerUser { # --- ById: single user detail --- $endpoint = "/beta/tenants/$resolvedTenantId/users/$UserId" $err = $null - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -ErrorVariable err -ErrorAction SilentlyContinue + # -PreserveStructure: user detail response is a single object with many nested arrays + # (groups, roles, devices, appRoleAssignments, assignedLicenses, businessPhones, ...). + # Without it, Invoke-InforcerApiRequest would unwrap to the first array property. + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure -ErrorVariable err -ErrorAction SilentlyContinue if ($null -eq $response) { # Only emit UserNotFound if API returned 404; otherwise the API helper already wrote the real error From 749539de22fd22163a4a9edab67feb448a8ca9ee Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:41:35 +0200 Subject: [PATCH 04/22] docs: add Bug Fixes section to 0.6.0 CHANGELOG entry --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f6a3d..bd0d587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). - **Removed stale `PolicyDiffFormatted` mention** in `docs/CMDLET-REFERENCE.md` — the property was removed from the module in an earlier version (see FINDINGS #63) but one line in the docs was missed. - Added `Get-InforcerSecureScore` section to `docs/CMDLET-REFERENCE.md`; endpoint description, schemas (`TenantSecureScoreDetails`, `TenantSecureScoreHistoryPoint`, `TenantSecureScoreControlProfile`), and scope-mapping row added to `docs/API-REFERENCE.md`; cmdlet added to the README public-surface table. +### Bug Fixes + +- **`Get-InforcerUser -UserId` was returning the wrong shape.** Live-API verification exposed this: without `-PreserveStructure`, `Invoke-InforcerApiRequest`'s "unwrap first array property of `.data`" convenience was unwrapping the user object's nested arrays (e.g. `groups`, `assignedLicenses`), so the cmdlet returned an array of group memberships instead of the user detail. `PSTypeName` was never applied. Fix: added `-PreserveStructure` to the API call. +- **New `Get-InforcerSecureScore` needed the same fix on first flight** — same root cause, same defense (`-PreserveStructure` added). Live-verified: cmdlet now returns a single `InforcerCommunity.SecureScore` object with 90-day history, control profiles, and category scores populated as documented. + ## [0.5.0] - 2026-06-30 ### Features From fc1ee319d2f6be14f17fa25e53a6850f54a26c24 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:44:41 +0200 Subject: [PATCH 05/22] chore: strip dev/localhost servers and mutable info.version from OpenAPI snapshot --- docs/openapi-snapshot.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/openapi-snapshot.json b/docs/openapi-snapshot.json index 5517ddd..13b35d5 100644 --- a/docs/openapi-snapshot.json +++ b/docs/openapi-snapshot.json @@ -3404,8 +3404,7 @@ "url": "https://inforcer.com" }, "description": "The inforcer Public API built for developers", - "title": "inforcer | Public API", - "version": "1.0.0" + "title": "inforcer | Public API" }, "openapi": "3.0.1", "paths": { @@ -4407,11 +4406,6 @@ "apiKeyAuth": [] } ], - "servers": [ - { - "url": "http://localhost:7072/api" - } - ], "tags": [ { "description": "Test endpoints for the public API", From 2f1d4f82f8c9f69b435fdfbe5813252df5479ab4 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:48:42 +0200 Subject: [PATCH 06/22] revert: restore openapi-snapshot.json to byte-identical live spec (localhost URL is public) --- docs/openapi-snapshot.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/openapi-snapshot.json b/docs/openapi-snapshot.json index 13b35d5..5517ddd 100644 --- a/docs/openapi-snapshot.json +++ b/docs/openapi-snapshot.json @@ -3404,7 +3404,8 @@ "url": "https://inforcer.com" }, "description": "The inforcer Public API built for developers", - "title": "inforcer | Public API" + "title": "inforcer | Public API", + "version": "1.0.0" }, "openapi": "3.0.1", "paths": { @@ -4406,6 +4407,11 @@ "apiKeyAuth": [] } ], + "servers": [ + { + "url": "http://localhost:7072/api" + } + ], "tags": [ { "description": "Test endpoints for the public API", From c54faffe661bbe3e3dffb51f02d66fbd07419b86 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:35:54 +0200 Subject: [PATCH 07/22] refactor: shrink ConvertFrom-InforcerSecureString to NetworkCredential one-liner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 6-line BSTR marshal + ZeroFreeBSTR try/finally with [System.Net.NetworkCredential]::new('', $s).Password. Behavioural equivalence verified: 8 samples (empty, unicode-emoji, newline, tab, 512-char string, standard secrets) round-trip byte-identical. Both paths zero the unmanaged buffer — NetworkCredential uses ZeroFreeGlobalAllocUnicode internally, same defence guarantee. --- module/Private/ConvertFrom-InforcerSecureString.ps1 | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/module/Private/ConvertFrom-InforcerSecureString.ps1 b/module/Private/ConvertFrom-InforcerSecureString.ps1 index f5a9c68..4f8b65d 100644 --- a/module/Private/ConvertFrom-InforcerSecureString.ps1 +++ b/module/Private/ConvertFrom-InforcerSecureString.ps1 @@ -14,10 +14,5 @@ function ConvertFrom-InforcerSecureString { [System.Security.SecureString]$SecureString ) if (-not $SecureString) { return '' } - $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) - try { - [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) - } finally { - [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) - } + [System.Net.NetworkCredential]::new('', $SecureString).Password } From fb820700a175988bf0b64b2e00fe120a90afec30 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:36:51 +0200 Subject: [PATCH 08/22] refactor: drop Get-InforcerReportTypeStaticKeys wrapper, keep $script: array Function had zero prod callers - the argument completers in Get-InforcerReportType.ps1 and Invoke-InforcerReport.ps1 read $script:InforcerReportTypeStaticKeys directly at 5 sites. Only the Pester tests called the wrapper, and they now target the array in the module scope for the same coverage. Not exported in .psd1 - no public API impact. --- Tests/Consistency.Tests.ps1 | 14 +++++------ .../Get-InforcerReportTypeStaticKeys.ps1 | 23 +------------------ 2 files changed, 7 insertions(+), 30 deletions(-) diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index 0271ce9..48e1e60 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -1516,24 +1516,22 @@ Describe 'Private helpers (via module scope)' { } } - Context 'Get-InforcerReportTypeStaticKeys' { - It 'Returns a non-empty string array' { - $keys = & (Get-Module InforcerCommunity) { Get-InforcerReportTypeStaticKeys } | ForEach-Object { $_ } + Context '$script:InforcerReportTypeStaticKeys (completer fallback list)' { + It 'Is a non-empty string array' { + $keys = & (Get-Module InforcerCommunity) { $script:InforcerReportTypeStaticKeys } ($keys | Measure-Object).Count | Should -BeGreaterThan 0 $keys | ForEach-Object { $_ | Should -BeOfType [string] } } It 'Includes the well-known report types used in completer fallback' { - $keys = & (Get-Module InforcerCommunity) { Get-InforcerReportTypeStaticKeys } | ForEach-Object { $_ } + $keys = & (Get-Module InforcerCommunity) { $script:InforcerReportTypeStaticKeys } foreach ($expected in 'ActiveUserCount','CopilotAdoption','Assessment','TenantAuditReport','SecureScores') { $keys | Should -Contain $expected } } - It 'Returns distinct values (no duplicates)' { - # Helper returns the array via unary comma to preserve identity; flatten with the - # pipeline so we get the real string array rather than a nested wrapper. - $keys = & (Get-Module InforcerCommunity) { Get-InforcerReportTypeStaticKeys } | ForEach-Object { $_ } + It 'Contains distinct values (no duplicates)' { + $keys = & (Get-Module InforcerCommunity) { $script:InforcerReportTypeStaticKeys } ($keys | Sort-Object -Unique).Count | Should -Be ($keys | Measure-Object).Count } } diff --git a/module/Private/Get-InforcerReportTypeStaticKeys.ps1 b/module/Private/Get-InforcerReportTypeStaticKeys.ps1 index 700f674..256e879 100644 --- a/module/Private/Get-InforcerReportTypeStaticKeys.ps1 +++ b/module/Private/Get-InforcerReportTypeStaticKeys.ps1 @@ -1,27 +1,6 @@ -function Get-InforcerReportTypeStaticKeys { - <# - .SYNOPSIS - Returns the static fallback list of Reports API type keys used by argument completers - before the live catalog cache ($script:InforcerReportTypeCache) is primed. - .DESCRIPTION - Argument completers on -ReportType / -Key prefer the live catalog when populated, but - until the first /beta/reports/types call this static list is what shows up at TAB. - Centralised here so Get-InforcerReportType and Invoke-InforcerReport stay in lockstep — - empirically matched to the production catalog observed against api.inforcer.com. - .OUTPUTS - System.String[] - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', - Justification = 'Returns a collection of keys; plural noun is intentional.')] - [CmdletBinding()] - [OutputType([string[]])] - param() - - ,$script:InforcerReportTypeStaticKeys -} - # Module-scoped canonical list — both completers read this directly so a single edit keeps # Get-InforcerReportType and Invoke-InforcerReport completion identical. +# Empirically matched to the production catalog observed against api.inforcer.com. $script:InforcerReportTypeStaticKeys = @( 'ActiveUserCount' 'ActiveUsers' From b9ac1404b5e6cff54fd9aa5826946592b21951d7 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:38:31 +0200 Subject: [PATCH 09/22] refactor: move Get-InforcerPolicyDisplayInfo next to its sibling helpers Get-InforcerPolicyDisplayInfo had exactly one caller (ConvertTo-InforcerDocModel) and zero direct test invocations. Merged its 114-line body into ConvertTo-InforcerDocModel.ps1 alongside its existing siblings Get-InforcerCategoryKey and Get-InforcerPolicyName, which follow the same 'helpers live in the caller file' pattern. Net: -1 private file, -31 lines. Behaviour unchanged - DocModel (66/66) and Consistency (162/162) suites pass. --- module/Private/ConvertTo-InforcerDocModel.ps1 | 83 +++++++++++++ .../Private/Get-InforcerPolicyDisplayInfo.ps1 | 114 ------------------ 2 files changed, 83 insertions(+), 114 deletions(-) delete mode 100644 module/Private/Get-InforcerPolicyDisplayInfo.ps1 diff --git a/module/Private/ConvertTo-InforcerDocModel.ps1 b/module/Private/ConvertTo-InforcerDocModel.ps1 index 5379afb..1b5f1f9 100644 --- a/module/Private/ConvertTo-InforcerDocModel.ps1 +++ b/module/Private/ConvertTo-InforcerDocModel.ps1 @@ -44,6 +44,89 @@ function Get-InforcerPolicyName { return $name } +function Get-InforcerPolicyDisplayInfo { + <# + .SYNOPSIS + Maps API policy names and types to friendly display names and Microsoft admin portal categories. + .DESCRIPTION + The Inforcer API uses internal identifiers (e.g., "MSAuthenticatorConfig", "Fido2Config") and + generic groupings ("Settings / All"). This function maps them to the names and categories IT + admins recognise from the Microsoft admin portals. Returns a hashtable with FriendlyName and + Category, both $null when no mapping matches. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$PolicyName, + [Parameter()][string]$Product, + [Parameter()][string]$PrimaryGroup, + [Parameter()][string]$SecondaryGroup, + [Parameter()][int]$PolicyTypeId + ) + + # Entra ID settings (policyTypeId 12) — internal name -> admin portal path + $entraSettingsMap = @{ + 'AdminAppConsent' = @{ FriendlyName = 'Admin consent requests'; Category = 'Enterprise applications' } + 'AuthenticatorSetupEnforce' = @{ FriendlyName = 'System-preferred MFA'; Category = 'Authentication methods' } + 'DefSecSettings' = @{ FriendlyName = 'Security defaults'; Category = 'Properties' } + 'DefUserRolePerms' = @{ FriendlyName = 'Default user role permissions'; Category = 'User settings' } + 'EmailOTPConfig' = @{ FriendlyName = 'Email OTP'; Category = 'Authentication methods' } + 'ExternalUserLeaveConfig' = @{ FriendlyName = 'External user leave settings'; Category = 'External Identities' } + 'Fido2Config' = @{ FriendlyName = 'FIDO2 security keys'; Category = 'Authentication methods' } + 'GuestUserAccessConfig' = @{ FriendlyName = 'Guest user access restrictions';Category = 'External Identities' } + 'HardwareOathConfig' = @{ FriendlyName = 'Hardware OATH tokens'; Category = 'Authentication methods' } + 'LocalAdministratorPassword' = @{ FriendlyName = 'Windows LAPS'; Category = 'Device settings' } + 'MSAuthenticatorConfig' = @{ FriendlyName = 'Microsoft Authenticator'; Category = 'Authentication methods' } + 'MultiFactorAuthConfig' = @{ FriendlyName = 'Multifactor authentication'; Category = 'Authentication methods' } + 'QrCodeConfig' = @{ FriendlyName = 'QR code authentication'; Category = 'Authentication methods' } + 'SelfServicePassConfig' = @{ FriendlyName = 'Self-service password reset'; Category = 'Password reset' } + 'SmsAuthConfig' = @{ FriendlyName = 'SMS'; Category = 'Authentication methods' } + 'SoftwareOathConfig' = @{ FriendlyName = 'Software OATH tokens'; Category = 'Authentication methods' } + 'TempPasswordConfig' = @{ FriendlyName = 'Temporary Access Pass'; Category = 'Authentication methods' } + 'UserAllowedToRegister' = @{ FriendlyName = 'User registration allowed'; Category = 'Device settings' } + 'UserAppConsent' = @{ FriendlyName = 'User consent settings'; Category = 'Enterprise applications' } + 'UserDeviceQuota' = @{ FriendlyName = 'Device limit per user'; Category = 'Device settings' } + 'VoiceConfig' = @{ FriendlyName = 'Voice call'; Category = 'Authentication methods' } + } + $entraTypeMap = @{ + 62 = @{ FriendlyName = $null; Category = 'Authentication methods' } # Certificate-based auth + 72 = @{ FriendlyName = $null; Category = 'Authentication methods' } # Password Protection + 17 = @{ FriendlyName = $null; Category = 'Authentication strengths' } # CA Auth Strengths + } + $sharepointSettingsMap = @{ + 'ActivityBasedTimeout' = @{ FriendlyName = 'Idle session sign-out'; Category = 'Access control' } + 'ExternalUserReshare' = @{ FriendlyName = 'External user resharing'; Category = 'Sharing' } + 'MacOneDriveSync' = @{ FriendlyName = 'Mac OneDrive sync'; Category = 'Sync' } + 'OneDriveDeletedUserDefaultRetention' = @{ FriendlyName = 'Deleted user data retention'; Category = 'OneDrive' } + 'OneDriveRetention' = @{ FriendlyName = 'OneDrive retention'; Category = 'OneDrive' } + 'OneDriveStorageQuota' = @{ FriendlyName = 'OneDrive storage quota'; Category = 'OneDrive' } + 'SharePointAllowGuestItemSharing' = @{ FriendlyName = 'Guest item sharing'; Category = 'Sharing' } + 'SharePointOneDriveSharingLevel' = @{ FriendlyName = 'External sharing level'; Category = 'Sharing' } + 'SharePointSiteCreation' = @{ FriendlyName = 'Site creation'; Category = 'Site creation' } + 'SyncFileExclusions' = @{ FriendlyName = 'Sync file exclusions'; Category = 'Sync' } + 'UnmanagedOneDriveSyncRestriction' = @{ FriendlyName = 'Unmanaged device sync'; Category = 'Sync' } + } + $m365SettingsMap = @{ + 'DirectoryGuestAccess' = @{ FriendlyName = 'Guest access'; Category = 'Org settings' } + 'OrganizationTechnicalContact' = @{ FriendlyName = 'Organization technical contact'; Category = 'Org settings' } + 'ReportAnonymization' = @{ FriendlyName = 'Report anonymization'; Category = 'Org settings' } + } + + $friendlyName = $null; $category = $null + if ($Product -eq 'Entra' -and $PolicyTypeId -eq 12 -and $entraSettingsMap.ContainsKey($PolicyName)) { + $m = $entraSettingsMap[$PolicyName]; $friendlyName = $m.FriendlyName; $category = $m.Category + } + elseif ($Product -eq 'Entra' -and $entraTypeMap.ContainsKey($PolicyTypeId)) { + $m = $entraTypeMap[$PolicyTypeId]; if ($m.FriendlyName) { $friendlyName = $m.FriendlyName }; $category = $m.Category + } + elseif ($Product -eq 'SharePoint' -and $PolicyTypeId -eq 14 -and $sharepointSettingsMap.ContainsKey($PolicyName)) { + $m = $sharepointSettingsMap[$PolicyName]; $friendlyName = $m.FriendlyName; $category = $m.Category + } + elseif ($Product -eq 'M365 Admin Center' -and $PolicyTypeId -eq 16 -and $m365SettingsMap.ContainsKey($PolicyName)) { + $m = $m365SettingsMap[$PolicyName]; $friendlyName = $m.FriendlyName; $category = $m.Category + } + @{ FriendlyName = $friendlyName; Category = $category } +} + function ConvertTo-InforcerDocModel { <# .SYNOPSIS diff --git a/module/Private/Get-InforcerPolicyDisplayInfo.ps1 b/module/Private/Get-InforcerPolicyDisplayInfo.ps1 deleted file mode 100644 index b6f2be6..0000000 --- a/module/Private/Get-InforcerPolicyDisplayInfo.ps1 +++ /dev/null @@ -1,114 +0,0 @@ -function Get-InforcerPolicyDisplayInfo { - <# - .SYNOPSIS - Maps API policy names and types to friendly display names and Microsoft admin portal categories. - .DESCRIPTION - The Inforcer API uses internal identifiers (e.g., "MSAuthenticatorConfig", "Fido2Config") and - generic groupings ("Settings / All"). This function maps them to the names and categories IT - admins recognise from the Microsoft admin portals. - - Returns a hashtable with FriendlyName and Category. When no mapping exists, returns the - original name and category unchanged. - .PARAMETER PolicyName - The raw policy name from the API (displayName or friendlyName). - .PARAMETER Product - The product field from the API (Entra, Intune, SharePoint, etc.). - .PARAMETER PrimaryGroup - The primaryGroup field from the API. - .PARAMETER SecondaryGroup - The secondaryGroup field from the API. - .PARAMETER PolicyTypeId - The policyTypeId field from the API. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)][string]$PolicyName, - [Parameter()][string]$Product, - [Parameter()][string]$PrimaryGroup, - [Parameter()][string]$SecondaryGroup, - [Parameter()][int]$PolicyTypeId - ) - - # Entra ID settings (policyTypeId 12) - map internal names to admin portal paths - $entraSettingsMap = @{ - 'AdminAppConsent' = @{ FriendlyName = 'Admin consent requests'; Category = 'Enterprise applications' } - 'AuthenticatorSetupEnforce' = @{ FriendlyName = 'System-preferred MFA'; Category = 'Authentication methods' } - 'DefSecSettings' = @{ FriendlyName = 'Security defaults'; Category = 'Properties' } - 'DefUserRolePerms' = @{ FriendlyName = 'Default user role permissions'; Category = 'User settings' } - 'EmailOTPConfig' = @{ FriendlyName = 'Email OTP'; Category = 'Authentication methods' } - 'ExternalUserLeaveConfig' = @{ FriendlyName = 'External user leave settings'; Category = 'External Identities' } - 'Fido2Config' = @{ FriendlyName = 'FIDO2 security keys'; Category = 'Authentication methods' } - 'GuestUserAccessConfig' = @{ FriendlyName = 'Guest user access restrictions'; Category = 'External Identities' } - 'HardwareOathConfig' = @{ FriendlyName = 'Hardware OATH tokens'; Category = 'Authentication methods' } - 'LocalAdministratorPassword'= @{ FriendlyName = 'Windows LAPS'; Category = 'Device settings' } - 'MSAuthenticatorConfig' = @{ FriendlyName = 'Microsoft Authenticator'; Category = 'Authentication methods' } - 'MultiFactorAuthConfig' = @{ FriendlyName = 'Multifactor authentication'; Category = 'Authentication methods' } - 'QrCodeConfig' = @{ FriendlyName = 'QR code authentication'; Category = 'Authentication methods' } - 'SelfServicePassConfig' = @{ FriendlyName = 'Self-service password reset'; Category = 'Password reset' } - 'SmsAuthConfig' = @{ FriendlyName = 'SMS'; Category = 'Authentication methods' } - 'SoftwareOathConfig' = @{ FriendlyName = 'Software OATH tokens'; Category = 'Authentication methods' } - 'TempPasswordConfig' = @{ FriendlyName = 'Temporary Access Pass'; Category = 'Authentication methods' } - 'UserAllowedToRegister' = @{ FriendlyName = 'User registration allowed'; Category = 'Device settings' } - 'UserAppConsent' = @{ FriendlyName = 'User consent settings'; Category = 'Enterprise applications' } - 'UserDeviceQuota' = @{ FriendlyName = 'Device limit per user'; Category = 'Device settings' } - 'VoiceConfig' = @{ FriendlyName = 'Voice call'; Category = 'Authentication methods' } - } - - # Entra ID other types - $entraTypeMap = @{ - 62 = @{ FriendlyName = $null; Category = 'Authentication methods' } # Certificate-based authentication - 72 = @{ FriendlyName = $null; Category = 'Authentication methods' } # Password Protection - 17 = @{ FriendlyName = $null; Category = 'Authentication strengths' } # CA Auth Strengths - } - - # SharePoint settings (policyTypeId 14) - $sharepointSettingsMap = @{ - 'ActivityBasedTimeout' = @{ FriendlyName = 'Idle session sign-out'; Category = 'Access control' } - 'ExternalUserReshare' = @{ FriendlyName = 'External user resharing'; Category = 'Sharing' } - 'MacOneDriveSync' = @{ FriendlyName = 'Mac OneDrive sync'; Category = 'Sync' } - 'OneDriveDeletedUserDefaultRetention' = @{ FriendlyName = 'Deleted user data retention'; Category = 'OneDrive' } - 'OneDriveRetention' = @{ FriendlyName = 'OneDrive retention'; Category = 'OneDrive' } - 'OneDriveStorageQuota' = @{ FriendlyName = 'OneDrive storage quota'; Category = 'OneDrive' } - 'SharePointAllowGuestItemSharing' = @{ FriendlyName = 'Guest item sharing'; Category = 'Sharing' } - 'SharePointOneDriveSharingLevel' = @{ FriendlyName = 'External sharing level'; Category = 'Sharing' } - 'SharePointSiteCreation' = @{ FriendlyName = 'Site creation'; Category = 'Site creation' } - 'SyncFileExclusions' = @{ FriendlyName = 'Sync file exclusions'; Category = 'Sync' } - 'UnmanagedOneDriveSyncRestriction' = @{ FriendlyName = 'Unmanaged device sync'; Category = 'Sync' } - } - - # M365 Admin Center settings (policyTypeId 16) - $m365SettingsMap = @{ - 'DirectoryGuestAccess' = @{ FriendlyName = 'Guest access'; Category = 'Org settings' } - 'OrganizationTechnicalContact' = @{ FriendlyName = 'Organization technical contact'; Category = 'Org settings' } - 'ReportAnonymization' = @{ FriendlyName = 'Report anonymization'; Category = 'Org settings' } - } - - $friendlyName = $null - $category = $null - - if ($Product -eq 'Entra' -and $PolicyTypeId -eq 12 -and $entraSettingsMap.ContainsKey($PolicyName)) { - $mapping = $entraSettingsMap[$PolicyName] - $friendlyName = $mapping.FriendlyName - $category = $mapping.Category - } - elseif ($Product -eq 'Entra' -and $entraTypeMap.ContainsKey($PolicyTypeId)) { - $mapping = $entraTypeMap[$PolicyTypeId] - if ($mapping.FriendlyName) { $friendlyName = $mapping.FriendlyName } - $category = $mapping.Category - } - elseif ($Product -eq 'SharePoint' -and $PolicyTypeId -eq 14 -and $sharepointSettingsMap.ContainsKey($PolicyName)) { - $mapping = $sharepointSettingsMap[$PolicyName] - $friendlyName = $mapping.FriendlyName - $category = $mapping.Category - } - elseif ($Product -eq 'M365 Admin Center' -and $PolicyTypeId -eq 16 -and $m365SettingsMap.ContainsKey($PolicyName)) { - $mapping = $m365SettingsMap[$PolicyName] - $friendlyName = $mapping.FriendlyName - $category = $mapping.Category - } - - @{ - FriendlyName = $friendlyName - Category = $category - } -} From 727850219134a3406f30268116f0f4becc6ad9f7 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:39:02 +0200 Subject: [PATCH 10/22] docs: log the 3 internal ponytail-audit cuts in v0.6.0 CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd0d587..b698b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). - **`Get-InforcerUser -UserId` was returning the wrong shape.** Live-API verification exposed this: without `-PreserveStructure`, `Invoke-InforcerApiRequest`'s "unwrap first array property of `.data`" convenience was unwrapping the user object's nested arrays (e.g. `groups`, `assignedLicenses`), so the cmdlet returned an array of group memberships instead of the user detail. `PSTypeName` was never applied. Fix: added `-PreserveStructure` to the API call. - **New `Get-InforcerSecureScore` needed the same fix on first flight** — same root cause, same defense (`-PreserveStructure` added). Live-verified: cmdlet now returns a single `InforcerCommunity.SecureScore` object with 90-day history, control profiles, and category scores populated as documented. +### Internal (no user-facing behaviour change) + +- **`ConvertFrom-InforcerSecureString` shrunk** to `[System.Net.NetworkCredential]::new('', $s).Password` (was a 6-line `SecureStringToBSTR` / `PtrToStringBSTR` / `ZeroFreeBSTR` marshal). Equivalent defence — both paths zero the unmanaged buffer — verified byte-identical across 8 samples (empty, unicode, newline, tab, 512-char, standard secrets). +- **Dropped `Get-InforcerReportTypeStaticKeys` wrapper.** Argument completers already read `$script:InforcerReportTypeStaticKeys` directly at 5 sites; the wrapper was called only by Pester tests, which now target the `$script:` array in the module scope for the same coverage. Function was not exported. +- **`Get-InforcerPolicyDisplayInfo` moved next to its sibling helpers.** Had exactly one caller (`ConvertTo-InforcerDocModel`) and zero direct test invocations, so it now lives inside `ConvertTo-InforcerDocModel.ps1` alongside `Get-InforcerCategoryKey` and `Get-InforcerPolicyName`. Net: -1 private file, -31 lines. Behaviour unchanged (DocModel 66/66, Consistency 162/162). + ## [0.5.0] - 2026-06-30 ### Features From b20fe95226abf3da4ff7b0327bec7619c9566637 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:28:26 +0200 Subject: [PATCH 11/22] refactor: drop dead -PrimaryGroup / -SecondaryGroup from Get-InforcerPolicyDisplayInfo PSScriptAnalyzer PSReviewUnusedParameter flagged both params - declared but never referenced in the function body. The single caller stopped passing them too. Aligns with the module's no-dead-parameters rule. DocModel 66/66 + Consistency 162/162 pass. --- CHANGELOG.md | 1 + module/Private/ConvertTo-InforcerDocModel.ps1 | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b698b61..2e85844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). - **`ConvertFrom-InforcerSecureString` shrunk** to `[System.Net.NetworkCredential]::new('', $s).Password` (was a 6-line `SecureStringToBSTR` / `PtrToStringBSTR` / `ZeroFreeBSTR` marshal). Equivalent defence — both paths zero the unmanaged buffer — verified byte-identical across 8 samples (empty, unicode, newline, tab, 512-char, standard secrets). - **Dropped `Get-InforcerReportTypeStaticKeys` wrapper.** Argument completers already read `$script:InforcerReportTypeStaticKeys` directly at 5 sites; the wrapper was called only by Pester tests, which now target the `$script:` array in the module scope for the same coverage. Function was not exported. - **`Get-InforcerPolicyDisplayInfo` moved next to its sibling helpers.** Had exactly one caller (`ConvertTo-InforcerDocModel`) and zero direct test invocations, so it now lives inside `ConvertTo-InforcerDocModel.ps1` alongside `Get-InforcerCategoryKey` and `Get-InforcerPolicyName`. Net: -1 private file, -31 lines. Behaviour unchanged (DocModel 66/66, Consistency 162/162). +- **Dropped two dead parameters from `Get-InforcerPolicyDisplayInfo`.** `-PrimaryGroup` and `-SecondaryGroup` were declared but never referenced in the body (surfaced by PSScriptAnalyzer `PSReviewUnusedParameter`). The single caller stopped passing them too. ## [0.5.0] - 2026-06-30 diff --git a/module/Private/ConvertTo-InforcerDocModel.ps1 b/module/Private/ConvertTo-InforcerDocModel.ps1 index 1b5f1f9..b2f85a3 100644 --- a/module/Private/ConvertTo-InforcerDocModel.ps1 +++ b/module/Private/ConvertTo-InforcerDocModel.ps1 @@ -58,8 +58,6 @@ function Get-InforcerPolicyDisplayInfo { param( [Parameter(Mandatory)][string]$PolicyName, [Parameter()][string]$Product, - [Parameter()][string]$PrimaryGroup, - [Parameter()][string]$SecondaryGroup, [Parameter()][int]$PolicyTypeId ) @@ -289,8 +287,7 @@ function ConvertTo-InforcerDocModel { # Map to friendly display name and Microsoft admin portal category $displayInfo = Get-InforcerPolicyDisplayInfo -PolicyName $policyName ` - -Product $prod -PrimaryGroup $policy.primaryGroup ` - -SecondaryGroup $policy.secondaryGroup -PolicyTypeId $policy.policyTypeId + -Product $prod -PolicyTypeId $policy.policyTypeId if ($displayInfo.FriendlyName) { $policyName = $displayInfo.FriendlyName } $catKey = if ($displayInfo.Category) { From 50bda557abe7e7d5bb90300644cc57d2254f7d6f Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:44:35 +0200 Subject: [PATCH 12/22] test: migrate Assert-MockCalled to Should -Invoke for Pester 6 compat Pester 6 removed Assert-MockCalled (deprecated in 5). CI on Pester 6.0.0 was hitting 5 failures with 'Should operator Be is not registered' when loading the removed cmdlet. Replaced all 6 call sites with the modern Should -Invoke form (same semantics, added explicit -CommandName). Suite green on Pester 5.7.1 and 6.0.0: 397 passed, 2 skipped. --- CHANGELOG.md | 1 + Tests/Consistency.Tests.ps1 | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e85844..c2fb61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). - **Dropped `Get-InforcerReportTypeStaticKeys` wrapper.** Argument completers already read `$script:InforcerReportTypeStaticKeys` directly at 5 sites; the wrapper was called only by Pester tests, which now target the `$script:` array in the module scope for the same coverage. Function was not exported. - **`Get-InforcerPolicyDisplayInfo` moved next to its sibling helpers.** Had exactly one caller (`ConvertTo-InforcerDocModel`) and zero direct test invocations, so it now lives inside `ConvertTo-InforcerDocModel.ps1` alongside `Get-InforcerCategoryKey` and `Get-InforcerPolicyName`. Net: -1 private file, -31 lines. Behaviour unchanged (DocModel 66/66, Consistency 162/162). - **Dropped two dead parameters from `Get-InforcerPolicyDisplayInfo`.** `-PrimaryGroup` and `-SecondaryGroup` were declared but never referenced in the body (surfaced by PSScriptAnalyzer `PSReviewUnusedParameter`). The single caller stopped passing them too. +- **Pester 6 compatibility for the test suite.** Replaced 6 `Assert-MockCalled` calls (removed in Pester 6) with `Should -Invoke`. Suite is now green on both Pester 5.7 and Pester 6.0 (397 passed, 2 skipped). ## [0.5.0] - 2026-06-30 diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index 48e1e60..9436c99 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -1675,7 +1675,7 @@ Describe 'Private helpers (via module scope)' { It 'Invoke-InforcerReport -WhatIf does not call Invoke-InforcerApiRequest with POST' { $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 14436 -WhatIf -ErrorAction SilentlyContinue - Assert-MockCalled -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 0 -Exactly -ParameterFilter { $Method -eq 'POST' } + Should -Invoke -ModuleName InforcerCommunity -CommandName Invoke-InforcerApiRequest -Times 0 -Exactly -ParameterFilter { $Method -eq 'POST' } } AfterEach { @@ -1711,7 +1711,7 @@ Describe 'Private helpers (via module scope)' { -WarningVariable w -WarningAction SilentlyContinue @($w).Count | Should -BeGreaterThan 0 ($w -join ' ') | Should -Match '-Open is ignored' - Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 0 -Exactly + Should -Invoke -ModuleName InforcerCommunity -CommandName Invoke-Item -Times 0 -Exactly } It '-Open with one saved file calls Invoke-Item once on the file' { @@ -1734,7 +1734,7 @@ Describe 'Private helpers (via module scope)' { } try { $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 14436 -OutputPath $tempDir -Open - Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 1 -Exactly + Should -Invoke -ModuleName InforcerCommunity -CommandName Invoke-Item -Times 1 -Exactly } finally { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue } @@ -1867,7 +1867,7 @@ Describe 'Private helpers (via module scope)' { try { $null = Invoke-InforcerReport -ReportType X -OutputFormat csv -TenantId 14436 -OutputPath $tempDir -Open # Invoke-Item should have been called exactly once on the .csv file - Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 1 -Exactly -ParameterFilter { $LiteralPath -eq (Join-Path $tempDir 'safe.csv') } + Should -Invoke -ModuleName InforcerCommunity -CommandName Invoke-Item -Times 1 -Exactly -ParameterFilter { $LiteralPath -eq (Join-Path $tempDir 'safe.csv') } } finally { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue } @@ -1897,9 +1897,9 @@ Describe 'Private helpers (via module scope)' { -WarningVariable w -WarningAction SilentlyContinue ($w -join ' ') | Should -Match 'non-allowlisted extension' # Invoke-Item should NOT have been called on the .command file - Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 0 -Exactly -ParameterFilter { $LiteralPath -eq (Join-Path $tempDir 'evil.command') } + Should -Invoke -ModuleName InforcerCommunity -CommandName Invoke-Item -Times 0 -Exactly -ParameterFilter { $LiteralPath -eq (Join-Path $tempDir 'evil.command') } # But SHOULD have been called once on the directory - Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 1 -Exactly -ParameterFilter { $LiteralPath -eq $tempDir } + Should -Invoke -ModuleName InforcerCommunity -CommandName Invoke-Item -Times 1 -Exactly -ParameterFilter { $LiteralPath -eq $tempDir } } finally { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue } From fcb765599ec36263b6332a2af8affbce2bca0bec Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:49:27 +0200 Subject: [PATCH 13/22] test: cover new SecureScore ControlCategoryScores / HistoricScores aliasing v0.6.0 CHANGELOG advertises PascalCase aliases for .ControlCategoryScores and its nested .HistoricScores, but the existing SecureScore aliasing test only covered top-level, .Scores, and .ControlProfiles. Copilot review flagged the regression risk. Extended the fixture with a controlCategoryScores entry and 4 assertions. Green on Pester 5.7.1 and 6.0.0. --- Tests/Consistency.Tests.ps1 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index 9436c99..e8167d7 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -804,6 +804,16 @@ Describe 'Private helpers (via module scope)' { controlProfiles = @( [PSCustomObject]@{ id = 'mfa-admins'; title = 'Require MFA for admins'; controlCategory = 'Identity'; currentScore = 0; maxScore = 10; scoreDifference = 10 } ) + controlCategoryScores = @( + [PSCustomObject]@{ + controlCategory = 'Identity' + currentScore = 1 + maxScore = 5 + historicScores = @( + [PSCustomObject]@{ createdDateTime = '2026-07-01'; currentScore = 1; maxScore = 5 } + ) + } + ) } $null = Add-InforcerPropertyAliases -InputObject $secure -ObjectType SecureScore $secure.CurrentScore | Should -Be 412.5 @@ -814,6 +824,10 @@ Describe 'Private helpers (via module scope)' { $secure.Scores[0].CurrentScore | Should -Be 410 $secure.ControlProfiles[0].Title | Should -Be 'Require MFA for admins' $secure.ControlProfiles[0].ScoreDifference | Should -Be 10 + $secure.ControlCategoryScores[0].ControlCategory | Should -Be 'Identity' + $secure.ControlCategoryScores[0].CurrentScore | Should -Be 1 + $secure.ControlCategoryScores[0].HistoricScores[0].CreatedDateTime | Should -Be '2026-07-01' + $secure.ControlCategoryScores[0].HistoricScores[0].CurrentScore | Should -Be 1 } } From e5c9c090500028f3266018ced707df30d33e319c Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:52:37 +0200 Subject: [PATCH 14/22] Fix cmdlet response structure and improve internals Fixed issues with `Get-InforcerUser` and `Get-InforcerSecureScore` cmdlets related to API response structure. Made internal improvements including code simplifications and Pester 6 compatibility. --- CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2fb61b..bd0d587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,14 +25,6 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). - **`Get-InforcerUser -UserId` was returning the wrong shape.** Live-API verification exposed this: without `-PreserveStructure`, `Invoke-InforcerApiRequest`'s "unwrap first array property of `.data`" convenience was unwrapping the user object's nested arrays (e.g. `groups`, `assignedLicenses`), so the cmdlet returned an array of group memberships instead of the user detail. `PSTypeName` was never applied. Fix: added `-PreserveStructure` to the API call. - **New `Get-InforcerSecureScore` needed the same fix on first flight** — same root cause, same defense (`-PreserveStructure` added). Live-verified: cmdlet now returns a single `InforcerCommunity.SecureScore` object with 90-day history, control profiles, and category scores populated as documented. -### Internal (no user-facing behaviour change) - -- **`ConvertFrom-InforcerSecureString` shrunk** to `[System.Net.NetworkCredential]::new('', $s).Password` (was a 6-line `SecureStringToBSTR` / `PtrToStringBSTR` / `ZeroFreeBSTR` marshal). Equivalent defence — both paths zero the unmanaged buffer — verified byte-identical across 8 samples (empty, unicode, newline, tab, 512-char, standard secrets). -- **Dropped `Get-InforcerReportTypeStaticKeys` wrapper.** Argument completers already read `$script:InforcerReportTypeStaticKeys` directly at 5 sites; the wrapper was called only by Pester tests, which now target the `$script:` array in the module scope for the same coverage. Function was not exported. -- **`Get-InforcerPolicyDisplayInfo` moved next to its sibling helpers.** Had exactly one caller (`ConvertTo-InforcerDocModel`) and zero direct test invocations, so it now lives inside `ConvertTo-InforcerDocModel.ps1` alongside `Get-InforcerCategoryKey` and `Get-InforcerPolicyName`. Net: -1 private file, -31 lines. Behaviour unchanged (DocModel 66/66, Consistency 162/162). -- **Dropped two dead parameters from `Get-InforcerPolicyDisplayInfo`.** `-PrimaryGroup` and `-SecondaryGroup` were declared but never referenced in the body (surfaced by PSScriptAnalyzer `PSReviewUnusedParameter`). The single caller stopped passing them too. -- **Pester 6 compatibility for the test suite.** Replaced 6 `Assert-MockCalled` calls (removed in Pester 6) with `Should -Invoke`. Suite is now green on both Pester 5.7 and Pester 6.0 (397 passed, 2 skipped). - ## [0.5.0] - 2026-06-30 ### Features From 39f06f9c7850bc7f80e504cb8e2027133536bd50 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:02:41 +0200 Subject: [PATCH 15/22] feat: expand Get-InforcerSecureScore default view + fix dead-var tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format view improvements: - ScoreHistory now shows day count, first→last date, and latest score (was just a count). - ControlCategoryScores added to the view - previously omitted, so users saw controlCategoryScores=System.Object[] in raw dumps and got no visible per-category breakdown at a glance. - Hint field points to $obj.Scores / .ControlCategoryScores[0].HistoricScores / .ControlProfiles for the full nested data. Tests: - 3 unused $err assignments cleared (PSSA PSUseDeclaredVarsMoreThanAssignments). Two tests now assert on the error path they were meant to verify; one had no assertion at all. Suite green on Pester 5.7.1 and 6.0.0: 397 passed, 2 skipped. --- CHANGELOG.md | 2 +- Tests/Consistency.Tests.ps1 | 11 +++++------ module/InforcerCommunity.Format.ps1xml | 15 ++++++++++++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd0d587..1376e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). ### Features -- **New cmdlet: `Get-InforcerSecureScore`** — retrieves the current and historic Microsoft Secure Score for a tenant from `GET /beta/tenants/{tenantId}/secureScores`. Returns the current score, max score, licensed user count, enabled services, up to 90 days of daily score history, per-category scores, and actionable control profiles with remediation guidance. `-TenantId` accepts numeric ID, GUID, or tenant name (name/GUID resolution via `Resolve-InforcerTenantId`). Supports `-OutputType JsonObject`. Required scopes: `Tenants.SecureScores.Read` + `Tenants.Read` (only when `-TenantId` is a GUID or tenant name). PSTypeName `InforcerCommunity.SecureScore` with a ListControl default view showing current/max/percentage plus counts of nested arrays. +- **New cmdlet: `Get-InforcerSecureScore`** — retrieves the current and historic Microsoft Secure Score for a tenant from `GET /beta/tenants/{tenantId}/secureScores`. Returns the current score, max score, licensed user count, enabled services, up to 90 days of daily score history, per-category scores, and actionable control profiles with remediation guidance. `-TenantId` accepts numeric ID, GUID, or tenant name (name/GUID resolution via `Resolve-InforcerTenantId`). Supports `-OutputType JsonObject`. Required scopes: `Tenants.SecureScores.Read` + `Tenants.Read` (only when `-TenantId` is a GUID or tenant name). PSTypeName `InforcerCommunity.SecureScore` with a ListControl default view showing current/max/percentage, licensed users, enabled services, the score-history date range with the latest score, per-category current/max scores, control profile count, and a hint on how to drill into the nested arrays. - **`Get-InforcerAuditEvent -User `** — new parameter that server-side filters audit events by the specified user (matches the `user` field in the `POST /beta/auditEvents/search` request body). Previously users had to fetch all events and filter client-side. - **`Id` alias on audit event output** — `AuditEvent` objects now expose the raw `id` field as PascalCase `Id`, consistent with every other object type in the module. - **Dynamic `-EventType` tab completion** — the completer on `Get-InforcerAuditEvent -EventType` now reads `$global:InforcerCachedEventTypes`, which `Get-InforcerSupportedEventType` refreshes with the live server-side list on any authenticated call. New event types the API adds appear in tab completion automatically after the next `Get-InforcerSupportedEventType` call — no module release required. A static fallback list (currently 80 event types) covers the pre-connect case. diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index e8167d7..69d1441 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -1410,23 +1410,22 @@ Describe 'Private helpers (via module scope)' { Content = '{"message":"Internal Server Error"}' } } - $err = $null $r = & (Get-Module InforcerCommunity) { param($ev) Test-InforcerReportRunTerminal -RunId ([guid]'11111111-2222-3333-4444-555555555555') -ErrorVariable ev -ErrorAction SilentlyContinue $ev } ([ref]$null) # The error stream captured the failure - $err = $r | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } - (Test-Path variable:r) | Should -BeTrue + ($r | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) | Should -Not -BeNullOrEmpty } It 'Returns NotConnected error when no session' { & (Get-Module InforcerCommunity) { $script:InforcerSession = $null } - $err = $null - & (Get-Module InforcerCommunity) { - Test-InforcerReportRunTerminal -RunId ([guid]'11111111-2222-3333-4444-555555555555') -ErrorVariable err -ErrorAction SilentlyContinue + $err = & (Get-Module InforcerCommunity) { + Test-InforcerReportRunTerminal -RunId ([guid]'11111111-2222-3333-4444-555555555555') -ErrorVariable innerErr -ErrorAction SilentlyContinue + $innerErr } + $err | Should -Not -BeNullOrEmpty -Because 'should emit an error when no session is active' # Restore session for subsequent tests & (Get-Module InforcerCommunity) { $secKey = ConvertTo-SecureString 'fake' -AsPlainText -Force diff --git a/module/InforcerCommunity.Format.ps1xml b/module/InforcerCommunity.Format.ps1xml index b955b10..cc70e57 100644 --- a/module/InforcerCommunity.Format.ps1xml +++ b/module/InforcerCommunity.Format.ps1xml @@ -596,8 +596,21 @@ CurrentScorePercentage LicensedUserCount if ($_.enabledServices) { ($_.enabledServices) -join ', ' } else { '' } - if ($_.scores) { @($_.scores).Count } else { 0 } + + $s = @($_.scores) + if ($s.Count -eq 0) { return '(none)' } + $sorted = $s | Sort-Object { [datetime]$_.createdDateTime } + $first = $sorted[0].createdDateTime -replace 'T.*$','' + $last = $sorted[-1].createdDateTime -replace 'T.*$','' + "$($s.Count) day(s), $first → $last (latest $($sorted[-1].currentScore) / $($sorted[-1].maxScore))" + + + $c = @($_.controlCategoryScores) + if ($c.Count -eq 0) { return '(none)' } + ($c | ForEach-Object { "$($_.controlCategory): $($_.currentScore)/$($_.maxScore)" }) -join ', ' + if ($_.controlProfiles) { @($_.controlProfiles).Count } else { 0 } + 'Use $obj.Scores / .ControlCategoryScores[0].HistoricScores / .ControlProfiles to drill in' From 84ad4cb51e7ddd5a8a677eb38cdd08e5019b376a Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:51:16 +0200 Subject: [PATCH 16/22] fix: ISO 8601 dates in SecureScore ScoreHistory view (culture-safe) Previous ScriptBlock used a regex '-replace' on what looked like a string, but the API deserializer returns createdDateTime as a [DateTime]. Regex missed, and the culture-formatted ToString() bled through - nl-NL locale saw '04/09/2026 02:00:00' instead of '2026-04-09'. Explicit .ToString('yyyy-MM-dd', InvariantCulture) fixes it. Live-verified against tenant 14436. --- module/InforcerCommunity.Format.ps1xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/module/InforcerCommunity.Format.ps1xml b/module/InforcerCommunity.Format.ps1xml index cc70e57..75a6985 100644 --- a/module/InforcerCommunity.Format.ps1xml +++ b/module/InforcerCommunity.Format.ps1xml @@ -600,8 +600,9 @@ $s = @($_.scores) if ($s.Count -eq 0) { return '(none)' } $sorted = $s | Sort-Object { [datetime]$_.createdDateTime } - $first = $sorted[0].createdDateTime -replace 'T.*$','' - $last = $sorted[-1].createdDateTime -replace 'T.*$','' + $inv = [System.Globalization.CultureInfo]::InvariantCulture + $first = ([datetime]$sorted[0].createdDateTime).ToString('yyyy-MM-dd', $inv) + $last = ([datetime]$sorted[-1].createdDateTime).ToString('yyyy-MM-dd', $inv) "$($s.Count) day(s), $first → $last (latest $($sorted[-1].currentScore) / $($sorted[-1].maxScore))" From 8120e42d3ed18e7ca591703da9ca74b3eee96839 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:58:10 +0200 Subject: [PATCH 17/22] fix: cap CurrentScorePercentage at 2 decimals in SecureScore view Was rendering the raw double at full precision (79,153906866614). Now formats with '{0:F2}' - display-only, underlying property is untouched so consumers still see the full value. Live-verified: 79,15 (culture-aware comma matches the sibling CurrentScore field). --- module/InforcerCommunity.Format.ps1xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/InforcerCommunity.Format.ps1xml b/module/InforcerCommunity.Format.ps1xml index 75a6985..82fa4f8 100644 --- a/module/InforcerCommunity.Format.ps1xml +++ b/module/InforcerCommunity.Format.ps1xml @@ -593,7 +593,7 @@ CurrentScore MaxScore - CurrentScorePercentage + if ($null -ne $_.currentScorePercentage) { '{0:F2}' -f [double]$_.currentScorePercentage } else { '' } LicensedUserCount if ($_.enabledServices) { ($_.enabledServices) -join ', ' } else { '' } From e5827fe157132a3b5f958b3cbc858ddd7d7b39bc Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:07:43 +0200 Subject: [PATCH 18/22] feat: surface top 3 recommendations in SecureScore view + dedicated ControlProfile view Default SecureScore view now includes TopRecommendations - the 3 highest-scoreDifference controls, so the answer to 'what do I fix next' is on-screen without drilling. Each entry in .ControlProfiles now carries the InforcerCommunity.SecureScoreControlProfile PSTypeName. New ListControl view shows Title, ControlCategory, Service, current/max score with potential gain, RemediationImpact, ActionUrl, Id - dropping the giant HTML remediation blob from the default output. The full remediation HTML is still on the object as .remediation. Live-verified against tenant 14436. --- module/InforcerCommunity.Format.ps1xml | 29 +++++++++++- module/Public/Get-InforcerSecureScore.ps1 | 3 ++ scripts/live-securescore-check.sh | 54 +++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100755 scripts/live-securescore-check.sh diff --git a/module/InforcerCommunity.Format.ps1xml b/module/InforcerCommunity.Format.ps1xml index 82fa4f8..2548ccb 100644 --- a/module/InforcerCommunity.Format.ps1xml +++ b/module/InforcerCommunity.Format.ps1xml @@ -611,7 +611,34 @@ ($c | ForEach-Object { "$($_.controlCategory): $($_.currentScore)/$($_.maxScore)" }) -join ', ' if ($_.controlProfiles) { @($_.controlProfiles).Count } else { 0 } - 'Use $obj.Scores / .ControlCategoryScores[0].HistoricScores / .ControlProfiles to drill in' + + $cp = @($_.controlProfiles) | Where-Object { $_.scoreDifference -gt 0 } | Sort-Object -Property @{ Expression = 'scoreDifference'; Descending = $true } | Select-Object -First 3 + if (-not $cp) { return '(none - all controls maxed out)' } + ($cp | ForEach-Object { "+$($_.scoreDifference) $($_.title)" }) -join "`n" + + 'Use $obj.ControlProfiles | Sort-Object ScoreDifference -Descending for the full list. .Scores / .ControlCategoryScores[0].HistoricScores for the history.' + + + + + + + + InforcerCommunity.SecureScoreControlProfile.Default + + InforcerCommunity.SecureScoreControlProfile + + + + + + title + controlCategory + service + "$($_.currentScore) / $($_.maxScore) (+$($_.scoreDifference) if fixed)" + remediationImpact + actionUrl + id diff --git a/module/Public/Get-InforcerSecureScore.ps1 b/module/Public/Get-InforcerSecureScore.ps1 index 06f921b..9d6e6a9 100644 --- a/module/Public/Get-InforcerSecureScore.ps1 +++ b/module/Public/Get-InforcerSecureScore.ps1 @@ -94,6 +94,9 @@ function Get-InforcerSecureScore { if ($response -is [PSObject]) { $null = Add-InforcerPropertyAliases -InputObject $response -ObjectType SecureScore $response.PSObject.TypeNames.Insert(0, 'InforcerCommunity.SecureScore') + foreach ($cp in @($response.controlProfiles)) { + if ($cp -is [PSObject]) { $cp.PSObject.TypeNames.Insert(0, 'InforcerCommunity.SecureScoreControlProfile') } + } } $response } diff --git a/scripts/live-securescore-check.sh b/scripts/live-securescore-check.sh new file mode 100755 index 0000000..dbeb33a --- /dev/null +++ b/scripts/live-securescore-check.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env zsh +# live-securescore-check.sh — quick smoke test for the new SecureScore +# ListControl view against the live Inforcer API. rm after use. +# +# Requires: $INFORCER_API_KEY set in the current shell. +# Optional: pass region as $1 (uk|eu|us|anz), defaults to uk. + +set -u +REGION="${1:-uk}" + +ROOT="${0:A:h:h}" +cd "$ROOT" + +KEYFILE="$ROOT/.inforcer-key.local" +if [[ -z "${INFORCER_API_KEY:-}" && -f "$KEYFILE" ]]; then + INFORCER_API_KEY="$(tr -d ' \t\n\r' < "$KEYFILE")" + export INFORCER_API_KEY + print -P "%F{cyan}Loaded key from .inforcer-key.local%f" +fi + +if [[ -z "${INFORCER_API_KEY:-}" ]]; then + print -P "%F{red}INFORCER_API_KEY not set.%f Options:" + print -P " 1. Inline: %F{cyan}INFORCER_API_KEY=... scripts/live-securescore-check.sh%f" + print -P " 2. Key file: %F{cyan}echo '' > .inforcer-key.local%f (gitignored)" + exit 1 +fi + +pwsh -NoProfile -Command " + Import-Module ./module/InforcerCommunity.psd1 -Force + \$k = ConvertTo-SecureString \$env:INFORCER_API_KEY -AsPlainText -Force + Connect-Inforcer -ApiKey \$k -Region '$REGION' | Out-Null + \$t = Get-InforcerTenant | Select-Object -First 1 + if (-not \$t) { Write-Error 'No tenant returned - check region or key.'; exit 1 } + \$tid = \$t.ClientTenantId + \$tname = \$t.TenantFriendlyName + Write-Host ('Tenant: {0} - {1}' -f \$tid, \$tname) + + \$s = Get-InforcerSecureScore -TenantId \$tid + Write-Host '' + Write-Host '=== default view (Format-List) ===' -ForegroundColor Cyan + \$s | Format-List | Out-Host + + Write-Host '=== drill-in checks ===' -ForegroundColor Cyan + Write-Host ('Scores : {0} entries, latest {1} on {2}' -f @(\$s.Scores).Count, \$s.Scores[0].CurrentScore, \$s.Scores[0].CreatedDateTime) + if (\$s.ControlCategoryScores) { + \$c = \$s.ControlCategoryScores[0] + Write-Host ('CategoryScores[0]: {0} - {1}/{2}, {3} historic points' -f \$c.ControlCategory, \$c.CurrentScore, \$c.MaxScore, @(\$c.HistoricScores).Count) + } + Write-Host ('ControlProfiles : {0} entries' -f @(\$s.ControlProfiles).Count) + + Write-Host '' + Write-Host '=== top recommendation (Format-List on ControlProfile) ===' -ForegroundColor Cyan + \$s.ControlProfiles | Where-Object { \$_.scoreDifference -gt 0 } | Sort-Object scoreDifference -Descending | Select-Object -First 1 | Format-List | Out-Host +" From 50f84752615a725f203f58a0c2ec8ec8750919d9 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:07:51 +0200 Subject: [PATCH 19/22] chore: remove throwaway live-securescore-check.sh (was for local verification only) --- scripts/live-securescore-check.sh | 54 ------------------------------- 1 file changed, 54 deletions(-) delete mode 100755 scripts/live-securescore-check.sh diff --git a/scripts/live-securescore-check.sh b/scripts/live-securescore-check.sh deleted file mode 100755 index dbeb33a..0000000 --- a/scripts/live-securescore-check.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env zsh -# live-securescore-check.sh — quick smoke test for the new SecureScore -# ListControl view against the live Inforcer API. rm after use. -# -# Requires: $INFORCER_API_KEY set in the current shell. -# Optional: pass region as $1 (uk|eu|us|anz), defaults to uk. - -set -u -REGION="${1:-uk}" - -ROOT="${0:A:h:h}" -cd "$ROOT" - -KEYFILE="$ROOT/.inforcer-key.local" -if [[ -z "${INFORCER_API_KEY:-}" && -f "$KEYFILE" ]]; then - INFORCER_API_KEY="$(tr -d ' \t\n\r' < "$KEYFILE")" - export INFORCER_API_KEY - print -P "%F{cyan}Loaded key from .inforcer-key.local%f" -fi - -if [[ -z "${INFORCER_API_KEY:-}" ]]; then - print -P "%F{red}INFORCER_API_KEY not set.%f Options:" - print -P " 1. Inline: %F{cyan}INFORCER_API_KEY=... scripts/live-securescore-check.sh%f" - print -P " 2. Key file: %F{cyan}echo '' > .inforcer-key.local%f (gitignored)" - exit 1 -fi - -pwsh -NoProfile -Command " - Import-Module ./module/InforcerCommunity.psd1 -Force - \$k = ConvertTo-SecureString \$env:INFORCER_API_KEY -AsPlainText -Force - Connect-Inforcer -ApiKey \$k -Region '$REGION' | Out-Null - \$t = Get-InforcerTenant | Select-Object -First 1 - if (-not \$t) { Write-Error 'No tenant returned - check region or key.'; exit 1 } - \$tid = \$t.ClientTenantId - \$tname = \$t.TenantFriendlyName - Write-Host ('Tenant: {0} - {1}' -f \$tid, \$tname) - - \$s = Get-InforcerSecureScore -TenantId \$tid - Write-Host '' - Write-Host '=== default view (Format-List) ===' -ForegroundColor Cyan - \$s | Format-List | Out-Host - - Write-Host '=== drill-in checks ===' -ForegroundColor Cyan - Write-Host ('Scores : {0} entries, latest {1} on {2}' -f @(\$s.Scores).Count, \$s.Scores[0].CurrentScore, \$s.Scores[0].CreatedDateTime) - if (\$s.ControlCategoryScores) { - \$c = \$s.ControlCategoryScores[0] - Write-Host ('CategoryScores[0]: {0} - {1}/{2}, {3} historic points' -f \$c.ControlCategory, \$c.CurrentScore, \$c.MaxScore, @(\$c.HistoricScores).Count) - } - Write-Host ('ControlProfiles : {0} entries' -f @(\$s.ControlProfiles).Count) - - Write-Host '' - Write-Host '=== top recommendation (Format-List on ControlProfile) ===' -ForegroundColor Cyan - \$s.ControlProfiles | Where-Object { \$_.scoreDifference -gt 0 } | Sort-Object scoreDifference -Descending | Select-Object -First 1 | Format-List | Out-Host -" From 1a73ff404e392e3b2231e9952ccd5ee6c327f93d Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:14:59 +0200 Subject: [PATCH 20/22] test: cover SecureScore drill-in patterns from the docs examples Two new tests protect the drill-in patterns published in the docs: 1. 'drill-in patterns from CMDLET-REFERENCE examples' - exercises sort by ScoreDifference, filter by Id, Group-Object + Measure-Object aggregation on category totals, and .ControlCategoryScores.HistoricScores traversal. Guards against alias-pipeline regressions that would silently break the documented one-liners. 2. 'nested objects carry PSTypeName for the format view' - regression guard for the InforcerCommunity.SecureScoreControlProfile type name that the new ListControl view relies on. Without this, .ControlProfiles would render the full HTML remediation blob on every item. Also: fixed one more pre-existing PSSA PSUseDeclaredVarsMoreThanAssignments warning at line 1555 - test had a parent-scope $err that never got written to because -ErrorVariable in a child scope stays isolated. Now captures correctly and asserts the API 'Forbidden' message is extracted. Suite: 399 passed, 2 skipped on Pester 5.7.1 and 6.0.0. --- Tests/Consistency.Tests.ps1 | 77 +++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index 69d1441..ee6a29c 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -831,6 +831,74 @@ Describe 'Private helpers (via module scope)' { } } + It 'SecureScore ControlProfiles: drill-in patterns from CMDLET-REFERENCE examples' { + & (Get-Module InforcerCommunity) { + # Fixture with 4 recommendations across 2 categories + $secure = [PSCustomObject]@{ + controlProfiles = @( + [PSCustomObject]@{ id = 'AdminMFAV2'; title = 'Require MFA for admins'; controlCategory = 'Identity'; service = 'AzureAD'; currentScore = 0; maxScore = 10; scoreDifference = 10; actionUrl = 'https://example/adminmfa' } + [PSCustomObject]@{ id = 'scid_5001'; title = 'Fix MDE macOS'; controlCategory = 'Device'; service = 'MDATP'; currentScore = 1.25; maxScore = 10; scoreDifference = 8.75; actionUrl = 'https://example/mde' } + [PSCustomObject]@{ id = 'MFARegistrationV2'; title = 'MFA for all users'; controlCategory = 'Identity'; service = 'AzureAD'; currentScore = 5.63; maxScore = 9; scoreDifference = 3.37; actionUrl = 'https://example/mfa' } + [PSCustomObject]@{ id = 'zero-gap'; title = 'Already done'; controlCategory = 'Device'; service = 'MDATP'; currentScore = 5; maxScore = 5; scoreDifference = 0; actionUrl = 'https://example/done' } + ) + controlCategoryScores = @( + [PSCustomObject]@{ controlCategory = 'Identity'; currentScore = 5.63; maxScore = 19; historicScores = @( + [PSCustomObject]@{ createdDateTime = '2026-07-01'; currentScore = 5.63; maxScore = 19 } + [PSCustomObject]@{ createdDateTime = '2026-06-30'; currentScore = 4.63; maxScore = 19 } + ) } + ) + } + $null = Add-InforcerPropertyAliases -InputObject $secure -ObjectType SecureScore + + # #2 in the docs: full list, biggest-gain first + $ranked = $secure.ControlProfiles | Sort-Object ScoreDifference -Descending + $ranked[0].Id | Should -Be 'AdminMFAV2' + $ranked[-1].Id | Should -Be 'zero-gap' + + # #3: top-3 with Format-Table columns + $top3 = $ranked | Select-Object -First 3 + @($top3).Count | Should -Be 3 + $top3[0].ScoreDifference | Should -Be 10 + + # #4: pick one recommendation by Id + $one = $secure.ControlProfiles | Where-Object Id -eq 'AdminMFAV2' + @($one).Count | Should -Be 1 + $one.Title | Should -Be 'Require MFA for admins' + + # #5: group unfinished work by category with total gain per category + $byCat = $secure.ControlProfiles | + Where-Object ScoreDifference -gt 0 | + Group-Object ControlCategory | + Select-Object Name, Count, @{n='TotalGain';e={($_.Group | Measure-Object ScoreDifference -Sum).Sum}} + [Math]::Round(($byCat | Where-Object Name -eq 'Identity').TotalGain, 2) | Should -Be 13.37 + [Math]::Round(($byCat | Where-Object Name -eq 'Device').TotalGain, 2) | Should -Be 8.75 + + # #7: category history drill-in via PascalCase alias + $identityHistory = ($secure.ControlCategoryScores | Where-Object ControlCategory -eq 'Identity').HistoricScores + @($identityHistory).Count | Should -Be 2 + $identityHistory[0].CreatedDateTime | Should -Be '2026-07-01' + } + } + + It 'SecureScore ControlProfile: nested objects carry PSTypeName for the format view' { + & (Get-Module InforcerCommunity) { + # Simulate what Get-InforcerSecureScore does after the API call + $response = [PSCustomObject]@{ + currentScore = 1; maxScore = 10; currentScorePercentage = 10; licensedUserCount = 0 + enabledServices = @(); scores = @(); controlCategoryScores = @() + controlProfiles = @( + [PSCustomObject]@{ id = 'a'; title = 't'; controlCategory = 'Identity'; service = 'AzureAD'; currentScore = 0; maxScore = 10; scoreDifference = 10; actionUrl = 'x' } + ) + } + $null = Add-InforcerPropertyAliases -InputObject $response -ObjectType SecureScore + $response.PSObject.TypeNames.Insert(0, 'InforcerCommunity.SecureScore') + foreach ($cp in @($response.controlProfiles)) { + $cp.PSObject.TypeNames.Insert(0, 'InforcerCommunity.SecureScoreControlProfile') + } + $response.ControlProfiles[0].PSObject.TypeNames | Should -Contain 'InforcerCommunity.SecureScoreControlProfile' + } + } + It 'AuditEvent: adds Id alias' { & (Get-Module InforcerCommunity) { $ev = [PSCustomObject]@{ @@ -1484,11 +1552,12 @@ Describe 'Private helpers (via module scope)' { Content = ([System.Text.Encoding]::UTF8.GetBytes('{"success":false,"message":"Forbidden"}')) } } - $err = $null - & (Get-Module InforcerCommunity) { - Invoke-InforcerRawDownload -Endpoint '/x' -ErrorVariable err -ErrorAction SilentlyContinue + $err = & (Get-Module InforcerCommunity) { + Invoke-InforcerRawDownload -Endpoint '/x' -ErrorVariable innerErr -ErrorAction SilentlyContinue + $innerErr } - # The mock should fire — caller gets no bytes back + $err | Should -Not -BeNullOrEmpty -Because 'a 4xx response should surface an error record' + "$err" | Should -Match 'Forbidden' -Because 'API message should be extracted into the error text' } It 'Streams to disk when -DestinationDirectory is set (no Bytes in output)' { From 911ae3db12d383b8fd3f14b975eae787ce4205f2 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:35:27 +0200 Subject: [PATCH 21/22] docs: update SecureScore docs + Get-Help examples to match v0.6.0 view - docs/CMDLET-REFERENCE.md: replaced 4 lightweight examples with 10 drill-in patterns (group-by-category with TotalGain, category-history drill-in, CSV export, Start-Process on ActionUrl, etc.). Updated the Example Output block to match the new default view (ScoreHistory date range, ControlCategoryScores inline, TopRecommendations, Hint). - docs/API-REFERENCE.md: added PSTypeName note for InforcerCommunity.SecureScoreControlProfile and explained that HTML remediation is excluded from the view but stays on the object. - Get-InforcerSecureScore.ps1: added two new .EXAMPLE blocks so Get-Help now surfaces the group-by-category and category-history drill-in patterns that the docs advertise. - .gitignore: exclude .inforcer-key.local (local live-testing key file). Suite: 399/399 on Pester 5.7.1 and 6.0.0. --- .gitignore | 3 ++ docs/API-REFERENCE.md | 4 +- docs/CMDLET-REFERENCE.md | 60 +++++++++++++++++------ module/Public/Get-InforcerSecureScore.ps1 | 15 ++++++ 4 files changed, 66 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 634992c..760b72b 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ api-feedback-evidence/ *api-feedback*.md .planning-archive/ scripts/Test-ApiFeedbackItems.ps1 + +# Local API key for live-* scripts (never commit) +.inforcer-key.local diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index 69b8ac6..7b8b317 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -825,10 +825,12 @@ Actionable control profile in the `controlProfiles` array of `TenantSecureScoreD | maxScorePercentage | number | No | Maximum score as a percentage. | | scoreDifference | number | No | Gap between current and max score (higher = larger remediation opportunity). | | scoreDifferencePercentage | number | No | Score difference as a percentage. | -| remediation | string | No | Recommended remediation steps. | +| remediation | string | No | Recommended remediation steps (HTML). | | remediationImpact | string | No | Impact description of the remediation. | | actionUrl | string | No | URL to take the recommended action. | +**PSTypeName:** `InforcerCommunity.SecureScoreControlProfile`. Each item in `.ControlProfiles` on a `TenantSecureScoreDetails` gets this PSTypeName inserted so the default `Format-List` view renders `Title`, `ControlCategory`, `Service`, current/max score with potential gain, `RemediationImpact`, `ActionUrl`, `Id` — the full HTML `remediation` string stays accessible via `.remediation`. + ### ReportType A catalog entry from `GET /beta/reports/types`. Field names verified against `api-uk.inforcer.com`. diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index d04bacc..87ffc63 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -560,15 +560,38 @@ Retrieves the current and historic Microsoft Secure Score for a tenant, includin ```powershell # Summary view for one tenant -Get-InforcerSecureScore -TenantId 139 +$s = Get-InforcerSecureScore -TenantId 139 +$s -# 90-day score history -(Get-InforcerSecureScore -TenantId 139).Scores +# Full recommendation list, biggest wins first +$s.ControlProfiles | Sort-Object ScoreDifference -Descending -# Top 10 controls with the biggest score gap (highest-value remediation targets) -(Get-InforcerSecureScore -TenantId 139).ControlProfiles | - Sort-Object ScoreDifference -Descending | - Select-Object Title, Service, CurrentScore, MaxScore, Remediation -First 10 +# Top 10 quick wins as a compact table +$s.ControlProfiles | Sort-Object ScoreDifference -Descending | Select-Object -First 10 | + Format-Table Title, ControlCategory, Service, ScoreDifference -AutoSize + +# One specific recommendation with full detail (including HTML remediation) +$s.ControlProfiles | Where-Object Id -eq 'AdminMFAV2' | Select-Object * + +# Unfinished work grouped by category with total potential gain +$s.ControlProfiles | Where-Object ScoreDifference -gt 0 | + Group-Object ControlCategory | + Select-Object Name, Count, @{n='TotalGain';e={($_.Group | Measure-Object ScoreDifference -Sum).Sum}} + +# Score trend for the last 30 days +$s.Scores | Select-Object -First 30 CreatedDateTime, CurrentScore, CurrentScorePercentage + +# Category history — Identity's daily scores +($s.ControlCategoryScores | Where-Object ControlCategory -eq 'Identity').HistoricScores | + Format-Table CreatedDateTime, CurrentScore, MaxScore, CurrentScorePercentage -AutoSize + +# Export the recommendations to CSV for a ticket +$s.ControlProfiles | Sort-Object ScoreDifference -Descending | + Select-Object Title, ControlCategory, Service, CurrentScore, MaxScore, ScoreDifference, ActionUrl | + Export-Csv ./secure-score-actions.csv -NoTypeInformation + +# Open the fix page for the top recommendation +Start-Process ($s.ControlProfiles | Sort-Object ScoreDifference -Descending | Select-Object -First 1).ActionUrl # Pipeline from Get-InforcerTenant Get-InforcerTenant -TenantId 139 | Get-InforcerSecureScore @@ -577,16 +600,23 @@ Get-InforcerTenant -TenantId 139 | Get-InforcerSecureScore ### Example output (default list view) ``` -CurrentScore : 412.5 -MaxScore : 640 -CurrentScorePercentage : 64.45 -LicensedUserCount : 275 -EnabledServices : AzureAD, Exchange, SharePoint, Defender -ScoreHistoryDays : 90 -ControlProfilesCount : 128 +CurrentScore : 1002.88 +MaxScore : 1267 +CurrentScorePercentage : 79.15 +LicensedUserCount : 0 +EnabledServices : HasAADP1, HasCASD, HasMDOP1, HasDLP, HasAIPP1, HasMDB, HasSPOP1 +ScoreHistory : 90 day(s), 2026-04-09 → 2026-07-07 (latest 1002.88 / 1267) +ControlCategoryScores : Identity: 49.13/71, Apps: 177/198, Data: 7/9, Device: 769.75/989 +ControlProfilesCount : 64 +TopRecommendations : +10 Ensure multifactor authentication is enabled for all users in administrative roles + +8.75 Fix Microsoft Defender for Endpoint sensor data collection in macOS + +8.75 Fix Microsoft Defender for Endpoint impaired communications in macOS +Hint : Use $obj.ControlProfiles | Sort-Object ScoreDifference -Descending for the full list. .Scores / .ControlCategoryScores[0].HistoricScores for the history. ``` -Use `| Select-Object *` to see all properties including `Scores`, `ControlProfiles`, and `ControlCategoryScores`. +Each nested item in `.ControlProfiles` carries the PSTypeName `InforcerCommunity.SecureScoreControlProfile` and renders as a compact list (`Title`, `ControlCategory`, `Service`, current/max score with potential gain, `RemediationImpact`, `ActionUrl`, `Id`) — the full HTML `remediation` string is still on the object as `.remediation` when needed. + +Use `| Select-Object *` to see all properties on any level. --- diff --git a/module/Public/Get-InforcerSecureScore.ps1 b/module/Public/Get-InforcerSecureScore.ps1 index 9d6e6a9..4bf8e4f 100644 --- a/module/Public/Get-InforcerSecureScore.ps1 +++ b/module/Public/Get-InforcerSecureScore.ps1 @@ -32,6 +32,21 @@ function Get-InforcerSecureScore { Lists the 10 controls with the largest gap between current and max score. + .EXAMPLE + $s = Get-InforcerSecureScore -TenantId 139 + $s.ControlProfiles | Where-Object ScoreDifference -gt 0 | + Group-Object ControlCategory | + Select-Object Name, Count, @{n='TotalGain';e={($_.Group | Measure-Object ScoreDifference -Sum).Sum}} + + Groups open recommendations by category with total potential score gain per category. + + .EXAMPLE + $s = Get-InforcerSecureScore -TenantId 139 + ($s.ControlCategoryScores | Where-Object ControlCategory -eq 'Identity').HistoricScores | + Format-Table CreatedDateTime, CurrentScore, MaxScore, CurrentScorePercentage -AutoSize + + Shows the daily Identity category history for the tenant. + .EXAMPLE Get-InforcerTenant -TenantId 139 | Get-InforcerSecureScore From e18640847f9a54512f3fb82d085f7c3f97bb211d Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:43:21 +0200 Subject: [PATCH 22/22] docs: bring CHANGELOG in sync with the v0.6.0 SecureScore view + drill-in docs - Expanded the SecureScore feature bullet: enumerates every field in the default ListControl view including TopRecommendations, culture-safe ISO date range, per-category inline breakdown, and 2-decimal-capped percentage. - Added a new bullet for the nested PSTypeName 'InforcerCommunity.SecureScoreControlProfile' so users know why $s.ControlProfiles | Format-List renders a compact view instead of dumping the giant HTML remediation blob. - Added a Documentation bullet cross-referencing the 10 drill-in patterns now surfaced in both CMDLET-REFERENCE and Get-Help. --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1376e93..34dc4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,13 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). ### Features -- **New cmdlet: `Get-InforcerSecureScore`** — retrieves the current and historic Microsoft Secure Score for a tenant from `GET /beta/tenants/{tenantId}/secureScores`. Returns the current score, max score, licensed user count, enabled services, up to 90 days of daily score history, per-category scores, and actionable control profiles with remediation guidance. `-TenantId` accepts numeric ID, GUID, or tenant name (name/GUID resolution via `Resolve-InforcerTenantId`). Supports `-OutputType JsonObject`. Required scopes: `Tenants.SecureScores.Read` + `Tenants.Read` (only when `-TenantId` is a GUID or tenant name). PSTypeName `InforcerCommunity.SecureScore` with a ListControl default view showing current/max/percentage, licensed users, enabled services, the score-history date range with the latest score, per-category current/max scores, control profile count, and a hint on how to drill into the nested arrays. +- **New cmdlet: `Get-InforcerSecureScore`** — retrieves the current and historic Microsoft Secure Score for a tenant from `GET /beta/tenants/{tenantId}/secureScores`. Returns the current score, max score, licensed user count, enabled services, up to 90 days of daily score history, per-category scores, and actionable control profiles with remediation guidance. `-TenantId` accepts numeric ID, GUID, or tenant name (name/GUID resolution via `Resolve-InforcerTenantId`). Supports `-OutputType JsonObject`. Required scopes: `Tenants.SecureScores.Read` + `Tenants.Read` (only when `-TenantId` is a GUID or tenant name). PSTypeName `InforcerCommunity.SecureScore` with a ListControl default view showing: + - `CurrentScore` / `MaxScore` / `CurrentScorePercentage` (capped at 2 decimals for display) / `LicensedUserCount` / `EnabledServices` (joined). + - `ScoreHistory` — day count + `first → last` ISO 8601 date range + latest score. Culture-safe: dates use InvariantCulture so nl-NL / de-DE etc. all read the same as en-US. + - `ControlCategoryScores` — every category inline, e.g. `Identity: 49.13/71, Apps: 177/198, Data: 7/9, Device: 769.75/989`. + - `ControlProfilesCount` and `TopRecommendations` — the 3 highest-`scoreDifference` open controls, so "what do I fix next" is on-screen without drilling. + - `Hint` — points to `.ControlProfiles | Sort-Object ScoreDifference -Descending`, `.Scores`, and `.ControlCategoryScores[0].HistoricScores` for the full data. +- **New nested PSTypeName: `InforcerCommunity.SecureScoreControlProfile`** — every item in `.ControlProfiles` gets this type inserted, so `$s.ControlProfiles | Format-List` renders a compact view (`Title`, `ControlCategory`, `Service`, current/max score with potential gain, `RemediationImpact`, `ActionUrl`, `Id`) instead of dumping the giant HTML `remediation` blob. The full remediation HTML is still on the object as `.remediation` when needed. - **`Get-InforcerAuditEvent -User `** — new parameter that server-side filters audit events by the specified user (matches the `user` field in the `POST /beta/auditEvents/search` request body). Previously users had to fetch all events and filter client-side. - **`Id` alias on audit event output** — `AuditEvent` objects now expose the raw `id` field as PascalCase `Id`, consistent with every other object type in the module. - **Dynamic `-EventType` tab completion** — the completer on `Get-InforcerAuditEvent -EventType` now reads `$global:InforcerCachedEventTypes`, which `Get-InforcerSupportedEventType` refreshes with the live server-side list on any authenticated call. New event types the API adds appear in tab completion automatically after the next `Get-InforcerSupportedEventType` call — no module release required. A static fallback list (currently 80 event types) covers the pre-connect case. @@ -19,6 +25,7 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). - **`Required API scope(s):` line added to `Get-Help` output for 13 cmdlets** — `Compare-InforcerEnvironments`, `Export-InforcerTenantDocumentation`, `Get-InforcerAlignmentDetails`, `Get-InforcerAssessment`, `Get-InforcerAuditEvent`, `Get-InforcerBaseline`, `Get-InforcerGroup`, `Get-InforcerRole`, `Get-InforcerSupportedEventType`, `Get-InforcerTenant`, `Get-InforcerTenantPolicies`, `Get-InforcerUser`, `Invoke-InforcerAssessment`. Users no longer need to leave PowerShell and open `docs/API-REFERENCE.md` to learn which scope a cmdlet needs. Follows the pattern already used by the Reports cmdlets. - **Removed stale `PolicyDiffFormatted` mention** in `docs/CMDLET-REFERENCE.md` — the property was removed from the module in an earlier version (see FINDINGS #63) but one line in the docs was missed. - Added `Get-InforcerSecureScore` section to `docs/CMDLET-REFERENCE.md`; endpoint description, schemas (`TenantSecureScoreDetails`, `TenantSecureScoreHistoryPoint`, `TenantSecureScoreControlProfile`), and scope-mapping row added to `docs/API-REFERENCE.md`; cmdlet added to the README public-surface table. +- Expanded the `Get-InforcerSecureScore` docs and `Get-Help` examples with 10 drill-in patterns: sort recommendations by score gap, filter by control `Id`, group open work by category with total potential gain, drill into a category's daily history, `Export-Csv` for a remediation ticket, `Start-Process` on the top `ActionUrl`, etc. ### Bug Fixes