From 56f9844c63f754b6bd8407909f96d76d830d9d8a Mon Sep 17 00:00:00 2001 From: "Christopher Scott (CJ)" Date: Mon, 28 Sep 2026 14:13:46 -0400 Subject: [PATCH 1/2] Harden deployment identity, emergency access, evidence, and Graph boundaries Return verified emergency exclusions in memory rather than trusting diagnostic JSON. Add synthetic module/orchestrator regressions and operator recovery guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 +- CHANGELOG.md | 17 + .../Defender/Deploy-DefenderBestPractice.ps1 | 26 +- Products/Defender/Modules/DefenderRunLog.ps1 | 4 +- .../Modules/DefenderStateManagement.ps1 | 3 +- Products/Defender/README.md | 6 + Products/Defender/docs/evidence.md | 11 +- Products/Defender/docs/troubleshooting.md | 6 + Products/Entra/Config/EntraConfig.psd1 | 4 +- Products/Entra/Deploy-EntraBestPractice.ps1 | 48 +- .../Entra/Modules/Connect-EntraServices.ps1 | 27 +- .../Entra/Modules/Setup-EmergencyAccess.ps1 | 89 +++- Products/Entra/README.md | 13 +- Products/Entra/docs/Break-Glass-Guide.md | 31 +- .../Entra/docs/Evidence-Troubleshooting.md | 18 +- Products/Entra/docs/Operator-Guide.md | 8 +- Products/Entra/docs/Scenarios.md | 7 +- Products/Intune/Deploy-IntuneBestPractice.ps1 | 3 + .../Intune/Modules/Connect-IntuneServices.ps1 | 28 +- Products/Intune/Modules/IntuneGraphClient.ps1 | 66 ++- .../Intune/Modules/Setup-AppDeployment.ps1 | 29 +- .../Modules/Setup-AppProtectionPolicies.ps1 | 26 +- .../Setup-DeviceCompliancePolicies.ps1 | 15 +- .../Modules/Setup-DeviceConditionalAccess.ps1 | 31 +- .../Modules/Setup-EnrollmentRestrictions.ps1 | 18 +- Products/Intune/README.md | 12 + .../Intune/docs/Configuration-Reference.md | 25 + .../Intune/docs/Evidence-Troubleshooting.md | 12 + docs/Responsible-AI-Review.md | 113 +++- docs/conventions.md | 8 + scripts/Test-DefenderComparableValues.ps1 | 43 ++ scripts/Test-DeploymentSafeguards.ps1 | 500 ++++++++++++++++++ 32 files changed, 1081 insertions(+), 168 deletions(-) create mode 100644 scripts/Test-DefenderComparableValues.ps1 create mode 100644 scripts/Test-DeploymentSafeguards.ps1 diff --git a/AGENTS.md b/AGENTS.md index b7ba56a..9d71740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,4 +37,4 @@ whenever creating or updating a PR or work item. | `.github/copilot-instructions.md` | Pointer | Redirects Copilot to this entry point. | | `scripts/verify.ps1` | Executable verification | Local syntax and configuration checks. | | `Products/` | Implementation and product docs | Product behavior and operator instructions. | -| [Responsible AI review](docs/Responsible-AI-Review.md) | Change review | Scope and safeguards of this context-consolidation change. | +| [Responsible AI reviews](docs/Responsible-AI-Review.md) | Change reviews | Separate scope and safeguards for context consolidation and deployment remediation. | diff --git a/CHANGELOG.md b/CHANGELOG.md index ee60ea8..8d3b569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,23 @@ landed but not yet been tagged in a release appear under **Unreleased**. ### Fixed +- [Entra] Stop after opt-in emergency-account creation, before Conditional + Access writes. Require manual credential and permanent Global Administrator + setup, configured object IDs and current-run verification; avoid duplicate + creation on rerun. Use only a validated in-memory module result for CA + exclusions, not editable JSON evidence, and reject malformed/stale results. +- [Entra, Intune] Verify the actual operator after fresh Graph sign-in before + successful connection evidence or setup. Invalid account/tenant contexts + trigger disconnect without masking the original error; scopes are unchanged. +- [Defender] Restore object-property initialization in desired-state + comparisons while retaining scalar string comparisons. Attempt JSON and + HTML exports independently, warn on failures, preserve deployment errors, + and always clean up run-log state. +- [Intune] Validate stable and beta Graph bases before authentication and + standalone app dispatch. Reject unsafe endpoint components and continuation + links outside the exact collection authority/path, while allowing legitimate + paging queries and supported Graph cloud hosts. + - [Defender] Target Graph sign-in at the delegated customer and verify the requested operator before reusing a cached session or reporting preflight success. Explicit tenant IDs retain precedence; permissions and blocked diff --git a/Products/Defender/Deploy-DefenderBestPractice.ps1 b/Products/Defender/Deploy-DefenderBestPractice.ps1 index 8ab85b5..a6e55b2 100644 --- a/Products/Defender/Deploy-DefenderBestPractice.ps1 +++ b/Products/Defender/Deploy-DefenderBestPractice.ps1 @@ -370,13 +370,25 @@ catch { throw } finally { - if ($global:DefenderRunLog) { + if ($null -ne $global:DefenderRunLog) { $endTime = [datetime]::UtcNow - Save-DefenderRunLogJson -EndTime $endTime - Write-DefenderHtmlReport -Path $htmlPath -Entries (Get-DefenderRunLog) ` - -RunId $runId -StartTime $startTime -EndTime $endTime ` - -TenantId $TenantId -TenantAdminUpn $TenantAdminUpn ` - -ScriptVersion $config.ProductVersion - Clear-DefenderRunLog + try { + try { Save-DefenderRunLogJson -EndTime $endTime } + catch { + Write-Warning ("JSON run log could not be written: {0}" -f (Protect-DefenderLogText $_.Exception.Message)) -WarningAction Continue + } + try { + Write-DefenderHtmlReport -Path $htmlPath -Entries (Get-DefenderRunLog) ` + -RunId $runId -StartTime $startTime -EndTime $endTime ` + -TenantId $TenantId -TenantAdminUpn $TenantAdminUpn ` + -ScriptVersion $config.ProductVersion + } + catch { + Write-Warning ("HTML report could not be written: {0}" -f (Protect-DefenderLogText $_.Exception.Message)) -WarningAction Continue + } + } + finally { + Clear-DefenderRunLog + } } } diff --git a/Products/Defender/Modules/DefenderRunLog.ps1 b/Products/Defender/Modules/DefenderRunLog.ps1 index 0733246..1499ce6 100644 --- a/Products/Defender/Modules/DefenderRunLog.ps1 +++ b/Products/Defender/Modules/DefenderRunLog.ps1 @@ -89,10 +89,10 @@ function Save-DefenderRunLogJson { } # Evidence output is local, not a tenant change; never suppress it under # -WhatIf. - $payload | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding utf8 -WhatIf:$false + $payload | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding utf8 -WhatIf:$false -ErrorAction Stop } catch { - Write-Verbose "Save-DefenderRunLogJson failed: $($_.Exception.Message)" + Write-Warning ("JSON run log could not be written: {0}" -f (Protect-DefenderLogText $_.Exception.Message)) -WarningAction Continue } } diff --git a/Products/Defender/Modules/DefenderStateManagement.ps1 b/Products/Defender/Modules/DefenderStateManagement.ps1 index 8f6ec00..458e2e0 100644 --- a/Products/Defender/Modules/DefenderStateManagement.ps1 +++ b/Products/Defender/Modules/DefenderStateManagement.ps1 @@ -72,11 +72,12 @@ function ConvertTo-DefenderComparableValue { if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { return @($Value | ForEach-Object { ConvertTo-DefenderComparableValue -Value $_ }) } -if ($Value -is [string]) { return $Value } + if ($Value -is [string]) { return $Value } $properties = @($Value.PSObject.Properties | Where-Object { $_.MemberType -in @('NoteProperty', 'Property') }) if ($properties.Count -gt 0) { + $result = [ordered]@{} foreach ($property in ($properties | Sort-Object Name)) { $result[$property.Name] = ConvertTo-DefenderComparableValue -Value $property.Value } diff --git a/Products/Defender/README.md b/Products/Defender/README.md index 493d15b..a967351 100644 --- a/Products/Defender/README.md +++ b/Products/Defender/README.md @@ -131,6 +131,12 @@ If a module contains both failed and blocked actions, its summary is `FAILED`. The HTML report and JSON sidecar use the same precedence; a blocked action does not hide an operation or readback failure. +JSON and HTML exports are attempted independently. A failed export emits a +warning even without verbose output and does not replace an earlier deployment +error, including with `$WarningPreference = 'Stop'`. Run-log cleanup still +occurs. Preserve console warnings and surviving reports; old files are not +evidence that the current export succeeded. + ## Safety gates Default execution is read-only. Unsupported automation and unproven writes diff --git a/Products/Defender/docs/evidence.md b/Products/Defender/docs/evidence.md index 592e0a2..35d1aed 100644 --- a/Products/Defender/docs/evidence.md +++ b/Products/Defender/docs/evidence.md @@ -6,11 +6,20 @@ nav_order: 3 # Evidence and reports -Each run writes a redacted JSON sidecar and HTML report under +Each run attempts to write a redacted JSON sidecar and HTML report under `Products\Defender\Reports\`. Entries include module, action, status, disposition, best-practice key, HTTP status when available, retry metadata, and readback status. +JSON and HTML exports are independent: either can succeed when the other fails. +Failures produce explicit warnings rather than verbose-only messages. The +original deployment error is preserved and run-log globals are cleared even +when `$WarningPreference = 'Stop'`. An export warning does not mean tenant +changes were rolled back or that the surviving report proves complete +deployment. Preserve the console and any surviving output, correct the local +path/permissions, and check run IDs and timestamps rather than trusting an old +file at the same path. + Retry entries include the HTTP status when available and identify when a message-only cmdlet signature classified an EXO, IPPS, or SPO failure as transient. The bounded retry decision is recorded before the next attempt; diff --git a/Products/Defender/docs/troubleshooting.md b/Products/Defender/docs/troubleshooting.md index b3ccaf8..79a6de0 100644 --- a/Products/Defender/docs/troubleshooting.md +++ b/Products/Defender/docs/troubleshooting.md @@ -6,6 +6,12 @@ nav_order: 5 # Troubleshooting +**JSON or HTML export warning:** preserve the console and any surviving report, +then check the configured local output path and write permissions. Both formats +are attempted independently and cleanup still runs. Warnings cannot replace an +earlier deployment failure, even with `WarningPreference=Stop`; a previous file +is not proof of a successful current export. No tenant rollback is implied. + **Permission plan is not ready:** inspect the operation key in the report. Unverified write operations are intentionally guided-only; do not add scopes or bypass the manifest. diff --git a/Products/Entra/Config/EntraConfig.psd1 b/Products/Entra/Config/EntraConfig.psd1 index 67fb8e4..e0d0db5 100644 --- a/Products/Entra/Config/EntraConfig.psd1 +++ b/Products/Entra/Config/EntraConfig.psd1 @@ -107,8 +107,8 @@ RequireBreakGlassExclusion = $true BreakGlass = @{ # Operator supplies an existing emergency-access account/group here, - # or sets CreateAccountIfMissing to have Setup-EmergencyAccess create - # a dedicated cloud-only break-glass account (guide Priority 1 task). + # or sets CreateAccountIfMissing to create an account and stop for + # manual credential and permanent Global Administrator setup. ExcludeUserIds = @() ExcludeGroupIds = @() CreateAccountIfMissing = $false diff --git a/Products/Entra/Deploy-EntraBestPractice.ps1 b/Products/Entra/Deploy-EntraBestPractice.ps1 index 2d0a302..f136bc7 100644 --- a/Products/Entra/Deploy-EntraBestPractice.ps1 +++ b/Products/Entra/Deploy-EntraBestPractice.ps1 @@ -289,6 +289,7 @@ try { -BaseUri $config.Api.GraphBaseUri -Module 'Deploy-EntraBestPractice' $commonContext = @{ + RunId = $runId TenantAdminUpn = $TenantAdminUpn TenantId = $resolvedTenantId ConnectionInfo = $connectionInfo @@ -308,12 +309,20 @@ try { SecurityDefaultsState = $securityDefaultsState } + $breakGlassVerified = $false foreach ($task in $tasks) { Add-EntraRunLogEntry -Module $task.Name -Action 'Stage' -Status 'Info' -Detail $task.Stage if ($task.Skip) { Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Skipped' -Detail 'Skip switch was supplied.' continue } + if ($task.Name -eq 'Setup-ConditionalAccessBaseline' -and -not $breakGlassVerified) { + $commonContext.BreakGlassUserIds = @() + $commonContext.BreakGlassGroupIds = @() + $commonContext.WriteBlockedItemKeys = @($commonContext.WriteBlockedItemKeys) + 'conditional-access-baseline' + Add-EntraRunLogEntry -Module $task.Name -Action 'BreakGlassGate' -Status 'Info' -Disposition 'Blocked' ` + -Detail 'Conditional Access writes withheld: no emergency-access principals were verified in this run. Run emergency-access verification with configured, enabled permanent Global Administrators.' + } $taskPath = Join-Path $moduleRoot $task.Script if (-not (Test-Path -LiteralPath $taskPath -PathType Leaf)) { Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Failed' -Detail "Module script not found: $taskPath" @@ -324,20 +333,37 @@ try { $taskArgs.Context = $commonContext if ($WhatIfPreference) { $taskArgs['WhatIf'] = $true } try { - & $taskPath @taskArgs - Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Succeeded' -Detail 'Module process completed.' - - # Fold the emergency-access module's resolved break-glass principals - # into the shared context so the Conditional Access baseline can - # exclude them. Later tasks receive this updated context at dispatch. - if ($task.Name -eq 'Setup-EmergencyAccess' -and - (Test-Path -LiteralPath $commonContext.BreakGlassOutputPath -PathType Leaf)) { - $bg = Get-Content -LiteralPath $commonContext.BreakGlassOutputPath -Raw | ConvertFrom-Json - $commonContext.BreakGlassUserIds = @(@($commonContext.BreakGlassUserIds) + @($bg.userIds) | Where-Object { $_ } | Select-Object -Unique) - $commonContext.BreakGlassGroupIds = @(@($commonContext.BreakGlassGroupIds) + @($bg.groupIds) | Where-Object { $_ } | Select-Object -Unique) + if ($task.Name -eq 'Setup-EmergencyAccess') { + $results = @(& $taskPath @taskArgs) + if ($results.Count -ne 1 -or $results[0] -isnot [pscustomobject]) { + throw 'Emergency-access module must return exactly one structured verification result.' + } + $bg = $results[0] + $fields = @($bg.PSObject.Properties.Name) + if ($fields.Count -ne 4 -or + @('runId', 'verified', 'userIds', 'groupIds' | Where-Object { $_ -cnotin $fields }).Count -gt 0 -or + $bg.runId -isnot [string] -or $bg.runId -cne [string] $runId -or + $bg.verified -isnot [bool] -or + $bg.userIds -isnot [string[]] -or $bg.groupIds -isnot [string[]]) { + throw 'Emergency-access module returned a stale or malformed verification result.' + } + $verifiedIds = @($bg.userIds) + @($bg.groupIds) + if (@($verifiedIds | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -gt 0 -or + $bg.verified -ne ($verifiedIds.Count -gt 0)) { + throw 'Emergency-access module returned an inconsistent verified principal set.' + } + # Only the module's in-memory result authorizes exclusions. + # The editable JSON evidence is never read back into this context. + $breakGlassVerified = $bg.verified + $commonContext.BreakGlassUserIds = @($bg.userIds) + $commonContext.BreakGlassGroupIds = @($bg.groupIds) Add-EntraRunLogEntry -Module 'Deploy-EntraBestPractice' -Action 'BreakGlass' -Status 'Info' ` -Detail "Break-glass exclusions available: $((@($commonContext.BreakGlassUserIds)+@($commonContext.BreakGlassGroupIds)).Count)." } + else { + & $taskPath @taskArgs + } + Add-EntraRunLogEntry -Module $task.Name -Action 'Module' -Status 'Succeeded' -Detail 'Module process completed.' } catch { $status = Get-EntraHttpStatusCode -ErrorRecord $_ diff --git a/Products/Entra/Modules/Connect-EntraServices.ps1 b/Products/Entra/Modules/Connect-EntraServices.ps1 index 1da9ac2..64da28b 100644 --- a/Products/Entra/Modules/Connect-EntraServices.ps1 +++ b/Products/Entra/Modules/Connect-EntraServices.ps1 @@ -365,8 +365,6 @@ if ($ConnectGraph) { try { Connect-MgGraph -TenantId $targetTenantDomain -Scopes $requiredScopes ` -NoWelcome -ErrorAction Stop - Add-EntraConnectGuardLog -Service 'Graph' -Status 'Info' ` - -ReasonKey 'connected' -Detail $guardDetail } catch { Add-EntraConnectGuardLog -Service 'Graph' -Status 'Failed' ` @@ -375,8 +373,29 @@ if ($ConnectGraph) { } } - $tenantIdentity = Get-EntraTenantIdentity -ExpectedDomain $targetTenantDomain ` - -TenantAdminUpn $TenantAdminUpn -GraphBaseUri $GraphBaseUri + try { + $verifiedContext = Get-MgContext -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace([string] $verifiedContext.Account) -or + $verifiedContext.Account -ine $TenantAdminUpn) { + throw 'Microsoft Graph signed in with a missing or different operator account. Rerun and select TenantAdminUpn at sign-in.' + } + $tenantIdentity = Get-EntraTenantIdentity -ExpectedDomain $targetTenantDomain ` + -TenantAdminUpn $TenantAdminUpn -GraphBaseUri $GraphBaseUri + if (-not $graphConnected) { + Add-EntraConnectGuardLog -Service 'Graph' -Status 'Info' ` + -ReasonKey 'connected' -Detail $guardDetail + } + } + catch { + $identityFailure = $_ + Add-EntraConnectGuardLog -Service 'Graph' -Status 'Failed' ` + -ReasonKey 'identity-verification-failed' -Detail @{ error = $identityFailure.Exception.Message } + try { Disconnect-MgGraph -ErrorAction Stop | Out-Null } + catch { + Write-Warning 'Invalid Graph context could not be disconnected. Close this PowerShell session before rerunning.' -WarningAction Continue + } + throw $identityFailure + } } [pscustomobject]@{ diff --git a/Products/Entra/Modules/Setup-EmergencyAccess.ps1 b/Products/Entra/Modules/Setup-EmergencyAccess.ps1 index 9bfdb0a..59a4a7d 100644 --- a/Products/Entra/Modules/Setup-EmergencyAccess.ps1 +++ b/Products/Entra/Modules/Setup-EmergencyAccess.ps1 @@ -9,8 +9,9 @@ must exclude. It verifies the accounts/groups configured in ConditionalAccess.BreakGlass, and, when none are configured and CreateAccountIfMissing is set, creates a dedicated cloud-only break-glass - account. The resolved principal IDs are written to the run's break-glass - output file so the Conditional Access baseline can exclude them. + account and stops for operator setup. Returns one in-memory verification + result with copied principal arrays for Conditional Access. The JSON file + is diagnostic evidence only, never an authorization input. A generated account password is never written to evidence; the operator must reset it in the portal and store it securely (see the end-user guide). All @@ -40,6 +41,11 @@ if (-not $Context -or [string]::IsNullOrWhiteSpace([string] $Context.TenantAdmin throw 'Setup-EmergencyAccess.ps1 requires Context.TenantAdminUpn and Graph connection settings.' } +if (-not $Context.RunId) { $Context.RunId = [guid]::NewGuid().ToString() } +# Invalidate previous evidence before any directory read, including failed reads. +$null = @{ runId = $Context.RunId; verified = $false; userIds = @(); groupIds = @() } | + ConvertTo-Json | Set-Content -LiteralPath $Context.BreakGlassOutputPath -Encoding utf8 -WhatIf:$false + $v1 = $Config.Api.GraphBaseUri $bg = $Config.ConditionalAccess.BreakGlass $userIds = [System.Collections.Generic.List[string]]::new() @@ -65,7 +71,7 @@ foreach ($u in @($userIds)) { } } catch { - Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` -Detail "Configured break-glass user could not be verified: $($_.Exception.Message)" @@ -73,7 +79,7 @@ foreach ($u in @($userIds)) { } if (-not [bool] $account.accountEnabled) { - Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` -Detail "Break-glass account '$($account.userPrincipalName)' is disabled; a disabled account cannot be used in an emergency. Enable it before deploying the baseline." throw "Break-glass account $u is disabled." @@ -90,7 +96,7 @@ foreach ($u in @($userIds)) { -GlobalAdminPrincipalIds @($gaPrincipalIds) } catch { - Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` -Detail "Could not confirm the break-glass account's Global Administrator role; stopping so it can be reviewed: $($_.Exception.Message)" @@ -98,12 +104,12 @@ foreach ($u in @($userIds)) { } if ($roleState.HasGlobalAdmin) { - Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $u ` -Detail "Break-glass account '$($account.userPrincipalName)' is enabled and holds a permanently assigned, tenant-wide Global Administrator role with no expiry ($($roleState.RoleVia))." } else { - Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` -Detail "Break-glass account '$($account.userPrincipalName)' is enabled but does not hold a permanently-assigned Global Administrator role. Microsoft's emergency-access guidance requires Global Administrator assigned permanently (not PIM-eligible). Assign it before deploying the baseline." throw "Break-glass account $u does not hold a permanently-assigned Global Administrator role." @@ -120,7 +126,7 @@ foreach ($g in @($groupIds)) { if ($null -eq $gaPrincipalIds) { $gaPrincipalIds = Get-EntraGlobalAdminPrincipalId -BaseUri $v1 -RoleId $globalAdminRoleId } } catch { - Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $g ` -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` -Detail "Configured break-glass group could not be verified; stopping so it can be reviewed: $($_.Exception.Message)" @@ -135,7 +141,7 @@ foreach ($g in @($groupIds)) { [bool] (Get-EntraPolicyProperty -InputObject $_ -Name 'accountEnabled') }) if ($enabledMembers.Count -eq 0) { - Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $g ` -Detail "Break-glass group '$($group.displayName)' contains no enabled user account, so it cannot serve as an emergency-access exclusion. Add an enabled emergency account that holds Global Administrator." throw "Break-glass group $g contains no enabled user account." @@ -150,12 +156,12 @@ foreach ($g in @($groupIds)) { -EnabledMemberIds $enabledMemberIds if ($roleState.HasGlobalAdmin) { - Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $g ` -Detail "Break-glass group '$($group.displayName)' has $($enabledMembers.Count) enabled member(s) and provides a permanently assigned, tenant-wide Global Administrator role with no expiry ($($roleState.RoleVia))." } else { - Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $g ` -Detail "Break-glass group '$($group.displayName)' has enabled members but neither the group nor any member holds a permanently assigned, tenant-wide Global Administrator role with no expiry. Assign Global Administrator permanently, not through PIM eligibility or temporary activation, before deploying." throw "Break-glass group $g provides no permanently-assigned Global Administrator." @@ -171,6 +177,28 @@ if ($userIds.Count -eq 0 -and $groupIds.Count -eq 0) { if (-not $domain) { throw 'Unable to derive a tenant domain for the break-glass account UPN.' } $upn = "$($bg.AccountUpnPrefix)@$domain" + $existingAccount = $null + try { + $encodedUpn = [uri]::EscapeDataString($upn) + $existingAccount = Invoke-WithTransientRetry -Description 'Check proposed break-glass account' -ExpectedStatusCodes @(404) -Action { + Invoke-MgGraphRequest -Method GET -Uri "$v1/users/$encodedUpn`?`$select=id" + } + } + catch { + if ((Get-EntraHttpStatusCode -ErrorRecord $_) -ne 404) { + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' -Target $upn ` + -Detail 'Could not determine whether the proposed account already exists. Resolve directory read access before rerunning; no account was created.' + throw + } + } + if ($null -ne $existingAccount) { + $reason = 'The proposed break-glass account already exists. Configure and test its credentials and permanent Global Administrator assignment, put its object ID in ConditionalAccess.BreakGlass.ExcludeUserIds, and rerun for verification. No account was created or automatically adopted.' + $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' -Target $upn -Detail $reason + throw $reason + } + if ($PSCmdlet.ShouldProcess($upn, 'Create cloud-only break-glass account')) { # A random, strong password that is never emitted to evidence; the # operator resets and stores it out of band (see end-user guide). @@ -182,28 +210,43 @@ if ($userIds.Count -eq 0 -and $groupIds.Count -eq 0) { userPrincipalName = $upn passwordProfile = @{ forceChangePasswordNextSignIn = $false; password = $password } } | ConvertTo-Json -Depth 5 - $created = Invoke-WithTransientRetry -Description 'Create break-glass account' -Action { - Invoke-MgGraphRequest -Method POST -Uri "$v1/users" -Body $body -ContentType 'application/json' + try { + $created = Invoke-WithTransientRetry -Description 'Create break-glass account' -Action { + Invoke-MgGraphRequest -Method POST -Uri "$v1/users" -Body $body -ContentType 'application/json' + } } - $password = $null - $userIds.Add([string] $created.id) - Add-EntraRunLogEntry -Module $module -Action 'CreateBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Created' -Disposition 'Applicable' -Target $upn ` - -Detail 'Created a cloud-only break-glass account. It has no admin role yet: assign it Global Administrator, reset its password in the portal, and store the credentials securely. The next run confirms the role; the account is excluded from every Conditional Access policy automatically.' + finally { + $password = $null + $body = $null + } + $null = Add-EntraRunLogEntry -Module $module -Action 'CreateBreakGlass' -BestPracticeKey $bestPracticeKey ` + -Status 'Created' -Disposition 'Applicable' -Readback 'NotAttempted' -Target $upn ` + -Detail 'Created a cloud-only account without an admin role. It is not a verified emergency-access exclusion.' + $reason = 'Deployment stopped before Conditional Access writes. Reset and securely store the account credentials, configure and test emergency authentication and a permanent, tenant-wide Global Administrator assignment, then set ConditionalAccess.BreakGlass.ExcludeUserIds to the account object ID and rerun for verification. No privileges were granted automatically.' + $null = Add-EntraRunLogEntry -Module $module -Action 'BreakGlassHandoff' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' -Target ([string] $created.id) -Detail $reason + throw $reason } else { - Add-EntraRunLogEntry -Module $module -Action 'CreateBreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'CreateBreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Info' -Disposition 'WillChange' -Target $upn -Readback 'NotAttempted' ` - -Detail "WhatIf: would create a cloud-only break-glass account '$upn' and exclude it from every Conditional Access policy." + -Detail "WhatIf: would create a cloud-only account '$upn', then stop before Conditional Access writes for operator credential and permanent Global Administrator setup. No verified exclusion is available." } } elseif (-not $bg.CreateAccountIfMissing) { - Add-EntraRunLogEntry -Module $module -Action 'BreakGlass' -BestPracticeKey $bestPracticeKey ` + $null = Add-EntraRunLogEntry -Module $module -Action 'BreakGlass' -BestPracticeKey $bestPracticeKey ` -Status 'Succeeded' -Disposition 'GuidedOnly' ` -Detail 'No emergency-access account is configured. Create one (Identity guide Priority 1), set ConditionalAccess.BreakGlass.ExcludeUserIds/ExcludeGroupIds, or set CreateAccountIfMissing. The Conditional Access baseline will not write until a break-glass exclusion exists.' } } -# Local evidence output, not a tenant change; never suppressed under -WhatIf. -@{ userIds = @($userIds); groupIds = @($groupIds) } | ConvertTo-Json | +$result = [pscustomobject]@{ + runId = [string] $Context.RunId + verified = ($userIds.Count + $groupIds.Count -gt 0) + userIds = $userIds.ToArray() + groupIds = $groupIds.ToArray() +} +# Local diagnostic output, not a tenant change or an authorization handoff. +$null = $result | ConvertTo-Json | Set-Content -LiteralPath $Context.BreakGlassOutputPath -Encoding utf8 -WhatIf:$false +return $result diff --git a/Products/Entra/README.md b/Products/Entra/README.md index f1c74ad..a557e58 100644 --- a/Products/Entra/README.md +++ b/Products/Entra/README.md @@ -100,8 +100,12 @@ distinguishes `P1Baseline`, `P1Hardened` and the P2 guidance boundary. See [coverage, prerequisites and current Microsoft references](docs/Coverage.md#p1-additions-and-p2-boundary). Plus **emergency access (break-glass)** handling (guide Priority 1): the toolkit -verifies a configured break-glass account/group, or creates a dedicated -cloud-only one, and excludes it from every policy. +verifies a configured break-glass account/group before using it as an exclusion. +Opt-in creation of a cloud-only account stops the run before Conditional Access +writes. Complete credential setup, permanent Global Administrator assignment +and recovery testing, configure its object ID, then rerun verification. +Verified exclusions flow through a validated in-memory module result, never +through the editable `entra-breakglass.json` diagnostic file. The **"Require MFA for Intune enrollment"** Conditional Access policy is intentionally **not** part of this product — it belongs to the Intune toolkit, @@ -193,6 +197,11 @@ only when the account and every required scope match, and verifies the live tenant through `/organization`. For GDAP, it authenticates directly to the customer domain and rejects a cached context for another customer. +After a fresh sign-in, the actual Graph account must also match +`-TenantAdminUpn` (case-insensitively) before successful connection evidence or +setup. A missing/wrong account or failed tenant verification stops the run and +attempts to disconnect the invalid context; consent scopes are unchanged. + Current Microsoft Graph Authentication modules use Windows Authentication Manager (WAM) on a supported interactive Windows desktop. A first consent, missing scope, account change, or customer-tenant switch can still require a diff --git a/Products/Entra/docs/Break-Glass-Guide.md b/Products/Entra/docs/Break-Glass-Guide.md index 7c312ea..4a750bc 100644 --- a/Products/Entra/docs/Break-Glass-Guide.md +++ b/Products/Entra/docs/Break-Glass-Guide.md @@ -32,7 +32,23 @@ break-glass exclusion.** You give it one of two ways: 2. **Let the toolkit create one.** Set `CreateAccountIfMissing = $true`. The `Setup-EmergencyAccess` module creates a dedicated **cloud-only** account - (`break-glass-emergency@`) and excludes it from every policy. + (`break-glass-emergency@`) without an admin role, then **stops + before Conditional Access writes**. It does not publish the new account + as a verified exclusion. Complete the steps below before rerunning. + +The proposed UPN is checked before creation. If it already exists, the toolkit +stops for manual configuration instead of creating a duplicate or silently +adopting it. A failed directory read also stops creation. `-WhatIf` creates +nothing and does not manufacture a verified exclusion. Skipping +`Setup-EmergencyAccess` withholds Conditional Access writes even when IDs +are present in configuration. + +Verified user/group IDs are returned in memory to the orchestrator, which +checks the result contract and current run before copying exclusions into the +CA context. `entra-breakglass.json` is diagnostic only: replacing its IDs or +markers does not authorize or alter deployment. A missing/malformed module +result stops the run. This is not protection against arbitrary code running +inside the same PowerShell process. Under the default configuration, every policy the toolkit creates excludes the break-glass principal, including report-only policies. This is the @@ -55,7 +71,8 @@ The script never performs that transition. ## After a break-glass account is created The toolkit sets a random password that it **never writes to logs or evidence** -(so it cannot leak). You must finish the setup by hand: +and never displays it. Creation is not evidence of recoverable access. You must +finish the setup by hand: 1. In the Microsoft Entra admin center, open the account and **reset its password** to a long, unique value. @@ -71,13 +88,19 @@ The toolkit sets a random password that it **never writes to logs or evidence** 5. Maintain at least two cloud-only emergency accounts on the tenant's `.onmicrosoft.com` domain, securely store their credentials, monitor every use, and test sign-in and administrative recovery at least every 90 days. +6. Put the prepared account's object ID in + `ConditionalAccess.BreakGlass.ExcludeUserIds` in the private configuration + and rerun with `-WhatIf`. The next run must verify it is enabled and holds a + permanent, tenant-wide Global Administrator assignment with no expiry. + Review that evidence before a separately approved apply. These steps follow [Microsoft's emergency-access guidance](https://learn.microsoft.com/entra/identity/role-based-access-control/security-emergency-access), checked on 2026-09-25. The script's account creation, role checks, and exclusion evidence do not replace an independently tested recovery procedure. -Every toolkit run finishes with a read-only deployment health check unless you -use `-SkipDeploymentHealth`. It verifies that the emergency principals remain +Runs that reach the health stage perform a read-only deployment health check +unless you use `-SkipDeploymentHealth`. Creation handoff and earlier failures +stop before that stage; later modules are recorded as not started. Health verifies that the emergency principals remain enabled, still provide a tenant-wide Global Administrator role through an active role assignment schedule instance with no expiry, and remain excluded from enforcing Conditional Access policies. Temporary PIM activations and diff --git a/Products/Entra/docs/Evidence-Troubleshooting.md b/Products/Entra/docs/Evidence-Troubleshooting.md index 292df84..eb4d533 100644 --- a/Products/Entra/docs/Evidence-Troubleshooting.md +++ b/Products/Entra/docs/Evidence-Troubleshooting.md @@ -29,8 +29,21 @@ A write warning means that file was not confirmed saved; an older file can still exist at the same location. Check the run ID and timestamps, not just file presence. Preserve reports before retrying because filenames are reused. -`entra-breakglass.json` is a local handoff containing resolved emergency-access -IDs. Empty lists mean no principals were resolved for that run, not that no +`entra-breakglass.json` is diagnostic evidence containing resolved emergency-access +IDs plus a `runId` and Boolean `verified` marker. It is invalidated before +directory verification, but it is **never read to authorize exclusions**. +The orchestrator accepts exactly one structured in-memory result from the +emergency-access module, validates its current run ID, Boolean verification +state and typed principal arrays, and copies those arrays into the CA context. +Editing, replacing or replaying the JSON cannot change those exclusions, even +when its markers look valid. Missing, malformed or inconsistent module results +stop deployment. This protects the file boundary, not a compromised PowerShell +process. Account creation +leaves `verified=false` and empty IDs, records `CreateBreakGlass` followed by a +failed/blocked `BreakGlassHandoff`, and stops before Conditional Access writes. +Complete [manual setup](Break-Glass-Guide.md#after-a-break-glass-account-is-created), +configure the existing account's object ID, and rerun; do not repeatedly create +accounts. Empty lists mean no principals were verified for that run, not that no emergency accounts exist anywhere in the tenant. Never treat this file as credential storage or as proof of successful recovery. @@ -102,6 +115,7 @@ emergency-access checks, not full policy coverage or a sign-in guarantee. | Symptom | What to do | |---|---| | Wrong tenant or verified-domain mismatch | Stop. Check the administrator UPN and, for GDAP, the customer domain. Do not bypass identity verification. | +| Missing or different operator after sign-in | Select the exact `TenantAdminUpn` account on rerun. The invalid context is disconnected where possible. If disconnect also fails, close PowerShell and start a fresh session; the original identity error remains authoritative. No broader scope is needed. | | `401` / `403` or a failed read | Check the exact Graph operation, configured scopes, effective roles, GDAP access, and session. Do not add broad permissions speculatively. | | Security Defaults enabled or unknown | Keep active protection. Use the [approved transition guide](Security-Defaults-Transition.md); the toolkit never disables it. | | Missing apply parameters or pilot group | Complete the [operator checklist](Operator-Guide.md). Removing `-WhatIf` alone does not authorize writes. | diff --git a/Products/Entra/docs/Operator-Guide.md b/Products/Entra/docs/Operator-Guide.md index 473d11e..180afb8 100644 --- a/Products/Entra/docs/Operator-Guide.md +++ b/Products/Entra/docs/Operator-Guide.md @@ -75,7 +75,7 @@ Keep the following settings for the initial pilot: | `ConditionalAccess.DefaultState` | Keep `enabledForReportingButNotEnforced`. | | `ConditionalAccess.RequireBreakGlassExclusion` | Keep `$true`. | | `ConditionalAccess.BreakGlass.ExcludeUserIds` / `ExcludeGroupIds` | Supply independently verified emergency-access object IDs. Do not use display names or assume an empty list proves no accounts exist. | -| `ConditionalAccess.BreakGlass.CreateAccountIfMissing` | Keep `$false` when using verified existing accounts. Account creation requires separate preparation and manual completion. | +| `ConditionalAccess.BreakGlass.CreateAccountIfMissing` | Keep `$false` when using verified existing accounts. Opt-in creation stops before Conditional Access writes; complete credentials, permanent Global Administrator assignment and recovery testing, configure the object ID, then rerun. An existing proposed UPN is not automatically adopted. | | `TenantSecurity.*.Apply` | Keep `$false` unless the individual tenant-wide change has its own approval and recovery plan. | | `Assignment.AllowTenantWideAssignmentForHighRisk` | Keep `$false` for the pilot. | @@ -85,6 +85,12 @@ licenses, and the intended test accounts before using it as `-PilotGroupId`. Keep emergency-access identities out of normal pilot activity and verify their exclusions independently. +Do not use `-SkipEmergencyAccess` to bypass a failed check. Conditional Access +writes require emergency principals verified in the current run. Disabled +accounts, missing permanent roles, and unreadable directory/role state stop +deployment. Only the module's validated in-memory result supplies exclusions; +the diagnostic JSON file cannot authorize continuation or substitute IDs. + The pilot group narrows all-users-style templates, not every policy. Role- and app-scoped templates retain their configured targeting. In particular, the optional phishing-resistant admin policy targets privileged diff --git a/Products/Entra/docs/Scenarios.md b/Products/Entra/docs/Scenarios.md index 9eadf24..7d48d66 100644 --- a/Products/Entra/docs/Scenarios.md +++ b/Products/Entra/docs/Scenarios.md @@ -123,9 +123,10 @@ duplication. See [policy naming and selection](../README.md#policy-naming-and-se The toolkit either **verifies** an emergency-access account you already have — confirming it is enabled and holds a permanently assigned, tenant-wide Global Administrator role with no expiry — or, if you opt in, **creates** a dedicated -cloud-only account for you. Temporary PIM activations and expiring assignments -do not qualify. Either way the account is excluded from every Conditional -Access policy. See the +cloud-only account and stops before Conditional Access deployment. Configure +and test credentials and permanent Global Administrator access, add the object +ID to the private config, and rerun. Only verified principals become exclusions. +Temporary PIM activations and expiring assignments do not qualify. See the [break-glass guide](Break-Glass-Guide.md) for the end-user steps. ## Tenant security settings (Zero Trust, opt-in) diff --git a/Products/Intune/Deploy-IntuneBestPractice.ps1 b/Products/Intune/Deploy-IntuneBestPractice.ps1 index 0f327cc..6cd2be8 100644 --- a/Products/Intune/Deploy-IntuneBestPractice.ps1 +++ b/Products/Intune/Deploy-IntuneBestPractice.ps1 @@ -126,6 +126,7 @@ $resolvedTenantId = $null . (Join-Path $moduleRoot 'IntuneRunLog.ps1') . (Join-Path $moduleRoot 'Invoke-WithTransientRetry.ps1') . (Join-Path $moduleRoot 'Write-IntuneHtmlReport.ps1') +. (Join-Path $moduleRoot 'IntuneGraphClient.ps1') function Test-IntuneConfigContract { [CmdletBinding()] @@ -145,6 +146,8 @@ function Test-IntuneConfigContract { if (-not $Config.BestPracticeItems) { throw 'Intune configuration must define at least one BestPracticeItems entry.' } + Assert-IntuneGraphBaseUri -BaseUri $Config.Api.GraphBaseUri + Assert-IntuneGraphBaseUri -BaseUri $Config.Api.GraphBetaBaseUri if ([string]::IsNullOrWhiteSpace([string] $Config.PolicyCatalog.ManifestPath)) { throw 'PolicyCatalog.ManifestPath must identify the imported catalog manifest.' } diff --git a/Products/Intune/Modules/Connect-IntuneServices.ps1 b/Products/Intune/Modules/Connect-IntuneServices.ps1 index 0b44888..a5f8da7 100644 --- a/Products/Intune/Modules/Connect-IntuneServices.ps1 +++ b/Products/Intune/Modules/Connect-IntuneServices.ps1 @@ -273,6 +273,7 @@ function Get-IntuneTenantIdentity { $tenantIdentity = $null if ($ConnectGraph) { + Assert-IntuneGraphBaseUri -BaseUri $GraphBaseUri Register-IntuneSensitiveValue -Value $DelegatedOrganization if ($TenantAdminUpn -notmatch '@(?[^@\s]+)$') { throw 'Unable to derive an expected tenant domain from TenantAdminUpn.' @@ -385,8 +386,6 @@ if ($ConnectGraph) { try { Connect-MgGraph -TenantId $targetTenantDomain -Scopes $requiredScopes ` -NoWelcome -ErrorAction Stop - Add-IntuneConnectGuardLog -Service 'Graph' -Status 'Info' ` - -ReasonKey 'connected' -Detail $guardDetail } catch { Add-IntuneConnectGuardLog -Service 'Graph' -Status 'Failed' ` @@ -395,8 +394,29 @@ if ($ConnectGraph) { } } - $tenantIdentity = Get-IntuneTenantIdentity -ExpectedDomain $targetTenantDomain ` - -TenantAdminUpn $TenantAdminUpn -GraphBaseUri $GraphBaseUri + try { + $verifiedContext = Get-MgContext -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace([string] $verifiedContext.Account) -or + $verifiedContext.Account -ine $TenantAdminUpn) { + throw 'Microsoft Graph signed in with a missing or different operator account. Rerun and select TenantAdminUpn at sign-in.' + } + $tenantIdentity = Get-IntuneTenantIdentity -ExpectedDomain $targetTenantDomain ` + -TenantAdminUpn $TenantAdminUpn -GraphBaseUri $GraphBaseUri + if (-not $graphConnected) { + Add-IntuneConnectGuardLog -Service 'Graph' -Status 'Info' ` + -ReasonKey 'connected' -Detail $guardDetail + } + } + catch { + $identityFailure = $_ + Add-IntuneConnectGuardLog -Service 'Graph' -Status 'Failed' ` + -ReasonKey 'identity-verification-failed' -Detail @{ error = $identityFailure.Exception.Message } + try { Disconnect-MgGraph -ErrorAction Stop | Out-Null } + catch { + Write-Warning 'Invalid Graph context could not be disconnected. Close this PowerShell session before rerunning.' -WarningAction Continue + } + throw $identityFailure + } } [pscustomobject]@{ diff --git a/Products/Intune/Modules/IntuneGraphClient.ps1 b/Products/Intune/Modules/IntuneGraphClient.ps1 index 5024a7a..6df63f3 100644 --- a/Products/Intune/Modules/IntuneGraphClient.ps1 +++ b/Products/Intune/Modules/IntuneGraphClient.ps1 @@ -21,6 +21,54 @@ # already idempotent to redefine. . (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +function Assert-IntuneGraphBaseUri { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $BaseUri) + + # Match the original value as well as parsing it: Uri normalizes dot segments, + # backslashes and some empty delimiters that are not valid configuration. + $basePattern = '^https://(graph\.microsoft\.com|graph\.microsoft\.us|dod-graph\.microsoft\.us|microsoftgraph\.chinacloudapi\.cn)(:443)?/(v1\.0|beta)/?\z' + $parsed = $null + if ($BaseUri -notmatch $basePattern -or + -not [uri]::TryCreate($BaseUri, [UriKind]::Absolute, [ref] $parsed) -or + $parsed.Scheme -ne 'https' -or -not $parsed.IsDefaultPort -or + $parsed.UserInfo -ne '' -or $parsed.Query -ne '' -or $parsed.Fragment -ne '') { + throw 'Graph API base must be an absolute HTTPS URL on an approved Microsoft Graph host, default port 443, and only the /v1.0 or /beta path (optional trailing slash). User information, query strings, fragments and ambiguous path components are not allowed.' + } +} + +function Assert-IntuneGraphCollectionUri { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [Parameter(Mandatory)] [string] $InitialUri, + [Parameter(Mandatory)] [string] $Uri + ) + + Assert-IntuneGraphBaseUri -BaseUri $BaseUri + $base = [uri] $BaseUri + $initial = $null + $next = $null + foreach ($candidate in @($InitialUri, $Uri)) { + $parsed = $null + if (-not [uri]::TryCreate($candidate, [UriKind]::Absolute, [ref] $parsed) -or + $candidate.Split('?')[0] -match '[\\\s]|^https://[^/]*@' -or + $candidate -match '[\x00-\x1F\x7F]' -or $candidate.Contains('#') -or + $parsed.Scheme -ne 'https' -or -not $parsed.IsDefaultPort -or + $parsed.UserInfo -ne '' -or $parsed.Fragment -ne '' -or + -not [string]::Equals($parsed.Host, $base.Host, [StringComparison]::OrdinalIgnoreCase) -or + -not $parsed.AbsolutePath.StartsWith($base.AbsolutePath.TrimEnd('/') + '/', [StringComparison]::Ordinal) -or + ($candidate.Split('?')[0] -match '(?i)/(?:\.|%2e){1,2}(/|$)')) { + throw 'Microsoft Graph returned a collection URI outside the configured Graph authority or API path.' + } + if ($null -eq $initial) { $initial = $parsed } + $next = $parsed + } + if (-not [string]::Equals($initial.AbsolutePath, $next.AbsolutePath, [StringComparison]::Ordinal)) { + throw 'Microsoft Graph returned a next link outside the exact requested collection path.' + } +} + function Resolve-IntuneGraphUri { <# Builds a request URI from the configured base. The base is validated @@ -34,23 +82,7 @@ function Resolve-IntuneGraphUri { [Parameter(Mandatory)] [string] $RelativePath ) - $approvedHosts = @( - 'graph.microsoft.com', - 'graph.microsoft.us', - 'dod-graph.microsoft.us', - 'microsoftgraph.chinacloudapi.cn' - ) - - $parsed = $null - if (-not [uri]::TryCreate($BaseUri, [UriKind]::Absolute, [ref] $parsed)) { - throw "Api.GraphBaseUri is not an absolute URI: $BaseUri" - } - if ($parsed.Scheme -ne 'https') { - throw "Api.GraphBaseUri must use HTTPS: $BaseUri" - } - if ($parsed.Host -notin $approvedHosts) { - throw "Api.GraphBaseUri host '$($parsed.Host)' is not an approved Microsoft Graph endpoint." - } + Assert-IntuneGraphBaseUri -BaseUri $BaseUri return ('{0}/{1}' -f $BaseUri.TrimEnd('/'), $RelativePath.TrimStart('/')) } diff --git a/Products/Intune/Modules/Setup-AppDeployment.ps1 b/Products/Intune/Modules/Setup-AppDeployment.ps1 index 2d2b35c..022cc9e 100644 --- a/Products/Intune/Modules/Setup-AppDeployment.ps1 +++ b/Products/Intune/Modules/Setup-AppDeployment.ps1 @@ -31,6 +31,10 @@ $ConfirmPreference = 'None' . (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') . (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'IntuneGraphClient.ps1') + +Assert-IntuneGraphBaseUri -BaseUri $Config.Api.GraphBaseUri +Assert-IntuneGraphBaseUri -BaseUri $Config.Api.GraphBetaBaseUri $module = 'Setup-AppDeployment' $bestPracticeKey = 'm365-apps-deployment' @@ -60,8 +64,6 @@ function Get-IntuneOfficeSuiteApps { [Parameter(Mandatory)] [string] $GraphBaseUri ) - $base = [uri] $GraphBaseUri - $expectedPathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' $visited = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase ) @@ -72,26 +74,13 @@ function Get-IntuneOfficeSuiteApps { if (-not $visited.Add($nextUri)) { throw 'Microsoft Graph returned a pagination cycle for Microsoft 365 Apps deployments.' } - $parsedNext = $null - if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsedNext) -or - $parsedNext.Scheme -ne 'https' -or - -not [string]::Equals( - $parsedNext.Host, - $base.Host, - [StringComparison]::OrdinalIgnoreCase - ) -or - -not $parsedNext.AbsolutePath.StartsWith( - $expectedPathPrefix, - [StringComparison]::OrdinalIgnoreCase - )) { - throw 'Microsoft Graph returned a Microsoft 365 Apps next link outside the configured Graph API path.' - } + Assert-IntuneGraphCollectionUri -BaseUri $GraphBaseUri -InitialUri $InitialUri -Uri $nextUri $response = Invoke-WithTransientRetry -Description 'Read existing officeSuiteApp' -Action { Invoke-MgGraphRequest -Method GET -Uri $nextUri } $value = Get-IntuneAppDeploymentProperty -InputObject $response -Name 'value' - if (-not $value.Exists -or $null -eq $value.Value) { + if (-not $value.Exists -or $value.Value -isnot [System.Collections.IList]) { throw 'Microsoft Graph returned no Microsoft 365 Apps collection.' } foreach ($item in @($value.Value)) { @@ -103,6 +92,10 @@ function Get-IntuneOfficeSuiteApps { $nextLink = Get-IntuneAppDeploymentProperty ` -InputObject $response -Name '@odata.nextLink' + if ($nextLink.Exists -and $null -ne $nextLink.Value -and + ($nextLink.Value -isnot [string] -or [string]::IsNullOrWhiteSpace($nextLink.Value))) { + throw 'Microsoft Graph returned a malformed next link for Microsoft 365 Apps deployments.' + } $nextUri = if ($nextLink.Exists) { [string] $nextLink.Value } else { $null } } @@ -127,7 +120,7 @@ if (-not $Context -or [string]::IsNullOrWhiteSpace([string] $Context.TenantAdmin $app = $Config.AppDeployment $tag = $Config.ManagedByTag -$appsUri = "$($Config.Api.GraphBetaBaseUri)/deviceAppManagement/mobileApps" +$appsUri = Resolve-IntuneGraphUri -BaseUri $Config.Api.GraphBetaBaseUri -RelativePath 'deviceAppManagement/mobileApps' # Read before write. A toolkit-owned officeSuiteApp is treated as already # compliant so re-runs do not create duplicates. diff --git a/Products/Intune/Modules/Setup-AppProtectionPolicies.ps1 b/Products/Intune/Modules/Setup-AppProtectionPolicies.ps1 index 47efe57..e0b71b7 100644 --- a/Products/Intune/Modules/Setup-AppProtectionPolicies.ps1 +++ b/Products/Intune/Modules/Setup-AppProtectionPolicies.ps1 @@ -31,6 +31,9 @@ $ConfirmPreference = 'None' . (Join-Path $PSScriptRoot 'IntuneAssignmentScope.ps1') . (Join-Path $PSScriptRoot 'IntuneGraphClient.ps1') +Assert-IntuneGraphBaseUri -BaseUri $Config.Api.GraphBaseUri +Assert-IntuneGraphBaseUri -BaseUri $Config.Api.GraphBetaBaseUri + function Get-IntuneObjectProperty { [CmdletBinding()] param( @@ -87,8 +90,6 @@ function Get-IntuneGraphCollection { [int[]] $ExpectedStatusCodes = @() ) - $base = [uri] $GraphBaseUri - $expectedPathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' $visited = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase ) @@ -100,18 +101,7 @@ function Get-IntuneGraphCollection { throw "Microsoft Graph returned a pagination cycle for $EvidenceTarget." } - $parsedNext = $null - if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsedNext) -or - $parsedNext.Scheme -ne 'https' -or - -not [string]::Equals($parsedNext.Host, $base.Host, [StringComparison]::OrdinalIgnoreCase)) { - throw "Microsoft Graph returned a next link outside the approved Microsoft Graph host for $EvidenceTarget." - } - if (-not $parsedNext.AbsolutePath.StartsWith( - $expectedPathPrefix, - [StringComparison]::OrdinalIgnoreCase - )) { - throw "Microsoft Graph returned a next link outside the configured Graph API path for $EvidenceTarget." - } + Assert-IntuneGraphCollectionUri -BaseUri $GraphBaseUri -InitialUri $InitialUri -Uri $nextUri $response = Invoke-WithTransientRetry ` -Description "Get $EvidenceTarget" ` @@ -130,7 +120,7 @@ function Get-IntuneGraphCollection { } $valueProperty = Get-IntuneObjectProperty -InputObject $response -Name 'value' - if (-not $valueProperty.Exists -or $null -eq $valueProperty.Value) { + if (-not $valueProperty.Exists -or $valueProperty.Value -isnot [System.Collections.IList]) { throw "Microsoft Graph returned no value collection for $EvidenceTarget." } foreach ($item in @($valueProperty.Value)) { @@ -142,6 +132,10 @@ function Get-IntuneGraphCollection { $nextLinkProperty = Get-IntuneObjectProperty ` -InputObject $response -Name '@odata.nextLink' + if ($nextLinkProperty.Exists -and $null -ne $nextLinkProperty.Value -and + ($nextLinkProperty.Value -isnot [string] -or [string]::IsNullOrWhiteSpace($nextLinkProperty.Value))) { + throw "Microsoft Graph returned a malformed next link for $EvidenceTarget." + } $nextUri = if ($nextLinkProperty.Exists) { [string] $nextLinkProperty.Value } @@ -403,7 +397,7 @@ if ([string] $Context.AssignmentScope -eq 'TenantWide' -and # Write phase: create the Level 1 policy per platform, target the core apps, # and assign. Idempotent on the managed-by tag. # --------------------------------------------------------------------------- -$beta = $Config.Api.GraphBetaBaseUri +$beta = $Config.Api.GraphBetaBaseUri.TrimEnd('/') $pilotGroup = if (-not [string]::IsNullOrWhiteSpace([string] $Context.PilotGroupId)) { $Context.PilotGroupId } else { $PilotGroupId } $assignmentTarget = $null $assignmentDetail = $null diff --git a/Products/Intune/Modules/Setup-DeviceCompliancePolicies.ps1 b/Products/Intune/Modules/Setup-DeviceCompliancePolicies.ps1 index d8129ee..3efcef9 100644 --- a/Products/Intune/Modules/Setup-DeviceCompliancePolicies.ps1 +++ b/Products/Intune/Modules/Setup-DeviceCompliancePolicies.ps1 @@ -132,8 +132,6 @@ function Get-ComplianceCollection { [int[]] $ExpectedStatusCodes = @() ) - $base = [uri] $GraphBaseUri - $pathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' $visited = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase ) @@ -144,15 +142,7 @@ function Get-ComplianceCollection { if (-not $visited.Add($nextUri)) { throw "Microsoft Graph returned a pagination cycle for $EvidenceTarget." } - $parsed = $null - if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsed) -or - $parsed.Scheme -ne 'https' -or -not $parsed.IsDefaultPort -or - $parsed.UserInfo -ne '' -or $parsed.Fragment -ne '' -or - -not [string]::Equals($parsed.Host, $base.Host, [StringComparison]::OrdinalIgnoreCase) -or - $parsed.AbsolutePath -ne ([uri] $InitialUri).AbsolutePath -or - -not $parsed.AbsolutePath.StartsWith($pathPrefix, [StringComparison]::OrdinalIgnoreCase)) { - throw "Microsoft Graph returned an unsafe next link for $EvidenceTarget." - } + Assert-IntuneGraphCollectionUri -BaseUri $GraphBaseUri -InitialUri $InitialUri -Uri $nextUri $response = Invoke-WithTransientRetry -Description "Get $EvidenceTarget" ` -ExpectedStatusCodes $ExpectedStatusCodes -Action { @@ -183,7 +173,8 @@ function Get-ComplianceCollection { $items.Add($item) } $next = Get-ComplianceProperty -InputObject $response -Name '@odata.nextLink' - if ($next.Exists -and $null -ne $next.Value -and $next.Value -isnot [string]) { + if ($next.Exists -and $null -ne $next.Value -and + ($next.Value -isnot [string] -or [string]::IsNullOrWhiteSpace($next.Value))) { throw "Microsoft Graph returned a malformed next link for $EvidenceTarget." } $nextUri = if ($next.Exists) { [string] $next.Value } else { $null } diff --git a/Products/Intune/Modules/Setup-DeviceConditionalAccess.ps1 b/Products/Intune/Modules/Setup-DeviceConditionalAccess.ps1 index 06e5cfd..abebdeb 100644 --- a/Products/Intune/Modules/Setup-DeviceConditionalAccess.ps1 +++ b/Products/Intune/Modules/Setup-DeviceConditionalAccess.ps1 @@ -37,6 +37,9 @@ $ConfirmPreference = 'None' . (Join-Path $PSScriptRoot 'IntuneRunLog.ps1') . (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $PSScriptRoot 'IntuneGraphClient.ps1') + +Assert-IntuneGraphBaseUri -BaseUri $Config.Api.GraphBaseUri function Get-IntuneCaProperty { param([AllowNull()] $InputObject, [Parameter(Mandatory)] [string] $Name) @@ -131,38 +134,24 @@ function Test-IntuneCaOnlyAllowedProperties { function Get-IntuneConditionalAccessPolicies { param([Parameter(Mandatory)] [string] $GraphBaseUri) - $base = [uri] $GraphBaseUri - $expectedPathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' $visited = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase ) $policies = [System.Collections.Generic.List[object]]::new() - $nextUri = "$GraphBaseUri/identity/conditionalAccess/policies" + $initialUri = Resolve-IntuneGraphUri -BaseUri $GraphBaseUri -RelativePath 'identity/conditionalAccess/policies' + $nextUri = $initialUri while (-not [string]::IsNullOrWhiteSpace($nextUri)) { if (-not $visited.Add($nextUri)) { throw 'Microsoft Graph returned a pagination cycle for Conditional Access policies.' } - $parsedNext = $null - if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsedNext) -or - $parsedNext.Scheme -ne 'https' -or - -not [string]::Equals( - $parsedNext.Host, - $base.Host, - [StringComparison]::OrdinalIgnoreCase - ) -or - -not $parsedNext.AbsolutePath.StartsWith( - $expectedPathPrefix, - [StringComparison]::OrdinalIgnoreCase - )) { - throw 'Microsoft Graph returned a Conditional Access next link outside the configured Graph API path.' - } + Assert-IntuneGraphCollectionUri -BaseUri $GraphBaseUri -InitialUri $initialUri -Uri $nextUri $response = Invoke-WithTransientRetry -Description 'Read existing Conditional Access policies' -Action { Invoke-MgGraphRequest -Method GET -Uri $nextUri } $value = Get-IntuneCaPropertyResult $response 'value' - if (-not $value.Exists -or $null -eq $value.Value) { + if (-not $value.Exists -or $value.Value -isnot [System.Collections.IList]) { throw 'Microsoft Graph returned no Conditional Access policy collection.' } foreach ($policy in @($value.Value)) { @@ -172,6 +161,10 @@ function Get-IntuneConditionalAccessPolicies { $policies.Add($policy) } $nextLink = Get-IntuneCaPropertyResult $response '@odata.nextLink' + if ($nextLink.Exists -and $null -ne $nextLink.Value -and + ($nextLink.Value -isnot [string] -or [string]::IsNullOrWhiteSpace($nextLink.Value))) { + throw 'Microsoft Graph returned a malformed next link for Conditional Access policies.' + } $nextUri = if ($nextLink.Exists) { [string] $nextLink.Value } else { $null } } @@ -391,7 +384,7 @@ try { throw $reason } - $v1 = $Config.Api.GraphBaseUri + $v1 = $Config.Api.GraphBaseUri.TrimEnd('/') $appId = $Config.ConditionalAccess.EnrollmentAppId $displayName = $Config.ConditionalAccess.DisplayName $state = $Config.ConditionalAccess.DefaultState diff --git a/Products/Intune/Modules/Setup-EnrollmentRestrictions.ps1 b/Products/Intune/Modules/Setup-EnrollmentRestrictions.ps1 index ae6f5e1..deff0e0 100644 --- a/Products/Intune/Modules/Setup-EnrollmentRestrictions.ps1 +++ b/Products/Intune/Modules/Setup-EnrollmentRestrictions.ps1 @@ -114,8 +114,6 @@ function Get-IntuneEnrollmentCollection { [Parameter(Mandatory)] [string] $EvidenceTarget ) - $base = [uri] $GraphBaseUri - $expectedPathPrefix = $base.AbsolutePath.TrimEnd('/') + '/' $visited = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase ) @@ -126,18 +124,7 @@ function Get-IntuneEnrollmentCollection { if (-not $visited.Add($nextUri)) { throw "Microsoft Graph returned a pagination cycle for $EvidenceTarget." } - $parsedNext = $null - if (-not [uri]::TryCreate($nextUri, [UriKind]::Absolute, [ref] $parsedNext) -or - $parsedNext.Scheme -ne 'https' -or -not $parsedNext.IsDefaultPort -or - $parsedNext.UserInfo -ne '' -or $parsedNext.Fragment -ne '' -or - -not [string]::Equals($parsedNext.Host, $base.Host, [StringComparison]::OrdinalIgnoreCase) -or - $parsedNext.AbsolutePath -ne ([uri] $InitialUri).AbsolutePath -or - -not $parsedNext.AbsolutePath.StartsWith( - $expectedPathPrefix, - [StringComparison]::OrdinalIgnoreCase - )) { - throw "Microsoft Graph returned a next link outside the configured Graph API path for $EvidenceTarget." - } + Assert-IntuneGraphCollectionUri -BaseUri $GraphBaseUri -InitialUri $InitialUri -Uri $nextUri $response = Invoke-WithTransientRetry -Description "Get $EvidenceTarget" -Action { try { @@ -161,7 +148,8 @@ function Get-IntuneEnrollmentCollection { } $nextLink = Get-IntuneEnrollmentProperty ` -InputObject $response -Name '@odata.nextLink' - if ($nextLink.Exists -and $null -ne $nextLink.Value -and $nextLink.Value -isnot [string]) { + if ($nextLink.Exists -and $null -ne $nextLink.Value -and + ($nextLink.Value -isnot [string] -or [string]::IsNullOrWhiteSpace($nextLink.Value))) { throw "Microsoft Graph returned a malformed next link for $EvidenceTarget." } $nextUri = if ($nextLink.Exists) { [string] $nextLink.Value } else { $null } diff --git a/Products/Intune/README.md b/Products/Intune/README.md index 4282dde..81f600e 100644 --- a/Products/Intune/README.md +++ b/Products/Intune/README.md @@ -461,6 +461,18 @@ context left over from a different tenant is rejected and reconnected on a direct run too. For GDAP, the toolkit authenticates directly to the customer domain. +The actual account after fresh sign-in must match `-TenantAdminUpn` +case-insensitively before success evidence or setup. Missing/wrong accounts and +tenant-verification failures stop the run and trigger an invalid-context +disconnect attempt. No additional Graph scopes are requested. + +Both Graph API bases are validated before authentication. Only approved +Microsoft Graph cloud hosts over HTTPS/default port 443, with a `/v1.0` or +`/beta` base path, are accepted. Query strings, user information, fragments, +extra or ambiguous base path components are rejected. Collection continuations +must retain the exact collection path and authority; paging queries remain +supported. See [configuration](docs/Configuration-Reference.md#graph-endpoint-boundary). + **Scope-set version history.** Intune 0.2.0's pilot requested only the five read scopes needed by the read-only assessments implemented at the time. Intune 0.3.0's authentication rewrite requests the full eleven-scope set as a diff --git a/Products/Intune/docs/Configuration-Reference.md b/Products/Intune/docs/Configuration-Reference.md index 0f30420..d6f7453 100644 --- a/Products/Intune/docs/Configuration-Reference.md +++ b/Products/Intune/docs/Configuration-Reference.md @@ -26,6 +26,25 @@ hardcode policy names, licenses, assignment scope, or report paths. ## Command-line controls +### Graph endpoint boundary + +`Api.GraphBaseUri` and `Api.GraphBetaBaseUri` are checked before authentication, +and the shared validator also protects independently invoked app modules. +Supported hosts are `graph.microsoft.com`, `graph.microsoft.us`, +`dod-graph.microsoft.us`, and `microsoftgraph.chinacloudapi.cn`. Bases require +HTTPS, default port 443 (explicit `:443` is allowed), and only `/v1.0` or +`/beta`, optionally ending in `/`. They cannot include user information, +queries, fragments, additional path segments, encoded or normalized-away path +components. This host validation is not certification that every resource is +available in every cloud; existing API and release boundaries still apply. + +App deployment, app protection, device Conditional Access, compliance and +enrollment collection readers reject continuation links that change authority, +version or exact collection path, include user information/fragments, are +malformed, or form a cycle. Legitimate paging query parameters are allowed. +Invalid destinations are rejected before SDK dispatch; no token-forwarding +behavior is assumed or tested with live credentials. + `-Skip*` switches omit modules intentionally; they are not fixes for module failures. `-NoLicenseAutoDetect` disables automatic SKU checks and reduces the strength of applicability evidence. `-PilotGroupId` is the safe default @@ -153,6 +172,12 @@ default Microsoft Graph PowerShell authentication persistence behavior applies. Authentication is delegated UPN/GDAP only; no other authentication mode is available. +Fresh sign-in is checked against the actual `Get-MgContext.Account`, not the +requested UPN alone. Missing or different accounts are rejected before setup or +successful connection evidence; case differences are accepted. Invalid account +or tenant contexts are disconnected where possible without replacing the +original failure. The configured scope set is unchanged. + ## Policy catalog `Config/PolicyCatalog/catalog.psd1` is the catalog manifest. It records every diff --git a/Products/Intune/docs/Evidence-Troubleshooting.md b/Products/Intune/docs/Evidence-Troubleshooting.md index 9f97e66..2649a8a 100644 --- a/Products/Intune/docs/Evidence-Troubleshooting.md +++ b/Products/Intune/docs/Evidence-Troubleshooting.md @@ -6,6 +6,18 @@ parent: Intune # Intune evidence and troubleshooting +An invalid Graph base stops before authentication and may precede run-log +initialization. Correct the private configuration using the +[endpoint contract](Configuration-Reference.md#graph-endpoint-boundary), not a +skip switch. A rejected continuation link stops that inventory before the next +SDK request; retain the sanitized failure and investigate the service response. +Do not substitute a cross-collection link or remove the boundary. + +A missing/wrong account after sign-in stops setup even if the tenant matches. +Select the requested `TenantAdminUpn` account on rerun. If disconnecting the +invalid context also fails, close PowerShell and open a fresh session. The +original identity error remains authoritative; do not add broader consent. + ## Evidence handling HTML and JSON reports are tenant evidence. Keep them in the approved private diff --git a/docs/Responsible-AI-Review.md b/docs/Responsible-AI-Review.md index b7b3612..486f508 100644 --- a/docs/Responsible-AI-Review.md +++ b/docs/Responsible-AI-Review.md @@ -1,10 +1,16 @@ --- authority: change-review -applies-to: "Tier 1 context consolidation" -last-reviewed: 2026-09-23 +applies-to: "Tier 1 context consolidation; deployment safeguard remediation" +last-reviewed: 2026-09-28 --- -# Responsible AI Review: Tier 1 Context Consolidation +# Responsible AI Reviews + +The first review below covers context consolidation only. The separate +deployment-safeguard review at the end covers the runtime changes of +2026-09-28; neither review grants tenant approval. + +## Tier 1 Context Consolidation ## Intended Use @@ -78,3 +84,104 @@ Ready for human review once the listed checks pass. This is not approval to merge, deploy, or publish telemetry. Residual risk is that an agent ignores the links; maintainers should review instruction-following failures and correct the entry point without duplicating the detailed guidance. + +## Deployment Safeguards: Intended Use + +Help deployment operators fail safely when emergency access is unverified, +Graph sign-in uses a different account, evidence cannot be written, or Intune +configuration/continuations identify an invalid destination. Restore the +Defender object-comparison regression without broadening supported writes. +This is deterministic PowerShell, not an AI-powered deployment feature. +No model, prompt, recommender, autonomous runtime or new tenant scope is added. + +### System and Data + +Runtime inputs remain operator configuration, Graph context and API responses. +Outputs remain local reports and explicit failures. The in-memory emergency +result includes a run ID, verification marker and copied principal arrays. +JSON is diagnostic only and never read to authorize exclusions. Generated passwords are +neither displayed nor placed in the handoff, and new accounts receive no role. +Tests use invented `.invalid` identities and synthetic SDK responses, not +customer data, tokens or live authentication. Fixtures remain under `C:\temp`. +No new external data store, retention policy or AI data flow is introduced. + +### Human Control + +Repository maintainers own diff review and merge approval. The deployment +team and tenant identity owner own credential setup, permanent role assignment, +recovery testing and pilot authorization. Account creation stops the run; +completion requires an operator-configured account and a new verification run. +Existing report-only defaults, `ShouldProcess`, approvals, scope and recovery +gates remain. No push, publication or tenant connection is authorized by this +review. Recovery remains the documented, human-approved product workflow. + +### Reliability and Safety + +Offline regressions execute the real module and orchestrator scripts with +mocked SDK boundaries. They require zero Conditional Access writes for newly +created, disabled, roleless or unreadable emergency accounts and reject +stale/malformed in-memory results. Tests replace diagnostic user/group IDs, +add/remove groups and replay stale markers after verification, requiring CA to +retain only the verified in-memory principal IDs. This closes file substitution, +not arbitrary in-process compromise. They test valid/case-insensitive and invalid fresh +accounts, cache replacement, GDAP targeting, tenant mismatch, report failure +combinations with stopping warning preferences, and invalid Intune bases or +paging links before dispatch. Missing verification never becomes success. +These are deterministic boundary evaluations, not model benchmarks or proof +of live service support. + +### Privacy and Security + +Existing delegated scopes and redaction boundaries remain. The URI allowlist +and exact collection checks constrain dispatch; tests demonstrate rejection at +the mock SDK boundary, not actual token forwarding or exfiltration. Evidence +export warnings do not hide deployment errors. Operators must retain surviving +evidence privately and check run IDs/timestamps. Pattern-based redaction does +not guarantee that every service error is safe for public sharing. + +### Fairness and Inclusiveness + +Identity comparison accepts case differences without changing operator intent. +The configured Graph cloud host allowlist remains supported; endpoint syntax +validation does not assert uniform resource availability across clouds. +Operator guidance is audience-neutral and provides recovery actions for +missing roles, invalid sign-in and incomplete reports. + +### Transparency and Accountability + +The work is AI-assisted engineering; maintainers must review generated changes +and verify claims. No tenant pilot has been performed for these changes. +Explicit blocked/failed handoff evidence separates created accounts from +verified recovery access. Local exports can fail independently; warnings are +not rollback or deployment-success evidence. Release approval remains with +maintainers and the deployment/identity owners. + +### Evaluation and Gate Triage + +Run the verification commands in [conventions](conventions.md#validation-and-definition-of-done), +including `Test-DefenderComparableValues.ps1` and +`Test-DeploymentSafeguards.ps1`. Compare against PR #7's merged baseline, +`24ee35c`, and preserve local command output separately from pilot evidence. +Before release, the deployment owner must approve and retain targeted and +orchestrator `-WhatIf` pilot evidence, then validate any separately approved +apply and recovery path. Stop release if role recovery, endpoint support, +readback or evidence cannot be established. + +The maintainer gate's `secure-operations.evidence` heuristic looks for the +Purview-specific `Add-RunLogEntry` spelling. Changed Entra/Intune modules use +their existing product-specific loggers; Defender comparison helpers are pure +value transforms and their callers own operation evidence. The generic +product-navigation warning is triggered by editing product READMEs: these fixes +add no product, navigation route or common prerequisite. No rich HTML companions +exist for the affected guides. The AI-sensitive heuristic matches existing +"agent" wording; this scoped review addresses AI-assisted engineering, not +new model functionality. None of these explanations waives the pilot gate or +unrelated pre-existing whole-repository findings. + +### Decision + +Local review only; **release readiness is blocked pending approved tenant +validation and maintainer review**. The deployment and identity owners must +close the pilot gap before release. Maintainers own disposition of unrelated +whole-repository quality findings; this change does not silently suppress or +repair those findings. No new AI runtime evaluation is applicable. diff --git a/docs/conventions.md b/docs/conventions.md index 8f3f4c9..4b620f2 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -37,6 +37,8 @@ not execute deployment scripts. Run the offline regression checks separately: pwsh -NoProfile -File .\scripts\Test-IdentityReviewFixes.ps1 pwsh -NoProfile -File .\scripts\Test-IntuneReviewFixes.ps1 pwsh -NoProfile -File .\scripts\Test-DefenderConnectionGuards.ps1 +pwsh -NoProfile -File .\scripts\Test-DefenderComparableValues.ps1 +pwsh -NoProfile -File .\scripts\Test-DeploymentSafeguards.ps1 ``` These plain PowerShell checks use synthetic Graph responses and require no @@ -44,6 +46,12 @@ Pester installation or credentials. They cover the corrected deployment paths, not every product feature. Neither parsing nor mocked execution is tenant validation. +The safeguard suite executes actual modules and orchestrators with a synthetic +SDK boundary, including emergency-access handoff, operator identity, evidence +export failures and Intune endpoint/paging rejection. Generated fixtures and +reports default to `C:\temp\deployment-safeguard-remediation` (`-TempRoot` +overrides this location). It never authenticates to a tenant. + For deployment-code changes, completion also requires validation of the affected deployment path against an approved non-production tenant. Do not connect to a tenant without approval, and report missing pilot evidence rather than claiming diff --git a/scripts/Test-DefenderComparableValues.ps1 b/scripts/Test-DefenderComparableValues.ps1 new file mode 100644 index 0000000..e5730d8 --- /dev/null +++ b/scripts/Test-DefenderComparableValues.ps1 @@ -0,0 +1,43 @@ +#requires -Version 7.0 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +. (Join-Path (Split-Path -Parent $PSScriptRoot) 'Products\Defender\Modules\DefenderStateManagement.ps1') +$assertions = 0 +function Assert-Equal { + param($Actual, $Expected, [string] $Message) + if ($Actual -cne $Expected) { throw $Message } + $script:assertions++ +} + +Assert-Equal (ConvertTo-DefenderComparableValue 'abc') 'abc' 'String content was lost' +Assert-Equal (Test-DefenderDesiredState -Current @{Mode='abc'} -Desired @{Mode='xyz'} ` + -Definition @{ManagedProperties=@('Mode')}) $false 'Equal-length strings compared equal' +Assert-Equal ((ConvertTo-DefenderComparableValue ([pscustomobject]@{Mode='Audit'})) | + ConvertTo-Json -Compress) '{"Mode":"Audit"}' 'PSObject conversion failed' +Assert-Equal ((ConvertTo-DefenderComparableValue @{Z=2; A=1}) | + ConvertTo-Json -Compress) '{"A":1,"Z":2}' 'Dictionary order was not normalized' +Assert-Equal (ConvertTo-DefenderComparableValue $null) $null 'Null conversion changed' +foreach ($value in @( + @{Mode='Audit'}, + [pscustomobject]@{Mode='Audit'}, + @{Mode=[pscustomobject]@{Names=@('abc','xyz'); Settings=@{Enabled=$true; Empty=$null}}}, + @{Mode=@([pscustomobject]@{A=1}, @{A=2})}, + @{Mode=@()}, + @{Mode=@('abc')}, + @{Mode=$null} +)) { + Assert-Equal (Test-DefenderDesiredState -Current $value -Desired $value ` + -Definition @{ManagedProperties=@('Mode')}) $true 'Equivalent values did not compare equal' +} +Assert-Equal (Test-DefenderDesiredState -Current @{Mode=[pscustomobject]@{Name='abc'}} ` + -Desired @{Mode=@{Name='xyz'}} -Definition @{ManagedProperties=@('Mode')}) $false 'Nested change was missed' +Assert-Equal (Test-DefenderDesiredState -Current @{Mode=@('abc','xyz')} ` + -Desired @{Mode=@('abc','def')} -Definition @{ManagedProperties=@('Mode')}) $false 'Array change was missed' +Assert-Equal (Test-DefenderDesiredState -Current @{Mode=[pscustomobject]@{B=@('abc','xyz'); A=@{Enabled=$true; Missing=$null}}} ` + -Desired @{Mode=@{A=[pscustomobject]@{Missing=$null; Enabled=$true}; B=@('abc','xyz')}} ` + -Definition @{ManagedProperties=@('Mode')}) $true 'Equivalent nested dictionary/PSObject shapes compared differently' +Assert-Equal (Test-DefenderDesiredState -Current @{Mode=@([pscustomobject]@{Name='abc'},[pscustomobject]@{Name='xyz'})} ` + -Desired @{Mode=@(@{Name='abc'},@{Name='xyz'})} -Definition @{ManagedProperties=@('Mode')}) $true 'Equivalent array object shapes compared differently' +Write-Host "PASS: $assertions Defender comparable-value assertions." diff --git a/scripts/Test-DeploymentSafeguards.ps1 b/scripts/Test-DeploymentSafeguards.ps1 new file mode 100644 index 0000000..16df44c --- /dev/null +++ b/scripts/Test-DeploymentSafeguards.ps1 @@ -0,0 +1,500 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Offline module and orchestrator regressions for deployment safeguards. +.DESCRIPTION + Executes repository scripts, not extracted copies. Graph, SDK discovery and + SDK import are synthetic. All generated fixtures and reports stay in TempRoot. +#> +[CmdletBinding()] +param([string] $TempRoot = 'C:\temp\deployment-safeguard-remediation') + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$scratch = Join-Path $TempRoot ('regression-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $scratch -Force | Out-Null +$script:passed = 0 +function Assert-True($Condition, [string] $Message) { + if (-not $Condition) { throw "ASSERTION: $Message" } +} +function Reset-State { + $script:state = @{ + Account = 'operator@example.invalid'; Domain = 'example.invalid' + Context = $null; Connects = 0; Disconnects = 0; DisconnectFails = $false + Requests = [Collections.Generic.List[object]]::new() + UserMode = 'New'; UserCreated = $false; CaWrites = 0 + CaObjects = @{}; LinkMode = ''; PageCalls = @{} + } + $global:DeploymentSafeguardTest = $script:state + foreach ($product in 'Entra','Intune','Defender') { + Set-Variable -Scope Global -Name "${product}RunLog" -Value ([Collections.Generic.List[hashtable]]::new()) + Set-Variable -Scope Global -Name "${product}RunLogPath" -Value $null + Set-Variable -Scope Global -Name "${product}RunMetadata" -Value @{} + } +} +function Test-Case([string] $Name, [scriptblock] $Action) { + Reset-State + & $Action + $script:passed++ + Write-Host "PASS $Name" +} +function Throw-Http([int] $Status) { + $failure = [Exception]::new("Synthetic HTTP $Status") + $failure | Add-Member Response ([pscustomobject]@{StatusCode=$Status}) + throw $failure +} +function Get-Module { + [CmdletBinding()] + param([string[]] $Name, [switch] $ListAvailable) + foreach ($n in $Name) { [pscustomobject]@{Name=$n; Version=[version]'2.30.0'} } +} +function Import-Module { + [CmdletBinding()] + param([string] $Name, [version] $RequiredVersion, [switch] $DisableNameChecking) + if ($Name -notlike 'Microsoft.Graph.*') { throw "Unexpected module import: $Name" } +} +function Install-Module { throw 'Offline tests must not install modules.' } +function Get-MgDeviceManagement {} +function Get-CimInstance { + [CmdletBinding()] + param([string] $ClassName) + return [pscustomobject]@{BuildNumber=22631; ProductType=1} +} +function Get-MgContext { + [CmdletBinding()] + param() + return $global:DeploymentSafeguardTest.Context +} +function Disconnect-MgGraph { + [CmdletBinding()] + param() + $global:DeploymentSafeguardTest.Disconnects++ + if ($global:DeploymentSafeguardTest.DisconnectFails) { throw 'Synthetic disconnect failure' } + $global:DeploymentSafeguardTest.Context = $null +} +function Connect-MgGraph { + [CmdletBinding()] + param([string] $TenantId, [string[]] $Scopes, [switch] $NoWelcome, [string] $ContextScope) + $global:DeploymentSafeguardTest.Connects++ + $global:DeploymentSafeguardTest.ConnectTenant = $TenantId + $global:DeploymentSafeguardTest.Scopes = $Scopes + $global:DeploymentSafeguardTest.Context = [pscustomobject]@{ + Account=$global:DeploymentSafeguardTest.Account; Scopes=$Scopes; AuthType='Delegated' + TenantId='11111111-1111-4111-8111-111111111111' + } +} +function Invoke-MgGraphRequest { + [CmdletBinding()] + param([string] $Method, [string] $Uri, $Body, [string] $ContentType) + $s = $global:DeploymentSafeguardTest + $s.Requests.Add(@{Method=$Method; Uri=$Uri}) + $parsed = [uri] $Uri + if ($parsed.Host -ne 'graph.microsoft.com' -or $parsed.Port -ne 443) { + throw 'Invalid destination reached the mock SDK' + } + $path = $parsed.AbsolutePath + if ($Method -eq 'GET' -and $path -eq '/v1.0/organization') { + $domain = if ($s.CachedWrongTenant -and $s.Connects -eq 0) {'wrong.example.invalid'} else {$s.Domain} + return @{value=@(@{id='11111111-1111-4111-8111-111111111111'; verifiedDomains=@(@{name=$domain})})} + } + if ($Method -eq 'GET' -and $path -eq '/v1.0/policies/identitySecurityDefaultsEnforcementPolicy') { + return @{isEnabled=$false} + } + if ($Method -eq 'GET' -and $path -like '/v1.0/users/*') { + if ($s.UserMode -eq 'Denied') { Throw-Http 403 } + if ($s.UserMode -eq 'New' -and -not $s.UserCreated) { Throw-Http 404 } + return @{id='22222222-2222-4222-8222-222222222222'; accountEnabled=($s.UserMode -ne 'Disabled'); userPrincipalName='emergency@example.invalid'} + } + if ($Method -eq 'GET' -and $path -eq '/v1.0/groups/44444444-4444-4444-8444-444444444444') { + return @{id='44444444-4444-4444-8444-444444444444'; displayName='Synthetic emergency group'} + } + if ($Method -eq 'GET' -and $path -eq '/v1.0/groups/44444444-4444-4444-8444-444444444444/transitiveMembers/microsoft.graph.user') { + return @{value=@(@{id='22222222-2222-4222-8222-222222222222'; accountEnabled=$true})} + } + if ($Method -eq 'POST' -and $path -eq '/v1.0/users') { + Assert-True (-not $s.UserCreated) 'Duplicate emergency account creation' + $s.UserCreated = $true + return @{id='22222222-2222-4222-8222-222222222222'} + } + if ($Method -eq 'GET' -and $path -like '/v1.0/roleManagement/*') { + if ($s.UserMode -eq 'RoleDenied') { Throw-Http 403 } + $roles = @() + if ($s.UserMode -eq 'Verified') { + $roles = @(@{principalId='22222222-2222-4222-8222-222222222222'; assignmentType='Assigned'; endDateTime=$null; directoryScopeId='/'}) + } + return @{value=$roles} + } + if ($Method -eq 'GET' -and $path -eq '/v1.0/servicePrincipals') { + return @{value=@(@{id='33333333-3333-4333-8333-333333333333'})} + } + if ($Method -eq 'POST' -and $path -eq '/v1.0/identity/conditionalAccess/policies') { + $s.CaWrites++ + $policy = ConvertFrom-Json $Body -AsHashtable + $policy.id = [guid]::NewGuid().ToString() + $s.CaObjects[$policy.id] = $policy + return $policy + } + if ($Method -eq 'GET' -and $path -like '/v1.0/identity/conditionalAccess/policies/*') { + return $s.CaObjects[$path.Split('/')[-1]] + } + if ($Method -eq 'GET' -and ($path -match '/deviceAppManagement/' -or $path -eq '/v1.0/identity/conditionalAccess/policies')) { + if (-not $s.PageCalls.ContainsKey($path)) { $s.PageCalls[$path] = 0 } + $s.PageCalls[$path]++ + $page = @{value=@()} + if ($s.LinkMode -and $s.PageCalls[$path] -eq 1) { + $next = "$($parsed.GetLeftPart([UriPartial]::Path))?`$skiptoken=synthetic%2Btoken&`$top=2" + $page['@odata.nextLink'] = switch ($s.LinkMode) { + 'Valid' { $next } + 'Host' { $next.Replace('graph.microsoft.com','example.invalid') } + 'Path' { 'https://graph.microsoft.com/v1.0/users?$skiptoken=synthetic' } + 'Version' { if ($next.Contains('/v1.0/')) { $next.Replace('/v1.0/','/beta/') } else { $next.Replace('/beta/','/v1.0/') } } + 'Port' { $next.Replace('graph.microsoft.com','graph.microsoft.com:444') } + 'UserInfo' { $next.Replace('https://','https://user@') } + 'EmptyUserInfo' { $next.Replace('https://','https://@') } + 'Fragment' { "$next#fragment" } + 'EmptyFragment' { "$next#" } + 'DotSegment' { $next.Replace('/deviceAppManagement/', '/extra/../deviceAppManagement/').Replace('/identity/', '/extra/../identity/') } + 'Malformed' { @{url=$next} } + 'Whitespace' { ' ' } + 'Relative' { '/v1.0/users' } + 'Cycle' { $Uri } + default { throw 'Unknown paging test' } + } + } + return $page + } + throw "Unexpected SDK dispatch: $Method $Uri" +} +function New-TestConfig([string] $Product, [string] $Name, [hashtable] $Replace = @{}) { + $productRoot = Join-Path $root "Products\$Product" + $out = Join-Path $scratch $Name + New-Item -ItemType Directory -Path $out -Force | Out-Null + $text = Get-Content (Join-Path $productRoot "Config\${Product}Config.psd1") -Raw + $relative = [IO.Path]::GetRelativePath($productRoot, $out) + $text = $text.Replace("OutputDirectory = '.\Reports'", "OutputDirectory = '$relative'") + foreach ($key in $Replace.Keys) { + Assert-True ($text.Contains($key)) "Missing fixture replacement: $key" + $text = $text.Replace($key, $Replace[$key]) + } + $path = Join-Path $out 'test-config.psd1' + Set-Content -LiteralPath $path -Value $text -Encoding utf8 + return @{Path=$path; Directory=$out; Config=(Import-PowerShellDataFile $path)} +} +function Invoke-EntraRun($Fixture, [switch] $Preview, [switch] $SkipEmergency) { + & (Join-Path $root 'Products\Entra\Deploy-EntraBestPractice.ps1') ` + -TenantAdminUpn 'operator@example.invalid' -ConfigPath $Fixture.Path ` + -IncludeHighRisk -CustomerApprovalId 'synthetic-approval' -BreakGlassExclusionsConfirmed ` + -RollbackAcknowledged -PilotGroupId '33333333-3333-4333-8333-333333333333' ` + -SkipTenantSecuritySettings -SkipDeploymentHealth -SkipEmergencyAccess:$SkipEmergency -WhatIf:$Preview | Out-Null +} +function Invoke-ExpectFailure([scriptblock] $Action, [string] $Pattern) { + $failure = $null + try { & $Action | Out-Null } catch { $failure = $_ } + Assert-True ($null -ne $failure) 'Expected failure was not raised' + if ($Pattern) { Assert-True ($failure.Exception.Message -match $Pattern) "Wrong failure: $($failure.Exception.Message)" } + return $failure +} + +try { + foreach ($product in 'Entra','Intune') { + foreach ($mode in 'Correct','Uppercase','Wrong','Missing','Cached','CachedWrong','CachedWrongTenant','WrongTenant','DisconnectFails','Gdap') { + Test-Case "$product operator identity: $mode" { + $upn = 'operator@example.invalid' + $callArgs = @{TenantAdminUpn=$upn; GraphBaseUri='https://graph.microsoft.com/v1.0'; ConnectGraph=$true; Scopes=@('User.Read'); NonInteractive=$true} + if ($mode -eq 'Uppercase') { $state.Account = $upn.ToUpperInvariant() } + if ($mode -in 'Wrong','DisconnectFails') { $state.Account = 'other@example.invalid' } + if ($mode -eq 'Missing') { $state.Account = '' } + if ($mode -eq 'WrongTenant') { $state.Domain = 'wrong.example.invalid' } + if ($mode -eq 'DisconnectFails') { $state.DisconnectFails = $true; $WarningPreference = 'Stop' } + if ($mode -eq 'Gdap') { $callArgs.DelegatedOrganization = 'customer.example.invalid'; $state.Domain = $callArgs.DelegatedOrganization } + if ($mode -in 'Cached','CachedWrong','CachedWrongTenant') { + $state.CachedWrongTenant = $mode -eq 'CachedWrongTenant' + $state.Context = [pscustomobject]@{Account=$(if ($mode -eq 'CachedWrong') {'other@example.invalid'} else {$upn.ToUpperInvariant()}); Scopes=@('User.Read')} + } + $connect = Join-Path $root "Products\$product\Modules\Connect-${product}Services.ps1" + if ($mode -in 'Wrong','Missing','WrongTenant','DisconnectFails') { + $null = Invoke-ExpectFailure { & $connect @callArgs } 'operator|expected verified domain' + Assert-True ($state.Disconnects -eq 1) 'Invalid fresh context was not disconnected' + $log = Get-Variable -Scope Global -Name "${product}RunLog" -ValueOnly + Assert-True (@($log | Where-Object Status -eq 'Failed').Count -gt 0) 'Missing failure evidence' + Assert-True (@($log | Where-Object { $_.Detail -match 'reason=connected;' }).Count -eq 0) 'Premature connected evidence' + } + else { + $result = & $connect @callArgs + Assert-True $result.GraphConnected 'Valid context was rejected' + Assert-True ($state.Connects -eq $(if ($mode -eq 'Cached') {0} else {1})) 'Unexpected reconnect count' + if ($mode -ne 'Cached') { Assert-True (($state.Scopes -join ',') -eq 'User.Read') 'Scopes were broadened' } + if ($mode -eq 'Gdap') { Assert-True ($state.ConnectTenant -eq $callArgs.DelegatedOrganization) 'GDAP target changed' } + } + } + } + } + foreach ($mode in 'New','Disabled','NoRole','Denied','RoleDenied','Verified','VerifiedPreview','Preview','Skipped') { + Test-Case "Entra real orchestrator emergency boundary: $mode" { + $state.UserMode = if ($mode -in 'Preview','Skipped') {'New'} elseif ($mode -eq 'VerifiedPreview') {'Verified'} else {$mode} + $replace = @{'CreateAccountIfMissing = $false'='CreateAccountIfMissing = $true'} + if ($mode -notin 'New','Preview') { $replace['ExcludeUserIds = @()'] = "ExcludeUserIds = @('22222222-2222-4222-8222-222222222222')" } + $fixture = New-TestConfig 'Entra' "entra-$mode" $replace + $bgPath = Join-Path $fixture.Directory 'entra-breakglass.json' + @{runId='stale'; verified=$true; userIds=@('22222222-2222-4222-8222-222222222222'); groupIds=@()} | + ConvertTo-Json | Set-Content $bgPath + if ($mode -in 'Verified','VerifiedPreview','Preview','Skipped') { + Invoke-EntraRun $fixture -Preview:($mode -in 'Preview','VerifiedPreview') -SkipEmergency:($mode -eq 'Skipped') + } + else { + $null = Invoke-ExpectFailure { Invoke-EntraRun $fixture } 'Deployment stopped|disabled|Global Administrator|403' + } + Assert-True ($state.CaWrites -eq $(if ($mode -eq 'Verified') {12} else {0})) 'Unverified principals allowed CA writes, or verified apply failed' + if ($mode -ne 'Skipped') { + $handoff = Get-Content $bgPath -Raw | ConvertFrom-Json + Assert-True ($handoff.verified -eq ($mode -in 'Verified','VerifiedPreview')) 'Handoff verification state is incorrect' + Assert-True ($handoff.runId -ne 'stale') 'Stale evidence survived verification' + if ($mode -notin 'Verified','VerifiedPreview') { Assert-True (@($handoff.userIds).Count -eq 0) 'Unverified IDs entered handoff' } + } + if ($mode -eq 'New') { + Assert-True $state.UserCreated 'Opt-in did not create account' + $null = Invoke-ExpectFailure { Invoke-EntraRun $fixture } 'already exists' + Assert-True (@($state.Requests | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/users$' }).Count -eq 1) 'Rerun attempted duplicate creation' + $evidence = Get-Content (Join-Path $fixture.Directory 'entra-run-log.json') -Raw + Assert-True ($evidence -notmatch 'passwordProfile|Aa1!') 'Secret appeared in evidence' + } + } + } + foreach ($evidenceFault in 'ReplaceUser','ReplaceGroup','AddGroup','RemoveGroup','StaleRun','StringMarker','MissingMarker') { + Test-Case "Entra ignores diagnostic JSON substitution: $evidenceFault" { + $state.UserMode = 'Verified' + $state.EvidenceFault = $evidenceFault + $fixture = New-TestConfig 'Entra' "evidence-$evidenceFault" @{ + 'ExcludeUserIds = @()'="ExcludeUserIds = @('22222222-2222-4222-8222-222222222222')" + 'ExcludeGroupIds = @()'="ExcludeGroupIds = @('44444444-4444-4444-8444-444444444444')" + } + function Set-Content { + [CmdletBinding(SupportsShouldProcess)] + param([string] $LiteralPath, [Parameter(ValueFromPipeline)] $Value, [string] $Encoding) + process { + $content = $Value + if ($LiteralPath -like '*entra-breakglass.json') { + $evidence = $content | ConvertFrom-Json -AsHashtable + if ($evidence.verified -eq $true) { + switch ($global:DeploymentSafeguardTest.EvidenceFault) { + 'ReplaceUser' { $evidence.userIds = @('99999999-9999-4999-8999-999999999999') } + 'ReplaceGroup' { $evidence.groupIds = @('99999999-9999-4999-8999-999999999999') } + 'AddGroup' { $evidence.groupIds += '99999999-9999-4999-8999-999999999999' } + 'RemoveGroup' { $evidence.groupIds = @() } + 'StaleRun' { $evidence.runId = 'old-run' } + 'StringMarker' { $evidence.verified = 'true' } + 'MissingMarker' { $evidence.Remove('verified') } + } + $content = $evidence | ConvertTo-Json + $global:DeploymentSafeguardTest.EvidenceTampered = $true + } + } + Microsoft.PowerShell.Management\Set-Content -LiteralPath $LiteralPath -Value $content -Encoding $Encoding -WhatIf:$false + if ($LiteralPath -like '*entra-breakglass.json') { 'incidental diagnostic output' } + } + } + function Get-Content { + [CmdletBinding()] + param([string] $LiteralPath, [switch] $Raw) + if ($LiteralPath -like '*entra-breakglass.json') { + throw 'Diagnostic JSON must never be read for authorization' + } + Microsoft.PowerShell.Management\Get-Content -LiteralPath $LiteralPath -Raw:$Raw + } + Invoke-EntraRun $fixture + Assert-True $state.EvidenceTampered 'Test did not substitute the diagnostic JSON' + Assert-True ($state.CaWrites -eq 12) 'Diagnostic evidence changed authorized deployment' + foreach ($policy in $state.CaObjects.Values) { + Assert-True ('22222222-2222-4222-8222-222222222222' -in $policy.conditions.users.excludeUsers -and + '99999999-9999-4999-8999-999999999999' -notin $policy.conditions.users.excludeUsers) 'CA excluded substituted/unverified user instead of verified user' + Assert-True (($policy.conditions.users.excludeGroups -join ',') -eq '44444444-4444-4444-8444-444444444444') 'CA exclusions changed after group evidence replacement/addition/removal' + } + } + } + foreach ($resultFault in 'Missing','Scalar','Multiple','StaleRun','StringMarker','MissingMarker', + 'WrongRunIdType','NullUsers','ObjectArray','ScalarGroup','BlankId','VerifiedEmpty','UnverifiedIds','ExtraField') { + Test-Case "Entra rejects malformed in-memory module result: $resultFault" { + $state.UserMode = 'Verified' + $state.ResultFault = $resultFault + $fixture = New-TestConfig 'Entra' "result-$resultFault" @{ + 'ExcludeUserIds = @()'="ExcludeUserIds = @('22222222-2222-4222-8222-222222222222')" + } + $state.EmergencyScript = Join-Path $root 'Products\Entra\Modules\Setup-EmergencyAccess.ps1' + $state.ResultWrapper = Join-Path $fixture.Directory 'result-boundary.ps1' + # Fault injection at the return boundary, after the real module ran. + @' +[CmdletBinding(SupportsShouldProcess)] +param([hashtable] $Config, [hashtable] $Context) +$result = & $global:DeploymentSafeguardTest.EmergencyScript -Config $Config -Context $Context -WhatIf:$WhatIfPreference +switch ($global:DeploymentSafeguardTest.ResultFault) { + 'Missing' { return } + 'Scalar' { return 'not a verification result' } + 'Multiple' { $result; $result; return } + 'StaleRun' { $result.runId = 'old-run' } + 'StringMarker' { $result.verified = 'true' } + 'MissingMarker' { $result.PSObject.Properties.Remove('verified') } + 'WrongRunIdType' { $result.runId = [guid] $result.runId } + 'NullUsers' { $result.userIds = $null } + 'ObjectArray' { $result.userIds = [object[]]@($result.userIds) } + 'ScalarGroup' { $result.groupIds = '44444444-4444-4444-8444-444444444444' } + 'BlankId' { $result.userIds = [string[]]@(' ') } + 'VerifiedEmpty' { $result.userIds = [string[]]@() } + 'UnverifiedIds' { $result.verified = $false } + 'ExtraField' { $result | Add-Member unexpected 'value' } +} +$result +'@ | Set-Content -LiteralPath $state.ResultWrapper + function Join-Path { + [CmdletBinding()] + param([string] $Path, [string] $ChildPath) + if ($ChildPath -eq 'Setup-EmergencyAccess.ps1') { return $global:DeploymentSafeguardTest.ResultWrapper } + Microsoft.PowerShell.Management\Join-Path -Path $Path -ChildPath $ChildPath + } + $null = Invoke-ExpectFailure { Invoke-EntraRun $fixture } 'structured verification result|stale or malformed|inconsistent verified' + Assert-True ($state.CaWrites -eq 0) 'Malformed module result allowed CA writes' + $log = Get-Content (Microsoft.PowerShell.Management\Join-Path $fixture.Directory 'entra-run-log.json') -Raw | ConvertFrom-Json + Assert-True (@($log.entries | Where-Object { $_.module -eq 'Setup-EmergencyAccess' -and $_.status -eq 'Failed' }).Count -gt 0) 'Malformed result did not produce failure evidence' + } + } + foreach ($preview in $false,$true) { + Test-Case "Emergency module returns exactly one copied structured result, WhatIf=$preview" { + $state.UserMode = 'Verified' + $fixture = New-TestConfig 'Entra' "result-copy-$preview" + $context = @{ + TenantAdminUpn='operator@example.invalid'; RunId=[guid]::NewGuid() + BreakGlassOutputPath=(Join-Path $fixture.Directory 'result.json') + BreakGlassUserIds=[string[]]@('22222222-2222-4222-8222-222222222222') + BreakGlassGroupIds=[string[]]@('44444444-4444-4444-8444-444444444444') + } + $results = @(& (Join-Path $root 'Products\Entra\Modules\Setup-EmergencyAccess.ps1') -Config $fixture.Config -Context $context -WhatIf:$preview) + Assert-True ($results.Count -eq 1 -and $results[0] -is [pscustomobject]) 'Module emitted incidental success output' + $result = $results[0] + Assert-True ($result.verified -is [bool] -and $result.verified -and $result.runId -ceq [string] $context.RunId) 'Module result markers are invalid' + Assert-True ($result.userIds -is [string[]] -and $result.groupIds -is [string[]]) 'Module result IDs are not typed arrays' + $context.BreakGlassUserIds[0] = 'changed' + $context.BreakGlassGroupIds[0] = 'changed' + Assert-True ($result.userIds[0] -eq '22222222-2222-4222-8222-222222222222' -and $result.groupIds[0] -eq '44444444-4444-4444-8444-444444444444') 'Module result shares input arrays' + Assert-True ($state.CaWrites -eq 0) 'Emergency verification wrote CA' + } + } + Test-Case 'Emergency module independently stops with an invalidated handoff' { + $fixture = New-TestConfig 'Entra' 'emergency-module' @{'CreateAccountIfMissing = $false'='CreateAccountIfMissing = $true'} + $context = @{TenantAdminUpn='operator@example.invalid'; BreakGlassOutputPath=(Join-Path $fixture.Directory 'handoff.json'); RunId='synthetic-run'} + $null = Invoke-ExpectFailure { & (Join-Path $root 'Products\Entra\Modules\Setup-EmergencyAccess.ps1') -Config $fixture.Config -Context $context } 'Deployment stopped' + $handoff = Get-Content $context.BreakGlassOutputPath -Raw | ConvertFrom-Json + Assert-True (-not $handoff.verified -and @($handoff.userIds).Count -eq 0) 'Module published unverified account' + Assert-True (@($global:EntraRunLog | Where-Object { $_.Action -eq 'BreakGlassHandoff' -and $_.Status -eq 'Failed' }).Count -eq 1) 'Missing actionable handoff evidence' + } + Test-Case 'Defender JSON failure warns even with nonterminating error preference' { + . (Join-Path $root 'Products\Defender\Modules\DefenderRunLog.ps1') + $warnings = @() + $ErrorActionPreference = 'Continue' + $WarningPreference = 'Stop' + Save-DefenderRunLogJson -Path $scratch -WarningVariable +warnings + Assert-True (($warnings -join ' ') -match 'JSON run log could not') 'JSON failure was not surfaced as a warning' + } + foreach ($deploymentFails in $false,$true) { + foreach ($jsonFails in $false,$true) { + foreach ($htmlFails in $false,$true) { + Test-Case "Defender evidence: deployment=$deploymentFails JSON=$jsonFails HTML=$htmlFails" { + $replace = @{} + if ($jsonFails) { $replace["JsonLogFileName = 'defender-run-log.json'"] = "JsonLogFileName = 'blocked-json'" } + if ($htmlFails) { $replace["HtmlReportFileName = 'defender-run-report.html'"] = "HtmlReportFileName = 'missing-html\report.html'" } + $fixture = New-TestConfig 'Defender' "defender-$deploymentFails-$jsonFails-$htmlFails" $replace + if ($jsonFails) { New-Item -ItemType Directory -Path (Join-Path $fixture.Directory 'blocked-json') | Out-Null } + if ($deploymentFails) { $state.Account = 'other@example.invalid' } + $warnings = @() + $failure = $null + $previous = $WarningPreference + try { + $WarningPreference = 'Stop' + & (Join-Path $root 'Products\Defender\Deploy-DefenderBestPractice.ps1') ` + -TenantAdminUpn 'operator@example.invalid' -ConfigPath $fixture.Path ` + -SkipPreflight -SkipMdoEop -SkipDefenderForBusiness -SkipMdeAdvanced -SkipDefenderForCloudApps ` + -WarningVariable +warnings | Out-Null + } + catch { $failure = $_ } + finally { $WarningPreference = $previous } + Assert-True (($null -ne $failure) -eq $deploymentFails) 'Export failure changed deployment result' + if ($failure) { Assert-True ($failure.Exception.Message -match 'operator|account') 'Original deployment error was masked' } + Assert-True ((Test-Path (Join-Path $fixture.Directory 'defender-run-log.json')) -eq (-not $jsonFails)) 'JSON export was not independently attempted' + Assert-True ((Test-Path (Join-Path $fixture.Directory 'defender-run-report.html')) -eq (-not $htmlFails)) 'HTML export was not independently attempted' + if ($jsonFails) { Assert-True (($warnings -join ' ') -match 'JSON run log could not') 'Missing JSON warning' } + if ($htmlFails) { Assert-True (($warnings -join ' ') -match 'HTML report could not') 'Missing HTML warning' } + Assert-True (-not (Get-Variable DefenderRunLog -Scope Global -ErrorAction SilentlyContinue)) 'Run log was not cleared' + Assert-True (-not (Get-Variable DefenderRunMetadata -Scope Global -ErrorAction SilentlyContinue)) 'Run metadata was not cleared' + } + } + } + } + foreach ($kind in 'Apps','Protection','CA') { + foreach ($linkMode in 'Valid','Host','Path','Version','Port','UserInfo','EmptyUserInfo','Fragment','EmptyFragment','DotSegment','Malformed','Whitespace','Relative','Cycle') { + Test-Case "Intune $kind collection boundary: $linkMode" { + $state.LinkMode = $linkMode + $config = Import-PowerShellDataFile (Join-Path $root 'Products\Intune\Config\IntuneConfig.psd1') + $context = @{ + TenantAdminUpn='operator@example.invalid'; AssignmentScope='PilotGroup' + PilotGroupId='33333333-3333-4333-8333-333333333333'; IncludeHighRisk=$true + RollbackAcknowledged=$true; BreakGlassUserIds=@('22222222-2222-4222-8222-222222222222') + } + $module = switch ($kind) {'Apps' {'Setup-AppDeployment'} 'Protection' {'Setup-AppProtectionPolicies'} 'CA' {'Setup-DeviceConditionalAccess'}} + $callArgs = @{Config=$config; Context=$context; WhatIf=$true} + if ($kind -eq 'CA') { + $callArgs.IncludeHighRisk=$true; $callArgs.EnableConditionalAccessEnforcement=$true + $callArgs.BreakGlassExclusionsConfirmed=$true; $callArgs.RollbackAcknowledged=$true + } + $invoke = { & (Join-Path $root "Products\Intune\Modules\$module.ps1") @callArgs } + if ($linkMode -eq 'Valid') { + & $invoke | Out-Null + Assert-True (@($state.PageCalls.Values | Where-Object {$_ -eq 2}).Count -gt 0) 'Legitimate paging was not followed' + } + else { + $null = Invoke-ExpectFailure $invoke '' + Assert-True (@($state.PageCalls.Values | Where-Object {$_ -gt 1}).Count -eq 0) 'Unsafe next link reached SDK' + } + Assert-True (@($state.Requests | Where-Object Method -ne 'GET').Count -eq 0) 'Paging test unexpectedly wrote tenant state' + } + } + } + . (Join-Path $root 'Products\Intune\Modules\IntuneGraphClient.ps1') + foreach ($hostName in 'graph.microsoft.com','graph.microsoft.us','dod-graph.microsoft.us','microsoftgraph.chinacloudapi.cn') { + foreach ($version in 'v1.0','beta') { + Test-Case "Approved Graph base: $hostName/$version" { + Assert-IntuneGraphBaseUri "https://$hostName/$version" + Assert-IntuneGraphBaseUri "https://${hostName}:443/$version/" + } + } + } + foreach ($badBase in 'http://graph.microsoft.com/beta','https://example.invalid/beta', + 'https://graph.microsoft.com:444/beta','https://user@graph.microsoft.com/beta', + 'https://graph.microsoft.com/beta#fragment','https://graph.microsoft.com/beta?x=1', + 'https://graph.microsoft.com/beta/../v1.0','https://graph.microsoft.com/beta/extra', + 'https://graph.microsoft.com//beta','https://graph.microsoft.com/beta#', + 'https://graph.microsoft.com/beta?','https://graph.microsoft.com/beta\', + 'https://graph.microsoft.com/%62eta','not-a-uri') { + foreach ($key in 'GraphBaseUri','GraphBetaBaseUri') { + Test-Case "Intune blocks $key before authentication: $badBase" { + $default = if ($key -eq 'GraphBaseUri') {'https://graph.microsoft.com/v1.0'} else {'https://graph.microsoft.com/beta'} + $fixture = New-TestConfig 'Intune' ([guid]::NewGuid().ToString('N')) @{"$key = '$default'"="$key = '$badBase'"} + $null = Invoke-ExpectFailure { + & (Join-Path $root 'Products\Intune\Deploy-IntuneBestPractice.ps1') -TenantAdminUpn 'operator@example.invalid' -ConfigPath $fixture.Path + } 'Graph API base' + Assert-True ($state.Connects -eq 0 -and $state.Requests.Count -eq 0) 'Bad base reached authentication or dispatch' + foreach ($module in 'Setup-AppDeployment','Setup-AppProtectionPolicies') { + $null = Invoke-ExpectFailure { & (Join-Path $root "Products\Intune\Modules\$module.ps1") -Config $fixture.Config -Context @{TenantAdminUpn='operator@example.invalid'} } 'Graph API base' + Assert-True ($state.Requests.Count -eq 0) 'Standalone bad base reached SDK' + } + } + } + } + Write-Host "$script:passed safeguard scenarios passed. Synthetic module/orchestrator execution; no tenant validation." +} +finally { + Remove-Variable -Scope Global -Name DeploymentSafeguardTest -ErrorAction SilentlyContinue + foreach ($product in 'Entra','Intune','Defender') { + foreach ($suffix in 'RunLog','RunLogPath','RunMetadata') { + Remove-Variable -Scope Global -Name "$product$suffix" -ErrorAction SilentlyContinue + } + } + Write-Host "Synthetic evidence retained at $scratch" +} From ee2ea452ea6780d2ef963e15596df5c20cca8138 Mon Sep 17 00:00:00 2001 From: "Christopher Scott (CJ)" Date: Mon, 28 Sep 2026 15:20:46 -0400 Subject: [PATCH 2/2] Verify emergency recovery at standalone Conditional Access write boundaries Share read-only principal verification and validate the actual operator and intended tenant before each create or adoption write. Preserve assessment, WhatIf, customer exclusions, and existing enforcement state; add 96 offline standalone boundary scenarios. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 9 + .../Entra/Modules/EntraDirectoryReads.ps1 | 102 ++++++++ .../Setup-ConditionalAccessBaseline.ps1 | 36 ++- .../Entra/Modules/Setup-EmergencyAccess.ps1 | 118 +--------- Products/Entra/README.md | 8 + Products/Entra/docs/Break-Glass-Guide.md | 10 + .../Entra/docs/Evidence-Troubleshooting.md | 10 + Products/Entra/docs/Operator-Guide.md | 29 ++- docs/Responsible-AI-Review.md | 12 + docs/conventions.md | 4 + scripts/Test-EntraStandaloneWriteGuards.ps1 | 217 ++++++++++++++++++ scripts/Test-IdentityReviewFixes.ps1 | 16 ++ 12 files changed, 451 insertions(+), 120 deletions(-) create mode 100644 scripts/Test-EntraStandaloneWriteGuards.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3b569..3430664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,15 @@ landed but not yet been tagged in a release appear under **Unreleased**. ### Security +- **[Entra] Standalone Conditional Access write verification.** Close an + inherited standalone-module gap by rechecking the actual Graph operator and + intended tenant, enabled emergency users/group members and permanent + tenant-wide Global Administrator recovery before each create or adoption + write. Standalone writes now require `Context.TenantId`; assessment/WhatIf + retain their existing gates. Caller markers and diagnostic JSON never + authorize writes. No roles/scopes are added, and existing-policy state and + customer exclusions remain preserved. + - **[Purview] Tenant validation rejects broader Graph consent.** The read-only validator now checks the effective token, disconnects contexts containing permissions outside its two documented Graph read scopes, and supports an diff --git a/Products/Entra/Modules/EntraDirectoryReads.ps1 b/Products/Entra/Modules/EntraDirectoryReads.ps1 index 00b4283..407bcc0 100644 --- a/Products/Entra/Modules/EntraDirectoryReads.ps1 +++ b/Products/Entra/Modules/EntraDirectoryReads.ps1 @@ -238,3 +238,105 @@ function Get-EntraGlobalAdminRecoveryState { RoleVia = 'none' } } + +function Assert-EntraWriteIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [AllowNull()] [string] $TenantAdminUpn, + [AllowNull()] [string] $TenantId + ) + + $intendedTenant = [guid]::Empty + if ([string]::IsNullOrWhiteSpace($TenantAdminUpn) -or + -not [guid]::TryParse($TenantId, [ref] $intendedTenant) -or $intendedTenant -eq [guid]::Empty) { + throw 'Conditional Access writes require Context.TenantAdminUpn and the intended Context.TenantId GUID.' + } + $actual = Get-MgContext -ErrorAction Stop + $actualTenant = [guid]::Empty + if ($null -eq $actual -or $actual.Account -isnot [string] -or + [string]::IsNullOrWhiteSpace($actual.Account) -or $actual.TenantId -isnot [string] -or + $actual.Account -ine $TenantAdminUpn -or + -not [guid]::TryParse([string] $actual.TenantId, [ref] $actualTenant) -or + $actualTenant -ne $intendedTenant) { + throw 'Microsoft Graph context does not match the intended operator account and tenant. Reconnect to the approved tenant before applying Conditional Access.' + } + $organizations = Get-EntraGraphCollection -BaseUri $BaseUri ` + -Uri "$BaseUri/organization?`$select=id" -Description 'Verify Conditional Access write tenant' + $organizationTenant = [guid]::Empty + if ($organizations.Count -ne 1 -or $organizations[0].id -isnot [string] -or + -not [guid]::TryParse([string] $organizations[0].id, [ref] $organizationTenant) -or + $organizationTenant -ne $intendedTenant) { + throw 'Microsoft Graph organization identity does not match the intended Conditional Access tenant.' + } +} + +function Get-EntraVerifiedEmergencyAccess { + <# + Read-only verification shared by setup and each CA write boundary. + Caller markers and diagnostic files are not proof of directory state. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $BaseUri, + [AllowEmptyCollection()] [string[]] $UserIds = @(), + [AllowEmptyCollection()] [string[]] $GroupIds = @(), + [Parameter(Mandatory)] [string] $Module, + [Parameter(Mandatory)] [string] $BestPracticeKey + ) + + $users = [string[]] @($UserIds) + $groups = [string[]] @($GroupIds) + $gaPrincipalIds = $null + foreach ($type in 'User', 'Group') { + $ids = if ($type -eq 'User') { $users } else { $groups } + foreach ($id in $ids) { + try { + if ([string]::IsNullOrWhiteSpace($id)) { throw 'Emergency-access principal ID must not be blank.' } + $encodedId = [uri]::EscapeDataString($id) + $enabledMemberIds = @() + if ($type -eq 'User') { + $principal = Invoke-WithTransientRetry -Description 'Verify break-glass user' -Action { + Invoke-MgGraphRequest -Method GET -Uri "$BaseUri/users/$encodedId`?`$select=id,accountEnabled,userPrincipalName" + } + if ($principal.id -isnot [string] -or $principal.id -ine $id) { throw 'Directory user response does not match the requested emergency-access ID.' } + if ($principal.accountEnabled -isnot [bool] -or -not $principal.accountEnabled) { + throw "Break-glass account $id is disabled or its enabled state could not be verified." + } + } + else { + $principal = Invoke-WithTransientRetry -Description 'Verify break-glass group' -Action { + Invoke-MgGraphRequest -Method GET -Uri "$BaseUri/groups/$encodedId`?`$select=id,displayName" + } + if ($principal.id -isnot [string] -or $principal.id -ine $id) { throw 'Directory group response does not match the requested emergency-access ID.' } + $members = Get-EntraGraphCollection -BaseUri $BaseUri ` + -Uri "$BaseUri/groups/$encodedId/transitiveMembers/microsoft.graph.user?`$select=id,accountEnabled,userPrincipalName" ` + -Description 'Read break-glass group members' + $enabledMemberIds = @($members | Where-Object { + $_.accountEnabled -is [bool] -and $_.accountEnabled -and + -not [string]::IsNullOrWhiteSpace([string] $_.id) + } | ForEach-Object { [string] $_.id }) + if ($enabledMemberIds.Count -eq 0) { throw "Break-glass group $id contains no enabled user account." } + } + if ($null -eq $gaPrincipalIds) { $gaPrincipalIds = Get-EntraGlobalAdminPrincipalId -BaseUri $BaseUri } + $role = Get-EntraGlobalAdminRecoveryState -BaseUri $BaseUri ` + -PrincipalId $id -PrincipalType $type -GlobalAdminPrincipalIds @($gaPrincipalIds) ` + -EnabledMemberIds $enabledMemberIds + if (-not $role.HasGlobalAdmin) { + throw "Break-glass $type $id does not provide a permanently-assigned, tenant-wide Global Administrator role with no expiry." + } + $null = Add-EntraRunLogEntry -Module $Module -Action 'VerifyBreakGlass' -BestPracticeKey $BestPracticeKey ` + -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $id ` + -Detail "Emergency-access $type is usable and provides permanent, tenant-wide Global Administrator recovery ($($role.RoleVia))." + } + catch { + $null = Add-EntraRunLogEntry -Module $Module -Action 'VerifyBreakGlass' -BestPracticeKey $BestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $id ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` + -Detail "Emergency-access $type could not be verified. Resolve directory access, enabled membership and permanent Global Administrator assignment before rerunning: $($_.Exception.Message)" + throw + } + } + } + return [pscustomobject] @{ userIds = [string[]] @($users); groupIds = [string[]] @($groups) } +} diff --git a/Products/Entra/Modules/Setup-ConditionalAccessBaseline.ps1 b/Products/Entra/Modules/Setup-ConditionalAccessBaseline.ps1 index 454c567..8e04300 100644 --- a/Products/Entra/Modules/Setup-ConditionalAccessBaseline.ps1 +++ b/Products/Entra/Modules/Setup-ConditionalAccessBaseline.ps1 @@ -8,11 +8,16 @@ from the templates in ConditionalAccess.PolicyTemplateDirectory. Every policy is created in ConditionalAccess.DefaultState (report-only by default), scoped to the pilot group for all-users-style policies, and always excludes the - break-glass principals resolved by Setup-EmergencyAccess. - - High risk: Conditional Access can deny sign-in tenant-wide. The write only - runs when the orchestrator has cleared the item; otherwise the module reports - what it would create. Idempotent on display name, gated by ShouldProcess, + break-glass principals verified by shared read-only directory checks. + Every actual POST/PATCH revalidates the current Graph operator, the intended + Context.TenantId GUID, and enabled emergency principals with permanent + tenant-wide Global Administrator recovery. Standalone writes are supported; + caller verification markers and JSON evidence are never trusted. + + High risk: Conditional Access can deny sign-in tenant-wide. Orchestrator + blockers are respected; standalone operators own approval and scope. + Assessment and WhatIf do not run the additional write-boundary verification. + Idempotent on display name, gated by ShouldProcess, wrapped in the shared retry boundary, and read back. The original ten-policy baseline has historical Business Premium pilot evidence; the newer P1 additions require separate pilot validation. @@ -311,6 +316,8 @@ foreach ($policyRef in @($ca.Policies)) { } # Always exclude the break-glass principals, merged with any template roles. + $templateExcludedUsers = @($users['excludeUsers']) + $templateExcludedGroups = @($users['excludeGroups']) if ($breakGlassUsers.Count -gt 0) { $users.excludeUsers = @(@($users['excludeUsers']) + $breakGlassUsers | Where-Object { $_ } | Select-Object -Unique) } @@ -387,6 +394,25 @@ foreach ($policyRef in @($ca.Policies)) { continue } + try { + Assert-EntraWriteIdentity -BaseUri $v1 -TenantAdminUpn $Context.TenantAdminUpn -TenantId $Context.TenantId + $verified = Get-EntraVerifiedEmergencyAccess -BaseUri $v1 ` + -UserIds $breakGlassUsers -GroupIds $breakGlassGroups -Module $module -BestPracticeKey $bestPracticeKey + if ($verified.userIds.Count + $verified.groupIds.Count -eq 0) { + throw 'Verified emergency access is required before Conditional Access writes.' + } + $breakGlassUsers = @($verified.userIds) + $breakGlassGroups = @($verified.groupIds) + $users.excludeUsers = @($templateExcludedUsers + $breakGlassUsers | Where-Object { $_ } | Select-Object -Unique) + $users.excludeGroups = @($templateExcludedGroups + $breakGlassGroups | Where-Object { $_ } | Select-Object -Unique) + } + catch { + Add-EntraRunLogEntry -Module $module -Action 'VerifyWriteBoundary' -BestPracticeKey $bestPracticeKey ` + -Status 'Failed' -Disposition 'Blocked' -Readback 'NotAttempted' -Target $displayName ` + -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) -Detail $_.Exception.Message + throw + } + $policy.Remove('id') | Out-Null $body = $policy | ConvertTo-Json -Depth 30 $created = $null diff --git a/Products/Entra/Modules/Setup-EmergencyAccess.ps1 b/Products/Entra/Modules/Setup-EmergencyAccess.ps1 index 59a4a7d..f273bec 100644 --- a/Products/Entra/Modules/Setup-EmergencyAccess.ps1 +++ b/Products/Entra/Modules/Setup-EmergencyAccess.ps1 @@ -53,120 +53,10 @@ $groupIds = [System.Collections.Generic.List[string]]::new() foreach ($u in @($Context.BreakGlassUserIds)) { if ($u) { $userIds.Add([string] $u) } } foreach ($g in @($Context.BreakGlassGroupIds)) { if ($g) { $groupIds.Add([string] $g) } } -# Microsoft's emergency-access guidance requires the break-glass account to hold -# Global Administrator *permanently* assigned (not PIM-eligible) so a PIM outage -# cannot lock the tenant out. The role id and the assignment read live in -# EntraDirectoryReads.ps1 so the deployment health check evaluates recoverability -# exactly the same way this module does. -$globalAdminRoleId = Get-EntraGlobalAdminRoleId -$gaPrincipalIds = $null - -# Verify configured principals exist, are usable, and can actually recover the -# tenant, so a typo or a disabled/under-privileged account cannot silently -# undermine the exclusion the CA baseline depends on. -foreach ($u in @($userIds)) { - try { - $account = Invoke-WithTransientRetry -Description 'Verify break-glass user' -Action { - Invoke-MgGraphRequest -Method GET -Uri "$v1/users/$u`?`$select=id,accountEnabled,userPrincipalName" - } - } - catch { - $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` - -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` - -Detail "Configured break-glass user could not be verified: $($_.Exception.Message)" - throw - } - - if (-not [bool] $account.accountEnabled) { - $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` - -Detail "Break-glass account '$($account.userPrincipalName)' is disabled; a disabled account cannot be used in an emergency. Enable it before deploying the baseline." - throw "Break-glass account $u is disabled." - } - - # Confirm the account can recover the tenant: it must hold a permanently - # assigned Global Administrator role, directly or through a role-assignable - # group. Any other role (or none) is not a valid break-glass account, and if - # the role cannot be read the run stops rather than assume it is safe. - try { - if ($null -eq $gaPrincipalIds) { $gaPrincipalIds = Get-EntraGlobalAdminPrincipalId -BaseUri $v1 -RoleId $globalAdminRoleId } - $roleState = Get-EntraGlobalAdminRecoveryState -BaseUri $v1 ` - -PrincipalId ([string] $u) -PrincipalType User ` - -GlobalAdminPrincipalIds @($gaPrincipalIds) - } - catch { - $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` - -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` - -Detail "Could not confirm the break-glass account's Global Administrator role; stopping so it can be reviewed: $($_.Exception.Message)" - throw - } - - if ($roleState.HasGlobalAdmin) { - $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $u ` - -Detail "Break-glass account '$($account.userPrincipalName)' is enabled and holds a permanently assigned, tenant-wide Global Administrator role with no expiry ($($roleState.RoleVia))." - } - else { - $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $u ` - -Detail "Break-glass account '$($account.userPrincipalName)' is enabled but does not hold a permanently-assigned Global Administrator role. Microsoft's emergency-access guidance requires Global Administrator assigned permanently (not PIM-eligible). Assign it before deploying the baseline." - throw "Break-glass account $u does not hold a permanently-assigned Global Administrator role." - } -} -foreach ($g in @($groupIds)) { - try { - $group = Invoke-WithTransientRetry -Description 'Verify break-glass group' -Action { - Invoke-MgGraphRequest -Method GET -Uri "$v1/groups/$g`?`$select=id,displayName" - } - $members = Get-EntraGraphCollection -BaseUri $v1 ` - -Uri "$v1/groups/$g/transitiveMembers/microsoft.graph.user?`$select=id,accountEnabled,userPrincipalName" ` - -Description 'Read break-glass group members' - if ($null -eq $gaPrincipalIds) { $gaPrincipalIds = Get-EntraGlobalAdminPrincipalId -BaseUri $v1 -RoleId $globalAdminRoleId } - } - catch { - $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $g ` - -HttpStatusCode (Get-EntraHttpStatusCode -ErrorRecord $_) ` - -Detail "Configured break-glass group could not be verified; stopping so it can be reviewed: $($_.Exception.Message)" - throw - } - - # A break-glass group is only usable if it actually contains an enabled - # emergency account (direct or nested, via transitive membership), and either - # the group or one of those members holds a permanently-assigned Global - # Administrator role. - $enabledMembers = @(@($members) | Where-Object { - [bool] (Get-EntraPolicyProperty -InputObject $_ -Name 'accountEnabled') - }) - if ($enabledMembers.Count -eq 0) { - $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $g ` - -Detail "Break-glass group '$($group.displayName)' contains no enabled user account, so it cannot serve as an emergency-access exclusion. Add an enabled emergency account that holds Global Administrator." - throw "Break-glass group $g contains no enabled user account." - } - - $enabledMemberIds = @($enabledMembers | ForEach-Object { - [string] (Get-EntraPolicyProperty -InputObject $_ -Name 'id') - } | Where-Object { $_ }) - $roleState = Get-EntraGlobalAdminRecoveryState -BaseUri $v1 ` - -PrincipalId ([string] $g) -PrincipalType Group ` - -GlobalAdminPrincipalIds @($gaPrincipalIds) ` - -EnabledMemberIds $enabledMemberIds - - if ($roleState.HasGlobalAdmin) { - $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Succeeded' -Disposition 'AlreadyCompliant' -Readback 'Verified' -Target $g ` - -Detail "Break-glass group '$($group.displayName)' has $($enabledMembers.Count) enabled member(s) and provides a permanently assigned, tenant-wide Global Administrator role with no expiry ($($roleState.RoleVia))." - } - else { - $null = Add-EntraRunLogEntry -Module $module -Action 'VerifyBreakGlass' -BestPracticeKey $bestPracticeKey ` - -Status 'Failed' -Disposition 'Blocked' -Readback 'Mismatch' -Target $g ` - -Detail "Break-glass group '$($group.displayName)' has enabled members but neither the group nor any member holds a permanently assigned, tenant-wide Global Administrator role with no expiry. Assign Global Administrator permanently, not through PIM eligibility or temporary activation, before deploying." - throw "Break-glass group $g provides no permanently-assigned Global Administrator." - } -} +$verified = Get-EntraVerifiedEmergencyAccess -BaseUri $v1 ` + -UserIds $userIds.ToArray() -GroupIds $groupIds.ToArray() -Module $module -BestPracticeKey $bestPracticeKey +$userIds = [System.Collections.Generic.List[string]]::new([string[]] $verified.userIds) +$groupIds = [System.Collections.Generic.List[string]]::new([string[]] $verified.groupIds) if ($userIds.Count -eq 0 -and $groupIds.Count -eq 0) { if ($bg.CreateAccountIfMissing -and diff --git a/Products/Entra/README.md b/Products/Entra/README.md index a557e58..bf00088 100644 --- a/Products/Entra/README.md +++ b/Products/Entra/README.md @@ -16,6 +16,14 @@ permalink: /entra/ > must be verified disabled before any tenant write. The toolkit never disables > it or performs the transition automatically. +Every actual Conditional Access create or adoption write also revalidates the +current Graph account/tenant and the supplied emergency users/groups against +live directory and permanent Global Administrator role state. Standalone +writes require the intended `Context.TenantId` GUID as well as +`Context.TenantAdminUpn`; caller verification flags are not proof. +Assessment/WhatIf remain available without these additional write-only reads. +See [standalone prerequisites](docs/Operator-Guide.md#standalone-conditional-access-writes). + PowerShell automation that deploys the **Conditional Access baseline** from the Microsoft **Identity Protection Best Practice Deployment** guide for a Microsoft 365 **Business Premium** tenant (Microsoft Entra ID P1). Built to the same diff --git a/Products/Entra/docs/Break-Glass-Guide.md b/Products/Entra/docs/Break-Glass-Guide.md index 4a750bc..95d7cc3 100644 --- a/Products/Entra/docs/Break-Glass-Guide.md +++ b/Products/Entra/docs/Break-Glass-Guide.md @@ -50,6 +50,16 @@ markers does not authorize or alter deployment. A missing/malformed module result stops the run. This is not protection against arbitrary code running inside the same PowerShell process. +The public CA module independently repeats read-only identity and emergency +principal verification before every actual POST/PATCH, including standalone +calls and adoption of enabled policies. It uses copied verified IDs, not +caller-supplied verification markers or diagnostic files. Standalone writes +need the intended tenant GUID and operator UPN; assessment/WhatIf do not run +these additional write-only checks. The verifier never creates accounts or +grants roles. Directory verification and the CA write are separate operations: +concurrent changes can still invalidate recovery, so operational recovery +testing and change control remain required. + Under the default configuration, every policy the toolkit creates excludes the break-glass principal, including report-only policies. This is the toolkit's preparation for later enforcement; Microsoft notes that report-only diff --git a/Products/Entra/docs/Evidence-Troubleshooting.md b/Products/Entra/docs/Evidence-Troubleshooting.md index eb4d533..bf763b8 100644 --- a/Products/Entra/docs/Evidence-Troubleshooting.md +++ b/Products/Entra/docs/Evidence-Troubleshooting.md @@ -47,6 +47,16 @@ accounts. Empty lists mean no principals were verified for that run, not that no emergency accounts exist anywhere in the tenant. Never treat this file as credential storage or as proof of successful recovery. +`VerifyWriteBoundary` with `Failed/Blocked` means the public CA module rejected +the actual session/tenant or could not reverify the supplied emergency IDs +before a POST/PATCH. Check the intended `Context.TenantId` GUID, operator UPN, +current Graph sign-in and directory/role read permissions. Confirm enabled +accounts/group membership and permanent tenant-wide Global Administrator +recovery, then rerun under approval. Fabricating a run ID or `verified=true` +does not authorize standalone writes. A later-policy failure does not roll back +earlier writes: review each applied policy and its readback. Checks are repeated +per write but are not atomic against concurrent directory changes. + Reports are private tenant evidence. Redaction is pattern-based, not a guarantee that every service-authored error is safe to share. Review excerpts, and never commit reports, private configuration, tokens, or credentials. diff --git a/Products/Entra/docs/Operator-Guide.md b/Products/Entra/docs/Operator-Guide.md index 180afb8..1b82921 100644 --- a/Products/Entra/docs/Operator-Guide.md +++ b/Products/Entra/docs/Operator-Guide.md @@ -187,13 +187,40 @@ home tenant. `-NonInteractive` suppresses toolkit prompts but does not provide app-only authentication or guarantee a prompt-free first sign-in. Standalone setup modules require a verified Graph connection in the same session. +### Standalone Conditional Access writes + +Prefer the orchestrator for its approval/preflight workflow. Standalone +`Setup-ConditionalAccessBaseline.ps1` remains supported, but the operator owns +change approval, scope and recovery readiness. For an actual write, supply +`Context.TenantAdminUpn` and **`Context.TenantId` as the independently confirmed +intended tenant GUID** (the customer tenant for GDAP), plus the emergency user +or group IDs. Do not derive the intended tenant from whichever session happens +to be cached. The orchestrator already supplies this tenant ID. + +Before each POST or adoption PATCH, the module checks the actual Graph account +and tenant against that intent, reads the organization ID, and re-reads the +exact emergency principals and permanent tenant-wide Global Administrator +assignments. Groups must contain an enabled recovery user. Missing IDs, +unreadable directory/role state or identity mismatches stop before that write. +Caller `verified`/`runId` markers do not bypass the checks. No account creation, +role grant, authentication connection or extra permission scope is performed +by this validation; resolve sign-in/consent through the normal approved workflow. + +Assessment and `-WhatIf` do not require this additional write-only verification. +Preserve existing blocker lists and Security Defaults gates. Adoption can +modify an already enabled policy even when the configured creation default is +report-only; it preserves that policy's state and still requires fresh recovery +verification. Verification and the subsequent write are not an atomic directory +transaction and do not guarantee recovery against concurrent changes. + ## Reports and stopping conditions The final output announces successfully saved HTML and JSON run reports and prints a manual open command. Defaults are `Reports\entra-run-report.html` and `Reports\entra-run-log.json` under the product folder. The emergency-access module can also write `Reports\entra-breakglass.json`; this contains resolved -IDs for module coordination and is not proof that recovery works. +IDs for diagnostics only, never write authorization, and is not proof that +recovery works. Preserve each run's files privately before rerunning because the default filenames are overwritten. Reporting can still finish after deployment diff --git a/docs/Responsible-AI-Review.md b/docs/Responsible-AI-Review.md index 486f508..94203cb 100644 --- a/docs/Responsible-AI-Review.md +++ b/docs/Responsible-AI-Review.md @@ -178,6 +178,18 @@ exist for the affected guides. The AI-sensitive heuristic matches existing new model functionality. None of these explanations waives the pilot gate or unrelated pre-existing whole-repository findings. +The subsequent independent standalone-CA finding is an inherited verification +gap, not a regression introduced by the in-memory handoff or evidence of +privilege escalation. The public CA module now revalidates the actual session, +intended tenant and exact emergency principals before every POST/PATCH, using +shared read-only verification and copied results. Standalone operators must +supply the intended tenant GUID for writes. Tests cover invalid user/group +responses, unavailable or temporary roles, wrong/missing identity, fabricated +markers, enabled-policy adoption and revocation between writes. Assessment, +WhatIf, manual migration and existing blockers remain. There is no atomic +directory-plus-CA transaction or protection against arbitrary in-process code; +pilot recovery validation is still required. + ### Decision Local review only; **release readiness is blocked pending approved tenant diff --git a/docs/conventions.md b/docs/conventions.md index 4b620f2..10c25e6 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -39,6 +39,7 @@ pwsh -NoProfile -File .\scripts\Test-IntuneReviewFixes.ps1 pwsh -NoProfile -File .\scripts\Test-DefenderConnectionGuards.ps1 pwsh -NoProfile -File .\scripts\Test-DefenderComparableValues.ps1 pwsh -NoProfile -File .\scripts\Test-DeploymentSafeguards.ps1 +pwsh -NoProfile -File .\scripts\Test-EntraStandaloneWriteGuards.ps1 ``` These plain PowerShell checks use synthetic Graph responses and require no @@ -51,6 +52,9 @@ SDK boundary, including emergency-access handoff, operator identity, evidence export failures and Intune endpoint/paging rejection. Generated fixtures and reports default to `C:\temp\deployment-safeguard-remediation` (`-TempRoot` overrides this location). It never authenticates to a tenant. +The standalone Entra suite exercises the actual CA module's POST/PATCH +boundary with synthetic account, organization, principal and role reads, +including enabled-policy adoption, invalid inputs and changes between writes. For deployment-code changes, completion also requires validation of the affected deployment path against an approved non-production tenant. Do not connect to a diff --git a/scripts/Test-EntraStandaloneWriteGuards.ps1 b/scripts/Test-EntraStandaloneWriteGuards.ps1 new file mode 100644 index 0000000..81c8f5d --- /dev/null +++ b/scripts/Test-EntraStandaloneWriteGuards.ps1 @@ -0,0 +1,217 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Offline actual-module tests for the public Conditional Access write boundary. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$entra = Join-Path $root 'Products\Entra' +$script:passed = 0 +$tenant = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +$user = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +$group = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +function Assert-True($Condition, [string] $Message) { + if (-not $Condition) { throw "ASSERTION: $Message" } +} +function Throw-Http([int] $Code) { + $errorObject = [Exception]::new("Synthetic HTTP $Code") + $errorObject | Add-Member Response ([pscustomobject] @{ StatusCode = $Code }) + throw $errorObject +} +function Copy-Value($Value) { return $Value | ConvertTo-Json -Depth 40 | ConvertFrom-Json -AsHashtable } +function Get-MgContext { + [CmdletBinding()] + param() + $s = $global:StandaloneCaTest + $s.ContextReads++ + if ($s.Mode -eq 'MissingContext') { return $null } + if ($s.Mode -eq 'MalformedContext') { + return [pscustomobject] @{ Account = [string[]] @('admin@example.invalid'); TenantId = $s.Tenant } + } + if ($s.Mode -eq 'ArrayTenant') { + return [pscustomobject] @{ Account = 'admin@example.invalid'; TenantId = [string[]] @($s.Tenant) } + } + return [pscustomobject] @{ + Account = $(switch ($s.Mode) { 'WrongAccount' {'other@example.invalid'} 'MissingAccount' {''} default {'ADMIN@EXAMPLE.INVALID'} }) + TenantId = $(switch ($s.Mode) { 'WrongTenant' {'dddddddd-dddd-4ddd-8ddd-dddddddddddd'} 'MissingTenant' {''} 'MalformedTenant' {'bad-id'} default {$s.Tenant.ToUpperInvariant()} }) + } +} +function Invoke-MgGraphRequest { + [CmdletBinding()] + param([string] $Method, [string] $Uri, $Body, [string] $ContentType) + $s = $global:StandaloneCaTest + $s.Requests.Add(@{ Method = $Method; Uri = $Uri }) + $path = ([uri] $Uri).AbsolutePath + if ($Method -eq 'GET') { + if ($path -eq '/v1.0/policies/identitySecurityDefaultsEnforcementPolicy') { return @{ isEnabled = ($s.Mode -eq 'SecurityDefaults') } } + if ($path -eq '/v1.0/organization') { + $s.IdentityReads++ + if ($s.Mode -eq 'OrganizationDenied') { Throw-Http 403 } + if ($s.Mode -eq 'MissingOrganization') { return @{ value = @() } } + if ($s.Mode -eq 'MultipleOrganizations') { return @{ value = @(@{id=$s.Tenant}, @{id=$s.Tenant}) } } + if ($s.Mode -eq 'MalformedOrganization') { return @{ value = @(@{ id = [string[]] @($s.Tenant) }) } } + return @{ value = @(@{ id = $(if ($s.Mode -eq 'WrongOrganization') {'dddddddd-dddd-4ddd-8ddd-dddddddddddd'} else {$s.Tenant}) }) } + } + if ($path -eq "/v1.0/users/$($s.User)") { + $s.PrincipalReads++ + if ($s.Mode -in 'InvalidUser','FabricatedMarker') { Throw-Http 404 } + if ($s.Mode -eq 'Denied') { Throw-Http 403 } + $response = @{ id = $(if ($s.Mode -eq 'WrongUserResponse') {$s.Group} else {$s.User}); accountEnabled = ($s.Mode -ne 'Disabled') } + if ($s.Mode -eq 'StringEnabled') { $response.accountEnabled = 'true' } + if ($s.Mode -eq 'ObjectResponse') { return [pscustomobject] $response } + return $response + } + if ($path -eq "/v1.0/groups/$($s.Group)") { + $s.PrincipalReads++ + if ($s.Mode -eq 'InvalidGroup') { Throw-Http 404 } + if ($s.Mode -eq 'GroupDenied') { Throw-Http 403 } + $response = @{ id = $(if ($s.Mode -eq 'WrongGroupResponse') {$s.User} else {$s.Group}); displayName = 'Synthetic recovery group' } + if ($s.Mode -eq 'GroupObjectResponse') { return [pscustomobject] $response } + return $response + } + if ($path -eq "/v1.0/groups/$($s.Group)/transitiveMembers/microsoft.graph.user") { + $s.MembershipReads++ + if ($s.Mode -eq 'MemberDenied') { Throw-Http 403 } + return @{ value = @(@{ id = $s.User; accountEnabled = ($s.Mode -ne 'DisabledGroupMembers') }) } + } + if ($path -eq "/v1.0/users/$($s.User)/transitiveMemberOf/microsoft.graph.group") { + $s.MembershipReads++ + return @{ value = @(@{ id = $s.Group }) } + } + if ($path -eq '/v1.0/roleManagement/directory/roleAssignmentScheduleInstances') { + $s.RoleReads++ + if ($s.Mode -eq 'RoleDenied') { Throw-Http 403 } + if ($s.Mode -in 'NoRole','GroupNoRole' -or ($s.Mode -eq 'RevokeBetweenWrites' -and $s.Writes -gt 0)) { return @{ value = @() } } + if ($s.Mode -eq 'MutateCaller') { + $s.Caller.BreakGlassUserIds[0] = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' + } + return @{ value = @(@{ + principalId = $(if ($s.Mode -in 'UserViaGroup','GroupAssignedRole') {$s.Group} else {$s.User}) + assignmentType = $(if ($s.Mode -eq 'TemporaryRole') {'Activated'} else {'Assigned'}) + endDateTime = $(if ($s.Mode -eq 'ExpiringRole') {'2099-01-01T00:00:00Z'} else {$null}) + directoryScopeId = $(if ($s.Mode -eq 'ScopedRole') {'/administrativeUnits/synthetic'} else {'/'}) + }) } + } + if ($path -eq '/v1.0/identity/conditionalAccess/policies') { return @{ value = @($s.Existing) } } + if ($path -like '/v1.0/identity/conditionalAccess/policies/*') { return Copy-Value $s.Stored[$path.Split('/')[-1]] } + } + if ($Method -in 'POST','PATCH' -and $path -like '/v1.0/identity/conditionalAccess/policies*') { + Assert-True ($s.ContextReads -gt $s.Writes -and $s.IdentityReads -gt $s.Writes -and + $s.PrincipalReads -gt $s.Writes -and $s.RoleReads -gt $s.Writes) 'Each write must have fresh context, organization, principal and role reads' + $s.Writes++ + $payload = $Body | ConvertFrom-Json -AsHashtable + if ($Method -eq 'PATCH') { + Assert-True (-not $payload.ContainsKey('state')) 'Adoption must preserve existing enforcement state' + $record = Copy-Value @($s.Existing | Where-Object id -eq $path.Split('/')[-1])[0] + foreach ($key in $payload.Keys) { $record[$key] = $payload[$key] } + Assert-True ('customer-exclusion' -in $record.conditions.users.excludeUsers) 'Customer exclusions were removed' + Assert-True ($record.state -eq 'enabled') 'Existing policy enforcement state was lost' + } + else { + $record = $payload + $record.id = [guid]::NewGuid().ToString() + } + Assert-True ('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' -notin $record.conditions.users.excludeUsers) 'Caller mutation reached serialized write' + $s.Stored[$record.id] = $record + return Copy-Value $record + } + throw "Unexpected synthetic SDK dispatch: $Method $Uri" +} + +function Invoke-Case([string] $Mode, [bool] $Adopt) { + $config = Import-PowerShellDataFile (Join-Path $entra 'Config\EntraConfig.psd1') + $config.ConditionalAccess.Policies = @($config.ConditionalAccess.Policies | + Where-Object Key -in 'block-legacy-authentication','require-mfa-all-users' | + Select-Object -First $(if ($Mode -in 'TwoWrites','RevokeBetweenWrites') {2} else {1})) + if ($Mode -eq 'EnabledCreate') { $config.ConditionalAccess.DefaultState = 'enabled' } + if ($Mode -eq 'EmptyOptional') { $config.ConditionalAccess.RequireBreakGlassExclusion = $false } + $groupMode = $Mode -in 'ValidGroup','GroupAssignedRole','InvalidGroup','GroupDenied','MemberDenied','WrongGroupResponse','GroupObjectResponse','DisabledGroupMembers','GroupNoRole' + $context = @{ + TenantAdminUpn = 'admin@example.invalid'; TenantId = $tenant + AssignmentScope = 'PilotGroup'; PilotGroupId = 'ffffffff-ffff-4fff-8fff-ffffffffffff' + BreakGlassUserIds = [string[]] @(if (-not $groupMode -and $Mode -notin 'Empty','EmptyOptional') { $user }) + BreakGlassGroupIds = [string[]] @(if ($groupMode) { $group }) + verified = $true; RunId = 'fabricated-marker' + BlockedItemKeys = $(if ($Mode -eq 'Blocked') {@('conditional-access-baseline')} else {@()}) + WriteBlockedItemKeys = $(if ($Mode -eq 'WriteBlocked') {@('conditional-access-baseline')} else {@()}) + } + if ($Mode -eq 'MissingIntendedTenant') { $context.Remove('TenantId') } + if ($Mode -eq 'WrongIntendedTenant') { $context.TenantId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' } + if ($Mode -eq 'WhatIf') { $context.Remove('TenantId') } + $existing = @() + if ($Adopt) { + foreach ($reference in $config.ConditionalAccess.Policies) { + $template = Join-Path $entra (Join-Path $config.ConditionalAccess.PolicyTemplateDirectory $reference.File) + $record = Get-Content $template -Raw | ConvertFrom-Json -AsHashtable + $record.id = [guid]::NewGuid().ToString() + $record.state = 'enabled' + $record.conditions.users.excludeUsers = @('customer-exclusion') + $existing += $record + } + } + $global:StandaloneCaTest = @{ + Mode=$Mode; Tenant=$tenant; User=$user; Group=$group; Caller=$context + Existing=$existing; Stored=@{}; Writes=0; ContextReads=0; IdentityReads=0; PrincipalReads=0; RoleReads=0; MembershipReads=0 + Requests=[Collections.Generic.List[object]]::new() + } + $global:EntraRunLog = [Collections.Generic.List[hashtable]]::new() + $global:EntraRunLogPath = $null + $failure = $null + try { + & (Join-Path $entra 'Modules\Setup-ConditionalAccessBaseline.ps1') -Config $config -Context $context ` + -AdoptExisting:$Adopt -WhatIf:($Mode -eq 'WhatIf') | Out-Null + } + catch { $failure = $_ } + $s = $global:StandaloneCaTest + $safeSkip = $Mode -in 'WhatIf','Blocked','WriteBlocked','SecurityDefaults','Empty' + $success = $Mode -in 'ValidUser','ValidGroup','GroupAssignedRole','UserViaGroup','ObjectResponse','GroupObjectResponse','EnabledCreate','MutateCaller','TwoWrites' + if ($success) { + if ($failure) { throw $failure } + Assert-True ($s.Writes -eq $(if ($Mode -eq 'TwoWrites') {2} else {1})) 'Valid standalone write count changed' + if ($groupMode -or $Mode -eq 'UserViaGroup') { Assert-True ($s.MembershipReads -ge $s.Writes) 'Group recovery was not freshly verified' } + foreach ($record in $s.Stored.Values) { + $field = if ($groupMode) {'excludeGroups'} else {'excludeUsers'} + $expected = if ($groupMode) {$group} else {$user} + Assert-True ($expected -in $record.conditions.users[$field]) 'Verified recovery ID was not excluded' + if (-not $Adopt) { Assert-True ($record.state -eq $config.ConditionalAccess.DefaultState) 'Configured creation state changed' } + } + } + elseif ($safeSkip) { + if ($failure) { throw $failure } + Assert-True ($s.Writes -eq 0 -and $s.ContextReads -eq 0 -and $s.RoleReads -eq 0) 'Assessment/WhatIf gates triggered write verification or writes' + } + else { + Assert-True ($null -ne $failure) "$Mode did not fail closed" + Assert-True ($s.Writes -eq $(if ($Mode -eq 'RevokeBetweenWrites') {1} else {0})) "$Mode allowed an unverified write" + Assert-True (@($global:EntraRunLog | Where-Object { $_.Action -eq 'VerifyWriteBoundary' -and $_.Status -eq 'Failed' }).Count -eq 1) 'Missing write-boundary failure evidence' + } + $writeRequests = @($s.Requests | Where-Object Method -in 'POST','PATCH') + Assert-True ($writeRequests.Count -eq $s.Writes) 'An unverified write reached the SDK boundary' + foreach ($request in $writeRequests) { + Assert-True ($request.Method -eq $(if ($Adopt) {'PATCH'} else {'POST'})) 'Scenario did not exercise the intended write path' + } + $script:passed++ + Write-Host "PASS standalone $Mode Adopt=$Adopt" +} + +try { + foreach ($adopt in $false,$true) { + foreach ($mode in 'ValidUser','ValidGroup','GroupAssignedRole','UserViaGroup','ObjectResponse','GroupObjectResponse', + 'EnabledCreate','MutateCaller','TwoWrites','RevokeBetweenWrites', + 'InvalidUser','InvalidGroup','Disabled','DisabledGroupMembers','NoRole','GroupNoRole','Denied','GroupDenied','MemberDenied','RoleDenied', + 'WrongUserResponse','WrongGroupResponse','StringEnabled','TemporaryRole','ExpiringRole','ScopedRole','FabricatedMarker', + 'WrongAccount','MissingAccount','MissingContext','MalformedContext','ArrayTenant','WrongTenant','MissingTenant','MalformedTenant', + 'MissingIntendedTenant','WrongIntendedTenant','WrongOrganization','MissingOrganization','MultipleOrganizations','MalformedOrganization','OrganizationDenied', + 'WhatIf','Blocked','WriteBlocked','SecurityDefaults','Empty','EmptyOptional') { + Invoke-Case -Mode $mode -Adopt $adopt + } + } + Write-Host "$script:passed standalone CA scenarios passed. Synthetic SDK only; no tenant validation." +} +finally { + Remove-Variable StandaloneCaTest -Scope Global -ErrorAction SilentlyContinue + foreach ($name in 'EntraRunLog','EntraRunLogPath') { Remove-Variable $name -Scope Global -ErrorAction SilentlyContinue } +} diff --git a/scripts/Test-IdentityReviewFixes.ps1 b/scripts/Test-IdentityReviewFixes.ps1 index a6fc2f1..d20520d 100644 --- a/scripts/Test-IdentityReviewFixes.ps1 +++ b/scripts/Test-IdentityReviewFixes.ps1 @@ -19,10 +19,25 @@ function Copy-TestValue { } # Synthetic responses only. Any unexpected request fails instead of reaching Graph. +function Get-MgContext { + [CmdletBinding()] + param() + return [pscustomobject] @{ Account = 'admin@example.invalid'; TenantId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' } +} + function Invoke-MgGraphRequest { [CmdletBinding()] param([string] $Method, [string] $Uri, $Body, [string] $ContentType) $mock = $global:IdentityReviewMock + if ($Method -eq 'GET' -and $Uri -match '/organization\?') { + return @{ value = @(@{ id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }) } + } + if ($Method -eq 'GET' -and $Uri -match '/users/11111111-1111-1111-1111-111111111111\?') { + return @{ id = '11111111-1111-1111-1111-111111111111'; accountEnabled = $true; userPrincipalName = 'recovery@example.invalid' } + } + if ($Method -eq 'GET' -and $Uri -match '/roleManagement/directory/roleAssignmentScheduleInstances\?') { + return @{ value = @(@{ principalId = '11111111-1111-1111-1111-111111111111'; assignmentType = 'Assigned'; endDateTime = $null; directoryScopeId = '/' }) } + } if ($Method -eq 'GET' -and $Uri -match '/policies/identitySecurityDefaultsEnforcementPolicy$') { return @{ isEnabled = $mock.SecurityDefaults } } @@ -70,6 +85,7 @@ function Invoke-BaselineScenario { $global:EntraRunLogPath = $null $context = @{ TenantAdminUpn = 'admin@example.invalid' + TenantId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' AssignmentScope = $(if ($TenantWide) { 'TenantWide' } else { 'PilotGroup' }) PilotGroupId = '33333333-3333-3333-3333-333333333333' BreakGlassUserIds = @('11111111-1111-1111-1111-111111111111')