tests/Test-Assessment.41012.ps1
|
<#
.SYNOPSIS Microsoft Entra Connect synchronization account does not hold unnecessary replication permissions .NOTES Test ID: 41012 Workshop Task: SECOPS-012 Pillar: SecOps Category: Identity threat protection Required permission: SecurityEvents.Read.All #> function Test-Assessment-41012 { [ZtTest( Category = 'Identity threat protection', CompatibleLicense = ('ATA'), ImplementationCost = 'Medium', Pillar = 'SecOps', RiskLevel = 'High', Service = ('Graph'), SfiPillar = 'Protect identities and secrets', TenantType = ('Workforce'), TestId = 41012, Title = 'Microsoft Entra Connect synchronization account does not hold unnecessary replication permissions', UserImpact = 'Low' )] [CmdletBinding()] param() #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose $activity = 'Checking Microsoft Entra Connect synchronization account replication permissions' $title = 'Microsoft Entra Connect synchronization account does not hold unnecessary replication permissions' $controlId = 'AATP_EntraConnectAccountUnnecessaryReplicationPermission' $controlService = 'Azure ATP' $defenderLink = 'https://security.microsoft.com/securescore?viewid=actions' $investigateParams = @{ TestId = '41012' Title = $title Status = $false CustomStatus = 'Investigate' Result = '⚠️ The Secure Score control profile or latest Secure Score snapshot could not be read due to a permission or connectivity error. Verify the caller has SecurityEvents.Read.All (Entra role: Security Reader) and re-run.' } # Q1: the shared Graph wrapper follows @odata.nextLink and applies framework retry handling. Write-ZtProgress -Activity $activity -Status 'Retrieving the MDI Secure Score control profile' $profileResults = @() try { $q1Filter = "service eq '$controlService' and id eq '$controlId'" $profileResults = @(Invoke-ZtGraphRequest -RelativeUri 'security/secureScoreControlProfiles' -Filter $q1Filter -ApiVersion beta -ErrorAction Stop) } catch { $q1Status = Get-ZtHttpStatusCode -ErrorRecord $_ Write-PSFMessage "Q1 failed. HTTP status: $q1Status. $(Get-ZtSafeErrorMessage -ErrorRecord $_)" -Tag Test -Level Warning if ($q1Status -in (401, 403)) { $investigateParams.Result = '⚠️ The Secure Score control profile could not be read because the request was not authorized. Verify the caller has SecurityEvents.Read.All (Entra role: Security Reader) and re-run.' } Add-ZtTestResultDetail @investigateParams return } if ($profileResults.Count -ne 1) { $investigateParams.Result = "⚠️ The MDI Secure Score control profile lookup returned $($profileResults.Count) matching profile(s); exactly one is required." Add-ZtTestResultDetail @investigateParams return } $controlProfile = $profileResults[0] if ($controlProfile.id -ne $controlId -or $controlProfile.service -ne $controlService) { $investigateParams.Result = '⚠️ The MDI Secure Score control profile lookup returned a profile that does not match the required identifier and service.' Add-ZtTestResultDetail @investigateParams return } if ($controlProfile.deprecated -eq $true) { $investigateParams.Result = '⚠️ The Secure Score recommendation is deprecated and cannot be evaluated automatically.' Add-ZtTestResultDetail @investigateParams return } # Q2: latest Secure Score snapshot only; keep paging disabled so historical snapshots are not followed. Write-ZtProgress -Activity $activity -Status 'Retrieving the latest Microsoft Secure Score snapshot' $secureScoresResponse = $null try { $secureScoresResponse = Invoke-ZtGraphRequest -RelativeUri 'security/secureScores' -Top 1 -ApiVersion beta -DisablePaging -ErrorAction Stop } catch { $q2Status = Get-ZtHttpStatusCode -ErrorRecord $_ Write-PSFMessage "Q2 failed. HTTP status: $q2Status. $(Get-ZtSafeErrorMessage -ErrorRecord $_)" -Tag Test -Level Warning Add-ZtTestResultDetail @investigateParams return } #endregion Data Collection #region Assessment Logic $passed = $false $customStatus = $null $reason = $null $controlScoreEntry = $null $currentScore = $null $scorePercentage = $null $snapshotTime = $null $controlState = $null $numericGuardsPassed = $false $maxScore = $controlProfile.maxScore # Invoke-ZtGraphRequest -OutputType PSObject deserializes Graph date-time strings into [datetime] # (Kind=Utc). Stringifying with the current culture and reparsing loses the ISO 8601 shape, so accept # native [datetime]/[datetimeoffset] directly and parse strings with invariant culture + RoundtripKind. $parseTimestamp = { param($value) if ($null -eq $value) { return $null } if ($value -is [datetimeoffset]) { return $value.ToUniversalTime() } if ($value -is [datetime]) { $dt = if ($value.Kind -eq [System.DateTimeKind]::Unspecified) { [datetime]::SpecifyKind($value, [System.DateTimeKind]::Utc) } else { $value } return ([datetimeoffset]$dt).ToUniversalTime() } $text = [string]$value if ([string]::IsNullOrWhiteSpace($text)) { return $null } $parsed = [datetimeoffset]::MinValue if ([datetimeoffset]::TryParse($text, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind, [ref]$parsed)) { return $parsed.ToUniversalTime() } return $null } # Resolve the latest administrative state per spec: undated Default is baseline; a dated non-Default # entry overrides; ties on the latest timestamp and undated non-Default entries are Investigate. if ($controlProfile.PSObject.Properties.Name -notcontains 'controlStateUpdates') { $reason = 'The control profile is missing controlStateUpdates.' } elseif ($null -eq $controlProfile.controlStateUpdates) { $controlState = 'Default' } elseif ($controlProfile.controlStateUpdates -is [string] -or $controlProfile.controlStateUpdates -isnot [System.Collections.IEnumerable]) { $reason = 'The controlStateUpdates value is malformed.' } else { $stateUpdates = @($controlProfile.controlStateUpdates) $datedStateUpdates = @() foreach ($stateUpdate in $stateUpdates) { if ($reason) { break } $state = if ($null -ne $stateUpdate) { [string]$stateUpdate.state } else { $null } if ([string]::IsNullOrWhiteSpace($state) -or $state -notin @('Default', 'Ignored', 'ThirdParty', 'Reviewed')) { $reason = 'The control profile contains a missing or unrecognized administrative state.' break } # Spec: distinguish an absent `updatedDateTime` property (malformed → Investigate) from # an explicit null value (valid undated Default baseline). PowerShell returns $null for # both cases when the property is read directly, so probe for property presence first. if ($stateUpdate.PSObject.Properties.Name -notcontains 'updatedDateTime') { $reason = 'The control profile contains a state update missing the updatedDateTime property.' break } $rawUpdatedAt = $stateUpdate.updatedDateTime $isUpdatedAtExplicitNull = ($null -eq $rawUpdatedAt) -or (($rawUpdatedAt -is [string]) -and [string]::IsNullOrWhiteSpace($rawUpdatedAt)) if ($isUpdatedAtExplicitNull) { if ($state -ine 'Default') { $reason = 'The control profile contains an undated non-Default administrative state.' } continue } $parsedStateTime = & $parseTimestamp $rawUpdatedAt if ($null -eq $parsedStateTime -or $parsedStateTime -gt [datetimeoffset]::UtcNow) { $reason = 'The control profile contains an invalid or future administrative-state timestamp.' break } $datedStateUpdates += [PSCustomObject]@{ State = $state Time = $parsedStateTime } } if (-not $reason) { if ($datedStateUpdates.Count -gt 0) { $latestTime = ($datedStateUpdates | Sort-Object Time -Descending | Select-Object -First 1).Time $latestStates = @($datedStateUpdates | Where-Object Time -eq $latestTime | Select-Object -ExpandProperty State -Unique) if ($latestStates.Count -ne 1) { $reason = 'The latest administrative-state updates conflict.' } else { $controlState = $latestStates[0] } } elseif (-not $controlState) { $controlState = 'Default' } } } if (-not $reason -and $controlState -ine 'Default') { $reason = "The recommendation has the administrative state $controlState; verify the underlying permissions in Defender XDR." } # Validate Q2 shape and locate the matching per-control score entry. if (-not $reason) { if ($null -eq $secureScoresResponse -or $secureScoresResponse.PSObject.Properties.Name -notcontains 'value') { $reason = 'The latest Secure Score response is missing the snapshot collection.' } elseif ($secureScoresResponse.value -is [string] -or $secureScoresResponse.value -isnot [System.Collections.IEnumerable]) { $reason = 'The latest Secure Score response contains a malformed snapshot collection.' } else { $secureScores = @($secureScoresResponse.value) if ($secureScores.Count -ne 1) { $reason = "The latest Secure Score response returned $($secureScores.Count) snapshots; exactly one is required." } else { $latestSecureScore = $secureScores[0] $parsedSnapshotTime = & $parseTimestamp $latestSecureScore.createdDateTime if ($null -eq $parsedSnapshotTime -or $parsedSnapshotTime -gt [datetimeoffset]::UtcNow) { $reason = 'The latest Secure Score snapshot has a missing, invalid, or future timestamp.' } else { $snapshotTime = $parsedSnapshotTime if ($latestSecureScore.PSObject.Properties.Name -notcontains 'controlScores' -or $null -eq $latestSecureScore.controlScores -or $latestSecureScore.controlScores -is [string] -or $latestSecureScore.controlScores -isnot [System.Collections.IEnumerable]) { $reason = 'The latest Secure Score snapshot has a missing or malformed controlScores collection.' } else { $matchingScores = @($latestSecureScore.controlScores | Where-Object controlName -eq $controlId) if ($matchingScores.Count -ne 1) { $reason = "The latest Secure Score snapshot contains $($matchingScores.Count) matching control scores; exactly one is required." } else { $controlScoreEntry = $matchingScores[0] $currentScore = $controlScoreEntry.score } } } } } } # Numeric guards: profile maxScore and score must both be finite JSON numbers with score in [0, maxScore]. if (-not $reason) { $numericTypes = @('Byte','SByte','Int16','UInt16','Int32','UInt32','Int64','UInt64','Single','Double','Decimal') $maxIsNumeric = $null -ne $maxScore -and $maxScore.GetType().Name -in $numericTypes $scoreIsNumeric = $null -ne $currentScore -and $currentScore.GetType().Name -in $numericTypes if (-not $maxIsNumeric -or -not $scoreIsNumeric) { $reason = 'The current score or maximum score is missing or is not a JSON number.' } else { $maxD = [double]$maxScore $curD = [double]$currentScore if ([double]::IsNaN($maxD) -or [double]::IsInfinity($maxD) -or [double]::IsNaN($curD) -or [double]::IsInfinity($curD) -or $maxD -le 0 -or $curD -lt 0 -or $curD -gt $maxD) { $reason = 'The current score or maximum score is outside the valid range.' } else { $numericGuardsPassed = $true $scorePercentage = 100 * ($curD / $maxD) } } } if ($numericGuardsPassed -and $currentScore -eq $maxScore) { $passed = $true $reason = 'The current score equals the recommendation maximum.' $testResultMarkdown = "✅ The latest Microsoft Defender for Identity Secure Score assessment reports no unnecessary replication permissions for monitored Microsoft Entra Connect synchronization accounts.`n`n%TestResult%" } elseif ($numericGuardsPassed) { $reason = 'The current score is below the recommendation maximum.' $testResultMarkdown = "❌ The latest Microsoft Defender for Identity Secure Score assessment reports unnecessary replication permissions for one or more monitored Microsoft Entra Connect synchronization accounts; review the exposed accounts in Defender XDR before removing permissions.`n`n%TestResult%" } else { $customStatus = 'Investigate' $testResultMarkdown = "⚠️ The assessment could not be evaluated reliably because evidence is unavailable, incomplete, invalid, or administratively overridden. Review the reported reason and verify MDI sensor coverage and the recommendation in Defender XDR.`n`n%TestResult%" } #endregion Assessment Logic #region Report Generation $recommendationTitle = if (-not [string]::IsNullOrWhiteSpace([string]$controlProfile.title)) { [string]$controlProfile.title } else { $title } $scoreDisplay = if ($numericGuardsPassed) { "$currentScore / $maxScore" } else { '—' } $percentageDisplay = if ($null -ne $scorePercentage) { '{0:N2}%' -f $scorePercentage } else { '—' } $implementationDisplay = if ($null -ne $controlScoreEntry -and -not [string]::IsNullOrWhiteSpace([string]$controlScoreEntry.implementationStatus)) { [string]$controlScoreEntry.implementationStatus } else { '—' } $lastSyncedRaw = if ($null -ne $controlScoreEntry) { $controlScoreEntry.lastSynced } else { $null } $lastSyncedTime = & $parseTimestamp $lastSyncedRaw $lastSyncedDisplay = if ($null -ne $lastSyncedTime) { Get-FormattedDate -DateString $lastSyncedTime.ToString('o') } else { '—' } $snapshotDisplay = if ($null -ne $snapshotTime) { Get-FormattedDate -DateString $snapshotTime.ToString('o') } else { '—' } $controlStateDisplay = if ($controlState) { $controlState } else { '—' } $statusDisplay = if ($passed) { '✅ Pass' } elseif ($customStatus -eq 'Investigate') { '⚠️ Investigate' } else { '❌ Fail' } # Prefer Q1 actionUrl when it's a valid HTTPS URL; otherwise link to the Defender XDR recommendations list. $parsedActionUrl = $null $recommendationUrl = if ([uri]::TryCreate([string]$controlProfile.actionUrl, [System.UriKind]::Absolute, [ref]$parsedActionUrl) -and $parsedActionUrl.Scheme -eq 'https') { $parsedActionUrl.AbsoluteUri } else { $defenderLink } $safeRecommendationTitle = Get-SafeMarkdown -Text $recommendationTitle $recommendationDisplay = if (-not $passed) { "[$safeRecommendationTitle]($recommendationUrl)" } else { $safeRecommendationTitle } $portalInstruction = if (-not $passed -and $recommendationUrl -eq $defenderLink) { "Review [Defender XDR > Secure Score > Recommendations]($defenderLink) and find the named recommendation.`n`n" } else { '' } $formatTemplate = @' {0}| Recommendation title | Current score / Maximum score | Score percentage | Implementation status | Last synced | Snapshot time (UTC) | Control state | Status | Reason | | :------------------- | :---------------------------- | :--------------- | :-------------------- | :---------- | :------------------ | :------------ | :----- | :----- | | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} | {9} | '@ $mdInfo = $formatTemplate -f $portalInstruction, $recommendationDisplay, $scoreDisplay, $percentageDisplay, $implementationDisplay, $lastSyncedDisplay, $snapshotDisplay, $controlStateDisplay, $statusDisplay, $reason $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo #endregion Report Generation $params = @{ TestId = '41012' Title = $title Status = $passed Result = $testResultMarkdown } if ($customStatus) { $params.CustomStatus = $customStatus } Add-ZtTestResultDetail @params } |