Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,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
Expand Down
26 changes: 19 additions & 7 deletions Products/Defender/Deploy-DefenderBestPractice.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
4 changes: 2 additions & 2 deletions Products/Defender/Modules/DefenderRunLog.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
3 changes: 2 additions & 1 deletion Products/Defender/Modules/DefenderStateManagement.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions Products/Defender/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion Products/Defender/docs/evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions Products/Defender/docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions Products/Entra/Config/EntraConfig.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 37 additions & 11 deletions Products/Entra/Deploy-EntraBestPractice.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ try {
-BaseUri $config.Api.GraphBaseUri -Module 'Deploy-EntraBestPractice'

$commonContext = @{
RunId = $runId
TenantAdminUpn = $TenantAdminUpn
TenantId = $resolvedTenantId
ConnectionInfo = $connectionInfo
Expand All @@ -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' `
Comment thread
Cj-Scott marked this conversation as resolved.
Resolved
-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"
Expand All @@ -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 $_
Expand Down
27 changes: 23 additions & 4 deletions Products/Entra/Modules/Connect-EntraServices.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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' `
Expand All @@ -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]@{
Expand Down
Loading
Loading