|
| 1 | +function Invoke-CIPPStandardColleagueImpersonationAlert { |
| 2 | + <# |
| 3 | + .FUNCTIONALITY |
| 4 | + Internal |
| 5 | + .COMPONENT |
| 6 | + (APIName) ColleagueImpersonationAlert |
| 7 | + .SYNOPSIS |
| 8 | + (Label) Colleague Impersonation Alert Transport Rules |
| 9 | + .DESCRIPTION |
| 10 | + (Helptext) Creates/updates 5x Exchange Online transport rules (A-E, F-J, K-O, P-T, U-Z) that prepend an HTML disclaimer banner to inbound emails where the sender display name matches a mailbox in the organisation. Accepted tenant domains are always exempt automatically. Inactive users are removed and enabled users are added. Any manually configured sender or domain exemptions already present on existing rules are preserved. |
| 11 | + (DocsDescription) Creates five Exchange Online transport rules grouped by the first letter of user display names (A-E, F-J, K-O, P-T, U-Z). Each rule fires when an external sender's From header matches a display name in that group, prepends a configurable HTML warning banner, and skips emails from accepted organisational domains. Any manually configured sender or domain exemptions on existing rules are preserved when the standard runs. The disclaimer HTML is fully customisable via the standard settings. |
| 12 | + .NOTES |
| 13 | + CAT |
| 14 | + Exchange Standards |
| 15 | + TAG |
| 16 | + EXECUTIVETEXT |
| 17 | + Automatically alerts recipients when an email arrives from outside the organisation using a display name that matches an internal user - a common social-engineering technique. Five transport rules cover all display-name initial letters, keeping each rule within Exchange Online size limits. The disclaimer banner is prepended to the message body and directs users to verify authenticity before acting on the email. |
| 18 | + ADDEDCOMPONENT |
| 19 | + {"type":"heading","label":"Alert Banner (HTML)","required":false} |
| 20 | + {"type":"textField","name":"standards.ColleagueImpersonationAlert.disclaimerHtml","label":"Disclaimer HTML - Paste the full HTML for the warning banner","required":true} |
| 21 | + {"type":"heading","label":"Keyword Exclusions for Transport Rule","required":false} |
| 22 | + {"type":"autoComplete","name":"standards.ColleagueImpersonationAlert.excludedMailboxes","label":"Exclude mailboxes by keyword (e.g. any DisplayName containing 'Leaver')","multiple":true,"creatable":true,"required":false,"options":[]} |
| 23 | + {"type":"heading","label":"Exempt Senders (ExceptIfFromAddressContainsWords)","required":false} |
| 24 | + {"type":"autoComplete","name":"standards.ColleagueImpersonationAlert.additionalExemptSenders","label":"Additional exempt sender addresses (for example no-reply@teams.mail.microsoft)","multiple":true,"creatable":true,"required":false,"options":[]} |
| 25 | + IMPACT |
| 26 | + Medium Impact |
| 27 | + ADDEDDATE |
| 28 | + 2026-03-25 |
| 29 | + POWERSHELLEQUIVALENT |
| 30 | + New-TransportRule / Set-TransportRule |
| 31 | + RECOMMENDEDBY |
| 32 | + UPDATECOMMENTBLOCK |
| 33 | + Run the Tools\Update-StandardsComments.ps1 script to update this comment block |
| 34 | + .LINK |
| 35 | + https://docs.cipp.app/user-documentation/tenant/standards/list-standards |
| 36 | + #> |
| 37 | + |
| 38 | + param($Tenant, $Settings) |
| 39 | + $TestResult = Test-CIPPStandardLicense -StandardName 'ColleagueImpersonationAlert' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_S_STANDARD_GOV', 'EXCHANGE_S_ENTERPRISE_GOV', 'EXCHANGE_LITE') |
| 40 | + |
| 41 | + if ($TestResult -eq $false) { |
| 42 | + return $true |
| 43 | + } #we're done. |
| 44 | + |
| 45 | + $ruleHtml = $Settings.disclaimerHtml |
| 46 | + |
| 47 | + $excludeKeywords = @( |
| 48 | + @($Settings.excludedMailboxes) | ForEach-Object { |
| 49 | + if ($_ -is [string]) { $_ } else { [string]($_.value ?? $_.label) } |
| 50 | + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
| 51 | + ) |
| 52 | + |
| 53 | + $additionalExemptSenders = @( |
| 54 | + @($Settings.additionalExemptSenders) | ForEach-Object { |
| 55 | + if ($_ -is [string]) { $_ } else { [string]($_.value ?? $_.label) } |
| 56 | + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
| 57 | + ) |
| 58 | + |
| 59 | + try { |
| 60 | + $acceptedDomains = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-AcceptedDomain' |
| 61 | + $autoExemptDomains = @( |
| 62 | + $acceptedDomains.DomainName | |
| 63 | + Where-Object { $_ -and $_ -notmatch '\.onmicrosoft\.com$|\.exclaimer\.cloud$' } | |
| 64 | + ForEach-Object { [string]$_ } |
| 65 | + ) |
| 66 | + } catch { |
| 67 | + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message |
| 68 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: could not retrieve accepted domains. Error: $ErrorMessage" -Sev Error |
| 69 | + return |
| 70 | + } |
| 71 | + |
| 72 | + try { |
| 73 | + $mailboxes = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-Mailbox' ` |
| 74 | + -cmdParams @{ ResultSize = 'Unlimited'; RecipientTypeDetails = @('UserMailbox', 'SharedMailbox') } |
| 75 | + $displayNames = @( |
| 76 | + $mailboxes | Where-Object { |
| 77 | + $mb = $_ |
| 78 | + if ($mb.AccountDisabled -eq $true) { return $false } |
| 79 | + foreach ($kw in $excludeKeywords) { |
| 80 | + if (-not [string]::IsNullOrWhiteSpace($mb.DisplayName) -and |
| 81 | + $mb.DisplayName -match [regex]::Escape($kw)) { return $false } |
| 82 | + } |
| 83 | + return -not [string]::IsNullOrWhiteSpace($mb.DisplayName) |
| 84 | + } | Select-Object -ExpandProperty DisplayName |
| 85 | + ) |
| 86 | + } catch { |
| 87 | + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message |
| 88 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: could not retrieve mailboxes. Error: $ErrorMessage" -Sev Error |
| 89 | + return |
| 90 | + } |
| 91 | + |
| 92 | + $groups = [ordered]@{ |
| 93 | + 'A-E' = '^[A-Ea-e]' |
| 94 | + 'F-J' = '^[F-Jf-j]' |
| 95 | + 'K-O' = '^[K-Ok-o]' |
| 96 | + 'P-T' = '^[P-Tp-t]' |
| 97 | + 'U-Z' = '^[U-Zu-z]' |
| 98 | + } |
| 99 | + |
| 100 | + try { |
| 101 | + $existingRules = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-TransportRule' |
| 102 | + } catch { |
| 103 | + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message |
| 104 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: could not retrieve transport rules. Error: $ErrorMessage" -Sev Error |
| 105 | + return |
| 106 | + } |
| 107 | + |
| 108 | + if ([string]::IsNullOrWhiteSpace($ruleHtml) -and $Settings.remediate -eq $true) { |
| 109 | + $fallbackRule = $existingRules | Where-Object { |
| 110 | + $_.Name -like '*Colleague Impersonation Alert*' -and |
| 111 | + -not [string]::IsNullOrWhiteSpace($_.ApplyHtmlDisclaimerText) |
| 112 | + } | Select-Object -First 1 |
| 113 | + |
| 114 | + if ($fallbackRule) { |
| 115 | + $ruleHtml = $fallbackRule.ApplyHtmlDisclaimerText |
| 116 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'ColleagueImpersonationAlert: disclaimerHtml not in settings; using HTML from existing rule.' -Sev Info |
| 117 | + } else { |
| 118 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'ColleagueImpersonationAlert: disclaimerHtml not set and no existing rule to fall back on. Save the standard with the Disclaimer HTML field populated.' -Sev Error |
| 119 | + return |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + $BuildRuleStateList = { |
| 124 | + param($Rules) |
| 125 | + foreach ($entry in $groups.GetEnumerator()) { |
| 126 | + $range = $entry.Key |
| 127 | + $pattern = $entry.Value |
| 128 | + $ruleName = "($range) Colleague Impersonation Alert" |
| 129 | + $names = @($displayNames | Where-Object { $_ -match $pattern }) |
| 130 | + if ($names.Count -eq 0) { $names = @("($range)") } |
| 131 | + $existing = $Rules | Where-Object { $_.Name -eq $ruleName } | Select-Object -First 1 |
| 132 | + |
| 133 | + $namesMatch = $false |
| 134 | + $expectedCount = $names.Count |
| 135 | + $actualCount = 0 |
| 136 | + if ($null -ne $existing) { |
| 137 | + $existingPatterns = @($existing.HeaderMatchesPatterns | ForEach-Object { [string]$_ }) |
| 138 | + $actualCount = $existingPatterns.Count |
| 139 | + $namesMatch = (($names | Sort-Object) -join "`n") -eq (($existingPatterns | Sort-Object) -join "`n") |
| 140 | + } |
| 141 | + |
| 142 | + [PSCustomObject]@{ |
| 143 | + RuleName = $ruleName |
| 144 | + Range = $range |
| 145 | + Names = $names |
| 146 | + ExistingRule = $existing |
| 147 | + NamesMatch = $namesMatch |
| 148 | + ExpectedCount = $expectedCount |
| 149 | + ActualCount = $actualCount |
| 150 | + } |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + $ruleStateList = @(& $BuildRuleStateList -Rules $existingRules) |
| 155 | + |
| 156 | + if ($Settings.remediate -eq $true) { |
| 157 | + foreach ($ruleInfo in $ruleStateList) { |
| 158 | + $ruleName = $ruleInfo.RuleName |
| 159 | + $range = $ruleInfo.Range |
| 160 | + $names = $ruleInfo.Names |
| 161 | + $existingRule = $ruleInfo.ExistingRule |
| 162 | + |
| 163 | + $seenSenders = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) |
| 164 | + $exemptSenders = [System.Collections.Generic.List[string]]::new() |
| 165 | + foreach ($addr in $additionalExemptSenders) { |
| 166 | + if (-not [string]::IsNullOrWhiteSpace($addr) -and $seenSenders.Add($addr.Trim())) { $exemptSenders.Add($addr.Trim()) } |
| 167 | + } |
| 168 | + foreach ($addr in @($existingRule.ExceptIfFromAddressContainsWords)) { |
| 169 | + $s = [string]$addr |
| 170 | + if (-not [string]::IsNullOrWhiteSpace($s) -and $seenSenders.Add($s.Trim())) { $exemptSenders.Add($s.Trim()) } |
| 171 | + } |
| 172 | + |
| 173 | + $seenDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) |
| 174 | + $exemptDomains = [System.Collections.Generic.List[string]]::new() |
| 175 | + foreach ($dom in $autoExemptDomains) { |
| 176 | + if (-not [string]::IsNullOrWhiteSpace($dom) -and $seenDomains.Add($dom.Trim())) { $exemptDomains.Add($dom.Trim()) } |
| 177 | + } |
| 178 | + foreach ($dom in @($existingRule.ExceptIfSenderDomainIs)) { |
| 179 | + $s = [string]$dom |
| 180 | + if (-not [string]::IsNullOrWhiteSpace($s) -and $seenDomains.Add($s.Trim())) { $exemptDomains.Add($s.Trim()) } |
| 181 | + } |
| 182 | + |
| 183 | + $cmdParams = @{ |
| 184 | + FromScope = 'NotInOrganization' |
| 185 | + ApplyHtmlDisclaimerLocation = 'Prepend' |
| 186 | + ApplyHtmlDisclaimerFallbackAction = 'Wrap' |
| 187 | + ApplyHtmlDisclaimerText = $ruleHtml |
| 188 | + ExceptIfSenderDomainIs = @($exemptDomains) |
| 189 | + HeaderMatchesMessageHeader = 'From' |
| 190 | + HeaderMatchesPatterns = $names |
| 191 | + Comments = "CIPP managed rule ($range) - Letters $range" |
| 192 | + } |
| 193 | + if ($exemptSenders.Count -gt 0) { |
| 194 | + $cmdParams['ExceptIfFromAddressContainsWords'] = @($exemptSenders) |
| 195 | + } |
| 196 | + |
| 197 | + if ($null -eq $existingRule) { |
| 198 | + try { |
| 199 | + $cmdParams['Name'] = $ruleName |
| 200 | + New-ExoRequest -tenantid $Tenant -cmdlet 'New-TransportRule' -cmdParams $cmdParams -UseSystemMailbox $true |
| 201 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: created rule '$ruleName'." -Sev Info |
| 202 | + } catch { |
| 203 | + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message |
| 204 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: failed to create rule '$ruleName'. Error: $ErrorMessage" -Sev Error |
| 205 | + } |
| 206 | + } else { |
| 207 | + try { |
| 208 | + $cmdParams['Identity'] = $ruleName |
| 209 | + New-ExoRequest -tenantid $Tenant -cmdlet 'Set-TransportRule' -cmdParams $cmdParams -UseSystemMailbox $true |
| 210 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: updated rule '$ruleName'." -Sev Info |
| 211 | + } catch { |
| 212 | + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message |
| 213 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: failed to update rule '$ruleName'. Error: $ErrorMessage" -Sev Error |
| 214 | + } |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + try { |
| 219 | + $existingRules = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-TransportRule' |
| 220 | + $ruleStateList = @(& $BuildRuleStateList -Rules $existingRules) |
| 221 | + } catch { |
| 222 | + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message |
| 223 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: could not re-fetch transport rules after remediation. Error: $ErrorMessage" -Sev Error |
| 224 | + } |
| 225 | + } |
| 226 | + |
| 227 | + $missingRules = @($ruleStateList | Where-Object { $null -eq $_.ExistingRule }) |
| 228 | + $staleRules = @($ruleStateList | Where-Object { $null -ne $_.ExistingRule -and -not $_.NamesMatch }) |
| 229 | + $StateIsCorrect = ($missingRules.Count -eq 0) -and ($staleRules.Count -eq 0) |
| 230 | + |
| 231 | + if ($Settings.alert -eq $true) { |
| 232 | + if ($StateIsCorrect) { |
| 233 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'ColleagueImpersonationAlert: all 5 transport rules are present and up to date.' -Sev Info |
| 234 | + } else { |
| 235 | + if ($missingRules.Count -gt 0) { |
| 236 | + $missingNames = ($missingRules.RuleName) -join ', ' |
| 237 | + Write-StandardsAlert -message "ColleagueImpersonationAlert: missing transport rules: $missingNames" -object @{ MissingRules = $missingNames } -tenant $Tenant -standardName 'ColleagueImpersonationAlert' -standardId $Settings.standardId |
| 238 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: missing transport rules: $missingNames" -Sev Alert |
| 239 | + } |
| 240 | + if ($staleRules.Count -gt 0) { |
| 241 | + $staleDetails = ($staleRules | ForEach-Object { "$($_.RuleName) (expected $($_.ExpectedCount), actual $($_.ActualCount))" }) -join ', ' |
| 242 | + Write-StandardsAlert -message "ColleagueImpersonationAlert: stale transport rules (user list out of date): $staleDetails" -object @{ StaleRules = $staleDetails } -tenant $Tenant -standardName 'ColleagueImpersonationAlert' -standardId $Settings.standardId |
| 243 | + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "ColleagueImpersonationAlert: stale transport rules (user list out of date): $staleDetails" -Sev Alert |
| 244 | + } |
| 245 | + } |
| 246 | + } |
| 247 | + |
| 248 | + if ($Settings.report -eq $true) { |
| 249 | + $CurrentValue = [PSCustomObject]@{ |
| 250 | + '(A-E) Colleague Impersonation Alert' = ($null -ne ($ruleStateList | Where-Object { $_.Range -eq 'A-E' } | Select-Object -First 1).ExistingRule) -and (($ruleStateList | Where-Object { $_.Range -eq 'A-E' } | Select-Object -First 1).NamesMatch) |
| 251 | + '(F-J) Colleague Impersonation Alert' = ($null -ne ($ruleStateList | Where-Object { $_.Range -eq 'F-J' } | Select-Object -First 1).ExistingRule) -and (($ruleStateList | Where-Object { $_.Range -eq 'F-J' } | Select-Object -First 1).NamesMatch) |
| 252 | + '(K-O) Colleague Impersonation Alert' = ($null -ne ($ruleStateList | Where-Object { $_.Range -eq 'K-O' } | Select-Object -First 1).ExistingRule) -and (($ruleStateList | Where-Object { $_.Range -eq 'K-O' } | Select-Object -First 1).NamesMatch) |
| 253 | + '(P-T) Colleague Impersonation Alert' = ($null -ne ($ruleStateList | Where-Object { $_.Range -eq 'P-T' } | Select-Object -First 1).ExistingRule) -and (($ruleStateList | Where-Object { $_.Range -eq 'P-T' } | Select-Object -First 1).NamesMatch) |
| 254 | + '(U-Z) Colleague Impersonation Alert' = ($null -ne ($ruleStateList | Where-Object { $_.Range -eq 'U-Z' } | Select-Object -First 1).ExistingRule) -and (($ruleStateList | Where-Object { $_.Range -eq 'U-Z' } | Select-Object -First 1).NamesMatch) |
| 255 | + } |
| 256 | + $ExpectedValue = [PSCustomObject]@{ |
| 257 | + '(A-E) Colleague Impersonation Alert' = $true |
| 258 | + '(F-J) Colleague Impersonation Alert' = $true |
| 259 | + '(K-O) Colleague Impersonation Alert' = $true |
| 260 | + '(P-T) Colleague Impersonation Alert' = $true |
| 261 | + '(U-Z) Colleague Impersonation Alert' = $true |
| 262 | + } |
| 263 | + Set-CIPPStandardsCompareField -FieldName 'standards.ColleagueImpersonationAlert' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant |
| 264 | + Add-CIPPBPAField -FieldName 'ColleagueImpersonationAlert' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant |
| 265 | + } |
| 266 | +} |
0 commit comments