Skip to content

Commit 7c91ae8

Browse files
authored
Merge pull request #867 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents 1f37391 + 133fdd4 commit 7c91ae8

1 file changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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

Comments
 (0)