Remediation/EntraID/ENT-COUNTRIES.ps1
|
# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk. <# .SYNOPSIS Has the sign-in countries validated, then prepares a block by location if the customer wants it (procedure ENT-COUNTRIES). .DESCRIPTION Data read: the countries of recent sign-ins, named locations and conditional access policies read. The first preview creates two files: - decisions\ENT-COUNTRIES.csv: one country per row, Legitimate or To examine (ENT-RISK for the latter); - decisions\ENT-COUNTRIES-BLOCK.csv: one row per tenant, Do not block, Report only or Enable. Nothing is created without a decision. Report only creates the location TC - Validated countries and a report-only policy. Enable turns this policy on, once the snapshot shows it in report-only. Guards of the activation, whatever the decisions: - every country has a decision; - the emergency accounts exist, are enabled, have a FIDO2 key in the target and are excluded; otherwise the script refuses; - the policy reads as report-only, unchanged for at least three days; - security defaults are off. Emergency accounts and the sync accounts role are excluded from the policy. The exclusion is read back after the activation: if it is missing, the policy goes back to report-only. The location takes all the countries decided Legitimate in the file, even with no sign-in in the last snapshot. Removing a country from an active policy is refused: put it back in report-only first to observe the effect. Limit: an address with no known country does not enter the validated location. Report-only mode shows what this would affect. #> $c = Open-TcRemediation $PolicyName = (Get-TcObjectName ' - Block outside validated countries') $LocationName = (Get-TcObjectName ' - Validated countries') $LocationUri = 'v1.0/identity/conditionalAccess/namedLocations' $ReportOnlyState = $script:TcReportOnlyState $UsualCountries = @(Get-TcRemediationOption 'UsualCountries') function Get-LocationPolicy { param([string]$Tenant) return @(Get-TcRemediationTable $Tenant 'conditionalAccess' | Where-Object { $_['Locations'] -and $_['State'] -ne 'disabled' -and $_['Name'] -ne $PolicyName }) } $countryProposals = foreach ($t in $c.Tenants) { Write-TcRemediationTenant $t 'Countries read' $country = @(Get-TcRemediationTable $t 'signInSummary' | Where-Object { $_['Dimension'] -eq 'Country' -and $_['Value'] -ne '(empty)' }) if (-not $country.Count -and -not (Test-TcCollectionComplete $t 'signInSummary')) { Write-TcRemediationAlert 'Sign-ins: incomplete collection, an empty table proves nothing.' } $unusual = @($country | Where-Object { $_['Value'] -notin $UsualCountries }) Write-TcRemediationNote ("{0} sign-in countries, of which {1} outside the usual countries" -f $country.Count, $unusual.Count) $byLocation = @(Get-LocationPolicy $t) if ($byLocation.Count) { Write-TcRemediationNote ("{0} location-based policy(ies) already active, {1} report-only: compare before creating another one (list in ENT-COUNTRIES-BLOCK.csv)" -f @($byLocation | Where-Object { $_['State'] -eq 'enabled' }).Count, @($byLocation | Where-Object { $_['State'] -eq $ReportOnlyState }).Count) } foreach ($p in $country | Sort-Object { - [int]$_['Sign-ins'] }) { [ordered]@{ Tenant = $t; Country = $p['Value']; SignIns = $p['Sign-ins']; Users = $p['Users']; Proposal = $(if ($p['Value'] -in $UsualCountries) { 'Legitimate' } else { '' }) } } } $countryDecisions = @(Get-TcDecision -Name 'ENT-COUNTRIES' -Proposal @($countryProposals | Where-Object { $_ }) -Key 'Tenant', 'Country' -Choice 'Legitimate', 'To examine') foreach ($l in $countryDecisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'To examine' }) { Write-TcRemediationNote ("{0}: country {1} to examine (ENT-RISK)" -f (Get-TcMember $l 'Tenant'), (Get-TcMember $l 'Country')) } $blockProposals = foreach ($t in $c.Tenants) { $existingItem = Get-TcRemediationTable $t 'conditionalAccess' | Where-Object { $_['Name'] -eq $PolicyName } | Select-Object -First 1 [ordered]@{ Tenant = $t; Policy = $PolicyName; 'State read' = $(if ($existingItem) { $existingItem['State'] } else { 'missing' }) 'Existing location-based policies' = (@(Get-LocationPolicy $t | ForEach-Object { '{0} ({1})' -f $_['Name'], $_['State'] }) -join ' ; '); Proposal = 'Do not block' } } $blockDecisions = @(Get-TcDecision -Name 'ENT-COUNTRIES-BLOCK' -Proposal @($blockProposals | Where-Object { $_ }) -Key 'Tenant' -Choice 'Do not block', 'Report only', 'Enable') foreach ($t in $c.Tenants) { $d = $blockDecisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t } | Select-Object -First 1 if (-not $d -or (Get-TcMember $d 'Decision') -eq 'Do not block') { continue } # the file, not the read: a legitimate country with no recent sign-in must not leave the location $validItems = @((Read-TcDecisionEntry -Name 'ENT-COUNTRIES' -Key 'Tenant', 'Country').Values | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Legitimate' } | ForEach-Object { (Get-TcMember $_ 'Country') }) $pending = @($countryProposals | Where-Object { $_ -and (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Country') -notin @($countryDecisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t } | ForEach-Object { (Get-TcMember $_ 'Country') }) }).Count if ($pending) { Write-TcRemediationAlert ("{0}: {1} countries with no decision: block postponed." -f $t, $pending); continue } if (-not $validItems.Count) { Write-TcRemediationAlert ("{0}: no legitimate country decided: block postponed." -f $t); continue } if (ConvertTo-TcBoolean (Get-TcRemediationValue $t 'securitySettings' 'Security defaults enabled')) { Write-TcRemediationAlert ("{0}: security defaults are enabled: conditional access cannot be enabled." -f $t); continue } $missing = @(Get-TcMissingProtectedAccount $t) if ($missing.Count) { Write-TcRemediationAlert ("{0}: emergency accounts missing from the snapshot: {1}; enabling will stay refused." -f $t, ($missing -join ', ')) } $existingItem = Get-TcRemediationTable $t 'conditionalAccess' | Where-Object { $_['Name'] -eq $PolicyName } | Select-Object -First 1 Add-TcBlockAction -Tenant $t -Service Graph -Object $LocationName -Description ("Create or update the named location with {0} validated country(ies)" -f $validItems.Count) -Data @{ Country = @($validItems | Sort-Object) } ` -Test { param($a) $l = @(Invoke-TcGraph -Tenant $a.tenant -Uri $LocationUri -All | Where-Object { (Get-TcMember $_ 'displayName') -eq $LocationName }) | Select-Object -First 1 return [bool]($l -and -not (Compare-Object @((Get-TcMember $l 'countriesAndRegions') | Sort-Object) $a.data.Country)) } ` -Block { param($a) $l = @(Invoke-TcGraph -Tenant $a.tenant -Uri $LocationUri -All | Where-Object { (Get-TcMember $_ 'displayName') -eq $LocationName }) | Select-Object -First 1 $type = '#microsoft.graph.countryNamedLocation' if ($l) { $removedItems = @((Get-TcMember $l 'countriesAndRegions') | Where-Object { $_ -notin $a.data.Country }) $s = Get-TcConditionalAccessPolicy $a.tenant $PolicyName if ($removedItems.Count -and $s -and (Get-TcMember $s 'state') -eq 'enabled') { throw ("Reduction refused: removing {0} would cut these countries off without observation. Put the policy back in report-only mode, observe, then run again." -f ($removedItems -join ', ')) } $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'PATCH'; uri = "$LocationUri/$((Get-TcMember $l 'id'))"; body = @{ '@odata.type' = $type; countriesAndRegions = @((Get-TcMember $l 'countriesAndRegions')) } } Invoke-TcGraph -Tenant $a.tenant -Method PATCH -Uri "$LocationUri/$((Get-TcMember $l 'id'))" -Body @{ '@odata.type' = $type; countriesAndRegions = $a.data.Country } } else { $r = Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri $LocationUri -Body @{ '@odata.type' = $type; displayName = $LocationName; countriesAndRegions = $a.data.Country; includeUnknownCountriesAndRegions = $false } $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = "$LocationUri/$((Get-TcMember $r 'id'))" } } } if (-not $existingItem) { Add-TcBlockAction -Tenant $t -Service Graph -Object $PolicyName -Description ("Create policy {0} in report-only mode" -f $PolicyName) ` -Test { param($a) [bool](Get-TcConditionalAccessPolicy $a.tenant $PolicyName) } ` -Block { param($a) $l = @(Invoke-TcGraph -Tenant $a.tenant -Uri $LocationUri -All | Where-Object { (Get-TcMember $_ 'displayName') -eq $LocationName }) | Select-Object -First 1 if (-not $l) { throw "Location missing: $LocationName" } $x = Resolve-TcAccountId $a.tenant @(Get-TcProtectedAccount $a.tenant) foreach ($u in (Get-TcMember $x 'Missing')) { Write-TcRemediationAlert ("{0} does not exist yet: to exclude before enabling, the script handles it at that time" -f $u) } $body = @{ displayName = $PolicyName; state = $ReportOnlyState conditions = @{ users = @{ includeUsers = @('All'); excludeUsers = @((Get-TcMember $x 'Ids')); excludeRoles = @($script:TcSyncRoleId) } applications = @{ includeApplications = @('All') }; clientAppTypes = @('all') locations = @{ includeLocations = @('All'); excludeLocations = @((Get-TcMember $l 'id')) } } grantControls = @{ operator = 'OR'; builtInControls = @('block') } } $r = Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri $script:TcPolicyUri -Body $body $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = "$script:TcPolicyUri/$((Get-TcMember $r 'id'))" } } } if ((Get-TcMember $d 'Decision') -eq 'Enable') { if (-not $existingItem) { Write-TcRemediationAlert ("{0}: enabling decided, but the policy is not yet in the snapshot: create, observe, refresh the snapshot." -f $t) } elseif ($missing.Count -or -not @(Get-TcProtectedAccount $t).Count) { Write-TcRemediationAlert ("{0}: enabling blocked: emergency accounts missing." -f $t) } elseif ($existingItem['State'] -eq $ReportOnlyState) { Add-TcPolicyActivation -Tenant $t -Name $PolicyName } } } Invoke-TcRemediationPlan -Verify { param($t) $p = Get-TcConditionalAccessPolicy $t $PolicyName if (-not $p) { New-TcRemediationResult Info 'no country block policy created by this script'; return } New-TcRemediationResult $(if ((Get-TcMember $p 'state') -eq 'enabled') { 'OK' } else { 'Info' }) ("policy {0}: {1}" -f $PolicyName, (Get-TcMember $p 'state')) if ($script:TcSyncRoleId -notin @((Get-TcMember (Get-TcMember (Get-TcMember $p 'conditions') 'users') 'excludeRoles'))) { New-TcRemediationResult Blocking 'the role of the sync accounts is not excluded' } Get-TcExclusionResult -Tenant $t -Policy $p } Close-TcRemediation |