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/CHANGELOG.md b/CHANGELOG.md index 9bbfc6c..34dc4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ 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: + - `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. +- **`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 + +- **`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 + +- **`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 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..ee6a29c 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,129 @@ 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 } + ) + 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 + $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 + $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 + } + } + + 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]@{ + 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' { @@ -1348,23 +1478,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 @@ -1423,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)' { @@ -1468,24 +1598,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 } } @@ -1629,7 +1757,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 { @@ -1665,7 +1793,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' { @@ -1688,7 +1816,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 } @@ -1821,7 +1949,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 } @@ -1851,9 +1979,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 } diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index d343638..7b8b317 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,56 @@ 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 (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 bf53dab..87ffc63 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` @@ -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,84 @@ 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 +$s = Get-InforcerSecureScore -TenantId 139 +$s + +# Full recommendation list, biggest wins first +$s.ControlProfiles | Sort-Object ScoreDifference -Descending + +# 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 +``` + +### Example output (default list view) + +``` +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. +``` + +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. + +--- + ## 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..2548ccb 100644 --- a/module/InforcerCommunity.Format.ps1xml +++ b/module/InforcerCommunity.Format.ps1xml @@ -582,5 +582,68 @@ + + InforcerCommunity.SecureScore.Default + + InforcerCommunity.SecureScore + + + + + + CurrentScore + MaxScore + if ($null -ne $_.currentScorePercentage) { '{0:F2}' -f [double]$_.currentScorePercentage } else { '' } + LicensedUserCount + if ($_.enabledServices) { ($_.enabledServices) -join ', ' } else { '' } + + $s = @($_.scores) + if ($s.Count -eq 0) { return '(none)' } + $sorted = $s | Sort-Object { [datetime]$_.createdDateTime } + $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))" + + + $c = @($_.controlCategoryScores) + if ($c.Count -eq 0) { return '(none)' } + ($c | ForEach-Object { "$($_.controlCategory): $($_.currentScore)/$($_.maxScore)" }) -join ', ' + + if ($_.controlProfiles) { @($_.controlProfiles).Count } else { 0 } + + $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/InforcerCommunity.psd1 b/module/InforcerCommunity.psd1 index 9f578f8..e42deff 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' @@ -37,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 c02e599..a3b01bf 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,74 @@ 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' + } + } + } + # 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' + } + } + } + } + } + } + } } $obj 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 } diff --git a/module/Private/ConvertTo-InforcerDocModel.ps1 b/module/Private/ConvertTo-InforcerDocModel.ps1 index 5379afb..b2f85a3 100644 --- a/module/Private/ConvertTo-InforcerDocModel.ps1 +++ b/module/Private/ConvertTo-InforcerDocModel.ps1 @@ -44,6 +44,87 @@ 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()][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 @@ -206,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) { 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 - } -} 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' 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..33f63f6 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). @@ -16,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 @@ -65,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*" }) @@ -109,6 +98,9 @@ param( [Parameter(Mandatory = $false)] [DateTime]$DateTo, + [Parameter(Mandatory = $false)] + [string]$User, + [Parameter(Mandatory = $false)] [int]$PageSize = 100, @@ -179,6 +171,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-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-InforcerSecureScore.ps1 b/module/Public/Get-InforcerSecureScore.ps1 new file mode 100644 index 0000000..4bf8e4f --- /dev/null +++ b/module/Public/Get-InforcerSecureScore.ps1 @@ -0,0 +1,118 @@ +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 + $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 + + 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..." + + # -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') { + return ($response | ConvertTo-Json -Depth 100) + } + + 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/module/Public/Get-InforcerSupportedEventType.ps1 b/module/Public/Get-InforcerSupportedEventType.ps1 index 7a55624..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' ) } @@ -34,6 +60,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..02b54bf 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. @@ -96,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 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. 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 }